@statorjs/stator 2.11.0-next.1 → 2.11.0-next.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "2.11.0-next.1",
3
+ "version": "2.11.0-next.2",
4
4
  "description": "Server-canonical web framework: business logic in composable state machines, UI as a thin renderer binding machine outputs to DOM positions. Ships TypeScript source, loaded natively by its own toolchain — no build step.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -101,7 +101,7 @@
101
101
  "pino-pretty": "^13.1.3",
102
102
  "vite": "^6.0.0",
103
103
  "vitest": "^2.1.0",
104
- "@statorjs/stator": "2.11.0-next.1"
104
+ "@statorjs/stator": "2.11.0-next.2"
105
105
  },
106
106
  "scripts": {
107
107
  "typecheck": "tsc --noEmit",
@@ -1,4 +1,5 @@
1
1
  import ts from 'typescript'
2
+ import { isVoidElement } from '../template/void-elements.ts'
2
3
  import { elementMarker, ISLAND_MARKER_ATTR, slotMarker } from '../wire/island-markers.ts'
3
4
  import { type ClientDirective, inferDeps } from './client-script.ts'
4
5
  import { CompileError, type DiagnosticLocation, locAt } from './diagnostics.ts'
@@ -476,7 +477,8 @@ export function lowerTemplate(template: string, opts: LowerOptions = {}): string
476
477
  return `\${${lowerComponent(tag, node.attributes, undefined)}}`
477
478
  }
478
479
  if (meta && tag.includes('-')) meta.customElements.add(tag)
479
- return `<${tag}${lowerAttributes(node.attributes)}${scopeSuffix} />`
480
+ const open = `<${tag}${lowerAttributes(node.attributes)}${scopeSuffix}>`
481
+ return isVoidElement(tag) ? open : `${open}</${tag}>`
480
482
  }
481
483
  if (ts.isJsxFragment(node)) return contentOfChildren(node.children)
