@statorjs/stator 2.11.0-next.0 → 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.0",
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",
@@ -59,6 +59,7 @@
59
59
  "types": "./src/components/index.ts",
60
60
  "default": "./src/components/index.ts"
61
61
  },
62
+ "./components.css": "./src/components/components.css",
62
63
  "./client": {
63
64
  "types": "./src/client/index.ts",
64
65
  "default": "./src/client/index.ts"
@@ -100,13 +101,14 @@
100
101
  "pino-pretty": "^13.1.3",
101
102
  "vite": "^6.0.0",
102
103
  "vitest": "^2.1.0",
103
- "@statorjs/stator": "2.11.0-next.0"
104
+ "@statorjs/stator": "2.11.0-next.2"
104
105
  },
105
106
  "scripts": {
106
107
  "typecheck": "tsc --noEmit",
107
108
  "test": "vitest run",
108
109
  "test:watch": "vitest",
109
110
  "test:browser": "node browser-tests/tables.mjs",
110
- "build:client": "esbuild src/client/runtime.ts --bundle --format=iife --target=es2020 --outfile=dist/client.js"
111
+ "build:client": "esbuild src/client/runtime.ts --bundle --format=iife --target=es2020 --outfile=dist/client.js",
112
+ "build:components": "node scripts/compile-components.mjs && biome check --write src/components/"
111
113
  }
112
114
  }
