@transtyle/exporter-css-variables 0.1.0-alpha.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/package.json +39 -0
- package/src/index.js +241 -0
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@transtyle/exporter-css-variables",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "Transtyle exporter emitting the resolved semantic catalog as plain CSS custom properties — the simplest possible backend and the plugin-API conformance fixture.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"transtyle": {
|
|
11
|
+
"kind": "exporter",
|
|
12
|
+
"name": "css-variables",
|
|
13
|
+
"irSpec": "v0-draft",
|
|
14
|
+
"pluginApi": "0",
|
|
15
|
+
"targets": {
|
|
16
|
+
"css-variables": [
|
|
17
|
+
"*"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"modes": [
|
|
21
|
+
"color-scheme"
|
|
22
|
+
],
|
|
23
|
+
"capabilities": [
|
|
24
|
+
"build"
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src"
|
|
29
|
+
],
|
|
30
|
+
"publishConfig": { "access": "public" },
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/transtyle/transtyle.git",
|
|
34
|
+
"directory": "packages/exporter-css-variables"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/transtyle/transtyle#readme",
|
|
37
|
+
"bugs": "https://github.com/transtyle/transtyle/issues",
|
|
38
|
+
"license": "MIT"
|
|
39
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @transtyle/exporter-css-variables — the simplest possible backend, kept
|
|
3
|
+
* deliberately boring: it dumps the resolved semantic catalog 1:1 as plain
|
|
4
|
+
* CSS custom properties. Spec: docs/specs/exporters/css-variables.md.
|
|
5
|
+
*
|
|
6
|
+
* Two jobs beyond being useful on its own:
|
|
7
|
+
* 1. Executable specification of the plugin API — an exporter is exactly
|
|
8
|
+
* this: `emit(normalized, ctx) -> { files, coverage }`, nothing more.
|
|
9
|
+
* 2. Conformance fixture for plugin testing (Phase 2 kit): its output is a
|
|
10
|
+
* total, framework-free projection of the IR, so any pipeline change
|
|
11
|
+
* that alters resolution shows up here first.
|
|
12
|
+
*
|
|
13
|
+
* Naming: strip the `semantic.` prefix, dots -> dashes. Color-role and
|
|
14
|
+
* content-hierarchy slots keep their `color.` segment (`--color-primary-solid`,
|
|
15
|
+
* `--color-text-base`); the elevation ladder and scrim drop it, since they
|
|
16
|
+
* read as surfaces, not role colors (`--elevation-1-surface`, `--scrim`).
|
|
17
|
+
* Everything else keeps its own top group: `--radius-md`, `--space-4`,
|
|
18
|
+
* `--type-size-md`, `--z-modal`. Composite `type.role.*` values (DTCG
|
|
19
|
+
* `typography`) expand to longhand sub-properties (`-size`/`-weight`/
|
|
20
|
+
* `-leading`/`-family`); `elevation.N.shadow` (DTCG `shadow`) collapses to one
|
|
21
|
+
* box-shadow-shaped value.
|
|
22
|
+
*
|
|
23
|
+
* Mode encoding: `:root` carries color slots for the light map (mode NAMES,
|
|
24
|
+
* never the default flag — ir.md#modes); the dark map goes under
|
|
25
|
+
* `[data-color-scheme="dark"]` (override via options.darkSelector). Non-color
|
|
26
|
+
* slots (radius/space/type/motion/...) are mode-invariant and emitted once —
|
|
27
|
+
* unless they vary by a *non-primary* mode dimension (T8, e.g. `density`),
|
|
28
|
+
* in which case they get their own selector block (see "Extra mode
|
|
29
|
+
* dimensions" below); this is the one exporter that expresses every
|
|
30
|
+
* configured dimension, not just `color-scheme`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export default {
|
|
34
|
+
name: 'css-variables',
|
|
35
|
+
|
|
36
|
+
// Validated by core against the target's `options` at load time (audit A8).
|
|
37
|
+
optionsSchema: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
additionalProperties: false,
|
|
40
|
+
properties: {
|
|
41
|
+
prefix: { type: 'string' },
|
|
42
|
+
darkSelector: { type: 'string' },
|
|
43
|
+
dimensionSelectors: { type: 'object', additionalProperties: { type: 'string' } },
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
emit(normalized, ctx) {
|
|
48
|
+
const prefix = ctx.targetConfig.options?.prefix ? `${ctx.targetConfig.options.prefix}-` : '';
|
|
49
|
+
const darkSelector = ctx.targetConfig.options?.darkSelector ?? '[data-color-scheme="dark"]';
|
|
50
|
+
const light = normalized.modes.light ?? normalized.modes[normalized.defaultMode];
|
|
51
|
+
const dark = normalized.modes.dark;
|
|
52
|
+
|
|
53
|
+
const coverage = [];
|
|
54
|
+
const colorLines = { light: [], dark: [] };
|
|
55
|
+
const invariantLines = [];
|
|
56
|
+
|
|
57
|
+
const slots = [...light.keys()].filter((k) => k.startsWith('semantic.')).sort((a, b) => varName(a, prefix).localeCompare(varName(b, prefix)));
|
|
58
|
+
|
|
59
|
+
for (const slot of slots) {
|
|
60
|
+
const entry = light.get(slot);
|
|
61
|
+
if (entry?.value === undefined) continue;
|
|
62
|
+
const isColor = slot.startsWith('semantic.color.');
|
|
63
|
+
const rendered = renderEntry(entry, ctx);
|
|
64
|
+
if (!rendered) continue;
|
|
65
|
+
|
|
66
|
+
if (isColor) {
|
|
67
|
+
for (const [suffix, value] of rendered) {
|
|
68
|
+
const name = varName(slot, prefix) + suffix;
|
|
69
|
+
colorLines.light.push(cssLine(name, value, entry));
|
|
70
|
+
coverage.push({ variable: name, slot, class: 'native', provenance: entry.provenance.kind });
|
|
71
|
+
}
|
|
72
|
+
const darkEntry = dark?.get(slot);
|
|
73
|
+
if (darkEntry?.value !== undefined) {
|
|
74
|
+
const renderedDark = renderEntry(darkEntry, ctx);
|
|
75
|
+
for (const [suffix, value] of renderedDark) {
|
|
76
|
+
colorLines.dark.push(cssLine(varName(slot, prefix) + suffix, value, darkEntry));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
for (const [suffix, value] of rendered) {
|
|
81
|
+
const name = varName(slot, prefix) + suffix;
|
|
82
|
+
invariantLines.push(cssLine(name, value, entry));
|
|
83
|
+
coverage.push({ variable: name, slot, class: 'native', provenance: entry.provenance.kind });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Extra mode dimensions (T8): every dimension beyond the primary
|
|
89
|
+
// (`color-scheme`, handled above) gets one selector block per non-default
|
|
90
|
+
// value, containing only the slots that actually differ from the
|
|
91
|
+
// all-defaults combo (`light`) — e.g. `density: compact` only touches
|
|
92
|
+
// `space.*`, so only those variables appear, not a full re-dump.
|
|
93
|
+
const dimNames = normalized.dimensionNames ?? [normalized.modeDimension];
|
|
94
|
+
const extraDimBlocks = [];
|
|
95
|
+
for (const dimName of dimNames) {
|
|
96
|
+
if (dimName === normalized.modeDimension) continue;
|
|
97
|
+
const dimDef = normalized.dimensions[dimName];
|
|
98
|
+
for (const value of dimDef.values) {
|
|
99
|
+
if (value === dimDef.default) continue;
|
|
100
|
+
const comboValues = Object.fromEntries(dimNames.map((d) => [d, normalized.dimensions[d].default]));
|
|
101
|
+
comboValues[dimName] = value;
|
|
102
|
+
const comboMap = normalized.modes[dimNames.map((d) => comboValues[d]).join('+')];
|
|
103
|
+
if (!comboMap) continue;
|
|
104
|
+
const lines = [];
|
|
105
|
+
for (const slot of [...comboMap.keys()].filter((k) => k.startsWith('semantic.'))) {
|
|
106
|
+
const comboEntry = comboMap.get(slot);
|
|
107
|
+
if (comboEntry?.value === undefined) continue;
|
|
108
|
+
const baseEntry = light.get(slot);
|
|
109
|
+
const rendered = renderEntry(comboEntry, ctx);
|
|
110
|
+
if (!rendered) continue;
|
|
111
|
+
const baseRendered = baseEntry ? renderEntry(baseEntry, ctx) : null;
|
|
112
|
+
if (baseRendered && JSON.stringify(baseRendered) === JSON.stringify(rendered)) continue; // unchanged under this dimension
|
|
113
|
+
for (const [suffix, val] of rendered) lines.push(cssLine(varName(slot, prefix) + suffix, val, comboEntry));
|
|
114
|
+
}
|
|
115
|
+
if (lines.length) {
|
|
116
|
+
const template = ctx.targetConfig.options?.dimensionSelectors?.[dimName] ?? `[data-${dimName}="{value}"]`;
|
|
117
|
+
extraDimBlocks.push('', `${template.replace('{value}', value)} {`, ...lines, '}');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const css = [
|
|
123
|
+
'/*',
|
|
124
|
+
` * GENERATED by transtyle — do not edit; source: ${ctx.projectName} token files`,
|
|
125
|
+
' * Target: css-variables (the resolved semantic catalog, 1:1) · rules standard@1',
|
|
126
|
+
` * Modes: :root = light · ${darkSelector} = dark (mode names, never the default flag)`,
|
|
127
|
+
' */',
|
|
128
|
+
'',
|
|
129
|
+
':root {',
|
|
130
|
+
...invariantLines,
|
|
131
|
+
...colorLines.light,
|
|
132
|
+
'}',
|
|
133
|
+
...(colorLines.dark.length ? ['', `${darkSelector} {`, ...colorLines.dark, '}'] : []),
|
|
134
|
+
...extraDimBlocks,
|
|
135
|
+
'',
|
|
136
|
+
].join('\n');
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
files: [
|
|
140
|
+
{ path: 'variables.transtyle.css', contents: css, kind: 'stylesheet' },
|
|
141
|
+
{ path: 'usage.md', contents: renderUsage(ctx, coverage.length, darkSelector, dimNames.filter((d) => d !== normalized.modeDimension)), kind: 'doc' },
|
|
142
|
+
],
|
|
143
|
+
coverage,
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// ---------- naming ----------
|
|
149
|
+
|
|
150
|
+
function varName(path, prefix) {
|
|
151
|
+
let rest = path.replace(/^semantic\./, '');
|
|
152
|
+
if (rest.startsWith('color.elevation.') || rest === 'color.scrim') {
|
|
153
|
+
rest = rest.replace(/^color\./, '');
|
|
154
|
+
}
|
|
155
|
+
return '--' + prefix + rest.replace(/\./g, '-');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------- value rendering: returns [[suffix, cssValueString], ...] ----------
|
|
159
|
+
|
|
160
|
+
function renderEntry(entry, ctx) {
|
|
161
|
+
const { type, value } = entry;
|
|
162
|
+
if (type === 'color') return [['', ctx.formatColor(value)]];
|
|
163
|
+
if (type === 'dimension' || type === 'duration' || type === 'cubicBezier') return [['', String(value)]];
|
|
164
|
+
if (type === 'number') return [['', String(value)]];
|
|
165
|
+
if (type === 'typography') {
|
|
166
|
+
// AL5: a composite member can be absent — a design system that authors no
|
|
167
|
+
// font family gets a type role with no `fontFamily`, and `String(undefined)`
|
|
168
|
+
// wrote `--type-role-body-md-family: undefined;` into the stylesheet. Emit
|
|
169
|
+
// the longhands that exist; a missing custom property is inert, a malformed
|
|
170
|
+
// one is not.
|
|
171
|
+
return [
|
|
172
|
+
['-size', value.fontSize],
|
|
173
|
+
['-weight', value.fontWeight === undefined ? undefined : String(value.fontWeight)],
|
|
174
|
+
['-leading', value.lineHeight === undefined ? undefined : String(value.lineHeight)],
|
|
175
|
+
['-family', Array.isArray(value.fontFamily) ? fontList(value.fontFamily) : value.fontFamily],
|
|
176
|
+
].filter(([, v]) => v !== undefined);
|
|
177
|
+
}
|
|
178
|
+
if (type === 'shadow') {
|
|
179
|
+
return [['', `${value.offsetX} ${value.offsetY} ${value.blur} ${value.spread} ${ctx.formatColor(value.color)}`]];
|
|
180
|
+
}
|
|
181
|
+
if (Array.isArray(value)) return [['', fontList(value)]];
|
|
182
|
+
return [['', String(value)]];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const fontList = (value) => value.map((f) => (/[^a-z-]/.test(f) ? `"${f}"` : f)).join(', ');
|
|
186
|
+
|
|
187
|
+
const cssLine = (name, value, entry) =>
|
|
188
|
+
` ${name}: ${value}; /* ${entry.provenance.kind !== 'authored' ? entry.provenance.kind + ' · ' : ''}${entry.type} */`;
|
|
189
|
+
|
|
190
|
+
// ---------- usage ----------
|
|
191
|
+
|
|
192
|
+
function renderUsage(ctx, count, darkSelector, extraDims) {
|
|
193
|
+
return `# Using these CSS variables
|
|
194
|
+
|
|
195
|
+
The complete resolved semantic catalog of **${ctx.projectName}** (${count} custom properties), framework-free. This is transtyle's simplest target — and the reference projection of the IR: every other exporter's output is some mapping of what you see here.
|
|
196
|
+
|
|
197
|
+
## Install
|
|
198
|
+
|
|
199
|
+
\`\`\`html
|
|
200
|
+
<link rel="stylesheet" href="variables.transtyle.css">
|
|
201
|
+
\`\`\`
|
|
202
|
+
|
|
203
|
+
\`\`\`css
|
|
204
|
+
.my-button {
|
|
205
|
+
background: var(--color-primary-solid);
|
|
206
|
+
color: var(--color-primary-on-solid);
|
|
207
|
+
border-radius: var(--radius-md);
|
|
208
|
+
}
|
|
209
|
+
.my-button:hover { background: var(--color-primary-solid-hover); }
|
|
210
|
+
\`\`\`
|
|
211
|
+
|
|
212
|
+
## Dark mode
|
|
213
|
+
|
|
214
|
+
\`:root\` carries the light values; the dark values live under \`${darkSelector}\`:
|
|
215
|
+
|
|
216
|
+
\`\`\`js
|
|
217
|
+
document.documentElement.setAttribute('data-color-scheme', 'dark');
|
|
218
|
+
\`\`\`
|
|
219
|
+
|
|
220
|
+
(Configure the selector via \`options.darkSelector\`, and prefix all variables via \`options.prefix\`.)
|
|
221
|
+
${extraDims?.length ? `
|
|
222
|
+
## Other mode dimensions (${extraDims.join(', ')})
|
|
223
|
+
|
|
224
|
+
This design system also declares ${extraDims.length === 1 ? 'a' : ''} mode dimension${extraDims.length === 1 ? '' : 's'} beyond \`color-scheme\`. Each non-default value that actually changes something gets its own selector block, containing only the variables that differ from the default — set the attribute to activate it:
|
|
225
|
+
|
|
226
|
+
\`\`\`js
|
|
227
|
+
document.documentElement.setAttribute('data-${extraDims[0]}', '<non-default-value>');
|
|
228
|
+
\`\`\`
|
|
229
|
+
|
|
230
|
+
Default selector is \`[data-<dimension>="<value>"]\`; override per dimension via \`options.dimensionSelectors\` (e.g. \`{ "${extraDims[0]}": ".${extraDims[0]}-{value}" }\`, where \`{value}\` is replaced with the mode value).
|
|
231
|
+
` : ''}
|
|
232
|
+
## Naming
|
|
233
|
+
|
|
234
|
+
Strip \`semantic.\`, dots become dashes: \`color.primary.solid\` → \`--color-primary-solid\`. The elevation ladder and \`scrim\` drop the \`color.\` segment (\`--elevation-1-surface\`, \`--scrim\`) since they're surfaces, not role colors. Composite typography roles (\`type.role.*\`) expand to \`-size\`/\`-weight\`/\`-leading\`/\`-family\`; elevation shadows collapse to one box-shadow-shaped value (\`--elevation-1-shadow\`).
|
|
235
|
+
|
|
236
|
+
## Regenerating
|
|
237
|
+
|
|
238
|
+
Never edit this file — change the design system tokens and run \`transtyle build css-variables\`.
|
|
239
|
+
See \`report.json\` for provenance per variable (authored vs derived vs defaulted).
|
|
240
|
+
`;
|
|
241
|
+
}
|