@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 +5 -3
- package/src/build/build.ts +45 -33
- package/src/build/compile-component.ts +36 -0
- package/src/build/head.ts +10 -12
- package/src/build/route-css.ts +211 -0
- package/src/compiler/lower.ts +3 -1
- package/src/compiler/styles.ts +43 -20
- package/src/compiler/virtual-code.ts +2 -18
- package/src/components/components.css +8 -0
- package/src/components/image.stator +37 -0
- package/src/components/image.stator.css +7 -0
- package/src/components/image.stator.ts +29 -0
- package/src/components/images.ts +37 -44
- package/src/components/index.ts +7 -1
- package/src/components/json-ld.ts +15 -36
- package/src/components/picture.stator +17 -0
- package/src/components/picture.stator.ts +21 -0
- package/src/server/create-app.ts +2 -2
- package/src/server/dev-native.ts +24 -8
- 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",
|
|
@@ -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.
|
|
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
|
}
|
package/src/build/build.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
21
|
-
*
|
|
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.
|
|
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
|
|
32
|
-
* manifest's
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
|
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
|
|
151
|
-
//
|
|
152
|
-
//
|
|
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
|
|
158
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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:
|
|
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
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
+
}
|
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(
|
package/src/compiler/styles.ts
CHANGED
|
@@ -156,31 +156,54 @@ function scopeSelectorList(selector: string, attr: string): string {
|
|
|
156
156
|
|
|
157
157
|
if (subjectIsGlobal) return
|
|
158
158
|
|
|
159
|
-
|
|
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 (
|
|
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,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,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
|
+
}
|
package/src/components/images.ts
CHANGED
|
@@ -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()`
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/components/index.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
}
|
|
@@ -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
|
+
}
|
package/src/server/create-app.ts
CHANGED
|
@@ -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
|
|
94
|
-
* prebuilt
|
|
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`. */
|
package/src/server/dev-native.ts
CHANGED
|
@@ -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,
|
|
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)
|
|
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
|
-
|
|
326
|
+
const regenCss = async (): Promise<void> => {
|
|
327
|
+
let next = ''
|
|
319
328
|
for (const [file, info] of infos)
|
|
320
|
-
if (info.css)
|
|
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) {
|
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
|
+
}
|