@lovrozagar/flare 0.9.2 → 0.9.4
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 +1 -1
- package/src/plugins/dev-server.ts +4 -5
- package/src/plugins/sx-ast/compose-css.ts +92 -0
- package/src/plugins/sx-ast/index.ts +72 -83
- package/src/plugins/tw-compile.ts +1 -27
- package/src/plugins/virtual.d.ts +7 -1
- package/src/ssr/head-prefix.ts +10 -5
- package/src/ssr/head-prepend.ts +26 -0
- package/src/ssr/index.tsx +5 -7
- package/src/ssr/hoist-head-markers.ts +0 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { markSolidHeadStart, moveHeadPrependsAfterSolid } from "../ssr/head-prepend.ts";
|
|
4
4
|
import type { ResolvedEntries, VitePlugin } from "./types.ts";
|
|
5
5
|
|
|
6
6
|
interface NodeReq {
|
|
@@ -210,10 +210,9 @@ export function createDevServerPlugin(entries: ResolvedEntries, _assetsBase: str
|
|
|
210
210
|
htmlPath = "/";
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
|
-
const { html: peeledHtml, tags } = peelInlineTags(html);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
);
|
|
213
|
+
const { html: peeledHtml, tags } = peelInlineTags(markSolidHeadStart(html));
|
|
214
|
+
const transformed = await vite.transformIndexHtml(htmlPath, peeledHtml);
|
|
215
|
+
html = moveHeadPrependsAfterSolid(restoreInlineTags(transformed, tags));
|
|
217
216
|
|
|
218
217
|
const htmlHeaders: Record<string, string | string[]> = {
|
|
219
218
|
"content-type": "text/html; charset=utf-8",
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Stylesheet composition shared by the build (flare-global.css) and the dev SSR runtime
|
|
3
|
+
* (virtual:flare-sx-dev-css imports this file into the server runner). No Node imports.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const LAYER_PRELUDE = "@layer reset, sx, app, user.lib, user.app, inline;";
|
|
7
|
+
|
|
8
|
+
/* Tailwind's fallback for browsers without @property: the same initial values, set directly. */
|
|
9
|
+
const PROPERTIES_SUPPORTS =
|
|
10
|
+
"((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b))))";
|
|
11
|
+
|
|
12
|
+
/** Tailwind's element-local stacks (`--tw-shadow`, `--tw-scale-x`, …): never theme values. */
|
|
13
|
+
export function isLocalVar(name: string): boolean {
|
|
14
|
+
return name.startsWith("--tw-");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function propertiesFallback(properties: Map<string, string>): string {
|
|
18
|
+
const decls = [...properties].map(([name, rule]) => {
|
|
19
|
+
const initial = /initial-value:\s*([^;]+);/.exec(rule);
|
|
20
|
+
return `${name}: ${initial ? initial[1].trim() : "initial"};`;
|
|
21
|
+
});
|
|
22
|
+
return `@layer properties { @supports ${PROPERTIES_SUPPORTS} { *, ::before, ::after, ::backdrop { ${decls.join(" ")} } } }`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* At-rules last so @media utilities beat earlier base utilities of equal
|
|
26
|
+
* specificity. classPool insertion order is first-seen across the whole
|
|
27
|
+
* build, so a later page's `fontSize: 12px` would otherwise override an
|
|
28
|
+
* earlier `@media (min-width: 1px) { fontSize: 24px }`. */
|
|
29
|
+
function atLast(a: string, b: string): number {
|
|
30
|
+
return Number(a.trimStart().startsWith("@")) - Number(b.trimStart().startsWith("@"));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Compose the final CSS text from the class pool, wrapped in @layer blocks. */
|
|
34
|
+
export function composeCss(
|
|
35
|
+
classPool: Map<string, string>,
|
|
36
|
+
layerByClass: Map<string, "sx" | "app">,
|
|
37
|
+
skip: Set<string>,
|
|
38
|
+
twPrefaceCss: string,
|
|
39
|
+
properties: Map<string, string> = new Map(),
|
|
40
|
+
themeBlock = "",
|
|
41
|
+
): string {
|
|
42
|
+
const sxRules: string[] = [];
|
|
43
|
+
const appRules: string[] = [];
|
|
44
|
+
|
|
45
|
+
for (const [cls, rule] of classPool) {
|
|
46
|
+
if (skip.has(cls)) continue;
|
|
47
|
+
/* istanbul ignore next -- layerByClass is always set alongside classPool in cssEmit */
|
|
48
|
+
const layer = layerByClass.get(cls) ?? "app";
|
|
49
|
+
if (layer === "sx") sxRules.push(rule);
|
|
50
|
+
else appRules.push(rule);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
sxRules.sort(atLast);
|
|
54
|
+
appRules.sort(atLast);
|
|
55
|
+
|
|
56
|
+
const parts: string[] = [];
|
|
57
|
+
/* First statement, so the fallback layer ranks below every other layer. */
|
|
58
|
+
if (properties.size > 0) parts.push("@layer properties;");
|
|
59
|
+
if (twPrefaceCss) parts.push(twPrefaceCss);
|
|
60
|
+
if (themeBlock) parts.push(themeBlock);
|
|
61
|
+
parts.push(LAYER_PRELUDE);
|
|
62
|
+
if (sxRules.length > 0) parts.push(`@layer sx { ${sxRules.join(" ")} }`);
|
|
63
|
+
if (appRules.length > 0) parts.push(`@layer app { ${appRules.join(" ")} }`);
|
|
64
|
+
if (properties.size > 0) {
|
|
65
|
+
parts.push(...properties.values());
|
|
66
|
+
parts.push(propertiesFallback(properties));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return parts.join("\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `@layer theme { :root, :host { … } }` defining the theme vars `referenced` needs: each one and,
|
|
74
|
+
* transitively, the vars its value references. Locals (`--tw-*`), vars the preface already defines,
|
|
75
|
+
* and names Tailwind never emitted are skipped. Empty string when nothing is needed.
|
|
76
|
+
*/
|
|
77
|
+
export function themeVarsBlock(referenced: Set<string>, themeVars: Map<string, string>, preface: string): string {
|
|
78
|
+
const decls: string[] = [];
|
|
79
|
+
const seen = new Set<string>();
|
|
80
|
+
const queue = [...referenced];
|
|
81
|
+
while (queue.length > 0) {
|
|
82
|
+
const name = queue.shift() as string;
|
|
83
|
+
if (seen.has(name) || isLocalVar(name)) continue;
|
|
84
|
+
seen.add(name);
|
|
85
|
+
if (new RegExp(`${name.replace(/[-]/g, "\\-")}\\s*:`).test(preface)) continue;
|
|
86
|
+
const value = themeVars.get(name);
|
|
87
|
+
if (value === undefined) continue;
|
|
88
|
+
decls.push(`${name}: ${value};`);
|
|
89
|
+
for (const m of value.matchAll(/var\((--[\w-]+)/g)) queue.push(m[1]);
|
|
90
|
+
}
|
|
91
|
+
return decls.length > 0 ? `@layer theme { :root, :host { ${decls.join(" ")} } }` : "";
|
|
92
|
+
}
|
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
3
4
|
import type { Plugin, ViteDevServer } from "vite";
|
|
4
5
|
import { createThemeCn } from "../cn-theme.ts";
|
|
5
6
|
import type { ThemeCn } from "../cn-theme.ts";
|
|
6
|
-
import {
|
|
7
|
-
extractDeclarations,
|
|
8
|
-
extractPrefaceCss,
|
|
9
|
-
extractPropertyRules,
|
|
10
|
-
initTailwindCompiler,
|
|
11
|
-
themeVarsBlock,
|
|
12
|
-
} from "../tw-compile.ts";
|
|
7
|
+
import { extractDeclarations, extractPrefaceCss, extractPropertyRules, initTailwindCompiler } from "../tw-compile.ts";
|
|
13
8
|
import type { TailwindCompiler } from "../tw-compile.ts";
|
|
9
|
+
import { composeCss, themeVarsBlock } from "./compose-css.ts";
|
|
14
10
|
import { rewriteModule } from "./rewrite.ts";
|
|
15
11
|
|
|
16
12
|
export interface SxStrictOptions {
|
|
@@ -59,8 +55,6 @@ export interface SxAstOptions {
|
|
|
59
55
|
tw?: boolean;
|
|
60
56
|
}
|
|
61
57
|
|
|
62
|
-
const LAYER_PRELUDE = "@layer reset, sx, app, user.lib, user.app, inline;";
|
|
63
|
-
|
|
64
58
|
/** CSS rule pool accumulated across all transforms in a build. */
|
|
65
59
|
interface PluginState {
|
|
66
60
|
/** className → CSS rule text */
|
|
@@ -160,63 +154,6 @@ function resolveLayer(
|
|
|
160
154
|
return "app";
|
|
161
155
|
}
|
|
162
156
|
|
|
163
|
-
/** Compose the final CSS text from the class pool, wrapped in @layer blocks. */
|
|
164
|
-
/* Tailwind's fallback for browsers without @property: the same initial values, set directly. */
|
|
165
|
-
const PROPERTIES_SUPPORTS =
|
|
166
|
-
"((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b))))";
|
|
167
|
-
|
|
168
|
-
function propertiesFallback(properties: Map<string, string>): string {
|
|
169
|
-
const decls = [...properties].map(([name, rule]) => {
|
|
170
|
-
const initial = /initial-value:\s*([^;]+);/.exec(rule);
|
|
171
|
-
return `${name}: ${initial ? initial[1].trim() : "initial"};`;
|
|
172
|
-
});
|
|
173
|
-
return `@layer properties { @supports ${PROPERTIES_SUPPORTS} { *, ::before, ::after, ::backdrop { ${decls.join(" ")} } } }`;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function composeCss(
|
|
177
|
-
classPool: Map<string, string>,
|
|
178
|
-
layerByClass: Map<string, "sx" | "app">,
|
|
179
|
-
skip: Set<string>,
|
|
180
|
-
twPrefaceCss: string,
|
|
181
|
-
properties: Map<string, string> = new Map(),
|
|
182
|
-
themeBlock = "",
|
|
183
|
-
): string {
|
|
184
|
-
const sxRules: string[] = [];
|
|
185
|
-
const appRules: string[] = [];
|
|
186
|
-
|
|
187
|
-
for (const [cls, rule] of classPool) {
|
|
188
|
-
if (skip.has(cls)) continue;
|
|
189
|
-
/* istanbul ignore next -- layerByClass is always set alongside classPool in cssEmit */
|
|
190
|
-
const layer = layerByClass.get(cls) ?? "app";
|
|
191
|
-
if (layer === "sx") sxRules.push(rule);
|
|
192
|
-
else appRules.push(rule);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/* At-rules last so @media utilities beat earlier base utilities of equal
|
|
196
|
-
* specificity. classPool insertion order is first-seen across the whole
|
|
197
|
-
* build, so a later page's `fontSize: 12px` would otherwise override an
|
|
198
|
-
* earlier `@media (min-width: 1px) { fontSize: 24px }`. */
|
|
199
|
-
const atLast = (a: string, b: string) =>
|
|
200
|
-
Number(a.trimStart().startsWith("@")) - Number(b.trimStart().startsWith("@"));
|
|
201
|
-
sxRules.sort(atLast);
|
|
202
|
-
appRules.sort(atLast);
|
|
203
|
-
|
|
204
|
-
const parts: string[] = [];
|
|
205
|
-
/* First statement, so the fallback layer ranks below every other layer. */
|
|
206
|
-
if (properties.size > 0) parts.push("@layer properties;");
|
|
207
|
-
if (twPrefaceCss) parts.push(twPrefaceCss);
|
|
208
|
-
if (themeBlock) parts.push(themeBlock);
|
|
209
|
-
parts.push(LAYER_PRELUDE);
|
|
210
|
-
if (sxRules.length > 0) parts.push(`@layer sx { ${sxRules.join(" ")} }`);
|
|
211
|
-
if (appRules.length > 0) parts.push(`@layer app { ${appRules.join(" ")} }`);
|
|
212
|
-
if (properties.size > 0) {
|
|
213
|
-
parts.push(...properties.values());
|
|
214
|
-
parts.push(propertiesFallback(properties));
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
return parts.join("\n");
|
|
218
|
-
}
|
|
219
|
-
|
|
220
157
|
/* Flare's own `cn` tables module. With a twCssPath, the plugin serves tables compiled from that
|
|
221
158
|
theme in its place, so `cn` merges the app's custom scale names (`rounded-control`). */
|
|
222
159
|
const CN_TABLES_RE = /[\\/]styles[\\/]cn-vendor[\\/]tables\.generated\.ts(?:\?.*)?$/;
|
|
@@ -247,6 +184,40 @@ export function createSxAstPlugin(opts: SxAstOptions = {}, assetsBase: string =
|
|
|
247
184
|
referencedVars: new Set(),
|
|
248
185
|
};
|
|
249
186
|
const referenceVars = opts.themeVars === "reference";
|
|
187
|
+
/*
|
|
188
|
+
* Dev SSR stylesheet: a registry living in the server runtime. Every module the server
|
|
189
|
+
* transforms registers its rules when it runs (see transform), so the stylesheet SSR reads
|
|
190
|
+
* at render time covers every module evaluated so far. A snapshot of plugin state baked into
|
|
191
|
+
* this module would go stale: the runner caches the module after its first import, while the
|
|
192
|
+
* pool keeps growing as later routes are first rendered. Composition is shared with the build.
|
|
193
|
+
*/
|
|
194
|
+
const devCssModule = (): string => {
|
|
195
|
+
const themeVars = referenceVars && state.twCompiler ? [...state.twCompiler.themeVars] : null;
|
|
196
|
+
const composeCssPath = fileURLToPath(new URL("./compose-css.ts", import.meta.url));
|
|
197
|
+
return `import { composeCss, themeVarsBlock } from ${JSON.stringify(composeCssPath)};
|
|
198
|
+
const rules = new Map();
|
|
199
|
+
const layers = new Map();
|
|
200
|
+
const properties = new Map();
|
|
201
|
+
const referenced = new Set();
|
|
202
|
+
const skip = new Set(${JSON.stringify([...state.providedByLibs])});
|
|
203
|
+
const preface = ${JSON.stringify(state.twPrefaceCss)};
|
|
204
|
+
const themeVars = ${themeVars ? `new Map(${JSON.stringify(themeVars)})` : "null"};
|
|
205
|
+
export function registerDevSx(moduleRules, moduleProperties, moduleReferenced) {
|
|
206
|
+
for (const [cls, rule, layer] of moduleRules) {
|
|
207
|
+
rules.set(cls, rule);
|
|
208
|
+
layers.set(cls, layer);
|
|
209
|
+
}
|
|
210
|
+
for (const [name, rule] of moduleProperties) properties.set(name, rule);
|
|
211
|
+
for (const name of moduleReferenced) referenced.add(name);
|
|
212
|
+
}
|
|
213
|
+
export function getDevSxCss() {
|
|
214
|
+
return composeCss(rules, layers, skip, preface, properties, themeVars ? themeVarsBlock(referenced, themeVars, preface) : "");
|
|
215
|
+
}
|
|
216
|
+
export function getDevSxClasses() {
|
|
217
|
+
return [...rules.keys()];
|
|
218
|
+
}
|
|
219
|
+
`;
|
|
220
|
+
};
|
|
250
221
|
const themeBlockOf = (): string =>
|
|
251
222
|
referenceVars && state.twCompiler
|
|
252
223
|
? themeVarsBlock(state.referencedVars, state.twCompiler.themeVars, state.twPrefaceCss)
|
|
@@ -324,20 +295,7 @@ export function createSxAstPlugin(opts: SxAstOptions = {}, assetsBase: string =
|
|
|
324
295
|
return theme ? { code: theme.source, moduleType: "js" } : null;
|
|
325
296
|
}
|
|
326
297
|
if (id !== DEV_CSS_RESOLVED_ID) return null;
|
|
327
|
-
|
|
328
|
-
const css = composeCss(
|
|
329
|
-
state.classPool,
|
|
330
|
-
state.layerByClass,
|
|
331
|
-
state.providedByLibs,
|
|
332
|
-
state.twPrefaceCss,
|
|
333
|
-
state.properties,
|
|
334
|
-
themeBlockOf(),
|
|
335
|
-
);
|
|
336
|
-
const classNames = [...state.classPool.keys()];
|
|
337
|
-
return {
|
|
338
|
-
code: `export function getDevSxCss() { return ${JSON.stringify(css)} }\nexport function getDevSxClasses() { return ${JSON.stringify(classNames)} }`,
|
|
339
|
-
moduleType: "js",
|
|
340
|
-
};
|
|
298
|
+
return { code: devCssModule(), moduleType: "js" };
|
|
341
299
|
},
|
|
342
300
|
|
|
343
301
|
generateBundle() {
|
|
@@ -385,7 +343,12 @@ export function createSxAstPlugin(opts: SxAstOptions = {}, assetsBase: string =
|
|
|
385
343
|
|
|
386
344
|
name: "flare:sx-ast",
|
|
387
345
|
|
|
388
|
-
transform(
|
|
346
|
+
transform(
|
|
347
|
+
this: { environment?: { config?: { consumer?: string } } },
|
|
348
|
+
code: string,
|
|
349
|
+
id: string,
|
|
350
|
+
options?: { ssr?: boolean },
|
|
351
|
+
): { code: string; map: null } | null {
|
|
389
352
|
if (!id.endsWith(".tsx") && !id.endsWith(".jsx")) return null;
|
|
390
353
|
|
|
391
354
|
/* Quick filter — skip files that obviously have no relevant attrs */
|
|
@@ -394,6 +357,8 @@ export function createSxAstPlugin(opts: SxAstOptions = {}, assetsBase: string =
|
|
|
394
357
|
const layer = resolveLayer(id, libPaths, opts.layerOverride);
|
|
395
358
|
const emittedForModule = new Set<string>();
|
|
396
359
|
const moduleRules: Array<{ cls: string; rule: string }> = [];
|
|
360
|
+
const moduleProperties = new Map<string, string>();
|
|
361
|
+
const moduleReferenced = new Set<string>();
|
|
397
362
|
|
|
398
363
|
const tw = state.twCompiler;
|
|
399
364
|
const violations: string[] = [];
|
|
@@ -430,10 +395,16 @@ export function createSxAstPlugin(opts: SxAstOptions = {}, assetsBase: string =
|
|
|
430
395
|
twCompile: tw
|
|
431
396
|
? (token: string) => {
|
|
432
397
|
const output = tw.build([token]);
|
|
433
|
-
for (const [name, rule] of extractPropertyRules(output))
|
|
398
|
+
for (const [name, rule] of extractPropertyRules(output)) {
|
|
399
|
+
state.properties.set(name, rule);
|
|
400
|
+
moduleProperties.set(name, rule);
|
|
401
|
+
}
|
|
434
402
|
const decls = extractDeclarations(output, [token], referenceVars ? undefined : tw.themeVars);
|
|
435
403
|
if (referenceVars) {
|
|
436
|
-
for (const m of decls.matchAll(/var\((--[\w-]+)/g))
|
|
404
|
+
for (const m of decls.matchAll(/var\((--[\w-]+)/g)) {
|
|
405
|
+
state.referencedVars.add(m[1]);
|
|
406
|
+
moduleReferenced.add(m[1]);
|
|
407
|
+
}
|
|
437
408
|
}
|
|
438
409
|
return decls || null;
|
|
439
410
|
}
|
|
@@ -468,6 +439,24 @@ export function createSxAstPlugin(opts: SxAstOptions = {}, assetsBase: string =
|
|
|
468
439
|
* result === null means the AST needed no code rewrite (pure class= literals, no sx/spread).
|
|
469
440
|
* CSS was still collected into moduleRules via cssEmit — must still inject the snippet.
|
|
470
441
|
*/
|
|
442
|
+
if (
|
|
443
|
+
mode === "dev" &&
|
|
444
|
+
moduleRules.length > 0 &&
|
|
445
|
+
(options?.ssr || this.environment?.config?.consumer === "server")
|
|
446
|
+
) {
|
|
447
|
+
/* Server runtime: register into the dev stylesheet SSR reads at render (devCssModule). */
|
|
448
|
+
const registration = JSON.stringify([
|
|
449
|
+
moduleRules.map(({ cls, rule }) => [cls, rule, layer]),
|
|
450
|
+
[...moduleProperties],
|
|
451
|
+
[...moduleReferenced],
|
|
452
|
+
]);
|
|
453
|
+
const baseCode = result !== null ? result.code : code;
|
|
454
|
+
return {
|
|
455
|
+
code: `${baseCode}\nimport { registerDevSx as __flareRegisterDevSx__ } from "${DEV_CSS_VIRTUAL_ID}";\n__flareRegisterDevSx__(...${registration});\n`,
|
|
456
|
+
map: null,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
471
460
|
if (mode === "dev" && moduleRules.length > 0) {
|
|
472
461
|
const layerName: "sx" | "app" = layer;
|
|
473
462
|
const perClassJson = JSON.stringify(moduleRules.map(({ cls, rule }) => [cls, rule]));
|
|
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
import { isLocalVar } from "./sx-ast/compose-css.ts";
|
|
5
6
|
|
|
6
7
|
export interface TailwindCompiler {
|
|
7
8
|
build: (classes: string[]) => string;
|
|
@@ -124,11 +125,6 @@ function extractDeclsInner(css: string, selectorSet?: Set<string>): string {
|
|
|
124
125
|
return result.join(";");
|
|
125
126
|
}
|
|
126
127
|
|
|
127
|
-
/** Tailwind's element-local stacks (`--tw-shadow`, `--tw-scale-x`, …): never theme values. */
|
|
128
|
-
function isLocalVar(name: string): boolean {
|
|
129
|
-
return name.startsWith("--tw-");
|
|
130
|
-
}
|
|
131
|
-
|
|
132
128
|
/**
|
|
133
129
|
* The `@property` rules in a build output, keyed by variable name, whitespace-normalized
|
|
134
130
|
* (`@property --tw-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; }`).
|
|
@@ -147,28 +143,6 @@ export function extractPropertyRules(cssOutput: string): Map<string, string> {
|
|
|
147
143
|
return rules;
|
|
148
144
|
}
|
|
149
145
|
|
|
150
|
-
/**
|
|
151
|
-
* `@layer theme { :root, :host { … } }` defining the theme vars `referenced` needs: each one and,
|
|
152
|
-
* transitively, the vars its value references. Locals (`--tw-*`), vars the preface already defines,
|
|
153
|
-
* and names Tailwind never emitted are skipped. Empty string when nothing is needed.
|
|
154
|
-
*/
|
|
155
|
-
export function themeVarsBlock(referenced: Set<string>, themeVars: Map<string, string>, preface: string): string {
|
|
156
|
-
const decls: string[] = [];
|
|
157
|
-
const seen = new Set<string>();
|
|
158
|
-
const queue = [...referenced];
|
|
159
|
-
while (queue.length > 0) {
|
|
160
|
-
const name = queue.shift() as string;
|
|
161
|
-
if (seen.has(name) || isLocalVar(name)) continue;
|
|
162
|
-
seen.add(name);
|
|
163
|
-
if (new RegExp(`${name.replace(/[-]/g, "\\-")}\\s*:`).test(preface)) continue;
|
|
164
|
-
const value = themeVars.get(name);
|
|
165
|
-
if (value === undefined) continue;
|
|
166
|
-
decls.push(`${name}: ${value};`);
|
|
167
|
-
for (const m of value.matchAll(/var\((--[\w-]+)/g)) queue.push(m[1]);
|
|
168
|
-
}
|
|
169
|
-
return decls.length > 0 ? `@layer theme { :root, :host { ${decls.join(" ")} } }` : "";
|
|
170
|
-
}
|
|
171
|
-
|
|
172
146
|
function resolveThemeVars(css: string, themeVars: Map<string, string>): string {
|
|
173
147
|
let result = css;
|
|
174
148
|
for (let i = 0; i < 5; i++) {
|
package/src/plugins/virtual.d.ts
CHANGED
|
@@ -57,7 +57,13 @@ declare module "virtual:flare-sx-manifest" {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
declare module "virtual:flare-sx-dev-css" {
|
|
60
|
-
/**
|
|
60
|
+
/** Dev SSR transforms call this when a module runs, with the rules, `@property` rules and theme vars it emits. */
|
|
61
|
+
export function registerDevSx(
|
|
62
|
+
rules: Array<[cls: string, rule: string, layer: "sx" | "app"]>,
|
|
63
|
+
properties: Array<[name: string, rule: string]>,
|
|
64
|
+
referenced: string[],
|
|
65
|
+
): void;
|
|
66
|
+
/** The sx stylesheet for every module the server runtime has run so far. */
|
|
61
67
|
export function getDevSxCss(): string;
|
|
62
68
|
/** Returns the current accumulated class-name list. Seeded into window state so client module injects dedupe against SSR-emitted classes. */
|
|
63
69
|
export function getDevSxClasses(): string[];
|
package/src/ssr/head-prefix.ts
CHANGED
|
@@ -8,9 +8,15 @@ function escapeAttr(str: string): string {
|
|
|
8
8
|
return str.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
/*
|
|
12
|
+
* The scheme before the theme script runs, or without JavaScript: the default theme, where
|
|
13
|
+
* "system" is the OS preference (`light dark`). Once the script sets the attribute, it wins.
|
|
14
|
+
*/
|
|
15
|
+
function colorSchemeCss(theme: ThemeConfig | undefined): string {
|
|
16
|
+
const attr = (theme?.attribute ?? "data-theme").replace(/[^\w-]/g, "") || "data-theme";
|
|
17
|
+
const fallback =
|
|
18
|
+
theme?.defaultTheme === "light" || theme?.defaultTheme === "dark" ? theme.defaultTheme : "light dark";
|
|
19
|
+
return `html{color-scheme:${fallback}}html[${attr}=light]{color-scheme:light}html[${attr}=dark]{color-scheme:dark}`;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
export interface HeadPrefixOptions {
|
|
@@ -42,9 +48,8 @@ export function buildHeadPrefix(options: HeadPrefixOptions): string {
|
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
const nonceAttr = ` nonce="${escapedNonce}"`;
|
|
45
|
-
const themeAttr = options.theme?.attribute ?? "data-theme";
|
|
46
51
|
prefix += `<script${nonceAttr}>${getThemeScript(options.theme)}</script>`;
|
|
47
|
-
prefix += `<style${nonceAttr}>${colorSchemeCss(
|
|
52
|
+
prefix += `<style${nonceAttr}>${colorSchemeCss(options.theme)}</style>`;
|
|
48
53
|
|
|
49
54
|
if (options.direction) {
|
|
50
55
|
prefix += `<script${nonceAttr}>${getDirectionScript(options.direction)}</script>`;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Solid 2 hydrates `<head>` by walking its children from `head.firstChild` in render order, static
|
|
3
|
+
* elements (meta, title) and component regions (`<!--$-->…<!--/-->`) alike. Vite's
|
|
4
|
+
* transformIndexHtml prepends tags (`/@vite/client`, plugin head-prepend tags) ahead of them, so
|
|
5
|
+
* hydration would meet those first. Mark where Solid's output starts before the transform, then
|
|
6
|
+
* move whatever landed ahead of the mark to the end of `<head>`.
|
|
7
|
+
*/
|
|
8
|
+
const SOLID_HEAD_START = "<!--flare-solid-head-->";
|
|
9
|
+
|
|
10
|
+
/** Marks the start of Solid's head children. Call on the SSR HTML before transformIndexHtml. */
|
|
11
|
+
export function markSolidHeadStart(html: string): string {
|
|
12
|
+
return html.replace(/<head(\s[^>]*)?>/i, (open) => `${open}${SOLID_HEAD_START}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Moves tags injected ahead of the mark to just before `</head>` and drops the mark. */
|
|
16
|
+
export function moveHeadPrependsAfterSolid(html: string): string {
|
|
17
|
+
const open = /<head(\s[^>]*)?>/i.exec(html);
|
|
18
|
+
if (!open) return html;
|
|
19
|
+
const start = open.index + open[0].length;
|
|
20
|
+
const mark = html.indexOf(SOLID_HEAD_START, start);
|
|
21
|
+
const end = html.indexOf("</head>", start);
|
|
22
|
+
if (mark === -1 || end === -1 || mark > end) return html;
|
|
23
|
+
const prepended = html.slice(start, mark);
|
|
24
|
+
const solidAndRest = html.slice(mark + SOLID_HEAD_START.length, end);
|
|
25
|
+
return html.slice(0, start) + solidAndRest + prepended + html.slice(end);
|
|
26
|
+
}
|
package/src/ssr/index.tsx
CHANGED
|
@@ -32,7 +32,6 @@ import { ThemeProvider } from "../theme.ts";
|
|
|
32
32
|
import { GLOBAL_DEFER, GLOBAL_QUERIES } from "../protocol.ts";
|
|
33
33
|
import { parseSearchParams, type SearchParams } from "../url/index.ts";
|
|
34
34
|
import { renderHeadToHtml } from "./head.ts";
|
|
35
|
-
import { hoistHydrationHeadMarkers } from "./hoist-head-markers.ts";
|
|
36
35
|
import { buildHeadPrefix } from "./head-prefix.ts";
|
|
37
36
|
|
|
38
37
|
export { mergeHeadConfigs } from "../internal.ts";
|
|
@@ -548,7 +547,7 @@ function buildComponentTree(config: SSRConfig, flareStateScript: string): () =>
|
|
|
548
547
|
* Returns updated buffer with head content injected, or original buffer if </head> not found.
|
|
549
548
|
*
|
|
550
549
|
* Head structure:
|
|
551
|
-
* 1. Solid's
|
|
550
|
+
* 1. Solid's head children, in render order (hydration walks them from `head.firstChild`)
|
|
552
551
|
* 2. CSP nonce / viewport / theme / direction / locale scripts (headPrefix)
|
|
553
552
|
* 3. modulepreload + stylesheets
|
|
554
553
|
* 4. Resolved <head> tags (title, meta, etc.)
|
|
@@ -630,8 +629,7 @@ function injectHeadContent(
|
|
|
630
629
|
headSuffix += `<style nonce="${escapedNonce}">body{${cssText}}</style>`;
|
|
631
630
|
}
|
|
632
631
|
|
|
633
|
-
|
|
634
|
-
return hoistHydrationHeadMarkers(result);
|
|
632
|
+
return buffer.replace("</head>", `${headPrefix}${headSuffix}${extraSuffix}</head>`);
|
|
635
633
|
}
|
|
636
634
|
|
|
637
635
|
/**
|
|
@@ -725,9 +723,9 @@ export function renderToStream(config: SSRConfig): SSRResult {
|
|
|
725
723
|
try {
|
|
726
724
|
/*
|
|
727
725
|
* Resolve dev sx CSS before the read loop so both injectHeadContent call sites
|
|
728
|
-
* (in-loop and flush) get the same snapshot.
|
|
729
|
-
* module
|
|
730
|
-
* Guarded by import.meta.env.DEV so prod builds tree-shake
|
|
726
|
+
* (in-loop and flush) get the same snapshot. The virtual module is a registry every
|
|
727
|
+
* server-transformed module registers its rules into as it runs, so it covers every
|
|
728
|
+
* module this render used. Guarded by import.meta.env.DEV so prod builds tree-shake it.
|
|
731
729
|
*/
|
|
732
730
|
let resolvedDevSxCss = "";
|
|
733
731
|
let resolvedDevSxClasses: string[] = [];
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Solid 2 hydratable `<head>` walks `head.firstChild` as `<!--$-->`.
|
|
3
|
-
* Vite (`/@vite/client`) and Flare headPrefix used to prepend nodes, so
|
|
4
|
-
* getNextMarker started on the wrong child and hydration threw
|
|
5
|
-
* `Cannot read properties of null (reading 'firstChild'|'nextSibling')`.
|
|
6
|
-
*
|
|
7
|
-
* Move the Solid `<!--$-->…<!--/-->` region to the start of `<head>`.
|
|
8
|
-
* Injected scripts/meta stay in `<head>` (still before `</head>` / body).
|
|
9
|
-
*/
|
|
10
|
-
export function hoistHydrationHeadMarkers(html: string): string {
|
|
11
|
-
const open = /<head[^>]*>/i.exec(html);
|
|
12
|
-
if (!open) return html;
|
|
13
|
-
const start = open.index + open[0].length;
|
|
14
|
-
const end = html.indexOf("</head>", start);
|
|
15
|
-
if (end === -1) return html;
|
|
16
|
-
const inner = html.slice(start, end);
|
|
17
|
-
const first = inner.indexOf("<!--$-->");
|
|
18
|
-
if (first <= 0) return html;
|
|
19
|
-
const last = inner.lastIndexOf("<!--/-->");
|
|
20
|
-
if (last === -1 || last < first) return html;
|
|
21
|
-
const closeAt = last + "<!--/-->".length;
|
|
22
|
-
const solid = inner.slice(first, closeAt);
|
|
23
|
-
const rest = inner.slice(0, first) + inner.slice(closeAt);
|
|
24
|
-
return html.slice(0, start) + solid + rest + html.slice(end);
|
|
25
|
-
}
|