Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 120 additions & 19 deletions src/server.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import { assert, is_html, is_iterable, is_renderable, lexer, single_part_template, type Displayable } from './shared.ts'
import type { HTML } from './index.ts'
import {
assert,
is_html,
is_iterable,
is_renderable,
lexer,
single_part_template,
type Displayable,
type Renderable,
} from './shared.ts'

interface PartRenderer {
replace_start: number
replace_end: number
render: (values: unknown[]) => string | Generator<string, void, void>
render: (values: unknown[], controller: Controller) => string | Generator<string, void, void>
}

interface CompiledTemplate {
Expand Down Expand Up @@ -69,7 +79,7 @@ function compile_template(statics: TemplateStringsArray): CompiledTemplate {
compiled.parts.push({
replace_start: offset,
replace_end: offset + 1,
render: values => render_child(values[i]),
render: (values, controller) => render_child(values[i], controller),
})
break

Expand Down Expand Up @@ -116,6 +126,18 @@ function compile_template(statics: TemplateStringsArray): CompiledTemplate {
return compiled
}

interface Controller {
injections: Promise<HTML>[]
}

const controllers = new WeakMap<Renderable, Controller>()

export function injectToStream(renderable: Renderable, markup: HTML | Promise<HTML>): void {
const controller = controllers.get(renderable)
assert(controller, 'the renderable has not been rendered')
controller.injections.push(Promise.resolve(markup))
}

function render_directive(value: unknown) {
if (value === null) return ''

Expand All @@ -133,16 +155,19 @@ function render_attribute(name: string, value: unknown) {
return `${name}="${escape(value)}"`
}

function* render_child(value: unknown) {
const seen = new Map<object, number>()
function* render_child(value: unknown, controller: Controller) {
const seen = new WeakMap<object, number>()

while (is_renderable(value))
try {
const times = seen.get(value) ?? 0
const renderable = value

const times = seen.get(renderable) ?? 0
if (times > 100) throw new Error('circular render')
seen.set(value, times + 1)
seen.set(renderable, times + 1)

value = value.render()
controllers.set(renderable, controller)
value = renderable.render()
} catch (thrown) {
if (is_html(thrown)) {
value = thrown
Expand All @@ -152,9 +177,9 @@ function* render_child(value: unknown) {
}

if (is_iterable(value)) {
for (const item of value) yield* render_to_iterable(item as Displayable)
for (const item of value) yield* render_to_iterable(item as Displayable, controller)
} else if (is_html(value)) {
yield* render_to_iterable(value)
yield* render_to_iterable(value, controller)
} else if (value !== null) {
yield escape(value)
}
Expand All @@ -172,7 +197,7 @@ function escape(str: unknown) {
return String(str).replace(ESCAPE_RE, c => ESCAPE_SUBSTITUTIONS[c as keyof typeof ESCAPE_SUBSTITUTIONS])
}

function* render_to_iterable(value: Displayable) {
function* render_to_iterable(value: Displayable, controller: Controller) {
const { _statics: statics, _dynamics: dynamics } = is_html(value) ? value : single_part_template(value)
const template = compile_template(statics)

Expand All @@ -185,36 +210,106 @@ function* render_to_iterable(value: Displayable) {
let prev_end = 0
for (const { replace_start, replace_end, render } of template.parts) {
yield str.slice(prev_end, replace_start)
yield* render(dynamics)
yield* render(dynamics, controller)
prev_end = replace_end
}
yield str.slice(prev_end)
}

export function renderToString(value: Displayable): string {
async function* read_all_promises<T>(promises: Promise<T>[]): AsyncGenerator<T> {
type RecursivePromise<T> = Promise<{
value: T
promise: RecursivePromise<T>
}>

const pending = new Set(
promises.map(p => {
const promise: RecursivePromise<T> = p.then(value => ({ value, promise }))
return promise
}),
)

while (pending.size) {
const { value, promise } = await Promise.race(pending)
pending.delete(promise)
yield value
}
}

export async function renderToString(value: Displayable): Promise<string> {
const render_controller: Controller = { injections: [] }
let str = ''
for (const part of render_to_iterable(value)) str += part

for (const part of render_to_iterable(value, render_controller)) {
str += part
}

const count = render_controller.injections.length
for await (const injection of read_all_promises(render_controller.injections)) {
for (const part of render_to_iterable(injection, render_controller)) {
str += part
}
}
assert(
count === render_controller.injections.length,
'calling injectToStream from an injection is currently not supported',
)

return str
}

export function renderToReadableStream(value: Displayable): ReadableStream<Uint8Array> {
const iter = render_to_iterable(value)
const render_controller: Controller = { injections: [] }
const iter = render_to_iterable(value, render_controller)

return new ReadableStream<string>({
pull(controller) {
async pull(controller) {
const { done, value } = iter.next()

if (done) {
const count = render_controller.injections.length
for await (const injection of read_all_promises(render_controller.injections)) {
for (const part of render_to_iterable(injection, render_controller)) {
controller.enqueue(part)
}
}
assert(
count === render_controller.injections.length,
'calling injectToStream from an injection is currently not supported',
)
controller.close()
return
}

controller.enqueue(value)
},
}).pipeThrough(new TextEncoderStream())
}

// {
// const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))

// const suspends = {
// render() {
// const id = 'abcd'
// injectToStream(
// this,
// sleep(250).then(
// () =>
// html`<script>
// document.getElementById('${id}').replaceWith('${'the value'}')
// </script>`,
// ),
// )
// throw html`<span id=${id}></span>`
// },
// }

// const displayable = html`
// <!doctype html>
// <p>a${'text'.repeat(1000)}b</p>
// <!-- ${'z'} -->
// <p>a${'text'}b</p>
// <div>this suspends: ${suspends}</div>
// <a href=${'attr'} onclick=${() => {}}></a>
// <button ${() => 'directive'}>but</button>
// <script>
Expand All @@ -228,10 +323,16 @@ export function renderToReadableStream(value: Displayable): ReadableStream<Uint8
// ${html`[${'A'}|${'B'}]`}
// `

// globalThis.Deno?.serve(() => {
// return new Response(renderToReadableStream(displayable).pipeThrough(new TextEncoderStream()), {
// headers: { 'content-type': 'text/html' },
// })
// })

// const stream = renderToReadableStream(displayable).pipeThrough(new TextEncoderStream())

// new Response(stream).text().then(rendered => {
// new Response(stream).text().then(async rendered => {
// console.log(rendered)
// console.log(rendered === renderToString(displayable))
// console.log(rendered === (await renderToString(displayable)))
// })
// }
72 changes: 38 additions & 34 deletions src/server/tests/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,97 +2,101 @@
import { renderToReadableStream, renderToString } from 'dhtml/server'
import { assert, assert_eq, test } from '../../../scripts/test/test.ts'

test('basic html renders correctly', () => {
assert_eq(renderToString(html`<h1>Hello, world!</h1>`), '<h1>Hello, world!</h1>')
test('basic html renders correctly', async () => {
assert_eq(await renderToString(html`<h1>Hello, world!</h1>`), '<h1>Hello, world!</h1>')
})

test('basic html renders correctly via stream', async () => {
const stream = renderToReadableStream(html`<h1>Hello, world!</h1>`)
assert_eq(await new Response(stream).text(), '<h1>Hello, world!</h1>')
})

test('inner content renders correctly', () => {
assert_eq(renderToString(html`<h1>${html`Inner content!`}</h1>`), '<h1>Inner content!</h1>')
test('inner content renders correctly', async () => {
assert_eq(await renderToString(html`<h1>${html`Inner content!`}</h1>`), '<h1>Inner content!</h1>')
})

test('template with number renders correctly', () => {
test('template with number renders correctly', async () => {
const template = (n: number) => html`<h1>Hello, ${n}!</h1>`
assert_eq(renderToString(template(1)), '<h1>Hello, 1!</h1>')
assert_eq(renderToString(template(2)), '<h1>Hello, 2!</h1>')
assert_eq(await renderToString(template(1)), '<h1>Hello, 1!</h1>')
assert_eq(await renderToString(template(2)), '<h1>Hello, 2!</h1>')
})

test('lists of items', () => {
assert_eq(renderToString([1, 'a', html`<span>thing</span>`]), '1a<span>thing</span>')
test('lists of items', async () => {
assert_eq(await renderToString([1, 'a', html`<span>thing</span>`]), '1a<span>thing</span>')
})

test('basic children render correctly', () => {
test('basic children render correctly', async () => {
assert_eq(
renderToString(html`<span>${'This is a'}</span> ${html`test`} ${html`test`} ${html`test`}`),
await renderToString(html`<span>${'This is a'}</span> ${html`test`} ${html`test`} ${html`test`}`),
'<span>This is a</span> test test test',
)
})

if (__DEV__) {
test('invalid part placement raises error', () => {
test('invalid part placement raises error', async () => {
try {
renderToString(html`<${'div'}>${'text'}</${'div'}>`)
await renderToString(html`<${'div'}>${'text'}</${'div'}>`)
assert(false, 'Expected error to be thrown')
} catch (error) {
assert(error instanceof Error)
}
})
}

test('parts in comments do not throw', () => {
renderToString(html`<!-- ${'text'} -->`)
test('parts in comments work', async () => {
assert.equal(await renderToString(html`<!-- ${'text'} -->`), '<!-- text -->')

Check failure on line 47 in src/server/tests/basic.test.ts

View workflow job for this annotation

GitHub Actions / check

Property 'equal' does not exist on type '(value: unknown, message?: string | undefined) => asserts value'.
})

if (__DEV__) {
test('manually specifying internal template syntax throws', () => {
test('manually specifying internal template syntax throws', async () => {
try {
// why is prettier deleting null bytes?
// prettier-ignore
renderToString(html`${1} \0`)
await renderToString(html`${1} \0`)
assert(false, 'Expected error to be thrown')
} catch (error) {
assert(error instanceof Error)
}
})
}

test('directives', () => {
test('syntax close but not exact does not throw', async () => {
assert.equal(await renderToString(html`dyn-$${0}1$`), 'dyn-$01$')

Check failure on line 64 in src/server/tests/basic.test.ts

View workflow job for this annotation

GitHub Actions / check

Property 'equal' does not exist on type '(value: unknown, message?: string | undefined) => asserts value'.
})

test('directives', async () => {
let calls = 0
const directive = () => {
calls++
}
assert_eq(renderToString(html`<p ${directive}></p>`), '<p ></p>')
assert_eq(await renderToString(html`<p ${directive}></p>`), '<p ></p>')
assert_eq(calls, 1)
})

test('unquoted attributes', () => {
assert_eq(renderToString(html`<a href=${'/url'}></a>`), '<a href="/url"></a>')
assert_eq(renderToString(html`<details hidden=${false}></details>`), '<details ></details>')
assert_eq(renderToString(html`<details hidden=${true}></details>`), '<details hidden></details>')
test('unquoted attributes', async () => {
assert_eq(await renderToString(html`<a href=${'/url'}></a>`), '<a href="/url"></a>')
assert_eq(await renderToString(html`<details hidden=${false}></details>`), '<details ></details>')
assert_eq(await renderToString(html`<details hidden=${true}></details>`), '<details hidden></details>')
})

test('quoted attributes', () => {
assert_eq(renderToString(html`<a href="${'/url'}"></a>`), '<a href="/url"></a>')
assert_eq(renderToString(html`<details hidden="${false}"></details>`), '<details ></details>')
test('quoted attributes', async () => {
assert_eq(await renderToString(html`<a href="${'/url'}"></a>`), '<a href="/url"></a>')
assert_eq(await renderToString(html`<details hidden="${false}"></details>`), '<details ></details>')
// prettier-ignore
assert_eq(renderToString(html`<details hidden='${true}'></details>`), '<details hidden></details>')
assert_eq(await renderToString(html`<details hidden='${true}'></details>`), '<details hidden></details>')
})

test('collapses whitespace', () => {
test('collapses whitespace', async () => {
// prettier-ignore
assert_eq(renderToString(html` <p> </p> `), ' <p> </p> ')
assert_eq(await renderToString(html` <p> </p> `), ' <p> </p> ')

// prettier-ignore
assert_eq(renderToString(html` <p> x </p> `), ' <p> x </p> ')
assert_eq(await renderToString(html` <p> x </p> `), ' <p> x </p> ')
})

test('lexer edge cases', () => {
test('lexer edge cases', async () => {
// prettier-ignore
assert_eq(renderToString(html`<div attr="value"x>`), '<div attr="value"x>')
assert_eq(renderToString(html`<img/attr="value">`), '<img/attr="value">')
assert_eq(renderToString(html`<div attr /other="value"></div>`), '<div attr /other="value"></div>')
assert_eq(await renderToString(html`<div attr="value"x>`), '<div attr="value"x>')
assert_eq(await renderToString(html`<img/attr="value">`), '<img/attr="value">')
assert_eq(await renderToString(html`<div attr /other="value"></div>`), '<div attr /other="value"></div>')
})
30 changes: 30 additions & 0 deletions src/server/tests/inject-to-stream.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { test } from 'bun:test'

Check failure on line 1 in src/server/tests/inject-to-stream.test.ts

View workflow job for this annotation

GitHub Actions / check

Cannot find module 'bun:test' or its corresponding type declarations.
import { html } from 'dhtml'
import { injectToStream, renderToReadableStream, renderToString } from 'dhtml/server'
import assert from 'node:assert/strict'

test('injectToStream', async () => {
const loading = {
render() {
const id = 'abc'
injectToStream(
this,
html`
<div>Loaded</div>
<script>
${`${id}.replaceWith(document.currentScript.previousElementSibling)`}
</script>
`,
)
throw html`<span id=${id}>Loading...</span>`
},
}

const expected = `<main><span id="abc">Loading...</span></main> <div>Loaded</div> <script>
abc.replaceWith(document.currentScript.previousElementSibling)
</script> `
assert.equal(await renderToString(html`<main>${loading}</main>`), expected)

const stream = renderToReadableStream(html`<main>${loading}</main>`)
assert.equal(await new Response(stream).text(), expected)
})
Loading
Loading