482
484
  throw new CompileError(
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import ts from 'typescript'
22
+ import { VOID_ELEMENTS } from '../template/void-elements.ts'
22
23
  import { analyzeScriptClasses } from './client-script.ts'
23
24
  import { statorPropsType } from './dts.ts'
24
25
  import { CLIENT_AUTHOR_GLOBALS, TEMPLATE_AUTHOR_GLOBALS } from './emit-names.ts'
@@ -186,23 +187,6 @@ function buildServerTsx(regions: ScannedRegions): VirtualFile {
186
187
  return { lang: 'tsx', code, mappings }
187
188
  }
188
189
 
189
- /** HTML void elements — legal unclosed in templates, not in TSX. */
190
- const VOID_TAGS = new Set([
191
- 'area',
192
- 'base',
193
- 'br',
194
- 'col',
195
- 'embed',
196
- 'hr',
197
- 'img',
198
- 'input',
199
- 'link',
200
- 'meta',
201
- 'source',
202
- 'track',
203
- 'wbr',
204
- ])
205
-
206
190
  /**
207
191
  * Append the template to the virtual TSX with HTML→TSX compatibility applied
208
192
  * AND offset mappings kept exact around every edit:
@@ -270,7 +254,7 @@ function appendTemplateTsx(
270
254
  if (ch === '>') break
271
255
  j++
272
256
  }
273
- if (VOID_TAGS.has(tag.toLowerCase()) && j < tpl.length) {
257
+ if (VOID_ELEMENTS.has(tag.toLowerCase()) && j < tpl.length) {
274
258
  // Self-close unless the author already did.
275
259
  let k = j - 1
276
260
  while (k > i && /\s/.test(tpl[k]!)) k--
@@ -25,5 +25,5 @@ export default function (props: ImageProps) {
25
25
  decoding = 'async',
26
26
  } = p
27
27
  const img = getImage(p)
28
- return html`<img src="${img.src}" srcset="${img.srcset ?? undefined}" sizes="${img.srcset ? sizes : undefined}" alt="${alt}" width="${img.width}" height="${img.height}" loading="${loading}" fetchpriority="${fetchpriority}" decoding="${decoding}" class="${className}" data-s-6e2813d6 />`
28
+ return html`<img src="${img.src}" srcset="${img.srcset ?? undefined}" sizes="${img.srcset ? sizes : undefined}" alt="${alt}" width="${img.width}" height="${img.height}" loading="${loading}" fetchpriority="${fetchpriority}" decoding="${decoding}" class="${className}" data-s-6e2813d6>`
29
29
  }
@@ -24,48 +24,27 @@ export function JsonLd(props: JsonLdProps): HtmlFragment {
24
24
  return raw(`<script type="application/ld+json">${ldToString(props.json, props.space)}</script>`)
25
25
  }
26
26
 
27
- type JsonValueScalar = string | boolean | number
28
- type JsonValue = JsonValueScalar | Array<JsonValue> | { [key: string]: JsonValue }
29
- type JsonReplacer = (_: string, value: JsonValue) => JsonValue | undefined
27
+ type JsonValue = string | boolean | number | null | Array<JsonValue> | { [key: string]: JsonValue }
30
28
 
31
- const ESCAPE_ENTITIES = Object.freeze({
32
- '&': '&amp;',
33
- '<': '&lt;',
34
- '>': '&gt;',
35
- '"': '&quot;',
36
- "'": '&apos;',
37
- })
38
- const ESCAPE_REGEX = new RegExp(`[${Object.keys(ESCAPE_ENTITIES).join('')}]`, 'g')
39
- const ESCAPE_REPLACER = (t: string): string => ESCAPE_ENTITIES[t as keyof typeof ESCAPE_ENTITIES]
29
+ const stripNulls = (_: string, value: JsonValue): JsonValue | undefined =>
30
+ value === null ? undefined : value
40
31
 
41
32
  /**
42
- * A `JSON.stringify` replacer that strips JSON-LD of HTML sequences illegal in a
43
- * `<script>` element, per
33
+ * Escape the serialized JSON for a `<script>` element, per
44
34
  * https://www.w3.org/TR/json-ld11/#restrictions-for-contents-of-json-ld-script-elements
45
- * Escaping `<`/`>` is what guarantees no `</script>` breakout in the raw output.
35
+ * Script content is raw text to the HTML parser, so HTML entities would reach
36
+ * consumers literally; JSON `\uXXXX` escapes decode back to the real characters.
37
+ * Escaping `<` is what guarantees no `</script>` breakout.
46
38
  */
47
- const safeJsonLdReplacer: JsonReplacer = (_: string, value: JsonValue): JsonValue | undefined => {
48
- switch (typeof value) {
49
- case 'object':
50
- // Omit null values.
51
- if (value === null) return undefined
52
- return value // JSON.stringify recurses, re-applying this replacer.
53
- case 'number':
54
- case 'boolean':
55
- case 'bigint':
56
- return value // Not risky.
57
- case 'string':
58
- return value.replace(ESCAPE_REGEX, ESCAPE_REPLACER)
59
- default: {
60
- // No other types are expected; JSON.stringify drops an `undefined` return.
61
- isNever(value)
62
- return undefined
63
- }
64
- }
39
+ function escapeForScript(json: string): string {
40
+ return json
41
+ .replace(/</g, '\\u003c')
42
+ .replace(/>/g, '\\u003e')
43
+ .replace(/&/g, '\\u0026')
44
+ .replace(/\u2028/g, '\\u2028')
45
+ .replace(/\u2029/g, '\\u2029')
65
46
  }
66
47
 
67
- function isNever(_: never): void {}
68
-
69
48
  function withContext<T extends Thing>(thing: T): WithContext<T> {
70
49
  return {
71
50
  '@context': 'https://schema.org',
@@ -81,5 +60,5 @@ function asGraph(things: Thing[]): Graph {
81
60
  * JSON-LD string, safe to embed verbatim in a `<script>` element. */
82
61
  export function ldToString(json: Thing | Thing[], space?: number | string): string {
83
62
  const ld = Array.isArray(json) ? asGraph(json) : withContext(json)
84
- return JSON.stringify(ld, safeJsonLdReplacer, space)
63
+ return escapeForScript(JSON.stringify(ld, stripNulls, space))
85
64
  }
@@ -17,5 +17,5 @@ import { getPicture, type PictureProps, SOURCE_TYPES } from './images.ts'
17
17
  export default function (props: PictureProps) {
18
18
  const p = props
19
19
  const { sources } = getPicture(p)
20
- return html`${sources.length === 0 ? html`${Image({ ...p })}` : html`<picture>${each(sources, (s) => html`<source media="${s.media}" type="${SOURCE_TYPES[s.format]}" srcset="${s.srcset}" sizes="${s.sizes}" />`)}${Image({ ...p })}</picture>`}`
20
+ return html`${sources.length === 0 ? html`${Image({ ...p })}` : html`<picture>${each(sources, (s) => html`<source media="${s.media}" type="${SOURCE_TYPES[s.format]}" srcset="${s.srcset}" sizes="${s.sizes}">`)}${Image({ ...p })}</picture>`}`
21
21
  }
@@ -17,6 +17,7 @@ import {
17
17
  } from '../server/render-context.ts'
18
18
  import { isReadResult, type ReadResult } from './read.ts'
19
19
  import { type HtmlFragment, isHtmlFragment } from './types.ts'
20
+ import { VOID_ELEMENTS } from './void-elements.ts'
20
21
 
21
22
  export interface EachOptions<T> {
22
23
  /** Item identity. When present, list changes emit per-item insert/remove/move
@@ -298,23 +299,6 @@ export function renderKeyedItem<T>(
298
299
  return { html, bindings }
299
300
  }
300
301
 
301
- const VOID_TAGS = new Set([
302
- 'area',
303
- 'base',
304
- 'br',
305
- 'col',
306
- 'embed',
307
- 'hr',
308
- 'img',
309
- 'input',
310
- 'link',
311
- 'meta',
312
- 'param',
313
- 'source',
314
- 'track',
315
- 'wbr',
316
- ])
317
-
318
302
  /**
319
303
  * True when `html` consists of exactly one root element (plus optional
320
304
  * surrounding whitespace). A small scanner, quote- and comment-aware — not a
@@ -361,7 +345,7 @@ export function isSingleRootElement(html: string): boolean {
361
345
  } else {
362
346
  const nameMatch = html.slice(i + 1, j).match(/^([a-zA-Z][\w-]*)/)
363
347
  const tag = nameMatch ? nameMatch[1]!.toLowerCase() : ''
364
- const selfClosing = html[j - 1] === '/' || VOID_TAGS.has(tag)
348
+ const selfClosing = html[j - 1] === '/' || VOID_ELEMENTS.has(tag)
365
349
  if (depth === 0) {
366
350
  roots++
367
351
  if (roots > 1) return false
@@ -73,6 +73,14 @@ export interface ElementSpecificAttributes {
73
73
  autofocus?: boolean
74
74
  popovertarget?: string
75
75
  popovertargetaction?: 'show' | 'hide' | 'toggle'
76
+ /** Per-button overrides of the owning form's submission settings — the
77
+ * standard way to point one submit button in a multi-action form at a
78
+ * different endpoint without a second `<form>`. */
79
+ formaction?: string
80
+ formenctype?: 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain'
81
+ formmethod?: 'get' | 'post' | 'dialog'
82
+ formnovalidate?: boolean
83
+ formtarget?: string
76
84
  }
77
85
  input: {
78
86
  type?: string
@@ -99,6 +107,12 @@ export interface ElementSpecificAttributes {
99
107
  * the form's own DOM subtree (e.g. a per-row control alongside a
100
108
  * page-wide bulk-action form). `button` already carries this. */
101
109
  form?: string
110
+ /** Submission overrides for `type="submit"` / `type="image"`; same as on `button`. */
111
+ formaction?: string
112
+ formenctype?: 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain'
113
+ formmethod?: 'get' | 'post' | 'dialog'
114
+ formnovalidate?: boolean
115
+ formtarget?: string
102
116
  }
103
117
  label: { for?: string }
104
118
  form: {
@@ -0,0 +1,23 @@
1
+ /** HTML void elements: no content, no closing tag, and a trailing `/` is
2
+ * meaningless. Every other element must be explicitly closed — browsers
3
+ * parse `<div />` as an open tag. `param` is obsolete but still void. */
4
+ export const VOID_ELEMENTS: ReadonlySet<string> = new Set([
5
+ 'area',
6
+ 'base',
7
+ 'br',
8
+ 'col',
9
+ 'embed',
10
+ 'hr',
11
+ 'img',
12
+ 'input',
13
+ 'link',
14
+ 'meta',
15
+ 'param',
16
+ 'source',
17
+ 'track',
18
+ 'wbr',
19
+ ])
20
+
21
+ export function isVoidElement(tag: string): boolean {
22
+ return VOID_ELEMENTS.has(tag.toLowerCase())
23
+ }