@@ -2,12 +2,12 @@ import { randomUUID } from 'node:crypto'
2
2
  import { cp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
3
3
  import { createRequire } from 'node:module'
4
4
  import { dirname, join, relative, resolve } from 'node:path'
5
- import { compile, regionResolverFor } from '../compiler/index.ts'
6
5
  import { hashMachines } from '../server/machine-hash.ts'
7
6
  import { type ArtifactDeps, writeArtifactDeps } from './artifact.ts'
7
+ import { compileComponentFile } from './compile-component.ts'
8
8
  import { type CopySet, resolveCopySet } from './copy-set.ts'
9
9
  import { bundleIslands, routeIslandMap, walkFiles } from './islands.ts'
10
- import { sourceId } from './source-id.ts'
10
+ import { routeCssMap } from './route-css.ts'
11
11
 
12
12
  /**
13
13
  * Production build: compile a `.stator` app to a `dist/` of plain `.ts` that the
@@ -17,10 +17,15 @@ import { sourceId } from './source-id.ts'
17
17
  * directories its routes/machines/hooks import from, the root-level files
18
18
  * they open, and `static/`
19
19
  * 2. compile each `*.stator` → a sibling `*.stator.ts`, delete the `.stator`,
20
- * accumulate scoped CSS; for client components also write the generated
21
- * client entry as a sibling `*.stator.client.ts`
20
+ * writing any scoped CSS to a sibling `*.stator.css` (one file per
21
+ * component, not accumulated — see `route-css.ts`); for client
22
+ * components also write the generated client entry as a sibling
23
+ * `*.stator.client.ts`
22
24
  * 3. rewrite `.stator` import specifiers (`'./x.stator'` → `'./x.stator.ts'`)
23
- * 4. write the concatenated scoped CSS to `dist/static/components.css`
25
+ * 4. walk each route's import graph (`routeCssMap`) and write ONE scoped-CSS
26
+ * artifact per route under `dist/static/css/`, from exactly the
27
+ * components that route reaches — local siblings just written, plus any
28
+ * `node_modules` component library's published sibling stylesheet
24
29
  * 5. when the app has client components: bundle every island entry through
25
30
  * the `bundleIslands` seam (hashed output written under
26
31
  * `dist/static/assets/`, server-machine imports stubbed to `{ name }`),
@@ -28,10 +33,11 @@ import { sourceId } from './source-id.ts'
28
33
  * write `dist/stator-manifest.json` mapping route files → island script URLs
29
34
  *
30
35
  * The prod server runs `createApp` over `dist/` with the `headExtras` hook
31
- * from `loadProductionHead(dist)` — it links `components.css` and injects the
32
- * manifest's `<script type="module">` tags per route. File discovery + dynamic
33
- * import work unchanged on the precompiled output; the island bundler is
34
- * needed only at build time, and only when islands exist.
36
+ * from `loadProductionHead(dist)` — it links each route's own CSS href from
37
+ * the manifest's `routeCss` and injects the manifest's island
38
+ * `<script type="module">` tags per route. File discovery + dynamic import
39
+ * work unchanged on the precompiled output; the island bundler is needed only
40
+ * at build time, and only when islands exist.
35
41
  */
36
42
 
37
43
  export interface BuildConfig {
@@ -55,7 +61,7 @@ export interface BuildResult {
55
61
  outDir: string
56
62
  /** Number of `.stator` files compiled. */
57
63
  compiled: number
58
- /** True when any component produced scoped CSS (components.css written). */
64
+ /** True when any route's import graph reached a component with scoped CSS. */
59
65
  hasCss: boolean
60
66
  /** Number of client components bundled for the browser. */
61
67
  islands: number
@@ -83,6 +89,11 @@ export interface StatorManifest {
83
89
  islands: Record<string, string>
84
90
  /** Route file (dist-relative) → script URLs for every island it reaches. */
85
91
  routes: Record<string, string[]>
92
+ /** Route file (dist-relative) → its own scoped-CSS stylesheet href, only for
93
+ * routes whose import graph reaches at least one styled component. Absent
94
+ * on a dist built before per-route CSS existed, which `loadProductionHead`
95
+ * reads as "no CSS for any route" (byte-identical to an app with none). */
96
+ routeCss: Record<string, string>
86
97
  /** Machine file (relative to `machines/`) → code hash. Consumed by
87
98
  * `stator start` for the snapshot hydration policy; the build fails if a
88
99
  * machine's closure cannot be bundled, so this lands in CI, not at boot. */
@@ -147,28 +158,16 @@ export async function buildApp(config: BuildConfig): Promise<BuildResult> {
147
158
  await cp(src, dest)
148
159
  }
149
160
 
150
- // Compile every .stator into a sibling .stator.ts; collect CSS and islands.
151
- // The sources are deleted only after the whole set compiles — cross-file
152
- // region validation reads sibling `.stator` files mid-compile.
161
+ // Compile every .stator into a sibling .stator.ts (+ a sibling .stator.css
162
+ // when it has scoped styles — the same per-component shape a library's own
163
+ // publish-time compile produces, see route-css.ts) and islands. The sources
164
+ // are deleted only after the whole set compiles — cross-file region
165
+ // validation reads sibling `.stator` files mid-compile.
153
166
  const statorFiles = await walkFiles(outDir, (f) => f.endsWith('.stator'))
154
- let css = ''
155
167
  const islands: Array<{ rel: string; entry: string }> = []
156
168
  for (const file of statorFiles) {
157
- const source = await readFile(file, 'utf8')
158
- const { id: rel, kind } = sourceId(outDir, file)
159
- const result = compile(source, {
160
- id: rel,
161
- kind,
162
- resolveRegions: regionResolverFor(file, source),
163
- })
164
- await writeFile(`${file}.ts`, result.serverCode)
165
- if (result.isClient) {
166
- // The generated client entry, written as a sibling so the authored
167
- // script's relative imports resolve against the mirrored dist tree.
168
- await writeFile(`${file}.client.ts`, result.clientCode)
169
- islands.push({ rel, entry: `${file}.client.ts` })
170
- }
171
- if (result.css) css += `/* ${rel} */\n${result.css}\n`
169
+ const { rel, isClient, clientFile } = await compileComponentFile(file, outDir)
170
+ if (isClient) islands.push({ rel, entry: clientFile! })
172
171
  }
173
172
  for (const file of statorFiles) await rm(file)
174
173
 
@@ -180,9 +179,20 @@ export async function buildApp(config: BuildConfig): Promise<BuildResult> {
180
179
  if (rewritten !== code) await writeFile(file, rewritten)
181
180
  }
182
181
 
183
- if (css) {
184
- await mkdir(join(outDir, 'static'), { recursive: true })
185
- await writeFile(join(outDir, 'static', 'components.css'), css)
182
+ // Route-level, usage-driven CSS: one artifact per route, from exactly the
183
+ // components that route's own import graph reaches (local siblings just
184
+ // written above, plus any node_modules component library's published
185
+ // sibling stylesheet) — not every component anywhere in the app.
186
+ const routeCss = await routeCssMap({ routesDir: join(outDir, 'routes'), baseDir: outDir })
187
+ const routeCssHrefs: Record<string, string> = {}
188
+ for (const [routeFile, text] of routeCss) {
189
+ const rel = relative(outDir, routeFile)
190
+ .replace(/\\/g, '/')
191
+ .replace(/\.(ts|js)$/, '.css')
192
+ const dest = join(outDir, 'static', 'css', rel)
193
+ await mkdir(dirname(dest), { recursive: true })
194
+ await writeFile(dest, text)
195
+ routeCssHrefs[relative(outDir, routeFile).replace(/\\/g, '/')] = `/static/css/${rel}`
186
196
  }
187
197
 
188
198
  // Machine code hashes for the snapshot hydration policy: one esbuild pass
@@ -217,6 +227,7 @@ export async function buildApp(config: BuildConfig): Promise<BuildResult> {
217
227
  ? {
218
228
  buildId: randomUUID(),
219
229
  ...(await buildClientAssets(outDir, islands)),
230
+ routeCss: routeCssHrefs,
220
231
  machines,
221
232
  config: configFile,
222
233
  statorVersion,
@@ -225,6 +236,7 @@ export async function buildApp(config: BuildConfig): Promise<BuildResult> {
225
236
  buildId: randomUUID(),
226
237
  islands: {},
227
238
  routes: {},
239
+ routeCss: routeCssHrefs,
228
240
  machines,
229
241
  config: configFile,
230
242
  statorVersion,
@@ -234,7 +246,7 @@ export async function buildApp(config: BuildConfig): Promise<BuildResult> {
234
246
  return {
235
247
  outDir,
236
248
  compiled: statorFiles.length,
237
- hasCss: Boolean(css),
249
+ hasCss: Object.keys(routeCssHrefs).length > 0,
238
250
  islands: islands.length,
239
251
  machines: machineFiles.length,
240
252
  machineHashMs,
@@ -0,0 +1,36 @@
1
+ import { readFile, writeFile } from 'node:fs/promises'
2
+ import { compile, regionResolverFor } from '../compiler/index.ts'
3
+ import { sourceId } from './source-id.ts'
4
+
5
+ export interface CompileComponentResult {
6
+ /** Path (relative to `baseDir`, `/`-separated) the compiled module carries
7
+ * as its identity — seeds its scope hash, labels its diagnostics. */
8
+ rel: string
9
+ isClient: boolean
10
+ /** The written client-entry sibling (`${file}.client.ts`), when `isClient`. */
11
+ clientFile?: string
12
+ }
13
+
14
+ /** Compile one `.stator` file to its sibling `.ts` (+ a sibling `.css` when
15
+ * it has scoped styles). Used by both `build.ts` and
16
+ * `scripts/compile-components.ts`. */
17
+ export async function compileComponentFile(
18
+ file: string,
19
+ baseDir: string,
20
+ ): Promise<CompileComponentResult> {
21
+ const source = await readFile(file, 'utf8')
22
+ const { id: rel, kind } = sourceId(baseDir, file)
23
+ const result = compile(source, {
24
+ id: rel,
25
+ kind,
26
+ resolveRegions: regionResolverFor(file, source),
27
+ })
28
+ await writeFile(`${file}.ts`, result.serverCode)
29
+ let clientFile: string | undefined
30
+ if (result.isClient) {
31
+ clientFile = `${file}.client.ts`
32
+ await writeFile(clientFile, result.clientCode)
33
+ }
34
+ if (result.css) await writeFile(`${file}.css`, result.css)
35
+ return { rel, isClient: result.isClient, clientFile }
36
+ }
package/src/build/head.ts CHANGED
@@ -1,11 +1,13 @@
1
- import { readFile, stat } from 'node:fs/promises'
1
+ import { readFile } from 'node:fs/promises'
2
2
  import { join, relative, resolve } from 'node:path'
3
3
  import type { StatorManifest } from './build.ts'
4
4
 
5
5
  /**
6
- * Production `headExtras` for a built `dist/`: links `components.css` when the
7
- * build produced one, and injects each route's island `<script type="module">`
8
- * tags from `stator-manifest.json`. Pass the result to `createApp`:
6
+ * Production `headExtras` for a built `dist/`: links EACH ROUTE's own scoped-
7
+ * CSS href (`manifest.routeCss` — only the components that route's import
8
+ * graph actually reaches, not a global stylesheet shared by every page), and
9
+ * injects that route's island `<script type="module">` tags. Pass the result
10
+ * to `createApp`:
9
11
  *
10
12
  * const { headExtras, buildId } = await loadProductionHead(dist)
11
13
  * const app = await createApp({ ..., headExtras, buildId })
@@ -30,15 +32,8 @@ export async function loadProductionHead(distDir: string): Promise<{
30
32
  }> {
31
33
  const dist = resolve(distDir)
32
34
 
33
- let cssTag = ''
34
- try {
35
- await stat(join(dist, 'static', 'components.css'))
36
- cssTag = '<link rel="stylesheet" href="/static/components.css">'
37
- } catch {
38
- // no scoped component styles
39
- }
40
-
41
35
  let routes: StatorManifest['routes'] = {}
36
+ let routeCss: StatorManifest['routeCss'] = {}
42
37
  let buildId: string | undefined
43
38
  let machines: Record<string, string> | undefined
44
39
  let config: string | null | undefined
@@ -48,6 +43,7 @@ export async function loadProductionHead(distDir: string): Promise<{
48
43
  await readFile(join(dist, 'stator-manifest.json'), 'utf8'),
49
44
  ) as StatorManifest
50
45
  routes = manifest.routes ?? {}
46
+ routeCss = manifest.routeCss ?? {}
51
47
  buildId = manifest.buildId
52
48
  machines = manifest.machines
53
49
  config = manifest.config
@@ -58,6 +54,8 @@ export async function loadProductionHead(distDir: string): Promise<{
58
54
 
59
55
  const headExtras = (filePath: string): string => {
60
56
  const rel = relative(dist, resolve(filePath)).replace(/\\/g, '/')
57
+ const href = routeCss[rel]
58
+ const cssTag = href ? `<link rel="stylesheet" href="${href}">` : ''
61
59
  const scripts = routes[rel] ?? []
62
60
  return [cssTag, ...scripts.map((url) => `<script type="module" src="${url}"></script>`)]
63
61
  .filter(Boolean)
@@ -0,0 +1,211 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { createRequire } from 'node:module'
3
+ import { resolve } from 'node:path'
4
+ import { walkFiles } from './islands.ts'
5
+
6
+ /**
7
+ * Per-route scoped CSS (spec
8
+ * `stator-component-libraries-publish-time-compile-per-component-scoped-css-usage-driven-aggregation`).
9
+ * A separate reachability walk per route file. A reached module's CSS comes
10
+ * from its sibling `.css` file, resolved as a filesystem sibling (local) or
11
+ * through Node's module resolution (a `node_modules` package declaring
12
+ * `${specifier}.css` in its `exports` map) — `node_modules` is never traced
13
+ * beyond that one resolution.
14
+ *
15
+ * "Reached" is static import-graph reachability, not render-tracing: a
16
+ * component used only inside an inactive `match`/`when`/`each` arm is still
17
+ * reached, since a live SSE update can activate that arm later with no fresh
18
+ * HTTP request — its CSS must already be present at the route's first paint.
19
+ */
20
+
21
+ const IMPORT_SPECIFIER_RE = /(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g
22
+
23
+ export interface Reached {
24
+ local: Set<string>
25
+ external: Set<string>
26
+ }
27
+
28
+ /** Strip `/* *\/` and `//` comments before scanning for import specifiers,
29
+ * so a doc-comment usage example isn't mistaken for a real import. Tracks
30
+ * string/template-literal state so a `//` or `/*` inside one is left alone.
31
+ * Not a full tokenizer (doesn't walk into `${...}` interpolation); errs
32
+ * toward under-stripping. */
33
+ function stripComments(code: string): string {
34
+ let out = ''
35
+ let i = 0
36
+ let inString: '"' | "'" | '`' | null = null
37
+ while (i < code.length) {
38
+ const c = code[i]!
39
+ const next = code[i + 1]
40
+ if (inString) {
41
+ out += c
42
+ if (c === '\\' && next !== undefined) {
43
+ out += next
44
+ i += 2
45
+ continue
46
+ }
47
+ if (c === inString) inString = null
48
+ i += 1
49
+ continue
50
+ }
51
+ if (c === '"' || c === "'" || c === '`') {
52
+ inString = c
53
+ out += c
54
+ i += 1
55
+ continue
56
+ }
57
+ if (c === '/' && next === '/') {
58
+ const nl = code.indexOf('\n', i)
59
+ i = nl === -1 ? code.length : nl
60
+ continue
61
+ }
62
+ if (c === '/' && next === '*') {
63
+ const end = code.indexOf('*/', i + 2)
64
+ i = end === -1 ? code.length : end + 2
65
+ continue
66
+ }
67
+ out += c
68
+ i += 1
69
+ }
70
+ return out
71
+ }
72
+
73
+ /** A file's import specifiers, split by kind. Relative specifiers are
74
+ * resolved and bounded to `baseDir`; bare specifiers are recorded verbatim,
75
+ * never resolved or traced further here. */
76
+ async function importsOf(
77
+ file: string,
78
+ baseDir: string,
79
+ ): Promise<{ local: string[]; external: string[] }> {
80
+ let code: string
81
+ try {
82
+ code = await readFile(file, 'utf8')
83
+ } catch {
84
+ return { local: [], external: [] }
85
+ }
86
+ code = stripComments(code)
87
+ const local: string[] = []
88
+ const external: string[] = []
89
+ for (const match of code.matchAll(IMPORT_SPECIFIER_RE)) {
90
+ const spec = match[1]!
91
+ if (spec.startsWith('.')) {
92
+ const target = resolve(file, '..', spec)
93
+ if (target.startsWith(baseDir)) local.push(target)
94
+ } else if (!spec.startsWith('node:')) {
95
+ external.push(spec)
96
+ }
97
+ }
98
+ return { local, external }
99
+ }
100
+
101
+ /** A file's direct bare-specifier imports only — no recursion, no relative
102
+ * resolution. Used by `dev-native.ts`'s incremental graph tracking. */
103
+ export async function bareSpecifiersOf(file: string): Promise<string[]> {
104
+ let code: string
105
+ try {
106
+ code = await readFile(file, 'utf8')
107
+ } catch {
108
+ return []
109
+ }
110
+ code = stripComments(code)
111
+ const external: string[] = []
112
+ for (const match of code.matchAll(IMPORT_SPECIFIER_RE)) {
113
+ const spec = match[1]!
114
+ if (!spec.startsWith('.') && !spec.startsWith('node:')) external.push(spec)
115
+ }
116
+ return external
117
+ }
118
+
119
+ async function walkReachable(
120
+ file: string,
121
+ baseDir: string,
122
+ seen: Set<string>,
123
+ out: Reached,
124
+ ): Promise<void> {
125
+ if (seen.has(file)) return
126
+ seen.add(file)
127
+ out.local.add(file)
128
+ const { local, external } = await importsOf(file, baseDir)
129
+ for (const spec of external) out.external.add(spec)
130
+ for (const target of local) await walkReachable(target, baseDir, seen, out)
131
+ }
132
+
133
+ /** Every local file and bare specifier reachable from one entry file's
134
+ * import graph. */
135
+ export async function reachableFrom(entryFile: string, baseDir: string): Promise<Reached> {
136
+ const reached: Reached = { local: new Set(), external: new Set() }
137
+ await walkReachable(resolve(entryFile), resolve(baseDir), new Set(), reached)
138
+ return reached
139
+ }
140
+
141
+ /** A reached local module's sibling CSS — `foo.ts` → `foo.css`. Most reached
142
+ * modules have no sibling; that's a silent, expected miss. */
143
+ async function siblingCss(file: string): Promise<string | undefined> {
144
+ const cssPath = file.replace(/\.(ts|js)$/, '.css')
145
+ if (cssPath === file) return undefined
146
+ try {
147
+ return await readFile(cssPath, 'utf8')
148
+ } catch {
149
+ return undefined
150
+ }
151
+ }
152
+
153
+ /** A bare specifier's published sibling stylesheet, if its package declares
154
+ * one at `${specifier}.css` in its `exports` map. */
155
+ export async function externalCss(specifier: string, fromDir: string): Promise<string | undefined> {
156
+ try {
157
+ const require = createRequire(resolve(fromDir, 'noop.cjs'))
158
+ const path = require.resolve(`${specifier}.css`)
159
+ return await readFile(path, 'utf8')
160
+ } catch {
161
+ return undefined
162
+ }
163
+ }
164
+
165
+ function relFrom(baseDir: string, file: string): string {
166
+ return file.slice(baseDir.length + 1).replace(/\\/g, '/')
167
+ }
168
+
169
+ /** Collect the sibling CSS of everything in a `Reached` set, local and
170
+ * external alike — one algorithm, no branching on origin. */
171
+ async function cssOf(reached: Reached, baseDir: string): Promise<string> {
172
+ let css = ''
173
+ for (const file of [...reached.local].sort()) {
174
+ const text = await siblingCss(file)
175
+ if (text) css += `/* ${relFrom(baseDir, file)} */\n${text}\n`
176
+ }
177
+ for (const spec of [...reached.external].sort()) {
178
+ const text = await externalCss(spec, baseDir)
179
+ if (text) css += `/* ${spec} */\n${text}\n`
180
+ }
181
+ return css
182
+ }
183
+
184
+ /**
185
+ * Walk each route file's import graph and collect the sibling CSS of every
186
+ * reached module, local or external. Returns absolute route file → one
187
+ * concatenated stylesheet, only for routes that reached any CSS at all.
188
+ */
189
+ export async function routeCssMap(opts: {
190
+ routesDir: string
191
+ baseDir: string
192
+ }): Promise<Map<string, string>> {
193
+ const baseDir = resolve(opts.baseDir)
194
+ const routeFiles = await walkFiles(opts.routesDir, (f) => /\.(ts|js|stator)$/.test(f)).catch(
195
+ () => [] as string[],
196
+ )
197
+ const out = new Map<string, string>()
198
+ for (const routeFile of routeFiles) {
199
+ const reached = await reachableFrom(routeFile, baseDir)
200
+ const css = await cssOf(reached, baseDir)
201
+ if (css) out.set(routeFile, css)
202
+ }
203
+ return out
204
+ }
205
+
206
+ /** The same walk, rooted at a single entry file instead of a routes/
207
+ * directory — e.g. a component library's own `components.css`. */
208
+ export async function entryCss(entryFile: string, baseDir: string): Promise<string> {
209
+ const reached = await reachableFrom(entryFile, baseDir)
210
+ return cssOf(reached, baseDir)
211
+ }
@@ -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(
@@ -156,31 +156,54 @@ function scopeSelectorList(selector: string, attr: string): string {
156
156
 
157
157
  if (subjectIsGlobal) return
158
158
 
159
- // Insert the scope attribute after the subject's last simple selector,
160
- // before any trailing pseudo-elements (::before, ::after).
161
- const nodes = sel.nodes
162
- let insertAfter: selectorParser.Node | undefined
163
- for (const node of nodes) {
164
- if (node.type === 'combinator') {
165
- insertAfter = undefined // reset at each combinator; track the subject only
166
- continue
167
- }
168
- if (node.type === 'pseudo' && node.value.startsWith('::')) continue // pseudo-element stays last
169
- insertAfter = node
170
- }
171
- const attrNode = selectorParser.attribute({
172
- attribute: attr,
173
- value: undefined,
174
- raws: {},
175
- quoteMark: null,
176
- } as never)
177
- if (insertAfter) sel.insertAfter(insertAfter as never, attrNode)
178
- else sel.append(attrNode)
159
+ attachScopeAttr(sel, attr)
179
160
  })
180
161
  })
181
162
  return transform.processSync(selector)
182
163
  }
183
164
 
165
+ function makeAttrNode(attr: string): selectorParser.Attribute {
166
+ return selectorParser.attribute({
167
+ attribute: attr,
168
+ value: undefined,
169
+ raws: {},
170
+ quoteMark: null,
171
+ } as never)
172
+ }
173
+
174
+ /** Insert the scope attribute after the subject's last simple selector,
175
+ * before any trailing pseudo-elements (::before, ::after).
176
+ *
177
+ * `:where(...)` is special-cased: by spec its specificity is always zero
178
+ * regardless of its contents, so appending the attribute AFTER it
179
+ * (`:where(img)[data-s-h]`) would add a real specificity point and defeat
180
+ * that. The attribute goes INSIDE each of `:where()`'s own arguments instead
181
+ * (`:where(img[data-s-h])`), recursively, keeping the compiled selector
182
+ * genuinely zero-specificity. */
183
+ function attachScopeAttr(sel: selectorParser.Selector, attr: string): void {
184
+ const nodes = sel.nodes
185
+ let insertAfter: selectorParser.Node | undefined
186
+ for (const node of nodes) {
187
+ if (node.type === 'combinator') {
188
+ insertAfter = undefined // reset at each combinator; track the subject only
189
+ continue
190
+ }
191
+ if (node.type === 'pseudo' && node.value.startsWith('::')) continue // pseudo-element stays last
192
+ insertAfter = node
193
+ }
194
+ if (!insertAfter) {
195
+ sel.append(makeAttrNode(attr))
196
+ return
197
+ }
198
+ if (insertAfter.type === 'pseudo' && insertAfter.value === ':where') {
199
+ for (const inner of insertAfter.nodes) {
200
+ attachScopeAttr(inner as selectorParser.Selector, attr)
201
+ }
202
+ return
203
+ }
204
+ sel.insertAfter(insertAfter as never, makeAttrNode(attr))
205
+ }
206
+
184
207
  /** True when the selector's subject compound (after the last combinator) is
185
208
  * inside a `:global(...)`. */
186
209
  function isSubjectGlobal(sel: selectorParser.Selector): boolean {
@@ -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--
@@ -0,0 +1,8 @@
1
+ /* components/image.stator.ts */
2
+ /* height:auto preserves the aspect ratio the width/height attributes
3
+ declare; radius, margin, object-fit are the app's call. */
4
+ :where(img[data-s-6e2813d6]) {
5
+ display: block;
6
+ max-width: 100%;
7
+ height: auto;
8
+ }
@@ -0,0 +1,37 @@
1
+ ---
2
+ import { getImage, type ImageProps } from './images.ts'
3
+
4
+ const p = Stator.props<ImageProps>()
5
+ const {
6
+ alt,
7
+ class: className,
8
+ sizes = '100vw',
9
+ priority,
10
+ loading = priority ? 'eager' : 'lazy',
11
+ fetchpriority = priority ? 'high' : undefined,
12
+ decoding = 'async',
13
+ } = p
14
+ const img = getImage(p)
15
+ ---
16
+ <img
17
+ src={img.src}
18
+ srcset={img.srcset ?? undefined}
19
+ sizes={img.srcset ? sizes : undefined}
20
+ alt={alt}
21
+ width={img.width}
22
+ height={img.height}
23
+ loading={loading}
24
+ fetchpriority={fetchpriority}
25
+ decoding={decoding}
26
+ class={className}
27
+ />
28
+
29
+ <style>
30
+ /* height:auto preserves the aspect ratio the width/height attributes
31
+ declare; radius, margin, object-fit are the app's call. */
32
+ :where(img) {
33
+ display: block;
34
+ max-width: 100%;
35
+ height: auto;
36
+ }
37
+ </style>
@@ -0,0 +1,7 @@
1
+ /* height:auto preserves the aspect ratio the width/height attributes
2
+ declare; radius, margin, object-fit are the app's call. */
3
+ :where(img[data-s-6e2813d6]) {
4
+ display: block;
5
+ max-width: 100%;
6
+ height: auto;
7
+ }
@@ -0,0 +1,29 @@
1
+ import {
2
+ classList,
3
+ defer,
4
+ each,
5
+ html,
6
+ itemBind,
7
+ match,
8
+ on,
9
+ read,
10
+ spreadAttrs,
11
+ styleList,
12
+ when,
13
+ } from '@statorjs/stator/template'
14
+ import { getImage, type ImageProps } from './images.ts'
15
+
16
+ export default function (props: ImageProps) {
17
+ const p = props
18
+ const {
19
+ alt,
20
+ class: className,
21
+ sizes = '100vw',
22
+ priority,
23
+ loading = priority ? 'eager' : 'lazy',
24
+ fetchpriority = priority ? 'high' : undefined,
25
+ decoding = 'async',
26
+ } = p
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>`
29
+ }
@@ -1,11 +1,12 @@
1
1
  import { DEFAULT_IMAGE_WIDTHS } from '../server/images.ts'
2
2
  import { currentImages } from '../server/render-context.ts'
3
- import { html } from '../template/html.ts'
4
- import type { HtmlFragment } from '../template/types.ts'
5
3
 
6
4
  /**
7
- * `getImage()` + `<Image>`/`<Picture>` — the render half of framework image
8
- * support (spec `images-are-part-of-stator-*`; endpoint in `server/images.ts`).
5
+ * `getImage()`/`getPicture()` — pure math over provided dimensions, no
6
+ * markup (endpoint in `server/images.ts`). The `<Image>`/`<Picture>` markup
7
+ * components are `image.stator`/`picture.stator` in this directory; these
8
+ * two plus `SOURCE_TYPES` (the format→mime-type map) stay exported here as
9
+ * the public escape hatch for a custom art-direction component.
9
10
  *
10
11
  * `src` is the PUBLIC URL of the original: an image-endpoint path
11
12
  * (`/media/2026/08/x.jpg`) gets derived variants — the endpoint's contract is
@@ -154,31 +155,6 @@ export type ImageProps = GetImageOptions &
154
155
  class?: string
155
156
  }
156
157
 
157
- export function Image(props: ImageProps): HtmlFragment {
158
- const {
159
- alt,
160
- class: className,
161
- sizes = '100vw',
162
- priority,
163
- loading = priority ? 'eager' : 'lazy',
164
- fetchpriority = priority ? 'high' : undefined,
165
- decoding = 'async',
166
- } = props
167
- const img = getImage(props)
168
- return html`<img
169
- src="${img.src}"
170
- srcset="${img.srcset ?? undefined}"
171
- sizes="${img.srcset ? sizes : undefined}"
172
- alt="${alt}"
173
- width="${img.width}"
174
- height="${img.height}"
175
- loading="${loading}"
176
- fetchpriority="${fetchpriority}"
177
- decoding="${decoding}"
178
- class="${className}"
179
- />`
180
- }
181
-
182
158
  /** An art-directed source: under `media`, serve a different GEOMETRY — the
183
159
  * `crop` ratio the endpoint cover-crops to, same vocabulary and same
184
160
  * allowlist as the plain `crop` prop. Reach for `sources` when the geometry
@@ -201,20 +177,45 @@ export type PictureProps = ImageProps & {
201
177
  sources?: PictureSource[]
202
178
  }
203
179
 
204
- const SOURCE_TYPES: Record<ImageFormat, string> = {
180
+ /** `<source type>` for each delivery format — exported alongside `getPicture`
181
+ * so a hand-rolled `<picture>` (art direction the first-party `<Picture>`
182
+ * doesn't cover) can build its own `<source>` tags from `getImage`/
183
+ * `getPicture` output without re-deriving this mapping. */
184
+ export const SOURCE_TYPES: Record<ImageFormat, string> = {
205
185
  jpg: 'image/jpeg',
206
186
  png: 'image/png',
207
187
  webp: 'image/webp',
208
188
  avif: 'image/avif',
209
189
  }
210
190
 
211
- export function Picture(props: PictureProps): HtmlFragment {
191
+ /** One resolved `<source>` candidate, most-specific (art-directed) first. */
192
+ export interface ResolvedPictureSource {
193
+ media?: string
194
+ format: ImageFormat
195
+ srcset: string
196
+ sizes: string
197
+ }
198
+
199
+ export interface ResolvedPicture {
200
+ /** `<source>` candidates — empty when `src` is remote or no candidate
201
+ * produced a srcset, in which case only `image` (the plain `<img>`) applies. */
202
+ sources: ResolvedPictureSource[]
203
+ /** The fallback `<img>`'s own resolved data — same shape `getImage()` returns,
204
+ * built from the same props the sources were (`crop` included, so its
205
+ * reserved box describes the cropped output rather than the source's own
206
+ * shape). */
207
+ image: ResolvedImage
208
+ }
209
+
210
+ /** Pure `<picture>` math: every `<source>` candidate's resolved srcset plus
211
+ * the fallback `<img>`'s own resolved data — no markup. `<Picture>` renders
212
+ * this; a custom art-direction component that needs a different `<picture>`
213
+ * shape than `<Picture>` offers can call this directly instead. */
214
+ export function getPicture(props: PictureProps): ResolvedPicture {
212
215
  const { formats = ['avif', 'webp'], sources: artSources = [], sizes = '100vw', ...rest } = props
213
- // The fallback <img> is built from the same props the sources were — `crop`
214
- // included, so its reserved box describes the cropped output rather than the
215
- // source's own shape.
216
216
  const imageProps = { ...rest, sizes } as ImageProps
217
- if (isRemote(rest.src)) return Image(imageProps)
217
+ const image = getImage(imageProps)
218
+ if (isRemote(rest.src)) return { sources: [], image }
218
219
 
219
220
  const storedExt = rest.src.slice(rest.src.lastIndexOf('.') + 1).toLowerCase() as ImageFormat
220
221
 
@@ -242,19 +243,11 @@ export function Picture(props: PictureProps): HtmlFragment {
242
243
  )
243
244
  const plain = formats
244
245
  .map((format) => ({
245
- media: undefined,
246
246
  format,
247
247
  srcset: getImage({ ...imageProps, format }).srcset,
248
248
  sizes,
249
249
  }))
250
250
  .filter((s) => s.srcset !== null)
251
251
 
252
- const all = [...art, ...plain]
253
- if (all.length === 0) return Image(imageProps)
254
- return html`<picture
255
- >${all.map(
256
- (s) =>
257
- html`<source media="${s.media ?? undefined}" type="${SOURCE_TYPES[s.format]}" srcset="${s.srcset}" sizes="${s.sizes}" />`,
258
- )}${Image(imageProps)}</picture
259
- >`
252
+ return { sources: [...art, ...plain] as ResolvedPictureSource[], image }
260
253
  }
@@ -1,3 +1,6 @@
1
+ // Compiled sibling, not the raw `.stator` source — keep fresh with
2
+ // `pnpm build:components`.
3
+ export { default as Image } from './image.stator.ts'
1
4
  export type {
2
5
  GetImageOptions,
3
6
  ImageFormat,
@@ -6,7 +9,10 @@ export type {
6
9
  PictureProps,
7
10
  PictureSource,
8
11
  ResolvedImage,
12
+ ResolvedPicture,
13
+ ResolvedPictureSource,
9
14
  } from './images.ts'
10
- export { getImage, Image, Picture } from './images.ts'
15
+ export { getImage, getPicture, SOURCE_TYPES } from './images.ts'
11
16
  export type { JsonLdProps } from './json-ld.ts'
12
17
  export { JsonLd, ldToString } from './json-ld.ts'
18
+ export { default as Picture } from './picture.stator.ts'
@@ -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
  }
@@ -0,0 +1,17 @@
1
+ ---
2
+ import { getPicture, SOURCE_TYPES, type PictureProps } from './images.ts'
3
+ import Image from './image.stator'
4
+
5
+ const p = Stator.props<PictureProps>()
6
+ const { sources } = getPicture(p)
7
+ ---
8
+ {sources.length === 0 ? (
9
+ <Image {...p} />
10
+ ) : (
11
+ <picture>
12
+ {each(sources, (s) => (
13
+ <source media={s.media} type={SOURCE_TYPES[s.format]} srcset={s.srcset} sizes={s.sizes} />
14
+ ))}
15
+ <Image {...p} />
16
+ </picture>
17
+ )}
@@ -0,0 +1,21 @@
1
+ import {
2
+ classList,
3
+ defer,
4
+ each,
5
+ html,
6
+ itemBind,
7
+ match,
8
+ on,
9
+ read,
10
+ spreadAttrs,
11
+ styleList,
12
+ when,
13
+ } from '@statorjs/stator/template'
14
+ import Image from './image.stator.ts'
15
+ import { getPicture, type PictureProps, SOURCE_TYPES } from './images.ts'
16
+
17
+ export default function (props: PictureProps) {
18
+ const p = props
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>`}`
21
+ }
@@ -90,8 +90,8 @@ export interface CreateAppConfig extends DeprecatedFlatConfig {
90
90
  host?: string
91
91
  /** Cross-origin READ policy (CORS); `origins` defaults to `trustedOrigins`. */
92
92
  cors?: { origins?: string[]; credentials?: boolean }
93
- /** Extra `<head>` HTML per GET route. A production build uses this to link the
94
- * prebuilt `components.css`; ignored if omitted. */
93
+ /** Extra `<head>` HTML per GET route. A production build uses this to link
94
+ * that route's own prebuilt scoped-CSS artifact; ignored if omitted. */
95
95
  headExtras?: (filePath: string) => string | Promise<string>
96
96
  /** Path to the app's `middleware.ts` (if any). Loaded and validated; its
97
97
  * default export must be `defineMiddleware`/`dangerouslyDefineMiddleware`. */
@@ -16,6 +16,7 @@ import {
16
16
  routeIslandMap,
17
17
  walkFiles,
18
18
  } from '../build/islands.ts'
19
+ import { bareSpecifiersOf, externalCss } from '../build/route-css.ts'
19
20
  import { sourceId } from '../build/source-id.ts'
20
21
  import { CompileError, compile, formatCompileError, regionResolverFor } from '../compiler/index.ts'
21
22
  import type { AnyMachineDef, EventOf } from '../engine/index.ts'
@@ -59,8 +60,8 @@ import { InMemoryStore } from './store.ts'
59
60
  * graph serving and renders the error (with its code frame) in an overlay.
60
61
  * - **Scoped CSS + island scripts** — islands bundle through the
61
62
  * `bundleIslands` seam (Vite today, never on the SSR path) and are served
62
- * from memory with the concatenated scoped CSS, on the same URLs and with
63
- * the same `<head>` shape as production.
63
+ * from memory with the concatenated scoped CSS, local and `node_modules`
64
+ * alike, on the same URLs and with the same `<head>` shape as production.
64
65
  *
65
66
  * See spec `toolchain-adapter-seam-and-the-vite-exit`.
66
67
  */
@@ -164,6 +165,9 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
164
165
  const versions = new Map<string, number>()
165
166
  const forward = new Map<string, Set<string>>()
166
167
  const importers = new Map<string, Set<string>>()
168
+ // Bare specifiers each app file imports directly, feeding the CSS
169
+ // aggregate below via each package's own sibling `.css` (`externalCss`).
170
+ const externalSpecs = new Map<string, Set<string>>()
167
171
  let seq = 0
168
172
  const acks = new Map<number, () => void>()
169
173
  port1.on('message', (m: { type?: string; id?: number }) => {
@@ -274,11 +278,16 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
274
278
  }
275
279
  }
276
280
  const updateEdges = async (files: string[]): Promise<void> => {
277
- for (const f of files) setEdges(f, new Set((await localImports(f, root)).filter(isAppFile)))
281
+ for (const f of files) {
282
+ const [local, external] = await Promise.all([localImports(f, root), bareSpecifiersOf(f)])
283
+ setEdges(f, new Set(local.filter(isAppFile)))
284
+ externalSpecs.set(f, new Set(external))
285
+ }
278
286
  }
279
287
  const buildGraph = async (): Promise<void> => {
280
288
  forward.clear()
281
289
  importers.clear()
290
+ externalSpecs.clear()
282
291
  await updateEdges(await walkFiles(root, (f) => APP_EXT.test(f), skipDir))
283
292
  }
284
293
  /** The changed files plus everything that (transitively) imports them. */
@@ -314,10 +323,17 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
314
323
  }
315
324
  }
316
325
  let css = ''
317
- const regenCss = (): void => {
318
- css = ''
326
+ const regenCss = async (): Promise<void> => {
327
+ let next = ''
319
328
  for (const [file, info] of infos)
320
- if (info.css) css += `/* ${sourceId(root, file).id} */\n${info.css}\n`
329
+ if (info.css) next += `/* ${sourceId(root, file).id} */\n${info.css}\n`
330
+ const packages = new Set<string>()
331
+ for (const specs of externalSpecs.values()) for (const spec of specs) packages.add(spec)
332
+ for (const spec of [...packages].sort()) {
333
+ const text = await externalCss(spec, root)
334
+ if (text) next += `/* ${spec} */\n${text}\n`
335
+ }
336
+ css = next
321
337
  }
322
338
 
323
339
  // ── Islands: bundled through the seam, held in memory ──────────────────────
@@ -449,7 +465,7 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
449
465
 
450
466
  await scanAll()
451
467
  await buildGraph()
452
- regenCss()
468
+ await regenCss()
453
469
  await rebundleIslands()
454
470
  await rebuildStore()
455
471
  await rebuildServer()
@@ -478,7 +494,7 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
478
494
  }
479
495
  const affected = affectedBy(files)
480
496
  await bumpVersions(affected)
481
- regenCss()
497
+ await regenCss()
482
498
  const needsBundle =
483
499
  structural || files.some((f) => islandGraph.has(norm(f)) || infos.get(f)?.isClient)
484
500
  if (needsBundle) {
@@ -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
+ }