@abide/abide 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +16 -0
  3. package/package.json +1 -2
  4. package/src/checkAbide.ts +11 -6
  5. package/src/lib/ui/compile/AbideCompileError.ts +16 -0
  6. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +0 -1
  7. package/src/lib/ui/compile/abideUiPlugin.ts +25 -2
  8. package/src/lib/ui/compile/bindListenEvent.ts +19 -0
  9. package/src/lib/ui/compile/compileShadow.ts +22 -5
  10. package/src/lib/ui/compile/generateBuild.ts +110 -213
  11. package/src/lib/ui/compile/generateSSR.ts +51 -86
  12. package/src/lib/ui/compile/lowerContext.ts +64 -0
  13. package/src/lib/ui/compile/lowerDocAccess.ts +6 -1
  14. package/src/lib/ui/compile/offsetToLineColumn.ts +16 -0
  15. package/src/lib/ui/compile/scopeAttr.ts +9 -0
  16. package/src/lib/ui/compile/staticAttr.ts +11 -0
  17. package/src/lib/ui/compile/staticTextPart.ts +12 -0
  18. package/src/lib/ui/compile/unwrapParens.ts +10 -0
  19. package/src/lib/ui/dom/awaitBlock.ts +27 -21
  20. package/src/lib/ui/dom/clearBetween.ts +16 -0
  21. package/src/lib/ui/dom/each.ts +64 -38
  22. package/src/lib/ui/dom/eachAsync.ts +39 -52
  23. package/src/lib/ui/dom/fillBefore.ts +16 -0
  24. package/src/lib/ui/dom/moveRange.ts +19 -0
  25. package/src/lib/ui/dom/openMarker.ts +22 -0
  26. package/src/lib/ui/dom/removeRange.ts +18 -0
  27. package/src/lib/ui/dom/switchBlock.ts +32 -40
  28. package/src/lib/ui/dom/tryBlock.ts +31 -35
  29. package/src/lib/ui/dom/types/EachRow.ts +10 -3
  30. package/src/lib/ui/dom/types/SwitchCase.ts +3 -2
  31. package/src/lib/ui/dom/when.ts +34 -43
  32. package/src/lib/ui/installHotBridge.ts +0 -2
  33. package/src/lib/ui/state.ts +14 -5
  34. package/src/lib/ui/compile/branchElements.ts +0 -50
  35. package/src/lib/ui/dom/openRoot.ts +0 -20
@@ -1,12 +1,12 @@
1
1
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
2
- import { branchElements } from './branchElements.ts'
3
- import { escapeHtml } from './escapeHtml.ts'
2
+ import { bindListenEvent } from './bindListenEvent.ts'
4
3
  import { groupBindParts } from './groupBindParts.ts'
5
- import { lowerDocAccess } from './lowerDocAccess.ts'
4
+ import { lowerContext } from './lowerContext.ts'
6
5
  import { partitionSlots } from './partitionSlots.ts'
7
- import { nestedBindingNames } from './prepareNestedScript.ts'
8
- import { renameSignalRefs } from './renameSignalRefs.ts'
6
+ import { scopeAttr } from './scopeAttr.ts'
7
+ import { staticAttr } from './staticAttr.ts'
9
8
  import { staticAttrValue } from './staticAttrValue.ts'
9
+ import { staticTextPart } from './staticTextPart.ts'
10
10
  import type { TemplateNode } from './types/TemplateNode.ts'
11
11
  import { VOID_TAGS } from './VOID_TAGS.ts'
12
12
 
