@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 +6 -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/cli/commands/dev.ts +1 -0
- package/src/cli/commands/start.ts +1 -0
- package/src/compiler/styles.ts +43 -20
- 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 +253 -0
- package/src/components/index.ts +16 -0
- package/src/components/picture.stator +17 -0
- package/src/components/picture.stator.ts +21 -0
- package/src/config.ts +48 -0
- package/src/server/create-app.ts +21 -2
- package/src/server/dev-native.ts +27 -8
- package/src/server/dev-vite.ts +3 -0
- package/src/server/dev.ts +14 -0
- package/src/server/http.ts +35 -2
- package/src/server/images.ts +426 -0
- package/src/server/index.ts +7 -0
- package/src/server/render-context.ts +9 -0
- package/src/server/render.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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
|
}
|
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/cli/commands/dev.ts
CHANGED
|
@@ -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,
|
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 {
|
|
@@ -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
|
+
}
|