@burojs/ui-shadcn 0.2.0
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/LICENSE +21 -0
- package/README.md +65 -0
- package/dist/chunk-ABUQPOOE.js +11 -0
- package/dist/chunk-ABUQPOOE.js.map +1 -0
- package/dist/chunk-DBGGWBFG.cjs +199 -0
- package/dist/chunk-DBGGWBFG.cjs.map +1 -0
- package/dist/chunk-FMX4GLCQ.js +6 -0
- package/dist/chunk-FMX4GLCQ.js.map +1 -0
- package/dist/chunk-UC57Z243.js +143 -0
- package/dist/chunk-UC57Z243.js.map +1 -0
- package/dist/chunk-UPGPO75B.js +164 -0
- package/dist/chunk-UPGPO75B.js.map +1 -0
- package/dist/chunk-VXJAXJ3U.cjs +8 -0
- package/dist/chunk-VXJAXJ3U.cjs.map +1 -0
- package/dist/chunk-W7AEYE7A.cjs +13 -0
- package/dist/chunk-W7AEYE7A.cjs.map +1 -0
- package/dist/chunk-YF5IW525.cjs +155 -0
- package/dist/chunk-YF5IW525.cjs.map +1 -0
- package/dist/index.cjs +17752 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +6091 -0
- package/dist/index.d.ts +6091 -0
- package/dist/index.js +17369 -0
- package/dist/index.js.map +1 -0
- package/dist/layout/theme-storage-key.cjs +12 -0
- package/dist/layout/theme-storage-key.cjs.map +1 -0
- package/dist/layout/theme-storage-key.d.cts +23 -0
- package/dist/layout/theme-storage-key.d.ts +23 -0
- package/dist/layout/theme-storage-key.js +3 -0
- package/dist/layout/theme-storage-key.js.map +1 -0
- package/dist/markdown/index.cjs +550 -0
- package/dist/markdown/index.cjs.map +1 -0
- package/dist/markdown/index.d.cts +221 -0
- package/dist/markdown/index.d.ts +221 -0
- package/dist/markdown/index.js +492 -0
- package/dist/markdown/index.js.map +1 -0
- package/dist/rich-text-6V7WZ3WV.cjs +518 -0
- package/dist/rich-text-6V7WZ3WV.cjs.map +1 -0
- package/dist/rich-text-N2FZEJJV.js +491 -0
- package/dist/rich-text-N2FZEJJV.js.map +1 -0
- package/dist/styles.css +794 -0
- package/dist/theme/theme-css.cjs +105 -0
- package/dist/theme/theme-css.cjs.map +1 -0
- package/dist/theme/theme-css.d.cts +49 -0
- package/dist/theme/theme-css.d.ts +49 -0
- package/dist/theme/theme-css.js +102 -0
- package/dist/theme/theme-css.js.map +1 -0
- package/dist/theme/theme-storage.cjs +51 -0
- package/dist/theme/theme-storage.cjs.map +1 -0
- package/dist/theme/theme-storage.d.cts +75 -0
- package/dist/theme/theme-storage.d.ts +75 -0
- package/dist/theme/theme-storage.js +45 -0
- package/dist/theme/theme-storage.js.map +1 -0
- package/package.json +165 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/theme/theme-css.ts
|
|
4
|
+
function parseTopLevelBlocks(src) {
|
|
5
|
+
const n = src.length;
|
|
6
|
+
const blocks = [];
|
|
7
|
+
let i = 0;
|
|
8
|
+
const skipComment = (pos) => {
|
|
9
|
+
const end = src.indexOf("*/", pos + 2);
|
|
10
|
+
return end === -1 ? n : end + 2;
|
|
11
|
+
};
|
|
12
|
+
const skipString = (pos) => {
|
|
13
|
+
const quote = src[pos];
|
|
14
|
+
let j = pos + 1;
|
|
15
|
+
while (j < n && src[j] !== quote) {
|
|
16
|
+
if (src[j] === "\\") j++;
|
|
17
|
+
j++;
|
|
18
|
+
}
|
|
19
|
+
return j < n ? j + 1 : n;
|
|
20
|
+
};
|
|
21
|
+
while (i < n) {
|
|
22
|
+
while (i < n) {
|
|
23
|
+
const ch = src[i];
|
|
24
|
+
if (ch === " " || ch === " " || ch === "\n" || ch === "\r") {
|
|
25
|
+
i++;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (src.startsWith("/*", i)) {
|
|
29
|
+
i = skipComment(i);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
if (i >= n) break;
|
|
35
|
+
const statementStart = i;
|
|
36
|
+
let head = "";
|
|
37
|
+
while (i < n && src[i] !== "{" && src[i] !== ";") {
|
|
38
|
+
if (src.startsWith("/*", i)) {
|
|
39
|
+
i = skipComment(i);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (src[i] === '"' || src[i] === "'") {
|
|
43
|
+
const strStart = i;
|
|
44
|
+
i = skipString(i);
|
|
45
|
+
head += src.slice(strStart, i);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
head += src[i];
|
|
49
|
+
i++;
|
|
50
|
+
}
|
|
51
|
+
if (i >= n) break;
|
|
52
|
+
if (src[i] === ";") {
|
|
53
|
+
i++;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
let depth = 1;
|
|
57
|
+
let j = i + 1;
|
|
58
|
+
while (j < n && depth > 0) {
|
|
59
|
+
if (src.startsWith("/*", j)) {
|
|
60
|
+
j = skipComment(j);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (src[j] === '"' || src[j] === "'") {
|
|
64
|
+
j = skipString(j);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (src[j] === "{") depth++;
|
|
68
|
+
else if (src[j] === "}") depth--;
|
|
69
|
+
j++;
|
|
70
|
+
}
|
|
71
|
+
blocks.push({ selector: head.trim(), raw: src.slice(statementStart, j) });
|
|
72
|
+
i = j;
|
|
73
|
+
}
|
|
74
|
+
return blocks;
|
|
75
|
+
}
|
|
76
|
+
function stripThemeCss(raw) {
|
|
77
|
+
const normalized = raw.replace(/\r\n/g, "\n");
|
|
78
|
+
const blocks = parseTopLevelBlocks(normalized);
|
|
79
|
+
const kept = blocks.filter((b) => b.selector === ":root" || b.selector === ".dark");
|
|
80
|
+
return kept.map((b) => b.raw).join("\n\n");
|
|
81
|
+
}
|
|
82
|
+
function validateThemeCss(css) {
|
|
83
|
+
const normalized = css.replace(/\r\n/g, "\n");
|
|
84
|
+
if (normalized.trim().length === 0) {
|
|
85
|
+
return { ok: false, reason: "Theme CSS is empty." };
|
|
86
|
+
}
|
|
87
|
+
const blocks = parseTopLevelBlocks(normalized);
|
|
88
|
+
const hasDark = blocks.some((b) => b.selector === ".dark");
|
|
89
|
+
const hasRoot = blocks.some((b) => b.selector === ":root");
|
|
90
|
+
if (!hasDark) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
reason: `Theme has no ".dark" block. Without it, the theme's ":root" values override the app's ".dark" styles at equal CSS specificity, and dark mode silently stops working.`
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (!hasRoot) {
|
|
97
|
+
return { ok: false, reason: 'Theme has no ":root" block \u2014 light-mode values are missing.' };
|
|
98
|
+
}
|
|
99
|
+
return { ok: true };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
exports.stripThemeCss = stripThemeCss;
|
|
103
|
+
exports.validateThemeCss = validateThemeCss;
|
|
104
|
+
//# sourceMappingURL=theme-css.cjs.map
|
|
105
|
+
//# sourceMappingURL=theme-css.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/theme/theme-css.ts"],"names":[],"mappings":"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","file":"theme-css.cjs","sourcesContent":["/**\n * Pure functions for handling a pasted tweakcn theme.\n *\n * tweakcn's \"Copy code\" button emits six constructs. Only two of them mean\n * anything once dropped into a browser at runtime (not build time, since\n * there is no Tailwind build step for pasted CSS):\n *\n * @import \"tailwindcss\"; -> 404s if kept (no such route at runtime)\n * @custom-variant dark (...); -> meaningless outside a Tailwind build\n * :root { ... } -> survives, sets light-mode custom properties\n * .dark { ... } -> survives, sets dark-mode custom properties\n * @theme inline { ... } -> meaningless outside a Tailwind build\n * @layer base { ...; @apply ...; } -> @apply is not real CSS, dead weight\n *\n * `stripThemeCss` throws away everything except the `:root` and `.dark`\n * blocks. `validateThemeCss` catches the one failure mode that is otherwise\n * invisible: a theme with no `.dark` block. `.dark` and `:root` share equal\n * specificity, so injecting a `:root`-only theme *after* the app's own\n * stylesheet makes its `:root` win over `html.dark`'s rules too — dark mode\n * silently stops working, with no error anywhere.\n */\n\nexport type ThemeRecord = { id: string; name: string; css: string };\n\nexport type ThemeValidationResult = { ok: true } | { ok: false; reason: string };\n\ninterface TopLevelBlock {\n /** The selector/at-rule head, trimmed (e.g. \":root\", \".dark\", \"@theme inline\"). */\n selector: string;\n /** The full source slice for this block, selector through closing brace. */\n raw: string;\n}\n\n/**\n * Walks the source once, splitting it into top-level statements. Any\n * statement terminated by `;` (e.g. `@import \"tailwindcss\";`,\n * `@custom-variant dark (&:is(.dark *));`) is dropped outright — it never\n * produces a block. Any statement with a `{ ... }` body is returned as a\n * block, with brace depth tracked through the body so a nested block (e.g.\n * `@layer base { * { ... } body { ... } }`) is consumed as a single unit\n * instead of stopping at its first inner `}`.\n *\n * Comments and quoted strings are skipped transparently everywhere braces\n * are counted, so a brace inside `/* ... *\\/` or inside a string value never\n * perturbs the depth count.\n *\n * Unclosed input (an opened `{`, `/*`, or quote that never finds its\n * closer) is NOT dropped: the depth/comment/string scan simply runs off\n * the end of the string, and the block gets pushed with its `raw` spanning\n * from the opening selector all the way to EOF — swallowing whatever\n * well-formed blocks would otherwise have followed it. Concretely: an\n * unclosed `:root {` earlier in the source will absorb a perfectly valid\n * `.dark { ... }` that comes after it, so `.dark` never becomes its own\n * top-level block. This is a deliberately-left gap, not a bug: the\n * consequence is caught downstream by `validateThemeCss`'s \"no .dark\"\n * check, so truncated/corrupt input fails closed rather than corrupting\n * silently. The one sharp edge is the *reason string* it fails with — a\n * clipboard read that came back truncated will be reported as \"no .dark\n * block\", not \"your paste was cut off\", which is worth remembering when\n * debugging a fetch step that feeds this function.\n */\nfunction parseTopLevelBlocks(src: string): TopLevelBlock[] {\n const n = src.length;\n const blocks: TopLevelBlock[] = [];\n let i = 0;\n\n const skipComment = (pos: number): number => {\n const end = src.indexOf('*/', pos + 2);\n return end === -1 ? n : end + 2;\n };\n\n const skipString = (pos: number): number => {\n const quote = src[pos];\n let j = pos + 1;\n while (j < n && src[j] !== quote) {\n if (src[j] === '\\\\') j++;\n j++;\n }\n return j < n ? j + 1 : n;\n };\n\n while (i < n) {\n // Skip whitespace and comments between statements.\n while (i < n) {\n const ch = src[i];\n if (ch === ' ' || ch === '\\t' || ch === '\\n' || ch === '\\r') {\n i++;\n continue;\n }\n if (src.startsWith('/*', i)) {\n i = skipComment(i);\n continue;\n }\n break;\n }\n if (i >= n) break;\n\n const statementStart = i;\n let head = '';\n\n // Scan the head (selector / at-rule prelude) up to a top-level `{` or `;`.\n while (i < n && src[i] !== '{' && src[i] !== ';') {\n if (src.startsWith('/*', i)) {\n i = skipComment(i);\n continue;\n }\n if (src[i] === '\"' || src[i] === \"'\") {\n const strStart = i;\n i = skipString(i);\n head += src.slice(strStart, i);\n continue;\n }\n head += src[i];\n i++;\n }\n\n // Ran off the end while scanning the head itself (no `{` or `;` ever\n // showed up) — this fragment genuinely IS dropped, since there is no\n // `{` to even start a block from. Contrast with an opened-but-unclosed\n // `{ ... }` body below, which is NOT dropped — see the block comment\n // on this function.\n if (i >= n) break;\n\n if (src[i] === ';') {\n i++; // e.g. `@import \"tailwindcss\";` — drop, no block produced.\n continue;\n }\n\n // src[i] === '{' — consume the whole block, tracking nested brace depth.\n let depth = 1;\n let j = i + 1;\n while (j < n && depth > 0) {\n if (src.startsWith('/*', j)) {\n j = skipComment(j);\n continue;\n }\n if (src[j] === '\"' || src[j] === \"'\") {\n j = skipString(j);\n continue;\n }\n if (src[j] === '{') depth++;\n else if (src[j] === '}') depth--;\n j++;\n }\n\n // Pushed unconditionally — if `depth` never reached 0 the loop above\n // only stopped because `j` hit `n` (EOF). That's intentional: see the\n // \"Unclosed input\" paragraph on this function's doc comment.\n blocks.push({ selector: head.trim(), raw: src.slice(statementStart, j) });\n i = j;\n }\n\n return blocks;\n}\n\n/**\n * Reduces a pasted tweakcn theme to just its `:root { ... }` and\n * `.dark { ... }` blocks, in source order. Everything else (imports,\n * `@custom-variant`, `@theme inline`, `@layer base`) is dropped. Idempotent:\n * running it again on its own output is a no-op.\n */\nexport function stripThemeCss(raw: string): string {\n const normalized = raw.replace(/\\r\\n/g, '\\n');\n const blocks = parseTopLevelBlocks(normalized);\n const kept = blocks.filter((b) => b.selector === ':root' || b.selector === '.dark');\n return kept.map((b) => b.raw).join('\\n\\n');\n}\n\n/**\n * Validates that a (normally already-stripped) theme is safe to inject.\n * The load-bearing check is `.dark`: without it, a theme's `:root` values\n * override the app's `.dark` rules at equal specificity, and dark mode\n * breaks silently. Also rejects empty input and (defensively) a theme with\n * no `:root` block at all.\n */\nexport function validateThemeCss(css: string): ThemeValidationResult {\n const normalized = css.replace(/\\r\\n/g, '\\n');\n if (normalized.trim().length === 0) {\n return { ok: false, reason: 'Theme CSS is empty.' };\n }\n\n const blocks = parseTopLevelBlocks(normalized);\n const hasDark = blocks.some((b) => b.selector === '.dark');\n const hasRoot = blocks.some((b) => b.selector === ':root');\n\n if (!hasDark) {\n return {\n ok: false,\n reason:\n 'Theme has no \".dark\" block. Without it, the theme\\'s \":root\" values ' +\n 'override the app\\'s \".dark\" styles at equal CSS specificity, and dark ' +\n 'mode silently stops working.',\n };\n }\n\n if (!hasRoot) {\n return { ok: false, reason: 'Theme has no \":root\" block — light-mode values are missing.' };\n }\n\n return { ok: true };\n}\n"]}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure functions for handling a pasted tweakcn theme.
|
|
3
|
+
*
|
|
4
|
+
* tweakcn's "Copy code" button emits six constructs. Only two of them mean
|
|
5
|
+
* anything once dropped into a browser at runtime (not build time, since
|
|
6
|
+
* there is no Tailwind build step for pasted CSS):
|
|
7
|
+
*
|
|
8
|
+
* @import "tailwindcss"; -> 404s if kept (no such route at runtime)
|
|
9
|
+
* @custom-variant dark (...); -> meaningless outside a Tailwind build
|
|
10
|
+
* :root { ... } -> survives, sets light-mode custom properties
|
|
11
|
+
* .dark { ... } -> survives, sets dark-mode custom properties
|
|
12
|
+
* @theme inline { ... } -> meaningless outside a Tailwind build
|
|
13
|
+
* @layer base { ...; @apply ...; } -> @apply is not real CSS, dead weight
|
|
14
|
+
*
|
|
15
|
+
* `stripThemeCss` throws away everything except the `:root` and `.dark`
|
|
16
|
+
* blocks. `validateThemeCss` catches the one failure mode that is otherwise
|
|
17
|
+
* invisible: a theme with no `.dark` block. `.dark` and `:root` share equal
|
|
18
|
+
* specificity, so injecting a `:root`-only theme *after* the app's own
|
|
19
|
+
* stylesheet makes its `:root` win over `html.dark`'s rules too — dark mode
|
|
20
|
+
* silently stops working, with no error anywhere.
|
|
21
|
+
*/
|
|
22
|
+
type ThemeRecord = {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
css: string;
|
|
26
|
+
};
|
|
27
|
+
type ThemeValidationResult = {
|
|
28
|
+
ok: true;
|
|
29
|
+
} | {
|
|
30
|
+
ok: false;
|
|
31
|
+
reason: string;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Reduces a pasted tweakcn theme to just its `:root { ... }` and
|
|
35
|
+
* `.dark { ... }` blocks, in source order. Everything else (imports,
|
|
36
|
+
* `@custom-variant`, `@theme inline`, `@layer base`) is dropped. Idempotent:
|
|
37
|
+
* running it again on its own output is a no-op.
|
|
38
|
+
*/
|
|
39
|
+
declare function stripThemeCss(raw: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* Validates that a (normally already-stripped) theme is safe to inject.
|
|
42
|
+
* The load-bearing check is `.dark`: without it, a theme's `:root` values
|
|
43
|
+
* override the app's `.dark` rules at equal specificity, and dark mode
|
|
44
|
+
* breaks silently. Also rejects empty input and (defensively) a theme with
|
|
45
|
+
* no `:root` block at all.
|
|
46
|
+
*/
|
|
47
|
+
declare function validateThemeCss(css: string): ThemeValidationResult;
|
|
48
|
+
|
|
49
|
+
export { type ThemeRecord, type ThemeValidationResult, stripThemeCss, validateThemeCss };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure functions for handling a pasted tweakcn theme.
|
|
3
|
+
*
|
|
4
|
+
* tweakcn's "Copy code" button emits six constructs. Only two of them mean
|
|
5
|
+
* anything once dropped into a browser at runtime (not build time, since
|
|
6
|
+
* there is no Tailwind build step for pasted CSS):
|
|
7
|
+
*
|
|
8
|
+
* @import "tailwindcss"; -> 404s if kept (no such route at runtime)
|
|
9
|
+
* @custom-variant dark (...); -> meaningless outside a Tailwind build
|
|
10
|
+
* :root { ... } -> survives, sets light-mode custom properties
|
|
11
|
+
* .dark { ... } -> survives, sets dark-mode custom properties
|
|
12
|
+
* @theme inline { ... } -> meaningless outside a Tailwind build
|
|
13
|
+
* @layer base { ...; @apply ...; } -> @apply is not real CSS, dead weight
|
|
14
|
+
*
|
|
15
|
+
* `stripThemeCss` throws away everything except the `:root` and `.dark`
|
|
16
|
+
* blocks. `validateThemeCss` catches the one failure mode that is otherwise
|
|
17
|
+
* invisible: a theme with no `.dark` block. `.dark` and `:root` share equal
|
|
18
|
+
* specificity, so injecting a `:root`-only theme *after* the app's own
|
|
19
|
+
* stylesheet makes its `:root` win over `html.dark`'s rules too — dark mode
|
|
20
|
+
* silently stops working, with no error anywhere.
|
|
21
|
+
*/
|
|
22
|
+
type ThemeRecord = {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
css: string;
|
|
26
|
+
};
|
|
27
|
+
type ThemeValidationResult = {
|
|
28
|
+
ok: true;
|
|
29
|
+
} | {
|
|
30
|
+
ok: false;
|
|
31
|
+
reason: string;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Reduces a pasted tweakcn theme to just its `:root { ... }` and
|
|
35
|
+
* `.dark { ... }` blocks, in source order. Everything else (imports,
|
|
36
|
+
* `@custom-variant`, `@theme inline`, `@layer base`) is dropped. Idempotent:
|
|
37
|
+
* running it again on its own output is a no-op.
|
|
38
|
+
*/
|
|
39
|
+
declare function stripThemeCss(raw: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* Validates that a (normally already-stripped) theme is safe to inject.
|
|
42
|
+
* The load-bearing check is `.dark`: without it, a theme's `:root` values
|
|
43
|
+
* override the app's `.dark` rules at equal specificity, and dark mode
|
|
44
|
+
* breaks silently. Also rejects empty input and (defensively) a theme with
|
|
45
|
+
* no `:root` block at all.
|
|
46
|
+
*/
|
|
47
|
+
declare function validateThemeCss(css: string): ThemeValidationResult;
|
|
48
|
+
|
|
49
|
+
export { type ThemeRecord, type ThemeValidationResult, stripThemeCss, validateThemeCss };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// src/theme/theme-css.ts
|
|
2
|
+
function parseTopLevelBlocks(src) {
|
|
3
|
+
const n = src.length;
|
|
4
|
+
const blocks = [];
|
|
5
|
+
let i = 0;
|
|
6
|
+
const skipComment = (pos) => {
|
|
7
|
+
const end = src.indexOf("*/", pos + 2);
|
|
8
|
+
return end === -1 ? n : end + 2;
|
|
9
|
+
};
|
|
10
|
+
const skipString = (pos) => {
|
|
11
|
+
const quote = src[pos];
|
|
12
|
+
let j = pos + 1;
|
|
13
|
+
while (j < n && src[j] !== quote) {
|
|
14
|
+
if (src[j] === "\\") j++;
|
|
15
|
+
j++;
|
|
16
|
+
}
|
|
17
|
+
return j < n ? j + 1 : n;
|
|
18
|
+
};
|
|
19
|
+
while (i < n) {
|
|
20
|
+
while (i < n) {
|
|
21
|
+
const ch = src[i];
|
|
22
|
+
if (ch === " " || ch === " " || ch === "\n" || ch === "\r") {
|
|
23
|
+
i++;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (src.startsWith("/*", i)) {
|
|
27
|
+
i = skipComment(i);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
if (i >= n) break;
|
|
33
|
+
const statementStart = i;
|
|
34
|
+
let head = "";
|
|
35
|
+
while (i < n && src[i] !== "{" && src[i] !== ";") {
|
|
36
|
+
if (src.startsWith("/*", i)) {
|
|
37
|
+
i = skipComment(i);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (src[i] === '"' || src[i] === "'") {
|
|
41
|
+
const strStart = i;
|
|
42
|
+
i = skipString(i);
|
|
43
|
+
head += src.slice(strStart, i);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
head += src[i];
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
if (i >= n) break;
|
|
50
|
+
if (src[i] === ";") {
|
|
51
|
+
i++;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
let depth = 1;
|
|
55
|
+
let j = i + 1;
|
|
56
|
+
while (j < n && depth > 0) {
|
|
57
|
+
if (src.startsWith("/*", j)) {
|
|
58
|
+
j = skipComment(j);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (src[j] === '"' || src[j] === "'") {
|
|
62
|
+
j = skipString(j);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (src[j] === "{") depth++;
|
|
66
|
+
else if (src[j] === "}") depth--;
|
|
67
|
+
j++;
|
|
68
|
+
}
|
|
69
|
+
blocks.push({ selector: head.trim(), raw: src.slice(statementStart, j) });
|
|
70
|
+
i = j;
|
|
71
|
+
}
|
|
72
|
+
return blocks;
|
|
73
|
+
}
|
|
74
|
+
function stripThemeCss(raw) {
|
|
75
|
+
const normalized = raw.replace(/\r\n/g, "\n");
|
|
76
|
+
const blocks = parseTopLevelBlocks(normalized);
|
|
77
|
+
const kept = blocks.filter((b) => b.selector === ":root" || b.selector === ".dark");
|
|
78
|
+
return kept.map((b) => b.raw).join("\n\n");
|
|
79
|
+
}
|
|
80
|
+
function validateThemeCss(css) {
|
|
81
|
+
const normalized = css.replace(/\r\n/g, "\n");
|
|
82
|
+
if (normalized.trim().length === 0) {
|
|
83
|
+
return { ok: false, reason: "Theme CSS is empty." };
|
|
84
|
+
}
|
|
85
|
+
const blocks = parseTopLevelBlocks(normalized);
|
|
86
|
+
const hasDark = blocks.some((b) => b.selector === ".dark");
|
|
87
|
+
const hasRoot = blocks.some((b) => b.selector === ":root");
|
|
88
|
+
if (!hasDark) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
reason: `Theme has no ".dark" block. Without it, the theme's ":root" values override the app's ".dark" styles at equal CSS specificity, and dark mode silently stops working.`
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (!hasRoot) {
|
|
95
|
+
return { ok: false, reason: 'Theme has no ":root" block \u2014 light-mode values are missing.' };
|
|
96
|
+
}
|
|
97
|
+
return { ok: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export { stripThemeCss, validateThemeCss };
|
|
101
|
+
//# sourceMappingURL=theme-css.js.map
|
|
102
|
+
//# sourceMappingURL=theme-css.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/theme/theme-css.ts"],"names":[],"mappings":"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","file":"theme-css.js","sourcesContent":["/**\n * Pure functions for handling a pasted tweakcn theme.\n *\n * tweakcn's \"Copy code\" button emits six constructs. Only two of them mean\n * anything once dropped into a browser at runtime (not build time, since\n * there is no Tailwind build step for pasted CSS):\n *\n * @import \"tailwindcss\"; -> 404s if kept (no such route at runtime)\n * @custom-variant dark (...); -> meaningless outside a Tailwind build\n * :root { ... } -> survives, sets light-mode custom properties\n * .dark { ... } -> survives, sets dark-mode custom properties\n * @theme inline { ... } -> meaningless outside a Tailwind build\n * @layer base { ...; @apply ...; } -> @apply is not real CSS, dead weight\n *\n * `stripThemeCss` throws away everything except the `:root` and `.dark`\n * blocks. `validateThemeCss` catches the one failure mode that is otherwise\n * invisible: a theme with no `.dark` block. `.dark` and `:root` share equal\n * specificity, so injecting a `:root`-only theme *after* the app's own\n * stylesheet makes its `:root` win over `html.dark`'s rules too — dark mode\n * silently stops working, with no error anywhere.\n */\n\nexport type ThemeRecord = { id: string; name: string; css: string };\n\nexport type ThemeValidationResult = { ok: true } | { ok: false; reason: string };\n\ninterface TopLevelBlock {\n /** The selector/at-rule head, trimmed (e.g. \":root\", \".dark\", \"@theme inline\"). */\n selector: string;\n /** The full source slice for this block, selector through closing brace. */\n raw: string;\n}\n\n/**\n * Walks the source once, splitting it into top-level statements. Any\n * statement terminated by `;` (e.g. `@import \"tailwindcss\";`,\n * `@custom-variant dark (&:is(.dark *));`) is dropped outright — it never\n * produces a block. Any statement with a `{ ... }` body is returned as a\n * block, with brace depth tracked through the body so a nested block (e.g.\n * `@layer base { * { ... } body { ... } }`) is consumed as a single unit\n * instead of stopping at its first inner `}`.\n *\n * Comments and quoted strings are skipped transparently everywhere braces\n * are counted, so a brace inside `/* ... *\\/` or inside a string value never\n * perturbs the depth count.\n *\n * Unclosed input (an opened `{`, `/*`, or quote that never finds its\n * closer) is NOT dropped: the depth/comment/string scan simply runs off\n * the end of the string, and the block gets pushed with its `raw` spanning\n * from the opening selector all the way to EOF — swallowing whatever\n * well-formed blocks would otherwise have followed it. Concretely: an\n * unclosed `:root {` earlier in the source will absorb a perfectly valid\n * `.dark { ... }` that comes after it, so `.dark` never becomes its own\n * top-level block. This is a deliberately-left gap, not a bug: the\n * consequence is caught downstream by `validateThemeCss`'s \"no .dark\"\n * check, so truncated/corrupt input fails closed rather than corrupting\n * silently. The one sharp edge is the *reason string* it fails with — a\n * clipboard read that came back truncated will be reported as \"no .dark\n * block\", not \"your paste was cut off\", which is worth remembering when\n * debugging a fetch step that feeds this function.\n */\nfunction parseTopLevelBlocks(src: string): TopLevelBlock[] {\n const n = src.length;\n const blocks: TopLevelBlock[] = [];\n let i = 0;\n\n const skipComment = (pos: number): number => {\n const end = src.indexOf('*/', pos + 2);\n return end === -1 ? n : end + 2;\n };\n\n const skipString = (pos: number): number => {\n const quote = src[pos];\n let j = pos + 1;\n while (j < n && src[j] !== quote) {\n if (src[j] === '\\\\') j++;\n j++;\n }\n return j < n ? j + 1 : n;\n };\n\n while (i < n) {\n // Skip whitespace and comments between statements.\n while (i < n) {\n const ch = src[i];\n if (ch === ' ' || ch === '\\t' || ch === '\\n' || ch === '\\r') {\n i++;\n continue;\n }\n if (src.startsWith('/*', i)) {\n i = skipComment(i);\n continue;\n }\n break;\n }\n if (i >= n) break;\n\n const statementStart = i;\n let head = '';\n\n // Scan the head (selector / at-rule prelude) up to a top-level `{` or `;`.\n while (i < n && src[i] !== '{' && src[i] !== ';') {\n if (src.startsWith('/*', i)) {\n i = skipComment(i);\n continue;\n }\n if (src[i] === '\"' || src[i] === \"'\") {\n const strStart = i;\n i = skipString(i);\n head += src.slice(strStart, i);\n continue;\n }\n head += src[i];\n i++;\n }\n\n // Ran off the end while scanning the head itself (no `{` or `;` ever\n // showed up) — this fragment genuinely IS dropped, since there is no\n // `{` to even start a block from. Contrast with an opened-but-unclosed\n // `{ ... }` body below, which is NOT dropped — see the block comment\n // on this function.\n if (i >= n) break;\n\n if (src[i] === ';') {\n i++; // e.g. `@import \"tailwindcss\";` — drop, no block produced.\n continue;\n }\n\n // src[i] === '{' — consume the whole block, tracking nested brace depth.\n let depth = 1;\n let j = i + 1;\n while (j < n && depth > 0) {\n if (src.startsWith('/*', j)) {\n j = skipComment(j);\n continue;\n }\n if (src[j] === '\"' || src[j] === \"'\") {\n j = skipString(j);\n continue;\n }\n if (src[j] === '{') depth++;\n else if (src[j] === '}') depth--;\n j++;\n }\n\n // Pushed unconditionally — if `depth` never reached 0 the loop above\n // only stopped because `j` hit `n` (EOF). That's intentional: see the\n // \"Unclosed input\" paragraph on this function's doc comment.\n blocks.push({ selector: head.trim(), raw: src.slice(statementStart, j) });\n i = j;\n }\n\n return blocks;\n}\n\n/**\n * Reduces a pasted tweakcn theme to just its `:root { ... }` and\n * `.dark { ... }` blocks, in source order. Everything else (imports,\n * `@custom-variant`, `@theme inline`, `@layer base`) is dropped. Idempotent:\n * running it again on its own output is a no-op.\n */\nexport function stripThemeCss(raw: string): string {\n const normalized = raw.replace(/\\r\\n/g, '\\n');\n const blocks = parseTopLevelBlocks(normalized);\n const kept = blocks.filter((b) => b.selector === ':root' || b.selector === '.dark');\n return kept.map((b) => b.raw).join('\\n\\n');\n}\n\n/**\n * Validates that a (normally already-stripped) theme is safe to inject.\n * The load-bearing check is `.dark`: without it, a theme's `:root` values\n * override the app's `.dark` rules at equal specificity, and dark mode\n * breaks silently. Also rejects empty input and (defensively) a theme with\n * no `:root` block at all.\n */\nexport function validateThemeCss(css: string): ThemeValidationResult {\n const normalized = css.replace(/\\r\\n/g, '\\n');\n if (normalized.trim().length === 0) {\n return { ok: false, reason: 'Theme CSS is empty.' };\n }\n\n const blocks = parseTopLevelBlocks(normalized);\n const hasDark = blocks.some((b) => b.selector === '.dark');\n const hasRoot = blocks.some((b) => b.selector === ':root');\n\n if (!hasDark) {\n return {\n ok: false,\n reason:\n 'Theme has no \".dark\" block. Without it, the theme\\'s \":root\" values ' +\n 'override the app\\'s \".dark\" styles at equal CSS specificity, and dark ' +\n 'mode silently stops working.',\n };\n }\n\n if (!hasRoot) {\n return { ok: false, reason: 'Theme has no \":root\" block — light-mode values are missing.' };\n }\n\n return { ok: true };\n}\n"]}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/theme/theme-storage.ts
|
|
4
|
+
var THEME_CSS_STORAGE_KEY = "buro-theme-css";
|
|
5
|
+
var THEME_CSS_STYLE_ELEMENT_ID = "buro-theme-css";
|
|
6
|
+
function readThemeCss() {
|
|
7
|
+
if (typeof window === "undefined") return null;
|
|
8
|
+
try {
|
|
9
|
+
const css = window.localStorage.getItem(THEME_CSS_STORAGE_KEY);
|
|
10
|
+
return css && css.length > 0 ? css : null;
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function writeThemeCss(css) {
|
|
16
|
+
if (typeof window === "undefined") return;
|
|
17
|
+
try {
|
|
18
|
+
if (css === null || css.length === 0) {
|
|
19
|
+
window.localStorage.removeItem(THEME_CSS_STORAGE_KEY);
|
|
20
|
+
} else {
|
|
21
|
+
window.localStorage.setItem(THEME_CSS_STORAGE_KEY, css);
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function applyThemeCss(css) {
|
|
27
|
+
if (typeof document === "undefined") return;
|
|
28
|
+
const existing = document.getElementById(THEME_CSS_STYLE_ELEMENT_ID);
|
|
29
|
+
if (css === null || css.length === 0) {
|
|
30
|
+
existing?.remove();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (existing instanceof HTMLStyleElement) {
|
|
34
|
+
existing.textContent = css;
|
|
35
|
+
document.head.appendChild(existing);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
existing?.remove();
|
|
39
|
+
const style = document.createElement("style");
|
|
40
|
+
style.id = THEME_CSS_STYLE_ELEMENT_ID;
|
|
41
|
+
style.textContent = css;
|
|
42
|
+
document.head.appendChild(style);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
exports.THEME_CSS_STORAGE_KEY = THEME_CSS_STORAGE_KEY;
|
|
46
|
+
exports.THEME_CSS_STYLE_ELEMENT_ID = THEME_CSS_STYLE_ELEMENT_ID;
|
|
47
|
+
exports.applyThemeCss = applyThemeCss;
|
|
48
|
+
exports.readThemeCss = readThemeCss;
|
|
49
|
+
exports.writeThemeCss = writeThemeCss;
|
|
50
|
+
//# sourceMappingURL=theme-storage.cjs.map
|
|
51
|
+
//# sourceMappingURL=theme-storage.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/theme/theme-storage.ts"],"names":[],"mappings":";;;AAuCO,IAAM,qBAAA,GAAwB;AAK9B,IAAM,0BAAA,GAA6B;AAQnC,SAAS,YAAA,GAA8B;AAC5C,EAAA,IAAI,OAAO,MAAA,KAAW,WAAA,EAAa,OAAO,IAAA;AAC1C,EAAA,IAAI;AACF,IAAA,MAAM,GAAA,GAAM,MAAA,CAAO,YAAA,CAAa,OAAA,CAAQ,qBAAqB,CAAA;AAC7D,IAAA,OAAO,GAAA,IAAO,GAAA,CAAI,MAAA,GAAS,CAAA,GAAI,GAAA,GAAM,IAAA;AAAA,EACvC,CAAA,CAAA,MAAQ;AACN,IAAA,OAAO,IAAA;AAAA,EACT;AACF;AAWO,SAAS,cAAc,GAAA,EAA0B;AACtD,EAAA,IAAI,OAAO,WAAW,WAAA,EAAa;AACnC,EAAA,IAAI;AACF,IAAA,IAAI,GAAA,KAAQ,IAAA,IAAQ,GAAA,CAAI,MAAA,KAAW,CAAA,EAAG;AACpC,MAAA,MAAA,CAAO,YAAA,CAAa,WAAW,qBAAqB,CAAA;AAAA,IACtD,CAAA,MAAO;AACL,MAAA,MAAA,CAAO,YAAA,CAAa,OAAA,CAAQ,qBAAA,EAAuB,GAAG,CAAA;AAAA,IACxD;AAAA,EACF,CAAA,CAAA,MAAQ;AAAA,EAGR;AACF;AAcO,SAAS,cAAc,GAAA,EAA0B;AACtD,EAAA,IAAI,OAAO,aAAa,WAAA,EAAa;AACrC,EAAA,MAAM,QAAA,GAAW,QAAA,CAAS,cAAA,CAAe,0BAA0B,CAAA;AAEnE,EAAA,IAAI,GAAA,KAAQ,IAAA,IAAQ,GAAA,CAAI,MAAA,KAAW,CAAA,EAAG;AACpC,IAAA,QAAA,EAAU,MAAA,EAAO;AACjB,IAAA;AAAA,EACF;AAEA,EAAA,IAAI,oBAAoB,gBAAA,EAAkB;AACxC,IAAA,QAAA,CAAS,WAAA,GAAc,GAAA;AACvB,IAAA,QAAA,CAAS,IAAA,CAAK,YAAY,QAAQ,CAAA;AAClC,IAAA;AAAA,EACF;AACA,EAAA,QAAA,EAAU,MAAA,EAAO;AAEjB,EAAA,MAAM,KAAA,GAAQ,QAAA,CAAS,aAAA,CAAc,OAAO,CAAA;AAC5C,EAAA,KAAA,CAAM,EAAA,GAAK,0BAAA;AACX,EAAA,KAAA,CAAM,WAAA,GAAc,GAAA;AACpB,EAAA,QAAA,CAAS,IAAA,CAAK,YAAY,KAAK,CAAA;AACjC","file":"theme-storage.cjs","sourcesContent":["/**\n * Persists and applies the active PALETTE theme's CSS — separate concern\n * from `THEME_STORAGE_KEY` (`packages/ui-shadcn/src/layout/theme-storage-key.ts`),\n * which holds light/dark/system. That key answers \"which class goes on\n * `<html>`\"; this one answers \"what custom-property values does `:root`/\n * `.dark` carry\". A theme picker calling `writeThemeCss` must never touch\n * `THEME_STORAGE_KEY`, and the light/dark toggle must never touch this one —\n * each write path only ever calls its own key's functions, so there is no\n * shared code path where a bug could cross-contaminate them.\n *\n * The stored value is the theme's CSS **in full** (already reduced to just\n * `:root`/`.dark` by `stripThemeCss`), not a theme id. That is what makes a\n * pasted (tweakcn) theme and a built-in theme indistinguishable to this\n * module and to the bootstrap script: neither has to know the built-in\n * theme catalogue, ship it in the bundle, or fetch it over the network\n * before paint. The cost is that this module does NOT validate — a caller\n * that wants the \".dark block present\" guarantee must run `validateThemeCss`\n * itself before calling `writeThemeCss` (the theme picker does this; see its\n * task). `applyThemeCss` and the bootstrap script just inject whatever\n * string is there. That is deliberate, not an oversight: CSS parsing is\n * forgiving by construction — unknown/malformed declarations are dropped\n * rule-by-rule by the browser's own parser, not fatal — so blindly injecting\n * a corrupt or half-written string degrades to \"some rules don't apply\", not\n * a crash or a blank page. Re-validating on every read would also block\n * first paint on running `parseTopLevelBlocks` over an arbitrarily large\n * string, which is exactly the cost the bootstrap script exists to avoid.\n *\n * Deliberately its own module, no React import, same reasoning as\n * `theme-storage-key.ts`: the ONE consumer that needs `THEME_CSS_STORAGE_KEY`\n * and `applyThemeCss` outside a hook is the bootstrap `<script>` in the\n * playground's root `app/layout.tsx`, a Server Component that cannot import\n * anything carrying `'use client'`. This file has no hooks, so it can be\n * imported there directly via the `@burojs/ui-shadcn/theme-storage` subpath.\n */\n\n/**\n * The localStorage key for the active theme's CSS text. Kept apart from\n * `THEME_STORAGE_KEY` on purpose — see the module doc comment above.\n */\nexport const THEME_CSS_STORAGE_KEY = 'buro-theme-css';\n\n/** The id of the `<style>` element the bootstrap script and `applyThemeCss`\n * both create/update. Exported so a test can assert on it without\n * retyping the literal. */\nexport const THEME_CSS_STYLE_ELEMENT_ID = 'buro-theme-css';\n\n/**\n * Reads the persisted theme CSS, or `null` if nothing is stored, storage is\n * unavailable (SSR, or a browser with it disabled — e.g. some private-mode\n * configurations throw on access rather than just being empty), or the\n * stored value is an empty string (treated the same as absent).\n */\nexport function readThemeCss(): string | null {\n if (typeof window === 'undefined') return null;\n try {\n const css = window.localStorage.getItem(THEME_CSS_STORAGE_KEY);\n return css && css.length > 0 ? css : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Persists (or, given `null`, clears) the theme CSS. Does not validate —\n * callers that need the \".dark present\" guarantee run `validateThemeCss`\n * first (see module doc comment). Swallows storage errors (quota exceeded\n * from an oversized paste, storage disabled, etc.): the in-memory\n * application via `applyThemeCss` already happened by the time a caller\n * persists, so a failed write loses the choice on next reload but does not\n * throw or break the current page.\n */\nexport function writeThemeCss(css: string | null): void {\n if (typeof window === 'undefined') return;\n try {\n if (css === null || css.length === 0) {\n window.localStorage.removeItem(THEME_CSS_STORAGE_KEY);\n } else {\n window.localStorage.setItem(THEME_CSS_STORAGE_KEY, css);\n }\n } catch {\n // Quota exceeded, storage disabled, or a similar DOMException — nothing\n // persisted. The caller's in-memory `applyThemeCss` call still stands.\n }\n}\n\n/**\n * Creates, updates, or removes the `<style id=\"buro-theme-css\">`\n * element that carries the active theme's `:root`/`.dark` custom\n * properties. Given `null` (or an empty string), removes the element\n * entirely rather than leaving an empty tag behind.\n *\n * Order matters: this function always places the element at the END of\n * `<head>`, including when updating a style created by a prepaint bootstrap.\n * That lets an app call it after its own stylesheet has loaded so the theme's\n * equal-specificity `:root`/`.dark` rules win the cascade without creating a\n * duplicate element.\n */\nexport function applyThemeCss(css: string | null): void {\n if (typeof document === 'undefined') return;\n const existing = document.getElementById(THEME_CSS_STYLE_ELEMENT_ID);\n\n if (css === null || css.length === 0) {\n existing?.remove();\n return;\n }\n\n if (existing instanceof HTMLStyleElement) {\n existing.textContent = css;\n document.head.appendChild(existing);\n return;\n }\n existing?.remove(); // defensive: same id but somehow not a <style>\n\n const style = document.createElement('style');\n style.id = THEME_CSS_STYLE_ELEMENT_ID;\n style.textContent = css;\n document.head.appendChild(style);\n}\n"]}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persists and applies the active PALETTE theme's CSS — separate concern
|
|
3
|
+
* from `THEME_STORAGE_KEY` (`packages/ui-shadcn/src/layout/theme-storage-key.ts`),
|
|
4
|
+
* which holds light/dark/system. That key answers "which class goes on
|
|
5
|
+
* `<html>`"; this one answers "what custom-property values does `:root`/
|
|
6
|
+
* `.dark` carry". A theme picker calling `writeThemeCss` must never touch
|
|
7
|
+
* `THEME_STORAGE_KEY`, and the light/dark toggle must never touch this one —
|
|
8
|
+
* each write path only ever calls its own key's functions, so there is no
|
|
9
|
+
* shared code path where a bug could cross-contaminate them.
|
|
10
|
+
*
|
|
11
|
+
* The stored value is the theme's CSS **in full** (already reduced to just
|
|
12
|
+
* `:root`/`.dark` by `stripThemeCss`), not a theme id. That is what makes a
|
|
13
|
+
* pasted (tweakcn) theme and a built-in theme indistinguishable to this
|
|
14
|
+
* module and to the bootstrap script: neither has to know the built-in
|
|
15
|
+
* theme catalogue, ship it in the bundle, or fetch it over the network
|
|
16
|
+
* before paint. The cost is that this module does NOT validate — a caller
|
|
17
|
+
* that wants the ".dark block present" guarantee must run `validateThemeCss`
|
|
18
|
+
* itself before calling `writeThemeCss` (the theme picker does this; see its
|
|
19
|
+
* task). `applyThemeCss` and the bootstrap script just inject whatever
|
|
20
|
+
* string is there. That is deliberate, not an oversight: CSS parsing is
|
|
21
|
+
* forgiving by construction — unknown/malformed declarations are dropped
|
|
22
|
+
* rule-by-rule by the browser's own parser, not fatal — so blindly injecting
|
|
23
|
+
* a corrupt or half-written string degrades to "some rules don't apply", not
|
|
24
|
+
* a crash or a blank page. Re-validating on every read would also block
|
|
25
|
+
* first paint on running `parseTopLevelBlocks` over an arbitrarily large
|
|
26
|
+
* string, which is exactly the cost the bootstrap script exists to avoid.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately its own module, no React import, same reasoning as
|
|
29
|
+
* `theme-storage-key.ts`: the ONE consumer that needs `THEME_CSS_STORAGE_KEY`
|
|
30
|
+
* and `applyThemeCss` outside a hook is the bootstrap `<script>` in the
|
|
31
|
+
* playground's root `app/layout.tsx`, a Server Component that cannot import
|
|
32
|
+
* anything carrying `'use client'`. This file has no hooks, so it can be
|
|
33
|
+
* imported there directly via the `@burojs/ui-shadcn/theme-storage` subpath.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* The localStorage key for the active theme's CSS text. Kept apart from
|
|
37
|
+
* `THEME_STORAGE_KEY` on purpose — see the module doc comment above.
|
|
38
|
+
*/
|
|
39
|
+
declare const THEME_CSS_STORAGE_KEY = "buro-theme-css";
|
|
40
|
+
/** The id of the `<style>` element the bootstrap script and `applyThemeCss`
|
|
41
|
+
* both create/update. Exported so a test can assert on it without
|
|
42
|
+
* retyping the literal. */
|
|
43
|
+
declare const THEME_CSS_STYLE_ELEMENT_ID = "buro-theme-css";
|
|
44
|
+
/**
|
|
45
|
+
* Reads the persisted theme CSS, or `null` if nothing is stored, storage is
|
|
46
|
+
* unavailable (SSR, or a browser with it disabled — e.g. some private-mode
|
|
47
|
+
* configurations throw on access rather than just being empty), or the
|
|
48
|
+
* stored value is an empty string (treated the same as absent).
|
|
49
|
+
*/
|
|
50
|
+
declare function readThemeCss(): string | null;
|
|
51
|
+
/**
|
|
52
|
+
* Persists (or, given `null`, clears) the theme CSS. Does not validate —
|
|
53
|
+
* callers that need the ".dark present" guarantee run `validateThemeCss`
|
|
54
|
+
* first (see module doc comment). Swallows storage errors (quota exceeded
|
|
55
|
+
* from an oversized paste, storage disabled, etc.): the in-memory
|
|
56
|
+
* application via `applyThemeCss` already happened by the time a caller
|
|
57
|
+
* persists, so a failed write loses the choice on next reload but does not
|
|
58
|
+
* throw or break the current page.
|
|
59
|
+
*/
|
|
60
|
+
declare function writeThemeCss(css: string | null): void;
|
|
61
|
+
/**
|
|
62
|
+
* Creates, updates, or removes the `<style id="buro-theme-css">`
|
|
63
|
+
* element that carries the active theme's `:root`/`.dark` custom
|
|
64
|
+
* properties. Given `null` (or an empty string), removes the element
|
|
65
|
+
* entirely rather than leaving an empty tag behind.
|
|
66
|
+
*
|
|
67
|
+
* Order matters: this function always places the element at the END of
|
|
68
|
+
* `<head>`, including when updating a style created by a prepaint bootstrap.
|
|
69
|
+
* That lets an app call it after its own stylesheet has loaded so the theme's
|
|
70
|
+
* equal-specificity `:root`/`.dark` rules win the cascade without creating a
|
|
71
|
+
* duplicate element.
|
|
72
|
+
*/
|
|
73
|
+
declare function applyThemeCss(css: string | null): void;
|
|
74
|
+
|
|
75
|
+
export { THEME_CSS_STORAGE_KEY, THEME_CSS_STYLE_ELEMENT_ID, applyThemeCss, readThemeCss, writeThemeCss };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persists and applies the active PALETTE theme's CSS — separate concern
|
|
3
|
+
* from `THEME_STORAGE_KEY` (`packages/ui-shadcn/src/layout/theme-storage-key.ts`),
|
|
4
|
+
* which holds light/dark/system. That key answers "which class goes on
|
|
5
|
+
* `<html>`"; this one answers "what custom-property values does `:root`/
|
|
6
|
+
* `.dark` carry". A theme picker calling `writeThemeCss` must never touch
|
|
7
|
+
* `THEME_STORAGE_KEY`, and the light/dark toggle must never touch this one —
|
|
8
|
+
* each write path only ever calls its own key's functions, so there is no
|
|
9
|
+
* shared code path where a bug could cross-contaminate them.
|
|
10
|
+
*
|
|
11
|
+
* The stored value is the theme's CSS **in full** (already reduced to just
|
|
12
|
+
* `:root`/`.dark` by `stripThemeCss`), not a theme id. That is what makes a
|
|
13
|
+
* pasted (tweakcn) theme and a built-in theme indistinguishable to this
|
|
14
|
+
* module and to the bootstrap script: neither has to know the built-in
|
|
15
|
+
* theme catalogue, ship it in the bundle, or fetch it over the network
|
|
16
|
+
* before paint. The cost is that this module does NOT validate — a caller
|
|
17
|
+
* that wants the ".dark block present" guarantee must run `validateThemeCss`
|
|
18
|
+
* itself before calling `writeThemeCss` (the theme picker does this; see its
|
|
19
|
+
* task). `applyThemeCss` and the bootstrap script just inject whatever
|
|
20
|
+
* string is there. That is deliberate, not an oversight: CSS parsing is
|
|
21
|
+
* forgiving by construction — unknown/malformed declarations are dropped
|
|
22
|
+
* rule-by-rule by the browser's own parser, not fatal — so blindly injecting
|
|
23
|
+
* a corrupt or half-written string degrades to "some rules don't apply", not
|
|
24
|
+
* a crash or a blank page. Re-validating on every read would also block
|
|
25
|
+
* first paint on running `parseTopLevelBlocks` over an arbitrarily large
|
|
26
|
+
* string, which is exactly the cost the bootstrap script exists to avoid.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately its own module, no React import, same reasoning as
|
|
29
|
+
* `theme-storage-key.ts`: the ONE consumer that needs `THEME_CSS_STORAGE_KEY`
|
|
30
|
+
* and `applyThemeCss` outside a hook is the bootstrap `<script>` in the
|
|
31
|
+
* playground's root `app/layout.tsx`, a Server Component that cannot import
|
|
32
|
+
* anything carrying `'use client'`. This file has no hooks, so it can be
|
|
33
|
+
* imported there directly via the `@burojs/ui-shadcn/theme-storage` subpath.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* The localStorage key for the active theme's CSS text. Kept apart from
|
|
37
|
+
* `THEME_STORAGE_KEY` on purpose — see the module doc comment above.
|
|
38
|
+
*/
|
|
39
|
+
declare const THEME_CSS_STORAGE_KEY = "buro-theme-css";
|
|
40
|
+
/** The id of the `<style>` element the bootstrap script and `applyThemeCss`
|
|
41
|
+
* both create/update. Exported so a test can assert on it without
|
|
42
|
+
* retyping the literal. */
|
|
43
|
+
declare const THEME_CSS_STYLE_ELEMENT_ID = "buro-theme-css";
|
|
44
|
+
/**
|
|
45
|
+
* Reads the persisted theme CSS, or `null` if nothing is stored, storage is
|
|
46
|
+
* unavailable (SSR, or a browser with it disabled — e.g. some private-mode
|
|
47
|
+
* configurations throw on access rather than just being empty), or the
|
|
48
|
+
* stored value is an empty string (treated the same as absent).
|
|
49
|
+
*/
|
|
50
|
+
declare function readThemeCss(): string | null;
|
|
51
|
+
/**
|
|
52
|
+
* Persists (or, given `null`, clears) the theme CSS. Does not validate —
|
|
53
|
+
* callers that need the ".dark present" guarantee run `validateThemeCss`
|
|
54
|
+
* first (see module doc comment). Swallows storage errors (quota exceeded
|
|
55
|
+
* from an oversized paste, storage disabled, etc.): the in-memory
|
|
56
|
+
* application via `applyThemeCss` already happened by the time a caller
|
|
57
|
+
* persists, so a failed write loses the choice on next reload but does not
|
|
58
|
+
* throw or break the current page.
|
|
59
|
+
*/
|
|
60
|
+
declare function writeThemeCss(css: string | null): void;
|
|
61
|
+
/**
|
|
62
|
+
* Creates, updates, or removes the `<style id="buro-theme-css">`
|
|
63
|
+
* element that carries the active theme's `:root`/`.dark` custom
|
|
64
|
+
* properties. Given `null` (or an empty string), removes the element
|
|
65
|
+
* entirely rather than leaving an empty tag behind.
|
|
66
|
+
*
|
|
67
|
+
* Order matters: this function always places the element at the END of
|
|
68
|
+
* `<head>`, including when updating a style created by a prepaint bootstrap.
|
|
69
|
+
* That lets an app call it after its own stylesheet has loaded so the theme's
|
|
70
|
+
* equal-specificity `:root`/`.dark` rules win the cascade without creating a
|
|
71
|
+
* duplicate element.
|
|
72
|
+
*/
|
|
73
|
+
declare function applyThemeCss(css: string | null): void;
|
|
74
|
+
|
|
75
|
+
export { THEME_CSS_STORAGE_KEY, THEME_CSS_STYLE_ELEMENT_ID, applyThemeCss, readThemeCss, writeThemeCss };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// src/theme/theme-storage.ts
|
|
2
|
+
var THEME_CSS_STORAGE_KEY = "buro-theme-css";
|
|
3
|
+
var THEME_CSS_STYLE_ELEMENT_ID = "buro-theme-css";
|
|
4
|
+
function readThemeCss() {
|
|
5
|
+
if (typeof window === "undefined") return null;
|
|
6
|
+
try {
|
|
7
|
+
const css = window.localStorage.getItem(THEME_CSS_STORAGE_KEY);
|
|
8
|
+
return css && css.length > 0 ? css : null;
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function writeThemeCss(css) {
|
|
14
|
+
if (typeof window === "undefined") return;
|
|
15
|
+
try {
|
|
16
|
+
if (css === null || css.length === 0) {
|
|
17
|
+
window.localStorage.removeItem(THEME_CSS_STORAGE_KEY);
|
|
18
|
+
} else {
|
|
19
|
+
window.localStorage.setItem(THEME_CSS_STORAGE_KEY, css);
|
|
20
|
+
}
|
|
21
|
+
} catch {
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function applyThemeCss(css) {
|
|
25
|
+
if (typeof document === "undefined") return;
|
|
26
|
+
const existing = document.getElementById(THEME_CSS_STYLE_ELEMENT_ID);
|
|
27
|
+
if (css === null || css.length === 0) {
|
|
28
|
+
existing?.remove();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (existing instanceof HTMLStyleElement) {
|
|
32
|
+
existing.textContent = css;
|
|
33
|
+
document.head.appendChild(existing);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
existing?.remove();
|
|
37
|
+
const style = document.createElement("style");
|
|
38
|
+
style.id = THEME_CSS_STYLE_ELEMENT_ID;
|
|
39
|
+
style.textContent = css;
|
|
40
|
+
document.head.appendChild(style);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export { THEME_CSS_STORAGE_KEY, THEME_CSS_STYLE_ELEMENT_ID, applyThemeCss, readThemeCss, writeThemeCss };
|
|
44
|
+
//# sourceMappingURL=theme-storage.js.map
|
|
45
|
+
//# sourceMappingURL=theme-storage.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/theme/theme-storage.ts"],"names":[],"mappings":";AAuCO,IAAM,qBAAA,GAAwB;AAK9B,IAAM,0BAAA,GAA6B;AAQnC,SAAS,YAAA,GAA8B;AAC5C,EAAA,IAAI,OAAO,MAAA,KAAW,WAAA,EAAa,OAAO,IAAA;AAC1C,EAAA,IAAI;AACF,IAAA,MAAM,GAAA,GAAM,MAAA,CAAO,YAAA,CAAa,OAAA,CAAQ,qBAAqB,CAAA;AAC7D,IAAA,OAAO,GAAA,IAAO,GAAA,CAAI,MAAA,GAAS,CAAA,GAAI,GAAA,GAAM,IAAA;AAAA,EACvC,CAAA,CAAA,MAAQ;AACN,IAAA,OAAO,IAAA;AAAA,EACT;AACF;AAWO,SAAS,cAAc,GAAA,EAA0B;AACtD,EAAA,IAAI,OAAO,WAAW,WAAA,EAAa;AACnC,EAAA,IAAI;AACF,IAAA,IAAI,GAAA,KAAQ,IAAA,IAAQ,GAAA,CAAI,MAAA,KAAW,CAAA,EAAG;AACpC,MAAA,MAAA,CAAO,YAAA,CAAa,WAAW,qBAAqB,CAAA;AAAA,IACtD,CAAA,MAAO;AACL,MAAA,MAAA,CAAO,YAAA,CAAa,OAAA,CAAQ,qBAAA,EAAuB,GAAG,CAAA;AAAA,IACxD;AAAA,EACF,CAAA,CAAA,MAAQ;AAAA,EAGR;AACF;AAcO,SAAS,cAAc,GAAA,EAA0B;AACtD,EAAA,IAAI,OAAO,aAAa,WAAA,EAAa;AACrC,EAAA,MAAM,QAAA,GAAW,QAAA,CAAS,cAAA,CAAe,0BAA0B,CAAA;AAEnE,EAAA,IAAI,GAAA,KAAQ,IAAA,IAAQ,GAAA,CAAI,MAAA,KAAW,CAAA,EAAG;AACpC,IAAA,QAAA,EAAU,MAAA,EAAO;AACjB,IAAA;AAAA,EACF;AAEA,EAAA,IAAI,oBAAoB,gBAAA,EAAkB;AACxC,IAAA,QAAA,CAAS,WAAA,GAAc,GAAA;AACvB,IAAA,QAAA,CAAS,IAAA,CAAK,YAAY,QAAQ,CAAA;AAClC,IAAA;AAAA,EACF;AACA,EAAA,QAAA,EAAU,MAAA,EAAO;AAEjB,EAAA,MAAM,KAAA,GAAQ,QAAA,CAAS,aAAA,CAAc,OAAO,CAAA;AAC5C,EAAA,KAAA,CAAM,EAAA,GAAK,0BAAA;AACX,EAAA,KAAA,CAAM,WAAA,GAAc,GAAA;AACpB,EAAA,QAAA,CAAS,IAAA,CAAK,YAAY,KAAK,CAAA;AACjC","file":"theme-storage.js","sourcesContent":["/**\n * Persists and applies the active PALETTE theme's CSS — separate concern\n * from `THEME_STORAGE_KEY` (`packages/ui-shadcn/src/layout/theme-storage-key.ts`),\n * which holds light/dark/system. That key answers \"which class goes on\n * `<html>`\"; this one answers \"what custom-property values does `:root`/\n * `.dark` carry\". A theme picker calling `writeThemeCss` must never touch\n * `THEME_STORAGE_KEY`, and the light/dark toggle must never touch this one —\n * each write path only ever calls its own key's functions, so there is no\n * shared code path where a bug could cross-contaminate them.\n *\n * The stored value is the theme's CSS **in full** (already reduced to just\n * `:root`/`.dark` by `stripThemeCss`), not a theme id. That is what makes a\n * pasted (tweakcn) theme and a built-in theme indistinguishable to this\n * module and to the bootstrap script: neither has to know the built-in\n * theme catalogue, ship it in the bundle, or fetch it over the network\n * before paint. The cost is that this module does NOT validate — a caller\n * that wants the \".dark block present\" guarantee must run `validateThemeCss`\n * itself before calling `writeThemeCss` (the theme picker does this; see its\n * task). `applyThemeCss` and the bootstrap script just inject whatever\n * string is there. That is deliberate, not an oversight: CSS parsing is\n * forgiving by construction — unknown/malformed declarations are dropped\n * rule-by-rule by the browser's own parser, not fatal — so blindly injecting\n * a corrupt or half-written string degrades to \"some rules don't apply\", not\n * a crash or a blank page. Re-validating on every read would also block\n * first paint on running `parseTopLevelBlocks` over an arbitrarily large\n * string, which is exactly the cost the bootstrap script exists to avoid.\n *\n * Deliberately its own module, no React import, same reasoning as\n * `theme-storage-key.ts`: the ONE consumer that needs `THEME_CSS_STORAGE_KEY`\n * and `applyThemeCss` outside a hook is the bootstrap `<script>` in the\n * playground's root `app/layout.tsx`, a Server Component that cannot import\n * anything carrying `'use client'`. This file has no hooks, so it can be\n * imported there directly via the `@burojs/ui-shadcn/theme-storage` subpath.\n */\n\n/**\n * The localStorage key for the active theme's CSS text. Kept apart from\n * `THEME_STORAGE_KEY` on purpose — see the module doc comment above.\n */\nexport const THEME_CSS_STORAGE_KEY = 'buro-theme-css';\n\n/** The id of the `<style>` element the bootstrap script and `applyThemeCss`\n * both create/update. Exported so a test can assert on it without\n * retyping the literal. */\nexport const THEME_CSS_STYLE_ELEMENT_ID = 'buro-theme-css';\n\n/**\n * Reads the persisted theme CSS, or `null` if nothing is stored, storage is\n * unavailable (SSR, or a browser with it disabled — e.g. some private-mode\n * configurations throw on access rather than just being empty), or the\n * stored value is an empty string (treated the same as absent).\n */\nexport function readThemeCss(): string | null {\n if (typeof window === 'undefined') return null;\n try {\n const css = window.localStorage.getItem(THEME_CSS_STORAGE_KEY);\n return css && css.length > 0 ? css : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Persists (or, given `null`, clears) the theme CSS. Does not validate —\n * callers that need the \".dark present\" guarantee run `validateThemeCss`\n * first (see module doc comment). Swallows storage errors (quota exceeded\n * from an oversized paste, storage disabled, etc.): the in-memory\n * application via `applyThemeCss` already happened by the time a caller\n * persists, so a failed write loses the choice on next reload but does not\n * throw or break the current page.\n */\nexport function writeThemeCss(css: string | null): void {\n if (typeof window === 'undefined') return;\n try {\n if (css === null || css.length === 0) {\n window.localStorage.removeItem(THEME_CSS_STORAGE_KEY);\n } else {\n window.localStorage.setItem(THEME_CSS_STORAGE_KEY, css);\n }\n } catch {\n // Quota exceeded, storage disabled, or a similar DOMException — nothing\n // persisted. The caller's in-memory `applyThemeCss` call still stands.\n }\n}\n\n/**\n * Creates, updates, or removes the `<style id=\"buro-theme-css\">`\n * element that carries the active theme's `:root`/`.dark` custom\n * properties. Given `null` (or an empty string), removes the element\n * entirely rather than leaving an empty tag behind.\n *\n * Order matters: this function always places the element at the END of\n * `<head>`, including when updating a style created by a prepaint bootstrap.\n * That lets an app call it after its own stylesheet has loaded so the theme's\n * equal-specificity `:root`/`.dark` rules win the cascade without creating a\n * duplicate element.\n */\nexport function applyThemeCss(css: string | null): void {\n if (typeof document === 'undefined') return;\n const existing = document.getElementById(THEME_CSS_STYLE_ELEMENT_ID);\n\n if (css === null || css.length === 0) {\n existing?.remove();\n return;\n }\n\n if (existing instanceof HTMLStyleElement) {\n existing.textContent = css;\n document.head.appendChild(existing);\n return;\n }\n existing?.remove(); // defensive: same id but somehow not a <style>\n\n const style = document.createElement('style');\n style.id = THEME_CSS_STYLE_ELEMENT_ID;\n style.textContent = css;\n document.head.appendChild(style);\n}\n"]}
|