@@ -29,24 +29,12 @@ export function generateBuild(
29
29
  let counter = 0
30
30
  const nextVar = (prefix: string): string => `${prefix}${counter++}`
31
31
 
32
- /* Branch-scoped signal bindings (from nested `<script>`s) they deref to
33
- `.value` like a `derived`. Pushed while a branch's script + markup compile,
34
- popped after, so they shadow only within that subtree. */
35
- const localDerived = new Set<string>()
36
- const derefScope = (): ReadonlySet<string> =>
37
- localDerived.size === 0 ? derivedNames : new Set([...derivedNames, ...localDerived])
38
-
39
- /* Rewrites signal refs, then lowers a single expression (no trailing `;`). */
40
- function lowerExpression(code: string): string {
41
- const renamed = renameSignalRefs(code, stateNames, derefScope())
42
- return lowerDocAccess(renamed, 'model').trim().replace(/;$/, '')
43
- }
44
-
45
- /* As above but keeps the trailing `;` for a handler body. */
46
- function lowerStatement(code: string): string {
47
- const renamed = renameSignalRefs(code, stateNames, derefScope())
48
- return lowerDocAccess(renamed, 'model').trim()
49
- }
32
+ /* The shared signal→`model` lowering + branch-scoped nested-script deref scope. */
33
+ const {
34
+ expression: lowerExpression,
35
+ statement: lowerStatement,
36
+ withNestedScripts,
37
+ } = lowerContext(stateNames, derivedNames)
50
38
 
51
39
  /* Builds an element and its children; returns the build code and its var.
52
40
  `varExpr` is how the element is obtained — `openChild(parent, tag)` for a
@@ -90,39 +78,20 @@ export function generateBuild(
90
78
  }
91
79
  } else {
92
80
  /* Two-way: drive the property from the path, and write the path
93
- back on input. The path is an lvalue, so the write is lowered
94
- as an assignment statement. */
81
+ back on the property's native event (`input` for most fields,
82
+ but `toggle` for `<details open>`, `change` for checked/select).
83
+ The path is an lvalue, so the write lowers to an assignment. */
84
+ const event = bindListenEvent(attr.property, node.tag)
95
85
  code += `effect(() => { ${varName}.${attr.property} = ${lowerExpression(attr.code)}; });\n`
96
- code += `on(${varName}, "input", () => { ${lowerStatement(`${attr.code} = ${varName}.${attr.property}`)} });\n`
86
+ code += `on(${varName}, ${JSON.stringify(event)}, () => { ${lowerStatement(`${attr.code} = ${varName}.${attr.property}`)} });\n`
97
87
  }
98
88
  }
99
89
  /* A `<script>` among the children scopes its bindings to this element's
100
90
  subtree (its later siblings auto-deref them); pop after. */
101
- const added = scopeNestedScripts(node.children)
102
- code += generateChildren(node.children, varName)
103
- for (const name of added) {
104
- localDerived.delete(name)
105
- }
91
+ code += withNestedScripts(node.children, () => generateChildren(node.children, varName))
106
92
  return { code, varName }
107
93
  }
108
94
 
109
- /* Adds the binding names of any `<script>` children to the deref scope, returning
110
- the names it added (for the caller to pop). */
111
- function scopeNestedScripts(children: TemplateNode[]): string[] {
112
- const added: string[] = []
113
- for (const child of children) {
114
- if (child.kind === 'script') {
115
- for (const name of nestedBindingNames(child.code)) {
116
- if (!localDerived.has(name)) {
117
- localDerived.add(name)
118
- added.push(name)
119
- }
120
- }
121
- }
122
- }
123
- return added
124
- }
125
-
126
95
  /* Emits code appending `node` to `parentVar`. */
