@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.
Files changed (2) hide show
  1. package/package.json +43 -0
  2. 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
+ }