@statorjs/stator 2.10.0 → 2.11.0-next.1

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.10.0",
3
+ "version": "2.11.0-next.1",
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"
@@ -82,6 +83,7 @@
82
83
  "postcss": "^8.5.15",
83
84
  "postcss-selector-parser": "^7.1.4",
84
85
  "schema-dts": "^1.1.5",
86
+ "sharp": "^0.33.5",
85
87
  "typescript": "^5.6.0",
86
88
  "zod": "^3.23.0"
87
89
  },
@@ -99,13 +101,14 @@
99
101
  "pino-pretty": "^13.1.3",
100
102
  "vite": "^6.0.0",
101
103
  "vitest": "^2.1.0",
102
- "@statorjs/stator": "2.10.0"
104
+ "@statorjs/stator": "2.11.0-next.1"
103
105
  },
104
106
  "scripts": {
105
107
  "typecheck": "tsc --noEmit",
106
108
  "test": "vitest run",
107
109
  "test:watch": "vitest",
108
110
  "test:browser": "node browser-tests/tables.mjs",
109
- "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/"
110
113
  }
111
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
+ }
@@ -20,6 +20,7 @@ export async function run(ctx: CliContext): Promise<void> {
20
20
  persistence: config.persistence,
21
21
  sessions: config.sessions,
22
22
  dev: config.dev,
23
+ images: config.images,
23
24
  trustedOrigins: config.trustedOrigins,
24
25
  origin: config.origin,
25
26
  cors: config.cors,
@@ -90,6 +90,7 @@ export async function run(ctx: CliContext): Promise<void> {
90
90
  persistence: config.persistence,
91
91
  sessions: config.sessions,
92
92
  realtime: config.realtime,
93
+ images: config.images,
93
94
  trustedOrigins: config.trustedOrigins,
94
95
  origin: config.origin,
95
96
  host: config.host,
@@ -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 {
@@ -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
+ }