127
96
  function generateChild(node: TemplateNode, parentVar: string): string {
128
97
  if (node.kind === 'script') {
@@ -238,13 +207,11 @@ export function generateBuild(
238
207
  (child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
239
208
  )
240
209
  .map((branch) => {
241
- const param = nextVar('p')
242
- const roots = elementRoots(branch.children, '<template case>', param)
243
210
  const match =
244
211
  branch.match === undefined
245
212
  ? 'undefined'
246
213
  : `() => (${lowerExpression(branch.match)})`
247
- return `{ match: ${match}, render: (${param}) => {\n${roots.code}return ${roots.expr};\n} }`
214
+ return `{ match: ${match}, render: ${branchThunk(branch.children)} }`
248
215
  })
249
216
  .join(', ')
250
217
  return `switchBlock(${parentVar}, () => (${lowerExpression(node.subject)}), [${cases}]);\n`
@@ -275,17 +242,12 @@ export function generateBuild(
275
242
 
276
243
  /* Mounts a child component into a wrapper element, passing each prop as a
277
244
  reactive thunk so the child re-reads when the parent expression changes. */
278
- function generateComponent(
279
- node: Extract<TemplateNode, { kind: 'component' }>,
280
- parentVar: string,
281
- ): string {
282
- const wrapper = nextVar('cmp')
245
+ /* The prop + slot thunks a child mount receives — its props as value thunks and
246
+ its slot content as host-taking builders (`$children` / `$slots[name]`). */
247
+ function componentParts(node: Extract<TemplateNode, { kind: 'component' }>): string[] {
283
248
  const parts = node.props.map(
284
249
  (prop) => `${JSON.stringify(prop.name)}: () => (${lowerExpression(prop.code)})`,
285
250
  )
286
- /* Slot content compiles to builders the child mounts into the host it passes
287
- from each <slot> position: the default markup as `$children`, and each
288
- `slot="name"` group as `$slots[name]`. */
289
251
  const groups = partitionSlots(node.children)
290
252
  const slotCode = groups.default.map((child) => generateChild(child, '$slot')).join('')
291
253
  if (slotCode.trim() !== '') {
@@ -300,13 +262,31 @@ export function generateBuild(
300
262
  .join(', ')
301
263
  parts.push(`"$slots": { ${entries} }`)
302
264
  }
303
- /* openChild appends (create) or claims the SSR wrapper (hydrate); since
304
- hydration is still active, the child's own build then adopts its server
305
- markup inside the wrapper. */
306
- return (
307
- `const ${wrapper} = openChild(${parentVar}, ${JSON.stringify(node.name.toLowerCase())});\n` +
308
- `mountChild(${wrapper}, ${node.name}, { ${parts.join(', ')} });\n`
309
- )
265
+ return parts
266
+ }
267
+
268
+ /* Mounts a child into a wrapper obtained via `varExpr` (openChild — appends on
269
+ create / claims on hydrate). Hydration stays active, so the child adopts its
270
+ server markup inside the wrapper. Returns the wrapper var. */
271
+ function mountComponent(
272
+ node: Extract<TemplateNode, { kind: 'component' }>,
273
+ varExpr: string,
274
+ ): { code: string; varName: string } {
275
+ const wrapper = nextVar('cmp')
276
+ const code =
277
+ `const ${wrapper} = ${varExpr};\n` +
278
+ `mountChild(${wrapper}, ${node.name}, { ${componentParts(node).join(', ')} });\n`
279
+ return { code, varName: wrapper }
280
+ }
281
+
282
+ function generateComponent(
283
+ node: Extract<TemplateNode, { kind: 'component' }>,
284
+ parentVar: string,
285
+ ): string {
286
+ return mountComponent(
287
+ node,
288
+ `openChild(${parentVar}, ${JSON.stringify(node.name.toLowerCase())})`,
289
+ ).code
310
290
  }
311
291
 
312
292
  /* An await block: pending → resolved(value) / error branches. Each branch is a
@@ -324,17 +304,16 @@ export function generateBuild(
324
304
  const pending = node.blocking
325
305
  ? []
326
306
  : node.children.filter((child) => child.kind !== 'branch')
307
+ const thenBranch = node.children.find(isBranch('then'))
327
308
  const thenThunk = node.blocking
328
- ? renderRangeThunk(
309
+ ? branchThunk(
329
310
  node.children.filter((child) => child.kind !== 'branch'),
330
311
  node.as ?? '_value',
331
- '<template await then>',
332
312
  finallyChildren,
333
313
  )
334
- : renderSettledThunk(
335
- node.children.find(isBranch('then')),
336
- '_value',
337
- '<template then>',
314
+ : branchThunk(
315
+ branchChildren(thenBranch),
316
+ branchVar(thenBranch) ?? '_value',
338
317
  finallyChildren,
339
318
  )
340
319
  /* Neither catch nor finally → pass `undefined` so awaitBlock re-throws the
@@ -343,10 +322,15 @@ export function generateBuild(
343
322
  const catchThunk =
344
323
  catchBranch === undefined && finallyChildren.length === 0
345
324
  ? 'undefined'
346
- : renderSettledThunk(catchBranch, '_error', '<template catch>', finallyChildren)
325
+ : branchThunk(
326
+ branchChildren(catchBranch),
327
+ branchVar(catchBranch) ?? '_error',
328
+ finallyChildren,
329
+ )
330
+ const pendingThunk = hasRenderableContent(pending) ? branchThunk(pending) : 'undefined'
347
331
  return (
348
332
  `awaitBlock(${parentVar}, nextBlockId(), () => (${lowerExpression(node.promise)}), ` +
349
- `${renderThunk(pending, undefined, '<template await> pending')}, ` +
333
+ `${pendingThunk}, ` +
350
334
  `${thenThunk}, ` +
351
335
  `${catchThunk});\n`
352
336
  )
@@ -362,98 +346,45 @@ export function generateBuild(
362
346
  return branch !== undefined && branch.kind === 'branch' ? branch.as : undefined
363
347
  }
364
348
 
365
- /* Builds the element roots of a branch into `parentVar` (each via openRoot, so
366
- detached on create / claimed on hydrate), returning the code plus an array
367
- expression of the root nodes the block tracks as a range. */
368
- function elementRoots(
349
+ /* A branch's content as a void render thunk `(parent[, value]) => void` that
350
+ builds its children and an optional trailing `finally` branch into
351
+ `parent`. The full-range model tracks the built content between markers, so a
352
+ branch holds ANY content (components, text, nested control-flow, snippets) and
353
+ is generated exactly like a normal child list. `valueParam` binds a resolved /
354
+ error / item value into scope. Nested `<script>`s are emitted in document order
355
+ by `generateChildren`; `withNestedScripts` puts their bindings in deref scope. */
356
+ function branchThunk(
369
357
  children: TemplateNode[],
370
- context: string,
371
- parentVar: string,
372
- allowEmpty = false,
373
- ): { code: string; expr: string } {
374
- /* Nested `<script>`s: add their bindings to the deref scope (so the script
375
- body + this branch's markup auto-deref them), emit the lowered script
376
- bodies first, then build the element roots — all within the scope, which
377
- we pop afterward. The scripts run when the branch mounts, owned by its
378
- scope. */
379
- const added: string[] = []
380
- for (const child of children) {
381
- if (child.kind === 'script') {
382
- for (const name of nestedBindingNames(child.code)) {
383
- if (!localDerived.has(name)) {
384
- localDerived.add(name)
385
- added.push(name)
386
- }
387
- }
388
- }
389
- }
390
- const scriptCode = children
391
- .filter(
392
- (child): child is Extract<TemplateNode, { kind: 'script' }> =>
393
- child.kind === 'script',
394
- )
395
- .map((child) => `${lowerStatement(child.code)}\n`)
396
- .join('')
397
- const built = branchElements(children, context, allowEmpty).map((element) =>
398
- generateElement(element, `openRoot(${parentVar}, ${JSON.stringify(element.tag)})`),
399
- )
400
- for (const name of added) {
401
- localDerived.delete(name)
402
- }
403
- return {
404
- code: scriptCode + built.map((part) => part.code).join(''),
405
- expr: `[${built.map((part) => part.varName).join(', ')}]`,
406
- }
407
- }
408
-
409
- /* A `(parent[, value]) => Node[]` thunk over a branch's element roots, or
410
- `undefined` when empty (a `<template await>` with no pending branch).
411
- `paramName`/`fallback` name the resolved/error value the branch binds. */
412
- function renderThunk(
413
- children: TemplateNode[],
414
- paramName: string | undefined,
415
- context: string,
416
- fallback?: string,
358
+ valueParam?: string,
359
+ finallyChildren: TemplateNode[] = [],
417
360
  ): string {
418
- const hasElement = children.some((child) => child.kind === 'element')
419
361
  const parentParam = nextVar('p')
420
- if (!hasElement) {
421
- const value = fallback === undefined ? '' : `, ${paramName ?? fallback}`
422
- return fallback === undefined ? 'undefined' : `(${parentParam}${value}) => []`
423
- }
424
- const roots = elementRoots(children, context, parentParam)
425
- const value = fallback === undefined ? '' : `, ${paramName ?? fallback}`
426
- return `(${parentParam}${value}) => {\n${roots.code}return ${roots.expr};\n}`
427
- }
428
-
429
- /* A thunk over a node range: `children`'s roots concatenated with the `finally`
430
- roots, both possibly empty. `param` names a bound value (the resolved/error
431
- value, or the caught error); undefined for a value-less branch (try/pending). */
432
- function renderRangeThunk(
433
- children: TemplateNode[],
434
- param: string | undefined,
435
- context: string,
436
- finallyChildren: TemplateNode[],
437
- ): string {
438
- const parentParam = nextVar('p')
439
- const head = param === undefined ? `(${parentParam})` : `(${parentParam}, ${param})`
440
- const roots = elementRoots(children, context, parentParam, true)
441
- const finallyRoots = elementRoots(finallyChildren, '<template finally>', parentParam, true)
442
- return `${head} => {\n${roots.code}${finallyRoots.code}return [...${roots.expr}, ...${finallyRoots.expr}];\n}`
362
+ const head =
363
+ valueParam === undefined ? `(${parentParam})` : `(${parentParam}, ${valueParam})`
364
+ const body = withNestedScripts(children, () => generateChildren(children, parentParam))
365
+ const finallyBody =
366
+ finallyChildren.length > 0
367
+ ? withNestedScripts(finallyChildren, () =>
368
+ generateChildren(finallyChildren, parentParam),
369
+ )
370
+ : ''
371
+ return `${head} => {\n${body}${finallyBody}}`
443
372
  }
444
373
 
445
- /* A settled (then/catch) thunk: the outcome branch's roots ++ `finally`. */
446
- function renderSettledThunk(
447
- branch: TemplateNode | undefined,
448
- fallback: string,
449
- context: string,
450
- finallyChildren: TemplateNode[],
451
- ): string {
452
- return renderRangeThunk(
453
- branchChildren(branch),
454
- branchVar(branch) ?? fallback,
455
- context,
456
- finallyChildren,
374
+ /* True when a branch has content worth a render thunk — vs an absent/empty branch
375
+ a block represents with `undefined` (an `await` with no pending markup). */
376
+ function hasRenderableContent(children: TemplateNode[]): boolean {
377
+ return children.some(
378
+ (child) =>
379
+ child.kind === 'element' ||
380
+ child.kind === 'component' ||
381
+ child.kind === 'if' ||
382
+ child.kind === 'each' ||
383
+ child.kind === 'await' ||
384
+ child.kind === 'try' ||
385
+ child.kind === 'switch' ||
386
+ child.kind === 'snippet' ||
387
+ (child.kind === 'text' && !isWhitespaceText(child)),
457
388
  )
458
389
  }
459
390
 
@@ -475,62 +406,46 @@ export function generateBuild(
475
406
  const catchBranch = findBranch(node.children, 'catch')
476
407
  const finallyChildren = branchChildren(findBranch(node.children, 'finally'))
477
408
  const guarded = node.children.filter((child) => child.kind !== 'branch')
478
- const tryThunk = renderRangeThunk(guarded, undefined, '<template try>', finallyChildren)
409
+ const tryThunk = branchThunk(guarded, undefined, finallyChildren)
479
410
  const catchThunk =
480
411
  catchBranch === undefined
481
412
  ? 'undefined'
482
- : renderRangeThunk(
413
+ : branchThunk(
483
414
  branchChildren(catchBranch),
484
415
  branchVar(catchBranch) ?? '_error',
485
- '<template catch>',
486
416
  finallyChildren,
487
417
  )
488
418
  return `tryBlock(${parentVar}, nextBlockId(), ${tryThunk}, ${catchThunk});\n`
489
419
  }
490
420
 
491
- /* A conditional with an optional nested `<template else>` (a `case` child).
492
- Both branches are single-element roots. */
421
+ /* A conditional with an optional nested `<template else>` (a `case` child). Each
422
+ branch is a content range the runtime tracks between markers. */
493
423
  function generateIf(node: Extract<TemplateNode, { kind: 'if' }>, parentVar: string): string {
494
424
  const elseBranch = node.children.find(
495
425
  (child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
496
426
  )
497
427
  const thenChildren = node.children.filter((child) => child.kind !== 'case')
498
- const thenParam = nextVar('p')
499
- const thenRoots = elementRoots(thenChildren, '<template if>', thenParam)
500
- const thenThunk = `(${thenParam}) => {\n${thenRoots.code}return ${thenRoots.expr};\n}`
428
+ const thenThunk = branchThunk(thenChildren)
501
429
  if (elseBranch === undefined) {
502
430
  return `when(${parentVar}, () => (${lowerExpression(node.condition)}), ${thenThunk});\n`
503
431
  }
504
- const elseParam = nextVar('p')
505
- const elseRoots = elementRoots(elseBranch.children, '<template else>', elseParam)
506
- const elseThunk = `(${elseParam}) => {\n${elseRoots.code}return ${elseRoots.expr};\n}`
432
+ const elseThunk = branchThunk(elseBranch.children)
507
433
  return `when(${parentVar}, () => (${lowerExpression(node.condition)}), ${thenThunk}, ${elseThunk});\n`
508
434
  }
509
435
 
510
- /* A keyed each. The row must have a single element root (it returns one node). */
436
+ /* A keyed each. Each row is a content RANGE (any content, tracked between the
437
+ row's markers), built by a `(rowParent, item) => void` thunk. */
511
438
  function generateEach(
512
439
  node: Extract<TemplateNode, { kind: 'each' }>,
513
440
  parentVar: string,
514
441
  ): string {
515
442
  const rowParam = nextVar('p')
516
- /* A `<script>` in the row body declares per-row local signals (seeded from
517
- the row item), scoped to this row's render thunk. */
518
- const added = scopeNestedScripts(node.children)
519
- const scriptCode = node.children
520
- .filter(
521
- (child): child is Extract<TemplateNode, { kind: 'script' }> =>
522
- child.kind === 'script',
523
- )
524
- .map((child) => `${lowerStatement(child.code)}\n`)
525
- .join('')
526
- const row = singleElementRoot(
527
- node.children,
528
- '<template each> must contain a single element row',
529
- rowParam,
443
+ /* The row body builds its children (a `<script>` declares per-row local signals,
444
+ emitted in document order) into the row parent. A `<template catch>` child is
445
+ consumed by the async-each, not the row — `generateChildren` skips it. */
446
+ const rowBody = withNestedScripts(node.children, () =>
447
+ generateChildren(node.children, rowParam),
530
448
  )
531
- for (const name of added) {
532
- localDerived.delete(name)
533
- }
534
449
  const keyExpression = node.key === undefined ? node.as : lowerExpression(node.key)
535
450
  /* `await` → the AsyncIterable runtime, drained row-by-row on the client, with an
536
451
  optional `<template catch>` branch rendered (after the streamed rows) when the
@@ -540,30 +455,14 @@ export function generateBuild(
540
455
  (child) => child.kind === 'branch' && child.branch === 'catch',
541
456
  )
542
457
  const catchArg = node.async
543
- ? `, ${catchBranch === undefined ? 'undefined' : renderSettledThunk(catchBranch, '_error', '<template catch>', [])}`
458
+ ? `, ${catchBranch === undefined ? 'undefined' : branchThunk(branchChildren(catchBranch), branchVar(catchBranch) ?? '_error')}`
544
459
  : ''
545
460
  return (
546
461
  `${fn}(${parentVar}, () => (${lowerExpression(node.items)}), ` +
547
- `(${node.as}) => (${keyExpression}), (${rowParam}, ${node.as}) => {\n${scriptCode}${row.code}return ${row.varName};\n}${catchArg});\n`
462
+ `(${node.as}) => (${keyExpression}), (${rowParam}, ${node.as}) => {\n${rowBody}}${catchArg});\n`
548
463
  )
549
464
  }
550
465
 
551
- /* Builds the lone element child of a control-flow block (each/if return one
552
- node), erroring if the block isn't a single element. The root is opened
553
- with `openRoot(parentVar, tag)` so it's detached on create and claimed on
554
- hydrate — `parentVar` is the render thunk's parent parameter. */
555
- function singleElementRoot(
556
- children: TemplateNode[],
557
- message: string,
558
- parentVar: string,
559
- ): { code: string; varName: string } {
560
- const root = children.find((child) => child.kind === 'element')
561
- if (root === undefined || root.kind !== 'element') {
562
- throw new Error(`[abide] ${message}`)
563
- }
564
- return generateElement(root, `openRoot(${parentVar}, ${JSON.stringify(root.tag)})`)
565
- }
566
-
567
466
  return generateChildren(nodes, hostVar)
568
467
  }
569
468
 
@@ -613,9 +512,7 @@ template and the server markup parse to the same DOM. Only handles the shapes
613
512
  function staticHtml(node: TemplateNode): string {
614
513
  if (node.kind === 'text') {
615
514
  return node.parts
616
- .map((part) =>
617
- part.kind === 'static' && part.value.trim() !== '' ? escapeHtml(part.value) : '',
618
- )
515
+ .map((part) => (part.kind === 'static' ? staticTextPart(part.value) : ''))
619
516
  .join('')
620
517
  }
621
518
  if (node.kind !== 'element') {
@@ -623,11 +520,11 @@ function staticHtml(node: TemplateNode): string {
623
520
  }
624
521
  let html = `<${node.tag}`
625
522
  for (const scope of node.scopes ?? []) {
626
- html += ` ${scope}=""`
523
+ html += scopeAttr(scope)
627
524
  }
628
525
  for (const attr of node.attrs) {
629
526
  if (attr.kind === 'static') {
630
- html += ` ${attr.name}="${escapeHtml(attr.value)}"`
527
+ html += staticAttr(attr.name, attr.value)
631
528
  }
632
529
  }
633
530
  html += '>'