@caelo-cms/shared 0.10.21 → 0.10.23
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/dist/ai-tools.d.ts +291 -231
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +349 -281
- package/dist/ai-tools.js.map +1 -1
- package/dist/auth-forms.d.ts.map +1 -1
- package/dist/auth-forms.js +4 -1
- package/dist/auth-forms.js.map +1 -1
- package/dist/base-css.d.ts +24 -0
- package/dist/base-css.d.ts.map +1 -0
- package/dist/base-css.js +28 -0
- package/dist/base-css.js.map +1 -0
- package/dist/build-page.d.ts +330 -0
- package/dist/build-page.d.ts.map +1 -0
- package/dist/build-page.js +282 -0
- package/dist/build-page.js.map +1 -0
- package/dist/content.d.ts +322 -9
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +354 -11
- package/dist/content.js.map +1 -1
- package/dist/css-gradient-scan.d.ts +14 -0
- package/dist/css-gradient-scan.d.ts.map +1 -0
- package/dist/css-gradient-scan.js +81 -0
- package/dist/css-gradient-scan.js.map +1 -0
- package/dist/css-var-scan.d.ts +56 -0
- package/dist/css-var-scan.d.ts.map +1 -0
- package/dist/css-var-scan.js +97 -0
- package/dist/css-var-scan.js.map +1 -0
- package/dist/design-manifest.d.ts +36 -0
- package/dist/design-manifest.d.ts.map +1 -0
- package/dist/design-manifest.js +90 -0
- package/dist/design-manifest.js.map +1 -0
- package/dist/fonts.d.ts +89 -0
- package/dist/fonts.d.ts.map +1 -0
- package/dist/fonts.js +241 -0
- package/dist/fonts.js.map +1 -0
- package/dist/genesis-inventory.d.ts +32 -0
- package/dist/genesis-inventory.d.ts.map +1 -0
- package/dist/genesis-inventory.js +186 -0
- package/dist/genesis-inventory.js.map +1 -0
- package/dist/genesis.d.ts +62 -0
- package/dist/genesis.d.ts.map +1 -0
- package/dist/genesis.js +78 -0
- package/dist/genesis.js.map +1 -0
- package/dist/i18n.d.ts +44 -1
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +72 -6
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -1
- package/dist/interactions.d.ts +23 -0
- package/dist/interactions.d.ts.map +1 -0
- package/dist/interactions.js +44 -0
- package/dist/interactions.js.map +1 -0
- package/dist/media.d.ts +101 -16
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +126 -15
- package/dist/media.js.map +1 -1
- package/dist/page-log.d.ts +94 -0
- package/dist/page-log.d.ts.map +1 -0
- package/dist/page-log.js +111 -0
- package/dist/page-log.js.map +1 -0
- package/dist/preview-compose.d.ts +79 -0
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +155 -25
- package/dist/preview-compose.js.map +1 -1
- package/dist/proposal-status.d.ts +40 -0
- package/dist/proposal-status.d.ts.map +1 -0
- package/dist/proposal-status.js +34 -0
- package/dist/proposal-status.js.map +1 -0
- package/dist/responsive-images.d.ts +64 -0
- package/dist/responsive-images.d.ts.map +1 -0
- package/dist/responsive-images.js +98 -0
- package/dist/responsive-images.js.map +1 -0
- package/dist/safe-keys.d.ts +9 -0
- package/dist/safe-keys.d.ts.map +1 -0
- package/dist/safe-keys.js +20 -0
- package/dist/safe-keys.js.map +1 -0
- package/dist/seo.d.ts +8 -0
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +3 -1
- package/dist/seo.js.map +1 -1
- package/dist/skills.d.ts +14 -68
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +19 -113
- package/dist/skills.js.map +1 -1
- package/dist/strip-cdata.d.ts +7 -0
- package/dist/strip-cdata.d.ts.map +1 -0
- package/dist/strip-cdata.js +48 -0
- package/dist/strip-cdata.js.map +1 -0
- package/dist/structured-sets.d.ts +6 -16
- package/dist/structured-sets.d.ts.map +1 -1
- package/dist/structured-sets.js +5 -16
- package/dist/structured-sets.js.map +1 -1
- package/dist/subagents.d.ts +105 -3
- package/dist/subagents.d.ts.map +1 -1
- package/dist/subagents.js +224 -41
- package/dist/subagents.js.map +1 -1
- package/dist/template-engine.d.ts +85 -0
- package/dist/template-engine.d.ts.map +1 -0
- package/dist/template-engine.js +403 -0
- package/dist/template-engine.js.map +1 -0
- package/dist/theme-importers/auto-detect.d.ts +26 -0
- package/dist/theme-importers/auto-detect.d.ts.map +1 -0
- package/dist/theme-importers/auto-detect.js +42 -0
- package/dist/theme-importers/auto-detect.js.map +1 -0
- package/dist/theme-importers/css-comments.d.ts +12 -0
- package/dist/theme-importers/css-comments.d.ts.map +1 -0
- package/dist/theme-importers/css-comments.js +15 -0
- package/dist/theme-importers/css-comments.js.map +1 -0
- package/dist/theme-importers/dtcg.d.ts +46 -0
- package/dist/theme-importers/dtcg.d.ts.map +1 -0
- package/dist/theme-importers/dtcg.js +111 -0
- package/dist/theme-importers/dtcg.js.map +1 -0
- package/dist/theme-importers/loose.d.ts +3 -0
- package/dist/theme-importers/loose.d.ts.map +1 -0
- package/dist/theme-importers/loose.js +76 -0
- package/dist/theme-importers/loose.js.map +1 -0
- package/dist/theme-importers/shadcn.d.ts +24 -0
- package/dist/theme-importers/shadcn.d.ts.map +1 -0
- package/dist/theme-importers/shadcn.js +135 -0
- package/dist/theme-importers/shadcn.js.map +1 -0
- package/dist/theme-importers/style-dictionary.d.ts +17 -0
- package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
- package/dist/theme-importers/style-dictionary.js +125 -0
- package/dist/theme-importers/style-dictionary.js.map +1 -0
- package/dist/theme-importers/tailwind.d.ts +3 -0
- package/dist/theme-importers/tailwind.d.ts.map +1 -0
- package/dist/theme-importers/tailwind.js +218 -0
- package/dist/theme-importers/tailwind.js.map +1 -0
- package/dist/theme-literal-binding.d.ts +37 -0
- package/dist/theme-literal-binding.d.ts.map +1 -0
- package/dist/theme-literal-binding.js +138 -0
- package/dist/theme-literal-binding.js.map +1 -0
- package/dist/theme-normalize.d.ts +31 -0
- package/dist/theme-normalize.d.ts.map +1 -0
- package/dist/theme-normalize.js +587 -0
- package/dist/theme-normalize.js.map +1 -0
- package/dist/theme-ramp.d.ts +55 -0
- package/dist/theme-ramp.d.ts.map +1 -0
- package/dist/theme-ramp.js +149 -0
- package/dist/theme-ramp.js.map +1 -0
- package/dist/theme-render.d.ts +105 -0
- package/dist/theme-render.d.ts.map +1 -0
- package/dist/theme-render.js +441 -0
- package/dist/theme-render.js.map +1 -0
- package/dist/themes-errors.d.ts +109 -0
- package/dist/themes-errors.d.ts.map +1 -0
- package/dist/themes-errors.js +170 -0
- package/dist/themes-errors.js.map +1 -0
- package/dist/themes.d.ts +343 -0
- package/dist/themes.d.ts.map +1 -0
- package/dist/themes.js +697 -0
- package/dist/themes.js.map +1 -0
- package/dist/version.d.ts +7 -4
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +6 -3
- package/dist/version.js.map +1 -1
- package/package.json +10 -2
- package/src/__tests__/redos-hardening.test.ts +160 -0
- package/src/ai-tools-add-module-modes.test.ts +106 -0
- package/src/ai-tools-position.test.ts +134 -0
- package/src/ai-tools.test.ts +81 -0
- package/src/ai-tools.ts +1179 -0
- package/src/auth-forms.ts +36 -0
- package/src/base-css.ts +30 -0
- package/src/build-page.test.ts +228 -0
- package/src/build-page.ts +319 -0
- package/src/cap-failures.ts +67 -0
- package/src/content.test.ts +170 -0
- package/src/content.ts +620 -0
- package/src/context.ts +43 -0
- package/src/css-gradient-scan.ts +88 -0
- package/src/css-var-scan.test.ts +96 -0
- package/src/css-var-scan.ts +144 -0
- package/src/derive-module-type.test.ts +80 -0
- package/src/design-manifest.ts +93 -0
- package/src/fonts.test.ts +157 -0
- package/src/fonts.ts +296 -0
- package/src/genesis-inventory.test.ts +86 -0
- package/src/genesis-inventory.ts +215 -0
- package/src/genesis-sanitize.test.ts +35 -0
- package/src/genesis.ts +87 -0
- package/src/i18n.test.ts +274 -0
- package/src/i18n.ts +269 -0
- package/src/index.test.ts +10 -0
- package/src/index.ts +59 -0
- package/src/interactions.ts +48 -0
- package/src/logger.ts +147 -0
- package/src/media.test.ts +160 -0
- package/src/media.ts +355 -0
- package/src/page-log.test.ts +163 -0
- package/src/page-log.ts +124 -0
- package/src/preview-compose.test.ts +602 -0
- package/src/preview-compose.ts +709 -0
- package/src/preview-scanner.test.ts +96 -0
- package/src/preview-scanner.ts +214 -0
- package/src/proposal-status.test.ts +69 -0
- package/src/proposal-status.ts +40 -0
- package/src/responsive-images.test.ts +104 -0
- package/src/responsive-images.ts +151 -0
- package/src/result.ts +29 -0
- package/src/safe-keys.ts +21 -0
- package/src/seo.test.ts +234 -0
- package/src/seo.ts +261 -0
- package/src/skills.ts +48 -0
- package/src/snapshots.test.ts +80 -0
- package/src/snapshots.ts +81 -0
- package/src/strip-cdata.test.ts +41 -0
- package/src/strip-cdata.ts +50 -0
- package/src/structured-sets.ts +180 -0
- package/src/subagents.test.ts +262 -0
- package/src/subagents.ts +432 -0
- package/src/template-engine.test.ts +379 -0
- package/src/template-engine.ts +520 -0
- package/src/theme-gradient.test.ts +92 -0
- package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
- package/src/theme-importers/auto-detect.ts +84 -0
- package/src/theme-importers/css-comments.ts +15 -0
- package/src/theme-importers/dtcg.ts +106 -0
- package/src/theme-importers/loose.ts +76 -0
- package/src/theme-importers/shadcn.ts +133 -0
- package/src/theme-importers/style-dictionary.ts +125 -0
- package/src/theme-importers/tailwind.ts +217 -0
- package/src/theme-literal-binding.test.ts +71 -0
- package/src/theme-literal-binding.ts +159 -0
- package/src/theme-motion.test.ts +115 -0
- package/src/theme-normalize-envelope.test.ts +43 -0
- package/src/theme-normalize-gradient.test.ts +135 -0
- package/src/theme-normalize.ts +661 -0
- package/src/theme-ramp.ts +187 -0
- package/src/theme-render-sanitize.test.ts +45 -0
- package/src/theme-render.test.ts +119 -0
- package/src/theme-render.ts +487 -0
- package/src/theme-shadow.test.ts +56 -0
- package/src/themes-errors.ts +199 -0
- package/src/themes.ts +842 -0
- package/src/translation.test.ts +160 -0
- package/src/translation.ts +295 -0
- package/src/version.ts +66 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* v0.11.1 (issue #76) — theme import format auto-detection chain.
|
|
5
|
+
*
|
|
6
|
+
* Tries each importer in priority order:
|
|
7
|
+
*
|
|
8
|
+
* 1. DTCG (most specific: `$value` leaves)
|
|
9
|
+
* 2. Style Dictionary (`value` leaves)
|
|
10
|
+
* 3. Tailwind 4 (`@theme { … }` block)
|
|
11
|
+
* 4. shadcn (`:root { … }` CSS variables)
|
|
12
|
+
* 5. Loose key-value (`{looseName: value}` JSON object)
|
|
13
|
+
*
|
|
14
|
+
* The first importer that doesn't throw a "shape" rejection wins.
|
|
15
|
+
* "Hard" parse errors (e.g. TailwindImportError for calc()) surface
|
|
16
|
+
* verbatim — they signal the input WAS that format but unparseable.
|
|
17
|
+
*
|
|
18
|
+
* Returns `{format, tokens}` so the caller's tool result can echo the
|
|
19
|
+
* detected format back to the AI for next-turn awareness.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ThemeDocument } from "../themes.js";
|
|
23
|
+
import {
|
|
24
|
+
NoImporterMatched,
|
|
25
|
+
NotDtcgShape,
|
|
26
|
+
NotLooseShape,
|
|
27
|
+
NotShadcnShape,
|
|
28
|
+
NotStyleDictionaryShape,
|
|
29
|
+
NotTailwindShape,
|
|
30
|
+
} from "../themes-errors.js";
|
|
31
|
+
import { importDtcg } from "./dtcg.js";
|
|
32
|
+
import { importLoose } from "./loose.js";
|
|
33
|
+
import { importShadcn } from "./shadcn.js";
|
|
34
|
+
import { importStyleDictionary } from "./style-dictionary.js";
|
|
35
|
+
import { importTailwind } from "./tailwind.js";
|
|
36
|
+
|
|
37
|
+
export type DetectedFormat = "dtcg" | "style-dictionary" | "tailwind" | "shadcn" | "loose";
|
|
38
|
+
|
|
39
|
+
export interface AutoDetectResult {
|
|
40
|
+
readonly format: DetectedFormat;
|
|
41
|
+
readonly tokens: ThemeDocument;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ImporterDef {
|
|
45
|
+
readonly format: DetectedFormat;
|
|
46
|
+
readonly importer: (body: string) => ThemeDocument;
|
|
47
|
+
/** When true, the auto-detect chain falls through on this error class. */
|
|
48
|
+
readonly fallThroughOn: ReadonlyArray<new (...args: never[]) => Error>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const CHAIN: readonly ImporterDef[] = [
|
|
52
|
+
{ format: "dtcg", importer: importDtcg, fallThroughOn: [NotDtcgShape] },
|
|
53
|
+
{
|
|
54
|
+
format: "style-dictionary",
|
|
55
|
+
importer: importStyleDictionary,
|
|
56
|
+
fallThroughOn: [NotStyleDictionaryShape],
|
|
57
|
+
},
|
|
58
|
+
{ format: "tailwind", importer: importTailwind, fallThroughOn: [NotTailwindShape] },
|
|
59
|
+
{ format: "shadcn", importer: importShadcn, fallThroughOn: [NotShadcnShape] },
|
|
60
|
+
{ format: "loose", importer: importLoose, fallThroughOn: [NotLooseShape] },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
export function autoDetectAndImport(body: string): AutoDetectResult {
|
|
64
|
+
const attempts: Array<{ format: string; reason: string }> = [];
|
|
65
|
+
for (const def of CHAIN) {
|
|
66
|
+
try {
|
|
67
|
+
const tokens = def.importer(body);
|
|
68
|
+
return { format: def.format, tokens };
|
|
69
|
+
} catch (e) {
|
|
70
|
+
const isFallThrough = def.fallThroughOn.some((cls) => e instanceof cls);
|
|
71
|
+
if (!isFallThrough) {
|
|
72
|
+
// Hard error: the input WAS in this format but the importer
|
|
73
|
+
// can't handle it. Surface verbatim so the operator/AI gets a
|
|
74
|
+
// concrete fix-it.
|
|
75
|
+
throw e;
|
|
76
|
+
}
|
|
77
|
+
attempts.push({
|
|
78
|
+
format: def.format,
|
|
79
|
+
reason: e instanceof Error ? (e.message.split("\n")[0] ?? "rejected") : "rejected",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
throw new NoImporterMatched(attempts);
|
|
84
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Strip `/* … *\/` block comments from a CSS string.
|
|
5
|
+
*
|
|
6
|
+
* The body uses a "tempered dot" (`(?:(?!\*\/)[\s\S])*`) rather than a lazy
|
|
7
|
+
* `[\s\S]*?`: the lazy form backtracks O(n²) on an unterminated comment in
|
|
8
|
+
* untrusted theme CSS (CodeQL js/polynomial-redos). The tempered form has a
|
|
9
|
+
* single unambiguous match path and stops at the first `*\/`, so the output
|
|
10
|
+
* is identical to the lazy form on valid CSS. Single-sourced here so the two
|
|
11
|
+
* theme importers (tailwind, shadcn) cannot drift to a vulnerable variant.
|
|
12
|
+
*/
|
|
13
|
+
export function stripCssComments(css: string): string {
|
|
14
|
+
return css.replace(/\/\*(?:(?!\*\/)[\s\S])*\*\//g, "");
|
|
15
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* v0.11.0 — DTCG (W3C Design Tokens Format) import / export for the
|
|
5
|
+
* `themes` primitive (#45 Phase 7 slice 1).
|
|
6
|
+
*
|
|
7
|
+
* The full format-auto-detect (Style Dictionary / Tailwind 4 @theme /
|
|
8
|
+
* shadcn `:root` / loose key-value) is v0.11.2 — see the follow-up
|
|
9
|
+
* comment §4 on #45. This slice ships the DTCG side only so design
|
|
10
|
+
* tooling (Figma, Tokens Studio, Style Dictionary) round-trips cleanly
|
|
11
|
+
* from day one.
|
|
12
|
+
*
|
|
13
|
+
* The shape we accept IS the same shape `themes.tokens` jsonb carries,
|
|
14
|
+
* because `themes.ts`'s Zod schema mirrors DTCG. So `importDtcg` is
|
|
15
|
+
* essentially `JSON.parse + validateThemeTokens`; `exportDtcg` returns
|
|
16
|
+
* the stored tokens jsonb after stable-stringification. The wrappers
|
|
17
|
+
* exist so we have explicit entry points to grow Style-Dictionary /
|
|
18
|
+
* Tailwind / shadcn / loose parsers next to in v0.11.2 without
|
|
19
|
+
* widening `themes.ts`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { type Theme, type ThemeDocument, validateThemeTokens } from "../themes.js";
|
|
23
|
+
import { NotDtcgShape } from "../themes-errors.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse a DTCG JSON document into a validated tokens tree. Three
|
|
27
|
+
* failure surfaces:
|
|
28
|
+
*
|
|
29
|
+
* 1. Malformed JSON → `DTCG parse failed: <SyntaxError msg>`
|
|
30
|
+
* 2. Valid JSON, no `$value` anywhere → `NotDtcgShape` (round-2
|
|
31
|
+
* opt §2: pre-Zod sniff for the common case where the operator
|
|
32
|
+
* pastes a Tailwind / Style Dictionary / Figma export and the
|
|
33
|
+
* AI sees a deeply-nested Zod path mismatch instead of a clear
|
|
34
|
+
* "this isn't DTCG" hint).
|
|
35
|
+
* 3. Valid JSON, has $value leaves, but Zod rejects something → the
|
|
36
|
+
* ZodError surfaces verbatim (per-token path mismatch, missing
|
|
37
|
+
* composite sub-field, etc.).
|
|
38
|
+
*/
|
|
39
|
+
export function importDtcg(body: string): ThemeDocument {
|
|
40
|
+
let parsed: unknown;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(body);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
45
|
+
throw new Error(`DTCG parse failed: ${msg}`);
|
|
46
|
+
}
|
|
47
|
+
if (!hasAnyDollarValue(parsed)) {
|
|
48
|
+
throw new NotDtcgShape();
|
|
49
|
+
}
|
|
50
|
+
return validateThemeTokens(parsed);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Walks the parsed tree once, returns true as soon as ANY leaf object
|
|
55
|
+
* carries a `$value` key. Cheap O(n) over the parsed structure — same
|
|
56
|
+
* shape Zod will walk anyway during full validation, just early-exits.
|
|
57
|
+
* Treats arrays as nested containers so a shadow-layer array of
|
|
58
|
+
* composite values still sniffs as DTCG.
|
|
59
|
+
*/
|
|
60
|
+
function hasAnyDollarValue(node: unknown): boolean {
|
|
61
|
+
if (node === null || typeof node !== "object") return false;
|
|
62
|
+
if (Array.isArray(node)) return node.some(hasAnyDollarValue);
|
|
63
|
+
const obj = node as Record<string, unknown>;
|
|
64
|
+
if ("$value" in obj) return true;
|
|
65
|
+
for (const v of Object.values(obj)) {
|
|
66
|
+
if (hasAnyDollarValue(v)) return true;
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Emit a stable DTCG JSON document for the given theme. Keys are
|
|
73
|
+
* sorted alphabetically at every nesting level so the output is
|
|
74
|
+
* byte-for-byte deterministic — Style Dictionary / Tokens Studio diff
|
|
75
|
+
* cleanly under git.
|
|
76
|
+
*
|
|
77
|
+
* Only `tokens` is exported; asset URLs and the `is_active` flag are
|
|
78
|
+
* Caelo-specific and have no DTCG representation. Operators bind
|
|
79
|
+
* assets through `set_theme_asset`, not through an exported JSON file.
|
|
80
|
+
*/
|
|
81
|
+
export function exportDtcg(theme: Pick<Theme, "tokens">): string {
|
|
82
|
+
return `${JSON.stringify(sortDeep(theme.tokens), null, 2)}\n`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function sortDeep(value: unknown): unknown {
|
|
86
|
+
if (value === null) return null;
|
|
87
|
+
if (Array.isArray(value)) return value.map(sortDeep);
|
|
88
|
+
if (typeof value !== "object") return value;
|
|
89
|
+
const obj = value as Record<string, unknown>;
|
|
90
|
+
const sortedKeys = Object.keys(obj).sort((a, b) => {
|
|
91
|
+
// DTCG convention: $-prefixed metadata keys ($value, $type,
|
|
92
|
+
// $description, $extensions) sort BEFORE plain group keys so a
|
|
93
|
+
// leaf renders with its metadata up top, then its sub-references
|
|
94
|
+
// (if any) below. Within each tier keys sort alphabetically.
|
|
95
|
+
const aDollar = a.startsWith("$");
|
|
96
|
+
const bDollar = b.startsWith("$");
|
|
97
|
+
if (aDollar && !bDollar) return -1;
|
|
98
|
+
if (!aDollar && bDollar) return 1;
|
|
99
|
+
return a.localeCompare(b);
|
|
100
|
+
});
|
|
101
|
+
const out: Record<string, unknown> = {};
|
|
102
|
+
for (const k of sortedKeys) {
|
|
103
|
+
out[k] = sortDeep(obj[k]);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* v0.11.1 (issue #76) — loose key-value JSON object importer.
|
|
5
|
+
*
|
|
6
|
+
* Accepts a flat JSON object of `{looseName: value}` pairs and runs
|
|
7
|
+
* each entry through `normalizeTokens` + `applyDtcgWrites` so the
|
|
8
|
+
* result lands at canonical DTCG paths. This is the same path
|
|
9
|
+
* `themes.update_tokens` uses for AI-supplied input — extracted here
|
|
10
|
+
* so `import_theme` can offer a "paste a flat object, server figures
|
|
11
|
+
* out where each one goes" surface.
|
|
12
|
+
*
|
|
13
|
+
* The importer starts from an empty document so it produces a complete
|
|
14
|
+
* tokens tree (no existing tokens merged in). Callers wanting a patch
|
|
15
|
+
* use `themes.update_tokens` directly.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { normalizeTokens } from "../theme-normalize.js";
|
|
19
|
+
import { applyDtcgWrites, type ThemeDocument, validateThemeTokens } from "../themes.js";
|
|
20
|
+
import { NotLooseShape } from "../themes-errors.js";
|
|
21
|
+
|
|
22
|
+
export function importLoose(body: string): ThemeDocument {
|
|
23
|
+
let parsed: unknown;
|
|
24
|
+
try {
|
|
25
|
+
parsed = JSON.parse(body);
|
|
26
|
+
} catch (e) {
|
|
27
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
28
|
+
throw new NotLooseShape(`loose-format parse failed: ${msg}`);
|
|
29
|
+
}
|
|
30
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
31
|
+
throw new NotLooseShape("loose-format input must be a JSON object");
|
|
32
|
+
}
|
|
33
|
+
// Reject inputs that are clearly other formats so auto-detect doesn't
|
|
34
|
+
// accidentally swallow them at the last hop.
|
|
35
|
+
if (hasNestedDollarValue(parsed) || hasNestedPlainValue(parsed)) {
|
|
36
|
+
throw new NotLooseShape(
|
|
37
|
+
"input has nested `$value` / `value` leaves — looks like DTCG or Style Dictionary, not loose",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const obj = parsed as Record<string, unknown>;
|
|
42
|
+
if (Object.keys(obj).length === 0) {
|
|
43
|
+
throw new NotLooseShape("loose-format input is an empty object");
|
|
44
|
+
}
|
|
45
|
+
// Normalize loose names → canonical paths (throws UnknownTokenName on
|
|
46
|
+
// ambiguity, per the v0.11.0 normalizer's failure surface).
|
|
47
|
+
const normalized = normalizeTokens(obj);
|
|
48
|
+
// Apply to an empty document so the result is a complete tokens tree.
|
|
49
|
+
const written = applyDtcgWrites({}, normalized.set, normalized.types);
|
|
50
|
+
return validateThemeTokens(written);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hasNestedDollarValue(node: unknown): boolean {
|
|
54
|
+
if (node === null || typeof node !== "object" || Array.isArray(node)) return false;
|
|
55
|
+
const obj = node as Record<string, unknown>;
|
|
56
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
57
|
+
if (k === "$value") return true;
|
|
58
|
+
if (typeof v === "object" && v !== null && hasNestedDollarValue(v)) return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function hasNestedPlainValue(node: unknown): boolean {
|
|
64
|
+
if (node === null || typeof node !== "object" || Array.isArray(node)) return false;
|
|
65
|
+
const obj = node as Record<string, unknown>;
|
|
66
|
+
// A "Style Dictionary leaf" looks like `{value: <primitive>, ...}` —
|
|
67
|
+
// not a top-level flat key whose value is a primitive.
|
|
68
|
+
for (const v of Object.values(obj)) {
|
|
69
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
70
|
+
const inner = v as Record<string, unknown>;
|
|
71
|
+
if ("value" in inner && typeof inner.value !== "object") return true;
|
|
72
|
+
if (hasNestedPlainValue(v)) return true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* v0.11.1 (issue #76) — shadcn CSS-variable importer.
|
|
5
|
+
*
|
|
6
|
+
* shadcn-style themes ship as a `:root { … }` (+ optional `.dark { … }`)
|
|
7
|
+
* block of unprefixed CSS variables:
|
|
8
|
+
*
|
|
9
|
+
* :root {
|
|
10
|
+
* --background: 0 0% 100%;
|
|
11
|
+
* --primary: 222.2 47.4% 11.2%;
|
|
12
|
+
* --primary-foreground: 210 40% 98%;
|
|
13
|
+
* }
|
|
14
|
+
* .dark {
|
|
15
|
+
* --background: 222.2 84% 4.9%;
|
|
16
|
+
* --primary: 210 40% 98%;
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* Each unprefixed name maps to `color.<name>` (shadcn variables are
|
|
20
|
+
* exclusively colors today — typography/spacing live in Tailwind config).
|
|
21
|
+
* When both blocks exist, paired names land as `{light, dark}` composite
|
|
22
|
+
* color values; lone names emit flat values.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { type ThemeDocument, validateThemeTokens } from "../themes.js";
|
|
26
|
+
import { NotShadcnShape } from "../themes-errors.js";
|
|
27
|
+
import { stripCssComments } from "./css-comments.js";
|
|
28
|
+
|
|
29
|
+
interface CssVarMap {
|
|
30
|
+
readonly [name: string]: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function importShadcn(body: string): ThemeDocument {
|
|
34
|
+
// Quick rejections so the auto-detect chain can fall through fast.
|
|
35
|
+
if (/"value"\s*:/i.test(body)) {
|
|
36
|
+
throw new NotShadcnShape('input contains `"value":` — looks like Style Dictionary JSON');
|
|
37
|
+
}
|
|
38
|
+
const lightBlock = extractBlock(body, ":root");
|
|
39
|
+
if (lightBlock === null) {
|
|
40
|
+
throw new NotShadcnShape();
|
|
41
|
+
}
|
|
42
|
+
const lightVars = parseCssVars(lightBlock);
|
|
43
|
+
if (Object.keys(lightVars).length === 0) {
|
|
44
|
+
throw new NotShadcnShape(":root block found but no `--name: value;` pairs inside");
|
|
45
|
+
}
|
|
46
|
+
const darkBlock = extractBlock(body, ".dark");
|
|
47
|
+
const darkVars = darkBlock === null ? null : parseCssVars(darkBlock);
|
|
48
|
+
|
|
49
|
+
const color: Record<string, unknown> = {};
|
|
50
|
+
for (const [name, lightVal] of Object.entries(lightVars)) {
|
|
51
|
+
const wrapped = wrapColorValue(lightVal);
|
|
52
|
+
if (darkVars && darkVars[name] !== undefined) {
|
|
53
|
+
color[name] = {
|
|
54
|
+
$value: { light: wrapped, dark: wrapColorValue(darkVars[name]) },
|
|
55
|
+
$type: "color",
|
|
56
|
+
};
|
|
57
|
+
} else {
|
|
58
|
+
color[name] = { $value: wrapped, $type: "color" };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Surface dark-only names too (no light counterpart). Rare but real
|
|
62
|
+
// when an operator splits brand variants across blocks.
|
|
63
|
+
if (darkVars) {
|
|
64
|
+
for (const [name, darkVal] of Object.entries(darkVars)) {
|
|
65
|
+
if (name in color) continue;
|
|
66
|
+
color[name] = { $value: wrapColorValue(darkVal), $type: "color" };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const doc: ThemeDocument = { color };
|
|
71
|
+
return validateThemeTokens(doc);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Find a selector block (e.g. `:root` or `.dark`) and return the body
|
|
76
|
+
* inside the matching braces. Strips block comments before matching.
|
|
77
|
+
*/
|
|
78
|
+
function extractBlock(body: string, selector: string): string | null {
|
|
79
|
+
const stripped = stripCssComments(body);
|
|
80
|
+
// Escape regex specials in the selector (`.` for `.dark`).
|
|
81
|
+
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
82
|
+
const match = new RegExp(`${escaped}\\s*\\{`).exec(stripped);
|
|
83
|
+
if (!match) return null;
|
|
84
|
+
const start = match.index + match[0].length;
|
|
85
|
+
let depth = 1;
|
|
86
|
+
let i = start;
|
|
87
|
+
while (i < stripped.length && depth > 0) {
|
|
88
|
+
const ch = stripped[i];
|
|
89
|
+
if (ch === "{") depth += 1;
|
|
90
|
+
else if (ch === "}") depth -= 1;
|
|
91
|
+
if (depth === 0) break;
|
|
92
|
+
i += 1;
|
|
93
|
+
}
|
|
94
|
+
if (depth !== 0) return null;
|
|
95
|
+
return stripped.slice(start, i);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseCssVars(block: string): CssVarMap {
|
|
99
|
+
const out: Record<string, string> = {};
|
|
100
|
+
for (const raw of block.split(";")) {
|
|
101
|
+
const line = raw.trim();
|
|
102
|
+
if (line.length === 0 || !line.startsWith("--")) continue;
|
|
103
|
+
const colonIdx = line.indexOf(":");
|
|
104
|
+
if (colonIdx <= 0) continue;
|
|
105
|
+
const name = line.slice(2, colonIdx).trim();
|
|
106
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
107
|
+
if (name.length === 0 || value.length === 0) continue;
|
|
108
|
+
out[name] = value;
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* shadcn often stores HSL channels without the `hsl(...)` wrapper
|
|
115
|
+
* (`--primary: 222.2 47.4% 11.2%`). Detect that shape and wrap so the
|
|
116
|
+
* resulting value is a valid CSS color string the renderer / browser
|
|
117
|
+
* can use directly. Hex / oklch / rgb / hsl(...) values pass through
|
|
118
|
+
* unchanged.
|
|
119
|
+
*/
|
|
120
|
+
function wrapColorValue(raw: string): string {
|
|
121
|
+
const trimmed = raw.trim();
|
|
122
|
+
// Already a recognised CSS color → pass through.
|
|
123
|
+
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
|
|
124
|
+
if (/^(oklch|rgb|rgba|hsl|hsla|lab|lch|color|hwb)\s*\(/i.test(trimmed)) return trimmed;
|
|
125
|
+
if (/^(transparent|currentColor)$/i.test(trimmed)) return trimmed;
|
|
126
|
+
// Three space-separated tokens with a `%` somewhere → shadcn HSL triple.
|
|
127
|
+
if (/^\d+(\.\d+)?\s+\d+(\.\d+)?%\s+\d+(\.\d+)?%$/.test(trimmed)) {
|
|
128
|
+
return `hsl(${trimmed})`;
|
|
129
|
+
}
|
|
130
|
+
// Bare named color or anything else — pass through; the Zod schema
|
|
131
|
+
// will catch genuinely-broken inputs at validateThemeTokens.
|
|
132
|
+
return trimmed;
|
|
133
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* v0.11.1 (issue #76) — Style Dictionary v3/v4 importer.
|
|
5
|
+
*
|
|
6
|
+
* Style Dictionary stores tokens as `{ value: "…", attributes?: {…} }`
|
|
7
|
+
* leaves instead of DTCG's `$value` / `$type`. This importer walks the
|
|
8
|
+
* tree, rewrites `value` → `$value` (and `type` → `$type` where set),
|
|
9
|
+
* preserves `attributes` blocks under `$extensions.styleDictionary`,
|
|
10
|
+
* and feeds the result through `validateThemeTokens` for round-trip
|
|
11
|
+
* compatibility with the DTCG storage shape.
|
|
12
|
+
*
|
|
13
|
+
* Recognition heuristic: input must be valid JSON with at least one
|
|
14
|
+
* nested `{ value: … }` leaf and NO `$value` keys (the latter would
|
|
15
|
+
* indicate DTCG — the auto-detect chain handles priority).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { type ThemeDocument, validateThemeTokens } from "../themes.js";
|
|
19
|
+
import { NotStyleDictionaryShape } from "../themes-errors.js";
|
|
20
|
+
|
|
21
|
+
export function importStyleDictionary(body: string): ThemeDocument {
|
|
22
|
+
let parsed: unknown;
|
|
23
|
+
try {
|
|
24
|
+
parsed = JSON.parse(body);
|
|
25
|
+
} catch (e) {
|
|
26
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
27
|
+
throw new NotStyleDictionaryShape(`Style Dictionary parse failed: ${msg}`);
|
|
28
|
+
}
|
|
29
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
30
|
+
throw new NotStyleDictionaryShape("Style Dictionary input must be a JSON object");
|
|
31
|
+
}
|
|
32
|
+
if (hasDollarValue(parsed)) {
|
|
33
|
+
throw new NotStyleDictionaryShape(
|
|
34
|
+
"input has `$value` leaves — looks like DTCG, not Style Dictionary",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
if (!hasPlainValue(parsed)) {
|
|
38
|
+
throw new NotStyleDictionaryShape(
|
|
39
|
+
"input has no `{ value: … }` leaves typical of Style Dictionary",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
const dtcg = rewriteSdToDtcg(parsed);
|
|
43
|
+
// Top-level Style Dictionary categories may not exactly match DTCG
|
|
44
|
+
// (e.g. `size` instead of `spacing`); rename common cases so the
|
|
45
|
+
// resulting document validates and renders correctly.
|
|
46
|
+
const renamed = renameTopLevelCategories(dtcg);
|
|
47
|
+
return validateThemeTokens(renamed);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hasDollarValue(node: unknown): boolean {
|
|
51
|
+
if (node === null || typeof node !== "object") return false;
|
|
52
|
+
if (Array.isArray(node)) return node.some(hasDollarValue);
|
|
53
|
+
const obj = node as Record<string, unknown>;
|
|
54
|
+
if ("$value" in obj) return true;
|
|
55
|
+
for (const v of Object.values(obj)) {
|
|
56
|
+
if (hasDollarValue(v)) return true;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function hasPlainValue(node: unknown): boolean {
|
|
62
|
+
if (node === null || typeof node !== "object") return false;
|
|
63
|
+
if (Array.isArray(node)) return node.some(hasPlainValue);
|
|
64
|
+
const obj = node as Record<string, unknown>;
|
|
65
|
+
// A leaf in Style Dictionary has `value` AND no nested object siblings
|
|
66
|
+
// carrying their own value. Detecting "has value somewhere" is enough
|
|
67
|
+
// for the auto-detect sniff; full validation happens in rewriteSdToDtcg.
|
|
68
|
+
if ("value" in obj && typeof obj.value !== "object") return true;
|
|
69
|
+
for (const v of Object.values(obj)) {
|
|
70
|
+
if (hasPlainValue(v)) return true;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function rewriteSdToDtcg(node: unknown): unknown {
|
|
76
|
+
if (node === null || typeof node !== "object" || Array.isArray(node)) return node;
|
|
77
|
+
const obj = node as Record<string, unknown>;
|
|
78
|
+
// Leaf detection: has `value` but no nested children that themselves
|
|
79
|
+
// have `value` (deepest-first rewrite).
|
|
80
|
+
if ("value" in obj && typeof obj.value !== "object") {
|
|
81
|
+
const leaf: Record<string, unknown> = { $value: obj.value };
|
|
82
|
+
if ("type" in obj && typeof obj.type === "string") {
|
|
83
|
+
leaf.$type = obj.type;
|
|
84
|
+
}
|
|
85
|
+
if ("description" in obj && typeof obj.description === "string") {
|
|
86
|
+
leaf.$description = obj.description;
|
|
87
|
+
}
|
|
88
|
+
if ("attributes" in obj && obj.attributes && typeof obj.attributes === "object") {
|
|
89
|
+
leaf.$extensions = { styleDictionary: { attributes: obj.attributes } };
|
|
90
|
+
}
|
|
91
|
+
return leaf;
|
|
92
|
+
}
|
|
93
|
+
const out: Record<string, unknown> = {};
|
|
94
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
95
|
+
out[k] = rewriteSdToDtcg(v);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Common Style Dictionary category name → DTCG category map. Only
|
|
102
|
+
* applied at the root; nested groups keep their original names.
|
|
103
|
+
*/
|
|
104
|
+
const SD_TO_DTCG_CATEGORY: Record<string, string> = {
|
|
105
|
+
size: "spacing",
|
|
106
|
+
sizes: "spacing",
|
|
107
|
+
spaces: "spacing",
|
|
108
|
+
colors: "color",
|
|
109
|
+
fonts: "typography",
|
|
110
|
+
font: "typography",
|
|
111
|
+
shadows: "shadow",
|
|
112
|
+
radii: "radius",
|
|
113
|
+
borderRadius: "radius",
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
function renameTopLevelCategories(doc: unknown): unknown {
|
|
117
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return doc;
|
|
118
|
+
const obj = doc as Record<string, unknown>;
|
|
119
|
+
const out: Record<string, unknown> = {};
|
|
120
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
121
|
+
const renamed = SD_TO_DTCG_CATEGORY[k];
|
|
122
|
+
out[renamed ?? k] = v;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|