@transtyle/exporter-shadcn 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 +43 -0
- package/src/index.js +285 -0
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@transtyle/exporter-shadcn",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "Transtyle exporter for shadcn/ui (tailwind-v4 era).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@transtyle/ir": "0.1.0-alpha.0"
|
|
12
|
+
},
|
|
13
|
+
"transtyle": {
|
|
14
|
+
"kind": "exporter",
|
|
15
|
+
"name": "shadcn",
|
|
16
|
+
"irSpec": "v0-draft",
|
|
17
|
+
"pluginApi": "0",
|
|
18
|
+
"targets": {
|
|
19
|
+
"shadcn": [
|
|
20
|
+
"tailwind-v3",
|
|
21
|
+
"tailwind-v4"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"modes": [
|
|
25
|
+
"color-scheme"
|
|
26
|
+
],
|
|
27
|
+
"capabilities": [
|
|
28
|
+
"build"
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"src"
|
|
33
|
+
],
|
|
34
|
+
"publishConfig": { "access": "public" },
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/transtyle/transtyle.git",
|
|
38
|
+
"directory": "packages/exporter-shadcn"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/transtyle/transtyle#readme",
|
|
41
|
+
"bugs": "https://github.com/transtyle/transtyle/issues",
|
|
42
|
+
"license": "MIT"
|
|
43
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @transtyle/exporter-shadcn — emits a shadcn/ui theme from the resolved IR.
|
|
3
|
+
* Spec: docs/specs/exporters/shadcn.md. Two era profiles (ADR-0006 mapping
|
|
4
|
+
* profiles): "tailwind-v4" (OKLCH + @theme inline) and "tailwind-v3"
|
|
5
|
+
* (HSL channel triplets + tailwind.config snippet). Selected via target
|
|
6
|
+
* options.era in transtyle.config.json — never via CLI flags.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { droppedDimensions } from '@transtyle/ir';
|
|
10
|
+
|
|
11
|
+
const S = 'semantic.color.';
|
|
12
|
+
const P = 'semantic.palette.categorical.';
|
|
13
|
+
|
|
14
|
+
/** Declarative mapping table, shared by both era profiles. Grid cells per
|
|
15
|
+
* docs/architecture/ir.md#color-the-role-grid: solid = principal fill,
|
|
16
|
+
* tint = wash, on-* = the paired foreground. */
|
|
17
|
+
const MAPPING = [
|
|
18
|
+
{ css: '--background', slot: `${S}elevation.0.surface`, cls: 'native' },
|
|
19
|
+
{ css: '--foreground', slot: `${S}text.base`, cls: 'native' },
|
|
20
|
+
{ css: '--card', slot: `${S}elevation.1.surface`, cls: 'native' },
|
|
21
|
+
{ css: '--card-foreground', slot: `${S}text.base`, cls: 'native' },
|
|
22
|
+
{ css: '--popover', slot: `${S}elevation.3.surface`, cls: 'native' },
|
|
23
|
+
{ css: '--popover-foreground', slot: `${S}text.base`, cls: 'native' },
|
|
24
|
+
{ css: '--primary', slot: `${S}primary.solid`, cls: 'native' },
|
|
25
|
+
{ css: '--primary-foreground', slot: `${S}primary.on-solid`, cls: 'native' },
|
|
26
|
+
// shadcn "secondary"/"accent"/"muted" are subtle surfaces, not brand roles (exercise F1 note)
|
|
27
|
+
{ css: '--secondary', slot: `${S}neutral.tint`, cls: 'native' },
|
|
28
|
+
{ css: '--secondary-foreground', slot: `${S}neutral.on-tint`, cls: 'native' },
|
|
29
|
+
{ css: '--muted', slot: `${S}neutral.tint`, cls: 'native' },
|
|
30
|
+
{ css: '--muted-foreground', slot: `${S}text.muted`, cls: 'native' },
|
|
31
|
+
{ css: '--accent', slot: `${S}accent.tint`, cls: 'native' },
|
|
32
|
+
{ css: '--accent-foreground', slot: `${S}accent.on-tint`, cls: 'native' },
|
|
33
|
+
{ css: '--destructive', slot: `${S}danger.solid`, cls: 'native' },
|
|
34
|
+
{ css: '--destructive-foreground', slot: `${S}danger.on-solid`, cls: 'native' },
|
|
35
|
+
{ css: '--border', slot: `${S}border`, cls: 'native' },
|
|
36
|
+
// shadcn distinguishes input borders; the IR does not (exercise F4)
|
|
37
|
+
{ css: '--input', slot: `${S}border`, cls: 'approximated' },
|
|
38
|
+
{ css: '--ring', slot: `${S}ring`, cls: 'native' },
|
|
39
|
+
{ css: '--chart-1', slot: `${P}1`, cls: 'native' },
|
|
40
|
+
{ css: '--chart-2', slot: `${P}2`, cls: 'native' },
|
|
41
|
+
{ css: '--chart-3', slot: `${P}3`, cls: 'native' },
|
|
42
|
+
{ css: '--chart-4', slot: `${P}4`, cls: 'native' },
|
|
43
|
+
{ css: '--chart-5', slot: `${P}5`, cls: 'native' },
|
|
44
|
+
// sidebar family: component-tier concern mapped by exporter convention (exercise F6)
|
|
45
|
+
{ css: '--sidebar', slot: `${S}elevation.1.surface`, cls: 'native', note: 'exporter convention' },
|
|
46
|
+
{ css: '--sidebar-foreground', slot: `${S}text.base`, cls: 'native', note: 'exporter convention' },
|
|
47
|
+
{ css: '--sidebar-primary', slot: `${S}primary.solid`, cls: 'native', note: 'exporter convention' },
|
|
48
|
+
{ css: '--sidebar-primary-foreground', slot: `${S}primary.on-solid`, cls: 'native', note: 'exporter convention' },
|
|
49
|
+
{ css: '--sidebar-accent', slot: `${S}accent.tint`, cls: 'native', note: 'exporter convention' },
|
|
50
|
+
{ css: '--sidebar-accent-foreground', slot: `${S}accent.on-tint`, cls: 'native', note: 'exporter convention' },
|
|
51
|
+
{ css: '--sidebar-border', slot: `${S}border`, cls: 'native', note: 'exporter convention' },
|
|
52
|
+
{ css: '--sidebar-ring', slot: `${S}ring`, cls: 'native', note: 'exporter convention' },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const ERAS = ['tailwind-v4', 'tailwind-v3'];
|
|
56
|
+
|
|
57
|
+
export default {
|
|
58
|
+
name: 'shadcn',
|
|
59
|
+
|
|
60
|
+
// Validated by core against the target's `options` at load time (audit A8).
|
|
61
|
+
optionsSchema: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
additionalProperties: false,
|
|
64
|
+
properties: { era: { type: 'string', enum: ['tailwind-v3', 'tailwind-v4'] } },
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
emit(normalized, ctx) {
|
|
68
|
+
const era = ctx.targetConfig.options?.era ?? 'tailwind-v4';
|
|
69
|
+
if (!ERAS.includes(era)) {
|
|
70
|
+
throw new Error(`exporter-shadcn: unknown era "${era}" (supported: ${ERAS.join(', ')})`);
|
|
71
|
+
}
|
|
72
|
+
// Mode polarity: shadcn's structure is fixed (:root = light, .dark = dark).
|
|
73
|
+
// Bind mode NAMES, never the DS's default flag — a dark-native design
|
|
74
|
+
// system still compiles to shadcn's light-first layout (ir.md#modes).
|
|
75
|
+
const light = normalized.modes.light ?? normalized.modes[normalized.defaultMode];
|
|
76
|
+
const dark = normalized.modes.dark;
|
|
77
|
+
|
|
78
|
+
// RESOLVE: shared, era-independent
|
|
79
|
+
const coverage = [];
|
|
80
|
+
const vars = [];
|
|
81
|
+
for (const m of MAPPING) {
|
|
82
|
+
const lightEntry = light.get(m.slot);
|
|
83
|
+
if (!lightEntry?.value) {
|
|
84
|
+
coverage.push({ variable: m.css, slot: m.slot, class: 'unsupported', note: 'slot missing from IR' });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const darkEntry = dark?.get(m.slot);
|
|
88
|
+
const provKind = lightEntry.provenance.kind;
|
|
89
|
+
let cls = m.cls === 'approximated' ? 'approximated' : (provKind === 'derived' ? 'derived' : m.cls);
|
|
90
|
+
let note = m.note;
|
|
91
|
+
if (era === 'tailwind-v3') {
|
|
92
|
+
// oklch → HSL may clamp out-of-sRGB-gamut colors
|
|
93
|
+
const clamped = ctx.formatHslTriplet(lightEntry.value).clamped
|
|
94
|
+
|| (darkEntry?.value && ctx.formatHslTriplet(darkEntry.value).clamped);
|
|
95
|
+
if (clamped) { cls = 'approximated'; note = 'sRGB gamut clamp (HSL era)'; }
|
|
96
|
+
}
|
|
97
|
+
coverage.push({ variable: m.css, slot: m.slot, class: cls, provenance: provKind, ...(note && { note }) });
|
|
98
|
+
vars.push({ ...m, lightEntry, darkEntry });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const radius = light.get('semantic.radius.md');
|
|
102
|
+
const fontSans = light.get('semantic.font.sans');
|
|
103
|
+
const fontMono = light.get('semantic.font.mono');
|
|
104
|
+
if (radius) coverage.push({ variable: '--radius', slot: 'semantic.radius.md', class: 'native', provenance: radius.provenance.kind });
|
|
105
|
+
for (const [v, e, slot] of [['--font-sans', fontSans, 'semantic.font.sans'], ['--font-mono', fontMono, 'semantic.font.mono']]) {
|
|
106
|
+
if (e) coverage.push({ variable: v, slot, class: 'native', provenance: e.provenance.kind });
|
|
107
|
+
}
|
|
108
|
+
// Mode dimensions this exporter doesn't express (T8, ir.md#modes) — a
|
|
109
|
+
// no-op unless the compile actually declares one, e.g. `density`.
|
|
110
|
+
coverage.push(...droppedDimensions(normalized.dimensionNames, ['color-scheme']));
|
|
111
|
+
|
|
112
|
+
// EMIT: era profile decides artifacts
|
|
113
|
+
const shared = { vars, radius, fontSans, fontMono, ctx };
|
|
114
|
+
const files = era === 'tailwind-v4' ? emitV4(shared) : emitV3(shared);
|
|
115
|
+
files.push({ path: 'usage.md', contents: renderUsage(ctx, coverage, era), kind: 'doc' });
|
|
116
|
+
return { files, coverage };
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// ---------- shared helpers ----------
|
|
121
|
+
|
|
122
|
+
const header = (ctx, era) => [
|
|
123
|
+
'/*',
|
|
124
|
+
` * GENERATED by transtyle — do not edit; source: ${ctx.projectName} token files`,
|
|
125
|
+
` * Target: shadcn (era: ${era}) · rules standard@1 (skeleton subset)`,
|
|
126
|
+
' */',
|
|
127
|
+
'',
|
|
128
|
+
].join('\n');
|
|
129
|
+
|
|
130
|
+
const cssLine = (name, value, slot, prov) =>
|
|
131
|
+
` ${name}: ${value}; /* ${slot.replace('semantic.', '')}${prov === 'derived' ? ' · derived' : ''} */`;
|
|
132
|
+
|
|
133
|
+
const fontList = (value) => value.map((f) => (/[^a-z-]/.test(f) ? `"${f}"` : f)).join(', ');
|
|
134
|
+
|
|
135
|
+
function colorBlocks(vars, fmt) {
|
|
136
|
+
const lines = { light: [], dark: [] };
|
|
137
|
+
for (const v of vars) {
|
|
138
|
+
lines.light.push(cssLine(v.css, fmt(v.lightEntry.value), v.slot, v.lightEntry.provenance.kind));
|
|
139
|
+
if (v.darkEntry?.value) lines.dark.push(cssLine(v.css, fmt(v.darkEntry.value), v.slot, v.darkEntry.provenance.kind));
|
|
140
|
+
}
|
|
141
|
+
return lines;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------- tailwind-v4 profile ----------
|
|
145
|
+
|
|
146
|
+
function emitV4({ vars, radius, fontSans, fontMono, ctx }) {
|
|
147
|
+
const lines = colorBlocks(vars, (c) => ctx.formatColor(c));
|
|
148
|
+
const themeVars = vars.map((v) => ` --color${v.css.slice(1)}: var(${v.css});`);
|
|
149
|
+
const extras = [];
|
|
150
|
+
if (fontSans) extras.push(` --font-sans: ${fontList(fontSans.value)};`);
|
|
151
|
+
if (fontMono) extras.push(` --font-mono: ${fontList(fontMono.value)};`);
|
|
152
|
+
if (radius) {
|
|
153
|
+
extras.push(' --radius-sm: calc(var(--radius) - 4px);');
|
|
154
|
+
extras.push(' --radius-md: calc(var(--radius) - 2px);');
|
|
155
|
+
extras.push(' --radius-lg: var(--radius);');
|
|
156
|
+
extras.push(' --radius-xl: calc(var(--radius) + 4px);');
|
|
157
|
+
}
|
|
158
|
+
const css = [
|
|
159
|
+
header(ctx, 'tailwind-v4'),
|
|
160
|
+
':root {',
|
|
161
|
+
...(radius ? [` --radius: ${radius.value}; /* radius.md */`] : []),
|
|
162
|
+
...lines.light,
|
|
163
|
+
'}',
|
|
164
|
+
'',
|
|
165
|
+
'.dark {',
|
|
166
|
+
...lines.dark,
|
|
167
|
+
'}',
|
|
168
|
+
'',
|
|
169
|
+
'@theme inline {',
|
|
170
|
+
...themeVars,
|
|
171
|
+
...extras,
|
|
172
|
+
'}',
|
|
173
|
+
'',
|
|
174
|
+
].join('\n');
|
|
175
|
+
return [{ path: 'globals.transtyle.css', contents: css, kind: 'stylesheet' }];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------- tailwind-v3 profile ----------
|
|
179
|
+
|
|
180
|
+
function emitV3({ vars, radius, fontSans, fontMono, ctx }) {
|
|
181
|
+
const lines = colorBlocks(vars, (c) => ctx.formatHslTriplet(c).text);
|
|
182
|
+
const css = [
|
|
183
|
+
header(ctx, 'tailwind-v3'),
|
|
184
|
+
'@layer base {',
|
|
185
|
+
' :root {',
|
|
186
|
+
...(radius ? [` --radius: ${radius.value}; /* radius.md */`] : []),
|
|
187
|
+
...lines.light.map((l) => ' ' + l),
|
|
188
|
+
' }',
|
|
189
|
+
'',
|
|
190
|
+
' .dark {',
|
|
191
|
+
...lines.dark.map((l) => ' ' + l),
|
|
192
|
+
' }',
|
|
193
|
+
'}',
|
|
194
|
+
'',
|
|
195
|
+
].join('\n');
|
|
196
|
+
|
|
197
|
+
const wrap = (name) => `"hsl(var(${name}))"`;
|
|
198
|
+
const roleObj = (base, fg) => `{ DEFAULT: ${wrap(base)}, foreground: ${wrap(fg)} }`;
|
|
199
|
+
const config = [
|
|
200
|
+
'/*',
|
|
201
|
+
` * GENERATED by transtyle — do not edit; source: ${ctx.projectName} token files`,
|
|
202
|
+
' * Merge into your tailwind.config theme:',
|
|
203
|
+
' * const transtyle = require("./tailwind.theme.transtyle.cjs");',
|
|
204
|
+
' * module.exports = { theme: { extend: transtyle } };',
|
|
205
|
+
' */',
|
|
206
|
+
'module.exports = {',
|
|
207
|
+
' colors: {',
|
|
208
|
+
` border: ${wrap('--border')},`,
|
|
209
|
+
` input: ${wrap('--input')},`,
|
|
210
|
+
` ring: ${wrap('--ring')},`,
|
|
211
|
+
` background: ${wrap('--background')},`,
|
|
212
|
+
` foreground: ${wrap('--foreground')},`,
|
|
213
|
+
` primary: ${roleObj('--primary', '--primary-foreground')},`,
|
|
214
|
+
` secondary: ${roleObj('--secondary', '--secondary-foreground')},`,
|
|
215
|
+
` destructive: ${roleObj('--destructive', '--destructive-foreground')},`,
|
|
216
|
+
` muted: ${roleObj('--muted', '--muted-foreground')},`,
|
|
217
|
+
` accent: ${roleObj('--accent', '--accent-foreground')},`,
|
|
218
|
+
` popover: ${roleObj('--popover', '--popover-foreground')},`,
|
|
219
|
+
` card: ${roleObj('--card', '--card-foreground')},`,
|
|
220
|
+
` chart: { "1": ${wrap('--chart-1')}, "2": ${wrap('--chart-2')}, "3": ${wrap('--chart-3')}, "4": ${wrap('--chart-4')}, "5": ${wrap('--chart-5')} },`,
|
|
221
|
+
' },',
|
|
222
|
+
' borderRadius: {',
|
|
223
|
+
' lg: "var(--radius)",',
|
|
224
|
+
' md: "calc(var(--radius) - 2px)",',
|
|
225
|
+
' sm: "calc(var(--radius) - 4px)",',
|
|
226
|
+
' },',
|
|
227
|
+
...(fontSans || fontMono ? [
|
|
228
|
+
' fontFamily: {',
|
|
229
|
+
...(fontSans ? [` sans: ${JSON.stringify(fontSans.value)},`] : []),
|
|
230
|
+
...(fontMono ? [` mono: ${JSON.stringify(fontMono.value)},`] : []),
|
|
231
|
+
' },',
|
|
232
|
+
] : []),
|
|
233
|
+
'};',
|
|
234
|
+
'',
|
|
235
|
+
].join('\n');
|
|
236
|
+
|
|
237
|
+
return [
|
|
238
|
+
{ path: 'globals.transtyle.css', contents: css, kind: 'stylesheet' },
|
|
239
|
+
{ path: 'tailwind.theme.transtyle.cjs', contents: config, kind: 'config' },
|
|
240
|
+
];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------- usage ----------
|
|
244
|
+
|
|
245
|
+
function renderUsage(ctx, coverage, era) {
|
|
246
|
+
const counts = {};
|
|
247
|
+
for (const c of coverage) counts[c.class] = (counts[c.class] ?? 0) + 1;
|
|
248
|
+
const summary = Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(' · ');
|
|
249
|
+
const eraSteps = era === 'tailwind-v4'
|
|
250
|
+
? `## Tailwind v4 project (shadcn CLI ≥ 2.x era)
|
|
251
|
+
|
|
252
|
+
1. Copy \`globals.transtyle.css\` into your app (e.g. \`app/globals.transtyle.css\`).
|
|
253
|
+
2. In your global stylesheet, import it **after** Tailwind:
|
|
254
|
+
|
|
255
|
+
\`\`\`css
|
|
256
|
+
@import "tailwindcss";
|
|
257
|
+
@import "./globals.transtyle.css";
|
|
258
|
+
\`\`\`
|
|
259
|
+
|
|
260
|
+
(Or replace the \`:root\`/\`.dark\`/\`@theme inline\` blocks that \`npx shadcn init\` created.)
|
|
261
|
+
3. Dark mode: add the \`dark\` class on \`<html>\` (class strategy), as in standard shadcn setups.`
|
|
262
|
+
: `## Tailwind v3 project (shadcn "old" era, hsl(var(--x)) convention)
|
|
263
|
+
|
|
264
|
+
1. Copy \`globals.transtyle.css\` into your app and import it in your global CSS
|
|
265
|
+
(it replaces the \`@layer base\` variable blocks from \`npx shadcn-ui init\`).
|
|
266
|
+
2. Merge \`tailwind.theme.transtyle.cjs\` into your \`tailwind.config\`:
|
|
267
|
+
|
|
268
|
+
\`\`\`js
|
|
269
|
+
const transtyle = require("./tailwind.theme.transtyle.cjs");
|
|
270
|
+
module.exports = { darkMode: ["class"], theme: { extend: transtyle } };
|
|
271
|
+
\`\`\`
|
|
272
|
+
3. Dark mode: class strategy (\`darkMode: ["class"]\`), toggle \`dark\` on \`<html>\`.`;
|
|
273
|
+
|
|
274
|
+
return `# Using this shadcn theme
|
|
275
|
+
|
|
276
|
+
Generated from the **${ctx.projectName}** design system by transtyle (era: **${era}**). Coverage: ${summary}.
|
|
277
|
+
|
|
278
|
+
${eraSteps}
|
|
279
|
+
|
|
280
|
+
## Regenerating
|
|
281
|
+
|
|
282
|
+
Never edit these files — change the design system tokens and run \`transtyle build\` again.
|
|
283
|
+
See \`report.json\` next to this file for the full coverage/provenance breakdown.
|
|
284
|
+
`;
|
|
285
|
+
}
|