@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 +2 -2
- package/src/compiler/lower.ts +3 -1
- package/src/compiler/virtual-code.ts +2 -18
- package/src/components/image.stator.ts +1 -1
- package/src/components/json-ld.ts +15 -36
- package/src/components/picture.stator.ts +1 -1
- package/src/template/each.ts +2 -18
- package/src/template/html-attributes.ts +14 -0
- package/src/template/void-elements.ts +23 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "2.11.0-next.
|
|
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.
|
|
104
|
+
"@statorjs/stator": "2.11.0-next.2"
|
|
105
105
|
},
|
|
106
106
|
"scripts": {
|
|
107
107
|
"typecheck": "tsc --noEmit",
|
package/src/compiler/lower.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
32
|
-
|
|
33
|
-
'<': '<',
|
|
34
|
-
'>': '>',
|
|
35
|
-
'"': '"',
|
|
36
|
-
"'": ''',
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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,
|
|
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}"
|
|
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
|
}
|
package/src/template/each.ts
CHANGED
|
@@ -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] === '/' ||
|
|
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
|
+
}
|