@nebutra/design-sync 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -676
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +32 -3
- package/dist/detect.d.ts.map +1 -1
- package/dist/detect.js +4 -2
- package/dist/factory.d.ts.map +1 -1
- package/dist/factory.js +5 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/io.js +1 -1
- package/dist/providers/design-md.d.ts +49 -0
- package/dist/providers/design-md.d.ts.map +1 -0
- package/dist/providers/design-md.js +264 -0
- package/dist/serialize/from-design-md.d.ts +80 -0
- package/dist/serialize/from-design-md.d.ts.map +1 -0
- package/dist/serialize/from-design-md.js +1329 -0
- package/dist/serialize/to-brand-package.d.ts +37 -0
- package/dist/serialize/to-brand-package.d.ts.map +1 -0
- package/dist/serialize/to-brand-package.js +87 -0
- package/dist/serialize/to-design-md.d.ts +42 -0
- package/dist/serialize/to-design-md.d.ts.map +1 -0
- package/dist/serialize/to-design-md.js +114 -0
- package/dist/serialize/to-design-md.prose.d.ts +55 -0
- package/dist/serialize/to-design-md.prose.d.ts.map +1 -0
- package/dist/serialize/to-design-md.prose.js +127 -0
- package/dist/serialize/to-design-md.resolve.d.ts +36 -0
- package/dist/serialize/to-design-md.resolve.d.ts.map +1 -0
- package/dist/serialize/to-design-md.resolve.js +248 -0
- package/dist/serialize/to-preview-html.d.ts +42 -0
- package/dist/serialize/to-preview-html.d.ts.map +1 -0
- package/dist/serialize/to-preview-html.js +250 -0
- package/dist/serialize/to-preview-html.template.d.ts +75 -0
- package/dist/serialize/to-preview-html.template.d.ts.map +1 -0
- package/dist/serialize/to-preview-html.template.js +267 -0
- package/dist/types.d.ts +22 -6
- package/dist/types.d.ts.map +1 -1
- package/package.json +21 -6
- package/src/cli/index.ts +36 -3
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DTCG → DESIGN.md resolver layer
|
|
3
|
+
*
|
|
4
|
+
* Builds flat token indexes from token sets, resolves aliases, extracts
|
|
5
|
+
* typed values (colors, typography, rounded, spacing, containers).
|
|
6
|
+
*
|
|
7
|
+
* Imported by `to-design-md.ts`; do NOT import this file directly in app code.
|
|
8
|
+
*/
|
|
9
|
+
export { buildIndex, buildIndexForSet, buildRounded, buildSpacing, buildTypography, readContainers, resolveColorRoles, };
|
|
10
|
+
// ─── Index building ────────────────────────────────────────────────────────────
|
|
11
|
+
function buildIndex(sets) {
|
|
12
|
+
const index = new Map();
|
|
13
|
+
for (const set of sets) {
|
|
14
|
+
flattenTree(set.tokens, "", index);
|
|
15
|
+
}
|
|
16
|
+
return index;
|
|
17
|
+
}
|
|
18
|
+
function buildIndexForSet(tree) {
|
|
19
|
+
const index = new Map();
|
|
20
|
+
flattenTree(tree, "", index);
|
|
21
|
+
return index;
|
|
22
|
+
}
|
|
23
|
+
function flattenTree(node, prefix, out) {
|
|
24
|
+
for (const [key, value] of Object.entries(node)) {
|
|
25
|
+
if (key.startsWith("$"))
|
|
26
|
+
continue; // skip $schema, $description at group level
|
|
27
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
28
|
+
if (isLeaf(value)) {
|
|
29
|
+
out.set(path, value);
|
|
30
|
+
}
|
|
31
|
+
else if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
32
|
+
// Only recurse into plain objects — skip scalars (strings, numbers, booleans)
|
|
33
|
+
// that appear at the group level (e.g. a top-level "theme": "dark-dense" key).
|
|
34
|
+
flattenTree(value, path, out);
|
|
35
|
+
}
|
|
36
|
+
// Scalar non-leaf values (e.g. "$schema" companions without "$type") are silently skipped.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function isLeaf(value) {
|
|
40
|
+
return typeof value === "object" && value !== null && "$value" in value && "$type" in value;
|
|
41
|
+
}
|
|
42
|
+
// ─── Alias resolution ──────────────────────────────────────────────────────────
|
|
43
|
+
const ALIAS_RE = /^\{([^}]+)\}$/;
|
|
44
|
+
/**
|
|
45
|
+
* Resolve a token value to its literal (non-alias) string.
|
|
46
|
+
* Throws if the alias is dangling (referenced path not in index).
|
|
47
|
+
* Returns null only if the path itself is missing from the index (graceful skip).
|
|
48
|
+
*
|
|
49
|
+
* @param path The current token path being resolved.
|
|
50
|
+
* @param index The flat token index to look up.
|
|
51
|
+
* @param visited Set of paths already visited (cycle detection).
|
|
52
|
+
* @param originPath The originating token path (for cycle error messages).
|
|
53
|
+
*/
|
|
54
|
+
export function resolveValue(path, index, visited = new Set(), originPath) {
|
|
55
|
+
const leaf = index.get(path);
|
|
56
|
+
if (!leaf)
|
|
57
|
+
return null; // graceful: source token absent
|
|
58
|
+
const raw = String(leaf.$value);
|
|
59
|
+
const match = ALIAS_RE.exec(raw);
|
|
60
|
+
if (!match)
|
|
61
|
+
return raw; // literal value
|
|
62
|
+
const refPath = match[1];
|
|
63
|
+
if (visited.has(refPath)) {
|
|
64
|
+
const origin = originPath ?? path;
|
|
65
|
+
throw new Error(`[design-sync] Cycle detected resolving token alias starting from "${origin}": ${[...visited, refPath].join(" → ")}`);
|
|
66
|
+
}
|
|
67
|
+
// Check the alias exists in the index before recursing
|
|
68
|
+
if (!index.has(refPath)) {
|
|
69
|
+
throw new Error(`[design-sync] Dangling token alias: {${refPath}} referenced by "${path}" could not be resolved. ` +
|
|
70
|
+
`Ensure the token set containing "${refPath}" is included in the sets array.`);
|
|
71
|
+
}
|
|
72
|
+
return resolveValue(refPath, index, new Set([...visited, refPath]), originPath ?? path);
|
|
73
|
+
}
|
|
74
|
+
// ─── Hex validation ────────────────────────────────────────────────────────────
|
|
75
|
+
const HEX_RE = /^#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?$/;
|
|
76
|
+
/** Returns a lowercase #rrggbb string, or null if the value isn't a bare hex. */
|
|
77
|
+
function toHex(value) {
|
|
78
|
+
const v = value.trim();
|
|
79
|
+
if (!HEX_RE.test(v))
|
|
80
|
+
return null;
|
|
81
|
+
const lower = v.toLowerCase();
|
|
82
|
+
// Expand 3-char shorthand (#abc → #aabbcc)
|
|
83
|
+
if (lower.length === 4) {
|
|
84
|
+
const r = lower[1];
|
|
85
|
+
const g = lower[2];
|
|
86
|
+
const b = lower[3];
|
|
87
|
+
return `#${r}${r}${g}${g}${b}${b}`;
|
|
88
|
+
}
|
|
89
|
+
return lower;
|
|
90
|
+
}
|
|
91
|
+
// ─── Color role mapping ────────────────────────────────────────────────────────
|
|
92
|
+
/** Map from color role name to the dot-path in the merged index. */
|
|
93
|
+
const COLOR_ROLE_PATHS = [
|
|
94
|
+
["primary", "brand.primary"],
|
|
95
|
+
["accent", "brand.accent"],
|
|
96
|
+
["tertiary", "brand.tertiary"],
|
|
97
|
+
["danger", "status.danger"],
|
|
98
|
+
["warning", "status.warning"],
|
|
99
|
+
["success", "status.success"],
|
|
100
|
+
];
|
|
101
|
+
function resolveColorRoles(index, themeIndex) {
|
|
102
|
+
const roles = {};
|
|
103
|
+
const descriptions = {};
|
|
104
|
+
for (const [role, path] of COLOR_ROLE_PATHS) {
|
|
105
|
+
if (!index.has(path))
|
|
106
|
+
continue;
|
|
107
|
+
const resolved = resolveValue(path, index, new Set(), path);
|
|
108
|
+
if (resolved == null)
|
|
109
|
+
continue;
|
|
110
|
+
const hex = toHex(resolved);
|
|
111
|
+
if (!hex)
|
|
112
|
+
continue; // non-hex (gradient etc.) — skip from colors group
|
|
113
|
+
roles[role] = hex;
|
|
114
|
+
const sourceLeaf = index.get(path);
|
|
115
|
+
if (sourceLeaf?.$description) {
|
|
116
|
+
descriptions[role] = sourceLeaf.$description;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Theme-specific: background + foreground
|
|
120
|
+
if (themeIndex.size > 0) {
|
|
121
|
+
for (const [role, path] of [
|
|
122
|
+
["background", "color.background"],
|
|
123
|
+
["foreground", "color.foreground"],
|
|
124
|
+
]) {
|
|
125
|
+
if (!themeIndex.has(path))
|
|
126
|
+
continue;
|
|
127
|
+
const resolved = resolveValue(path, themeIndex, new Set(), path);
|
|
128
|
+
if (resolved == null)
|
|
129
|
+
continue;
|
|
130
|
+
const hex = toHex(resolved);
|
|
131
|
+
if (!hex)
|
|
132
|
+
continue;
|
|
133
|
+
roles[role] = hex;
|
|
134
|
+
const sourceLeaf = themeIndex.get(path);
|
|
135
|
+
if (sourceLeaf?.$description) {
|
|
136
|
+
descriptions[role] = sourceLeaf.$description;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { roles, descriptions };
|
|
141
|
+
}
|
|
142
|
+
// ─── Typography ────────────────────────────────────────────────────────────────
|
|
143
|
+
function buildTypography(index) {
|
|
144
|
+
const sansRaw = resolveValue("fontFamily.sans", index);
|
|
145
|
+
const monoRaw = resolveValue("fontFamily.mono", index);
|
|
146
|
+
const sansFontFamily = sansRaw ?? "Geist, system-ui, sans-serif";
|
|
147
|
+
const monoFontFamily = monoRaw ?? "Geist Mono, ui-monospace, monospace";
|
|
148
|
+
return {
|
|
149
|
+
h1: { fontFamily: sansFontFamily, fontSize: "3rem" },
|
|
150
|
+
"body-md": { fontFamily: sansFontFamily, fontSize: "1rem" },
|
|
151
|
+
label: { fontFamily: monoFontFamily, fontSize: "0.75rem" },
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
// ─── Rounded ──────────────────────────────────────────────────────────────────
|
|
155
|
+
/** Stable key order for size.radius entries. */
|
|
156
|
+
const RADIUS_KEY_ORDER = [
|
|
157
|
+
"none",
|
|
158
|
+
"sm",
|
|
159
|
+
"md",
|
|
160
|
+
"lg",
|
|
161
|
+
"xl",
|
|
162
|
+
"2xl",
|
|
163
|
+
"3xl",
|
|
164
|
+
"full",
|
|
165
|
+
"button",
|
|
166
|
+
"card",
|
|
167
|
+
"panel",
|
|
168
|
+
];
|
|
169
|
+
function buildRounded(index) {
|
|
170
|
+
const result = {};
|
|
171
|
+
const prefix = "size.radius.";
|
|
172
|
+
const found = [];
|
|
173
|
+
for (const key of index.keys()) {
|
|
174
|
+
if (key.startsWith(prefix)) {
|
|
175
|
+
found.push(key.slice(prefix.length));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Sort by canonical order then alphabetically for any extras
|
|
179
|
+
found.sort((a, b) => {
|
|
180
|
+
const ai = RADIUS_KEY_ORDER.indexOf(a);
|
|
181
|
+
const bi = RADIUS_KEY_ORDER.indexOf(b);
|
|
182
|
+
if (ai !== -1 && bi !== -1)
|
|
183
|
+
return ai - bi;
|
|
184
|
+
if (ai !== -1)
|
|
185
|
+
return -1;
|
|
186
|
+
if (bi !== -1)
|
|
187
|
+
return 1;
|
|
188
|
+
return a.localeCompare(b);
|
|
189
|
+
});
|
|
190
|
+
for (const key of found) {
|
|
191
|
+
const resolved = resolveValue(`${prefix}${key}`, index);
|
|
192
|
+
if (resolved != null) {
|
|
193
|
+
// DESIGN.md spec requires a unit suffix — normalize bare "0" to "0px"
|
|
194
|
+
// so the linter does not flag it as an invalid dimension.
|
|
195
|
+
result[key] = resolved === "0" ? "0px" : resolved;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
// ─── Spacing ──────────────────────────────────────────────────────────────────
|
|
201
|
+
/** Stable key order for size.spacing entries. */
|
|
202
|
+
const SPACING_KEY_ORDER = ["xs", "sm", "md", "lg", "xl", "2xl", "3xl", "4xl"];
|
|
203
|
+
function buildSpacing(index) {
|
|
204
|
+
const prefix = "size.spacing.";
|
|
205
|
+
const found = [];
|
|
206
|
+
for (const key of index.keys()) {
|
|
207
|
+
if (key.startsWith(prefix)) {
|
|
208
|
+
found.push(key.slice(prefix.length));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (found.length === 0)
|
|
212
|
+
return null;
|
|
213
|
+
// Sort by canonical order then alphabetically for any extras (determinism)
|
|
214
|
+
found.sort((a, b) => {
|
|
215
|
+
const ai = SPACING_KEY_ORDER.indexOf(a);
|
|
216
|
+
const bi = SPACING_KEY_ORDER.indexOf(b);
|
|
217
|
+
if (ai !== -1 && bi !== -1)
|
|
218
|
+
return ai - bi;
|
|
219
|
+
if (ai !== -1)
|
|
220
|
+
return -1;
|
|
221
|
+
if (bi !== -1)
|
|
222
|
+
return 1;
|
|
223
|
+
return a.localeCompare(b);
|
|
224
|
+
});
|
|
225
|
+
const result = {};
|
|
226
|
+
for (const shortKey of found) {
|
|
227
|
+
const resolved = resolveValue(`${prefix}${shortKey}`, index);
|
|
228
|
+
if (resolved != null) {
|
|
229
|
+
result[shortKey] = resolved;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return Object.keys(result).length > 0 ? result : null;
|
|
233
|
+
}
|
|
234
|
+
// ─── Containers ───────────────────────────────────────────────────────────────
|
|
235
|
+
function readContainers(index) {
|
|
236
|
+
const info = {};
|
|
237
|
+
for (const [role, path] of [
|
|
238
|
+
["text", "size.container.text"],
|
|
239
|
+
["content", "size.container.content"],
|
|
240
|
+
["wide", "size.container.wide"],
|
|
241
|
+
]) {
|
|
242
|
+
const resolved = resolveValue(path, index);
|
|
243
|
+
if (resolved != null) {
|
|
244
|
+
info[role] = resolved;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return info;
|
|
248
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DTCG → preview.html serializer
|
|
3
|
+
*
|
|
4
|
+
* Converts an array of W3C DTCG token sets into a self-contained, dependency-free
|
|
5
|
+
* HTML document that visually renders the design system:
|
|
6
|
+
*
|
|
7
|
+
* - Header with the design-system name
|
|
8
|
+
* - Color swatches (name + resolved hex) for all brand/status roles
|
|
9
|
+
* - Typography samples (h1 / body / label) using the resolved font family
|
|
10
|
+
* - Component samples (button, card, input) built with resolved tokens
|
|
11
|
+
* - Radius scale
|
|
12
|
+
* - Light + dark rendering via CSS `prefers-color-scheme` + `[data-theme="dark"]`
|
|
13
|
+
*
|
|
14
|
+
* PURE: no filesystem I/O, no @google/design.md dependency.
|
|
15
|
+
* Only imports from `../types`, `./to-design-md.resolve`, and `./to-preview-html.template`.
|
|
16
|
+
*/
|
|
17
|
+
import type { DesignTokenSet } from "../types";
|
|
18
|
+
export interface ToPreviewHtmlOptions {
|
|
19
|
+
/** Design system name. Default: "Nebutra" */
|
|
20
|
+
name?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Name of a theme token set (e.g. "themes/light") whose
|
|
23
|
+
* `color.background` and `color.foreground` tokens are used for the light
|
|
24
|
+
* mode CSS custom properties.
|
|
25
|
+
*/
|
|
26
|
+
theme?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Name of the dark-theme token set (e.g. "themes/dark").
|
|
29
|
+
* Defaults to the set named exactly "themes/dark".
|
|
30
|
+
* If not found, light values are used for dark mode (preview still renders).
|
|
31
|
+
*/
|
|
32
|
+
darkTheme?: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Serialize an array of DTCG token sets into a self-contained HTML preview.
|
|
36
|
+
*
|
|
37
|
+
* @param sets All token sets to merge (e.g. core + semantic + theme).
|
|
38
|
+
* @param options Optional overrides for name, theme, and darkTheme.
|
|
39
|
+
* @returns A deterministic, self-contained HTML string.
|
|
40
|
+
*/
|
|
41
|
+
export declare function serializeToPreviewHtml(sets: DesignTokenSet[], options?: ToPreviewHtmlOptions): string;
|
|
42
|
+
//# sourceMappingURL=to-preview-html.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"to-preview-html.d.ts","sourceRoot":"","sources":["../../src/serialize/to-preview-html.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAkB/C,MAAM,WAAW,oBAAoB;IACnC,6CAA6C;IAC7C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,cAAc,EAAE,EACtB,OAAO,CAAC,EAAE,oBAAoB,GAC7B,MAAM,CA2CR"}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DTCG → preview.html serializer
|
|
3
|
+
*
|
|
4
|
+
* Converts an array of W3C DTCG token sets into a self-contained, dependency-free
|
|
5
|
+
* HTML document that visually renders the design system:
|
|
6
|
+
*
|
|
7
|
+
* - Header with the design-system name
|
|
8
|
+
* - Color swatches (name + resolved hex) for all brand/status roles
|
|
9
|
+
* - Typography samples (h1 / body / label) using the resolved font family
|
|
10
|
+
* - Component samples (button, card, input) built with resolved tokens
|
|
11
|
+
* - Radius scale
|
|
12
|
+
* - Light + dark rendering via CSS `prefers-color-scheme` + `[data-theme="dark"]`
|
|
13
|
+
*
|
|
14
|
+
* PURE: no filesystem I/O, no @google/design.md dependency.
|
|
15
|
+
* Only imports from `../types`, `./to-design-md.resolve`, and `./to-preview-html.template`.
|
|
16
|
+
*/
|
|
17
|
+
import { buildIndex, buildIndexForSet, buildRounded, buildTypography, resolveColorRoles, } from "./to-design-md.resolve";
|
|
18
|
+
import { buildDocument, cssAttrValue, escapeHtml, } from "./to-preview-html.template";
|
|
19
|
+
/**
|
|
20
|
+
* Serialize an array of DTCG token sets into a self-contained HTML preview.
|
|
21
|
+
*
|
|
22
|
+
* @param sets All token sets to merge (e.g. core + semantic + theme).
|
|
23
|
+
* @param options Optional overrides for name, theme, and darkTheme.
|
|
24
|
+
* @returns A deterministic, self-contained HTML string.
|
|
25
|
+
*/
|
|
26
|
+
export function serializeToPreviewHtml(sets, options) {
|
|
27
|
+
const name = options?.name ?? "Nebutra";
|
|
28
|
+
const escapedName = escapeHtml(name);
|
|
29
|
+
// 1. Build flat indexes
|
|
30
|
+
const index = buildIndex(sets);
|
|
31
|
+
const themeIndex = options?.theme != null
|
|
32
|
+
? buildIndexForSet(sets.find((s) => s.name === options.theme)?.tokens ?? {})
|
|
33
|
+
: new Map();
|
|
34
|
+
// Find dark theme set: honour explicit darkTheme option, then fall back to
|
|
35
|
+
// the set named exactly "themes/dark". Do NOT fall through to arbitrary
|
|
36
|
+
// themes/* sets — that caused silent mis-detection.
|
|
37
|
+
const darkThemeName = options?.darkTheme ?? "themes/dark";
|
|
38
|
+
const darkThemeSet = sets.find((s) => s.name === darkThemeName);
|
|
39
|
+
const darkThemeIndex = darkThemeSet != null ? buildIndexForSet(darkThemeSet.tokens) : themeIndex;
|
|
40
|
+
// 2. Resolve tokens
|
|
41
|
+
const { roles: resolvedLightColors } = resolveColorRoles(index, themeIndex);
|
|
42
|
+
const { roles: resolvedDarkColors } = resolveColorRoles(index, darkThemeIndex);
|
|
43
|
+
const typography = buildTypography(index);
|
|
44
|
+
const rounded = buildRounded(index);
|
|
45
|
+
// 3. Build CSS variable blocks (all fields guaranteed present via ?? defaults)
|
|
46
|
+
const lightVars = buildCssVars(resolvedLightColors, "light");
|
|
47
|
+
const darkVars = buildCssVars(resolvedDarkColors, "dark");
|
|
48
|
+
// 4. Build content sections (use lightVars — CssVarBlock with guaranteed strings)
|
|
49
|
+
const swatchesHtml = buildSwatches(lightVars);
|
|
50
|
+
const typographyHtml = buildTypographySection(typography);
|
|
51
|
+
const componentsHtml = buildComponentsSection(lightVars, typography, rounded);
|
|
52
|
+
const radiusHtml = buildRadiusSection(rounded);
|
|
53
|
+
return buildDocument({
|
|
54
|
+
escapedName,
|
|
55
|
+
lightVars,
|
|
56
|
+
darkVars,
|
|
57
|
+
swatchesHtml,
|
|
58
|
+
typographyHtml,
|
|
59
|
+
componentsHtml,
|
|
60
|
+
radiusHtml,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
// ─── CSS variable builder ──────────────────────────────────────────────────────
|
|
64
|
+
function buildCssVars(colors, _mode) {
|
|
65
|
+
return {
|
|
66
|
+
primary: colors.primary ?? "#0033fe",
|
|
67
|
+
accent: colors.accent ?? "#0bf1c3",
|
|
68
|
+
tertiary: colors.tertiary ?? "#8b5cf6",
|
|
69
|
+
danger: colors.danger ?? "#ef4444",
|
|
70
|
+
warning: colors.warning ?? "#f59e0b",
|
|
71
|
+
success: colors.success ?? "#22c55e",
|
|
72
|
+
background: colors.background ?? (_mode === "light" ? "#ffffff" : "#0a0a0a"),
|
|
73
|
+
foreground: colors.foreground ?? (_mode === "light" ? "#0f172a" : "#fafafa"),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
// ─── Section builders ──────────────────────────────────────────────────────────
|
|
77
|
+
const COLOR_ROLE_LABELS = [
|
|
78
|
+
["primary", "Primary"],
|
|
79
|
+
["accent", "Accent"],
|
|
80
|
+
["tertiary", "Tertiary"],
|
|
81
|
+
["danger", "Danger"],
|
|
82
|
+
["warning", "Warning"],
|
|
83
|
+
["success", "Success"],
|
|
84
|
+
["background", "Background"],
|
|
85
|
+
["foreground", "Foreground"],
|
|
86
|
+
];
|
|
87
|
+
function buildSwatches(colors) {
|
|
88
|
+
const items = COLOR_ROLE_LABELS.map(([role, label]) => {
|
|
89
|
+
const hex = colors[role];
|
|
90
|
+
const textColor = isLightColor(hex) ? "#0a0a0a" : "#ffffff";
|
|
91
|
+
return `
|
|
92
|
+
<div class="swatch">
|
|
93
|
+
<div class="swatch-color" style="background:${hex};color:${textColor};">
|
|
94
|
+
<span class="swatch-hex">${escapeHtml(hex)}</span>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="swatch-label">${escapeHtml(label)}</div>
|
|
97
|
+
</div>`;
|
|
98
|
+
}).join("\n");
|
|
99
|
+
return `<section class="preview-section">
|
|
100
|
+
<h2 class="section-title">Colors</h2>
|
|
101
|
+
<div class="swatch-grid">
|
|
102
|
+
${items}
|
|
103
|
+
</div>
|
|
104
|
+
</section>`;
|
|
105
|
+
}
|
|
106
|
+
/** Naively estimates whether a hex color is light (for contrast text). */
|
|
107
|
+
function isLightColor(hex) {
|
|
108
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
109
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
110
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
111
|
+
// Perceived luminance (ITU-R BT.709)
|
|
112
|
+
const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
113
|
+
return luminance > 0.5;
|
|
114
|
+
}
|
|
115
|
+
function buildTypographySection(typography) {
|
|
116
|
+
const samples = Object.entries(typography)
|
|
117
|
+
.map(([variant, entry]) => {
|
|
118
|
+
const tag = variant === "h1" ? "h1" : "p";
|
|
119
|
+
const sampleText = variant === "h1"
|
|
120
|
+
? "The quick brown fox"
|
|
121
|
+
: variant === "body-md"
|
|
122
|
+
? "Body text — the quick brown fox jumps over the lazy dog."
|
|
123
|
+
: "label / mono sample";
|
|
124
|
+
return `
|
|
125
|
+
<div class="type-sample">
|
|
126
|
+
<div class="type-meta">${escapeHtml(variant)} — ${escapeHtml(entry.fontSize)} — ${escapeHtml(entry.fontFamily)}</div>
|
|
127
|
+
<${tag} class="type-preview" style="font-family:${cssAttrValue(entry.fontFamily)};font-size:${cssAttrValue(entry.fontSize)};margin:0;">${escapeHtml(sampleText)}</${tag}>
|
|
128
|
+
</div>`;
|
|
129
|
+
})
|
|
130
|
+
.join("\n");
|
|
131
|
+
return `<section class="preview-section">
|
|
132
|
+
<h2 class="section-title">Typography</h2>
|
|
133
|
+
<div class="type-stack">
|
|
134
|
+
${samples}
|
|
135
|
+
</div>
|
|
136
|
+
</section>`;
|
|
137
|
+
}
|
|
138
|
+
function buildComponentsSection(colors, typography, rounded) {
|
|
139
|
+
const fontFamily = cssAttrValue(typography["body-md"]?.fontFamily ?? "system-ui, sans-serif");
|
|
140
|
+
const radius = cssAttrValue(rounded["md"] ?? rounded["sm"] ?? "0.375rem");
|
|
141
|
+
const radiusLg = cssAttrValue(rounded["lg"] ?? rounded["md"] ?? "0.5rem");
|
|
142
|
+
const buttonHtml = `
|
|
143
|
+
<div class="component-item">
|
|
144
|
+
<div class="component-label">Primary Button</div>
|
|
145
|
+
<button type="button" style="
|
|
146
|
+
background:${colors.primary};
|
|
147
|
+
color:#ffffff;
|
|
148
|
+
border:none;
|
|
149
|
+
border-radius:${radius};
|
|
150
|
+
padding:0.5rem 1.25rem;
|
|
151
|
+
font-family:${fontFamily};
|
|
152
|
+
font-size:0.875rem;
|
|
153
|
+
font-weight:600;
|
|
154
|
+
cursor:pointer;
|
|
155
|
+
">Get Started</button>
|
|
156
|
+
</div>`;
|
|
157
|
+
const outlineButtonHtml = `
|
|
158
|
+
<div class="component-item">
|
|
159
|
+
<div class="component-label">Outline Button</div>
|
|
160
|
+
<button type="button" style="
|
|
161
|
+
background:transparent;
|
|
162
|
+
color:${colors.primary};
|
|
163
|
+
border:1.5px solid ${colors.primary};
|
|
164
|
+
border-radius:${radius};
|
|
165
|
+
padding:0.5rem 1.25rem;
|
|
166
|
+
font-family:${fontFamily};
|
|
167
|
+
font-size:0.875rem;
|
|
168
|
+
font-weight:600;
|
|
169
|
+
cursor:pointer;
|
|
170
|
+
">Secondary</button>
|
|
171
|
+
</div>`;
|
|
172
|
+
const cardHtml = `
|
|
173
|
+
<div class="component-item">
|
|
174
|
+
<div class="component-label">Card</div>
|
|
175
|
+
<div style="
|
|
176
|
+
background:var(--preview-bg);
|
|
177
|
+
border:1px solid rgba(0,0,0,0.1);
|
|
178
|
+
border-radius:${radiusLg};
|
|
179
|
+
padding:1rem 1.25rem;
|
|
180
|
+
font-family:${fontFamily};
|
|
181
|
+
max-width:220px;
|
|
182
|
+
box-shadow:0 1px 3px rgba(0,0,0,0.08);
|
|
183
|
+
">
|
|
184
|
+
<div style="font-weight:600;font-size:0.9rem;margin-bottom:0.25rem;">Card Title</div>
|
|
185
|
+
<div style="font-size:0.8rem;opacity:0.7;">Card body text — semantic tokens applied.</div>
|
|
186
|
+
</div>
|
|
187
|
+
</div>`;
|
|
188
|
+
const inputHtml = `
|
|
189
|
+
<div class="component-item">
|
|
190
|
+
<div class="component-label">Input</div>
|
|
191
|
+
<input
|
|
192
|
+
data-allow-native
|
|
193
|
+
type="text"
|
|
194
|
+
placeholder="Placeholder text…"
|
|
195
|
+
style="
|
|
196
|
+
background:var(--preview-bg);
|
|
197
|
+
color:var(--preview-fg);
|
|
198
|
+
border:1.5px solid rgba(0,0,0,0.18);
|
|
199
|
+
border-radius:${radius};
|
|
200
|
+
padding:0.5rem 0.75rem;
|
|
201
|
+
font-family:${fontFamily};
|
|
202
|
+
font-size:0.875rem;
|
|
203
|
+
outline:none;
|
|
204
|
+
width:200px;
|
|
205
|
+
"
|
|
206
|
+
/>
|
|
207
|
+
</div>`;
|
|
208
|
+
const badgeHtml = `
|
|
209
|
+
<div class="component-item">
|
|
210
|
+
<div class="component-label">Status Badges</div>
|
|
211
|
+
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;">
|
|
212
|
+
<span style="background:${colors.success};color:#fff;border-radius:999px;padding:0.2rem 0.6rem;font-size:0.7rem;font-weight:600;font-family:${fontFamily};">Success</span>
|
|
213
|
+
<span style="background:${colors.warning};color:#fff;border-radius:999px;padding:0.2rem 0.6rem;font-size:0.7rem;font-weight:600;font-family:${fontFamily};">Warning</span>
|
|
214
|
+
<span style="background:${colors.danger};color:#fff;border-radius:999px;padding:0.2rem 0.6rem;font-size:0.7rem;font-weight:600;font-family:${fontFamily};">Danger</span>
|
|
215
|
+
</div>
|
|
216
|
+
</div>`;
|
|
217
|
+
return `<section class="preview-section">
|
|
218
|
+
<h2 class="section-title">Components</h2>
|
|
219
|
+
<div class="component-grid">
|
|
220
|
+
${buttonHtml}
|
|
221
|
+
${outlineButtonHtml}
|
|
222
|
+
${cardHtml}
|
|
223
|
+
${inputHtml}
|
|
224
|
+
${badgeHtml}
|
|
225
|
+
</div>
|
|
226
|
+
</section>`;
|
|
227
|
+
}
|
|
228
|
+
function buildRadiusSection(rounded) {
|
|
229
|
+
const entries = Object.entries(rounded);
|
|
230
|
+
if (entries.length === 0) {
|
|
231
|
+
return `<section class="preview-section">
|
|
232
|
+
<h2 class="section-title">Radius Scale</h2>
|
|
233
|
+
<p style="opacity:0.6;font-size:0.875rem;">No radius tokens found in the provided token sets.</p>
|
|
234
|
+
</section>`;
|
|
235
|
+
}
|
|
236
|
+
const items = entries
|
|
237
|
+
.map(([key, value]) => `
|
|
238
|
+
<div class="radius-item">
|
|
239
|
+
<div class="radius-demo" style="border-radius:${cssAttrValue(value)};"></div>
|
|
240
|
+
<div class="radius-name">${escapeHtml(key)}</div>
|
|
241
|
+
<div class="radius-value">${escapeHtml(value)}</div>
|
|
242
|
+
</div>`)
|
|
243
|
+
.join("\n");
|
|
244
|
+
return `<section class="preview-section">
|
|
245
|
+
<h2 class="section-title">Radius Scale</h2>
|
|
246
|
+
<div class="radius-grid">
|
|
247
|
+
${items}
|
|
248
|
+
</div>
|
|
249
|
+
</section>`;
|
|
250
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* to-preview-html.template.ts
|
|
3
|
+
*
|
|
4
|
+
* HTML / CSS template layer for the DTCG → preview.html serializer.
|
|
5
|
+
*
|
|
6
|
+
* Exports:
|
|
7
|
+
* - escapeHtml — generic HTML-attribute / content escaping
|
|
8
|
+
* - cssAttrValue — escapes a token value for use inside style="…"
|
|
9
|
+
* - cssVarsDecl — turns a CssVarBlock into CSS custom-property lines
|
|
10
|
+
* - buildDocument — assembles the full <!DOCTYPE html> string
|
|
11
|
+
*
|
|
12
|
+
* Pure: no filesystem I/O, no token resolution.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Escape a string for safe insertion into HTML content or attribute values.
|
|
16
|
+
* Converts the five characters that have special meaning in HTML.
|
|
17
|
+
*/
|
|
18
|
+
export declare function escapeHtml(value: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* Escape a token-derived value for safe use inside a `style="…"` attribute.
|
|
21
|
+
*
|
|
22
|
+
* Strategy:
|
|
23
|
+
* 1. Strip CSS structural characters that could inject extra declarations or
|
|
24
|
+
* break the style attribute — specifically `;`, `{`, `}`, `<`, `>`.
|
|
25
|
+
* NOTE: We deliberately keep `"` in the string before step 2 so that
|
|
26
|
+
* font-family values like `"Geist", sans-serif` are preserved; `escapeHtml`
|
|
27
|
+
* will turn those `"` into `"` which the browser decodes correctly.
|
|
28
|
+
* 2. HTML-escape the result so embedded quotes survive as `"` and any
|
|
29
|
+
* remaining angle brackets become `<`/`>`.
|
|
30
|
+
*
|
|
31
|
+
* Example:
|
|
32
|
+
* cssAttrValue('"Geist", "Noto Sans SC", sans-serif')
|
|
33
|
+
* → '"Geist", "Noto Sans SC", sans-serif'
|
|
34
|
+
*
|
|
35
|
+
* cssAttrValue('red;display:none')
|
|
36
|
+
* → 'reddisplay:none' (semicolon stripped before HTML-escape)
|
|
37
|
+
*/
|
|
38
|
+
export declare function cssAttrValue(raw: string): string;
|
|
39
|
+
export interface CssVarBlock {
|
|
40
|
+
primary: string;
|
|
41
|
+
accent: string;
|
|
42
|
+
tertiary: string;
|
|
43
|
+
danger: string;
|
|
44
|
+
warning: string;
|
|
45
|
+
success: string;
|
|
46
|
+
background: string;
|
|
47
|
+
foreground: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Render a CssVarBlock as indented CSS custom-property declarations (no braces).
|
|
51
|
+
* Values come from the validated toHex path so they are hex strings, but we
|
|
52
|
+
* wrap in cssAttrValue for consistency (harmless for safe hex values).
|
|
53
|
+
*/
|
|
54
|
+
export declare function cssVarsDecl(vars: CssVarBlock): string;
|
|
55
|
+
export interface DocumentArgs {
|
|
56
|
+
escapedName: string;
|
|
57
|
+
lightVars: CssVarBlock;
|
|
58
|
+
darkVars: CssVarBlock;
|
|
59
|
+
swatchesHtml: string;
|
|
60
|
+
typographyHtml: string;
|
|
61
|
+
componentsHtml: string;
|
|
62
|
+
radiusHtml: string;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Assemble the full <!DOCTYPE html> preview document.
|
|
66
|
+
*
|
|
67
|
+
* Dark-mode CSS is emitted as TWO separate, valid rules:
|
|
68
|
+
* 1. `:root[data-theme="dark"]` — explicit toggle
|
|
69
|
+
* 2. `@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) }`
|
|
70
|
+
* — OS/browser preference (but not when the user has explicitly chosen light)
|
|
71
|
+
*
|
|
72
|
+
* This avoids the invalid CSS form of comma-joining a selector with an at-rule.
|
|
73
|
+
*/
|
|
74
|
+
export declare function buildDocument(args: DocumentArgs): string;
|
|
75
|
+
//# sourceMappingURL=to-preview-html.template.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"to-preview-html.template.d.ts","sourceRoot":"","sources":["../../src/serialize/to-preview-html.template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAIH;;;GAGG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAOhD;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEhD;AAID,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,CAarD;AAID,MAAM,WAAW,YAAY;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,WAAW,CAAC;IACvB,QAAQ,EAAE,WAAW,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,YAAY,GAAG,MAAM,CA8LxD"}
|