@transtyle/exporter-bootstrap 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/components.js +326 -0
- package/src/descriptors.js +665 -0
- package/src/index.js +686 -0
- package/surface-inventory.json +12855 -0
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@transtyle/exporter-bootstrap",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "Transtyle exporter for Bootstrap themes (5.3+, Sass and CSS-variable paths).",
|
|
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": "bootstrap",
|
|
16
|
+
"irSpec": "v0-draft",
|
|
17
|
+
"pluginApi": "0",
|
|
18
|
+
"targets": {
|
|
19
|
+
"bootstrap": [
|
|
20
|
+
">=5.3 <6"
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
"modes": [
|
|
24
|
+
"color-scheme"
|
|
25
|
+
],
|
|
26
|
+
"capabilities": [
|
|
27
|
+
"build"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"src",
|
|
32
|
+
"surface-inventory.json"
|
|
33
|
+
],
|
|
34
|
+
"publishConfig": { "access": "public" },
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/transtyle/transtyle.git",
|
|
38
|
+
"directory": "packages/exporter-bootstrap"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/transtyle/transtyle#readme",
|
|
41
|
+
"bugs": "https://github.com/transtyle/transtyle/issues",
|
|
42
|
+
"license": "MIT"
|
|
43
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AL1.3/AL1.4 (docs/plan/bootstrap-component-tier.md): component-tier
|
|
3
|
+
* emission for both paths, driven entirely by the AL1.2 cross-walk
|
|
4
|
+
* (descriptors.js) over the AL1.1 inventory (surface-inventory.json).
|
|
5
|
+
* Zero per-variable logic here — resolution walks the descriptor data.
|
|
6
|
+
*
|
|
7
|
+
* The coverage walk is the AL3 bar made real: every in-inventory variable
|
|
8
|
+
* produces exactly one report row — bound, explicitly classified, or
|
|
9
|
+
* mechanically chained — replacing the old single blanket line
|
|
10
|
+
* "component tier reserved for v2".
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { DESCRIPTORS, coverageForVariable } from './descriptors.js';
|
|
14
|
+
|
|
15
|
+
const INVENTORY = JSON.parse(
|
|
16
|
+
readFileSync(new URL('../surface-inventory.json', import.meta.url), 'utf8'),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const MECH = {
|
|
20
|
+
chained: {
|
|
21
|
+
class: 'derived',
|
|
22
|
+
slot: 'via driven roots',
|
|
23
|
+
note: "follows Bootstrap's own !default chain from roots this exporter drives (refs recorded in surface-inventory.json)",
|
|
24
|
+
},
|
|
25
|
+
'follows-global': {
|
|
26
|
+
class: 'derived',
|
|
27
|
+
slot: 'via global --bs-* vars',
|
|
28
|
+
note: 'aliases a global custom property the semantic tier drives',
|
|
29
|
+
},
|
|
30
|
+
'inherits-driven': {
|
|
31
|
+
class: 'derived',
|
|
32
|
+
slot: 'via the cascade',
|
|
33
|
+
// `from` is appended per variable — see the coverage walk below.
|
|
34
|
+
note: 'left at Bootstrap\'s cascade no-op so the value reaches it by inheritance from',
|
|
35
|
+
},
|
|
36
|
+
'inherit-default': {
|
|
37
|
+
class: 'dropped',
|
|
38
|
+
slot: '—',
|
|
39
|
+
note: 'Bootstrap cascade no-op marker (null/inherit/transparent/currentcolor) left untouched — overriding would change inheritance behavior, not theme values',
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve every emit recipe against the light map. Returns
|
|
45
|
+
* [{ v, recipe, value, cls, slot, note }] in inventory (source) order.
|
|
46
|
+
* Dimensions/motion are mode-invariant in Bootstrap's variable layer, so the
|
|
47
|
+
* light map is authoritative for the Sass path; AL1.4's per-mode color work
|
|
48
|
+
* reads the grid directly, not this.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveEmits(light, ctx) {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const v of INVENTORY.variables) {
|
|
53
|
+
if (v.scope !== 'component') continue;
|
|
54
|
+
const recipe = DESCRIPTORS[v.family]?.emit?.[v.name];
|
|
55
|
+
if (!recipe) continue;
|
|
56
|
+
let value,
|
|
57
|
+
slot,
|
|
58
|
+
cls,
|
|
59
|
+
note = recipe.note;
|
|
60
|
+
const provCls = (entry) =>
|
|
61
|
+
['authored', 'aliased'].includes(entry.provenance.kind) ? 'native' : 'derived';
|
|
62
|
+
// AL5: a recipe's source can legitimately be absent. `semantic.radius.*`
|
|
63
|
+
// only exists once something authors `radius.md`, so a perfectly valid
|
|
64
|
+
// minimal design system (brand color + text + surface) made this function
|
|
65
|
+
// throw a bare `Cannot read properties of undefined (reading 'value')` with
|
|
66
|
+
// no code, no slot name, and no hint — the worst failure in the sweep,
|
|
67
|
+
// because the user did nothing wrong. `check:bootstrap-surface` never saw
|
|
68
|
+
// it: it validates recipe paths against Acme, which authors everything.
|
|
69
|
+
// An absent source is reported as an honest coverage row instead.
|
|
70
|
+
const sourcePath = recipe.comp
|
|
71
|
+
? `component.${recipe.comp}`
|
|
72
|
+
: recipe.sem
|
|
73
|
+
? `semantic.${recipe.sem}`
|
|
74
|
+
: recipe.trans
|
|
75
|
+
? `semantic.${recipe.trans.duration}`
|
|
76
|
+
: null;
|
|
77
|
+
if (sourcePath && light.get(sourcePath)?.value === undefined) {
|
|
78
|
+
out.push({
|
|
79
|
+
v,
|
|
80
|
+
recipe,
|
|
81
|
+
value: undefined,
|
|
82
|
+
cls: 'dropped',
|
|
83
|
+
slot: '—',
|
|
84
|
+
note: `nothing to bind: this design system has no ${sourcePath}. The binding exists, its source does not — author that slot (or the scale it derives from) and this variable starts being driven.`,
|
|
85
|
+
});
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (recipe.comp) {
|
|
89
|
+
const entry = light.get(`component.${recipe.comp}`);
|
|
90
|
+
value = entry.value;
|
|
91
|
+
slot = `component.${recipe.comp}`;
|
|
92
|
+
cls = recipe.cls ?? provCls(entry);
|
|
93
|
+
} else if (recipe.sem) {
|
|
94
|
+
const entry = light.get(`semantic.${recipe.sem}`);
|
|
95
|
+
// `part` splits a color into the two halves Bootstrap wants separately
|
|
96
|
+
// (a backdrop is `$..-bg` + `$..-opacity`, never one rgba). The alpha is
|
|
97
|
+
// already carried by the IR color — see the overlay pass in proposal 0003.
|
|
98
|
+
value =
|
|
99
|
+
recipe.part === 'alpha'
|
|
100
|
+
? (entry.value.alpha ?? 1)
|
|
101
|
+
: recipe.part === 'opaque'
|
|
102
|
+
? ctx.formatHex({ ...entry.value, alpha: 1 }).text
|
|
103
|
+
: recipe.part
|
|
104
|
+
? // A `semantic.type.role.*` composite: Bootstrap splits the role
|
|
105
|
+
// across separate $..-font-size / $..-font-weight variables, so
|
|
106
|
+
// each reads one member of the same composite.
|
|
107
|
+
entry.value[recipe.part]
|
|
108
|
+
: entry.value;
|
|
109
|
+
slot = `semantic.${recipe.sem}${recipe.part ? ` (${recipe.part})` : ''}`;
|
|
110
|
+
cls = recipe.cls ?? provCls(entry);
|
|
111
|
+
} else if (recipe.trans) {
|
|
112
|
+
const dur = light.get(`semantic.${recipe.trans.duration}`).value;
|
|
113
|
+
const ease = light.get(`semantic.${recipe.trans.easing}`).value;
|
|
114
|
+
const props = v.value.split(',').map((seg) => seg.trim().split(/\s+/)[0]);
|
|
115
|
+
value = props.map((p) => `${p} ${dur} ${ease}`).join(', ');
|
|
116
|
+
slot = `semantic.${recipe.trans.duration} + ${recipe.trans.easing}`;
|
|
117
|
+
cls = 'approximated';
|
|
118
|
+
note = note ?? `timing from the motion scale; property list kept from Bootstrap's default`;
|
|
119
|
+
}
|
|
120
|
+
out.push({ v, recipe, value: String(value), cls, slot, note });
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The Sass-path component section + the full per-variable coverage walk.
|
|
127
|
+
* Returns { lines, coverage }.
|
|
128
|
+
*/
|
|
129
|
+
export function componentVariables(light, ctx) {
|
|
130
|
+
const emits = resolveEmits(light, ctx);
|
|
131
|
+
const coverage = [];
|
|
132
|
+
const lines = [
|
|
133
|
+
'// ------------------------------------------------ component tier (AL1.3)',
|
|
134
|
+
'// Bound by meaning per packages/exporter-bootstrap/src/descriptors.js;',
|
|
135
|
+
"// everything not listed here follows Bootstrap's own !default chains from",
|
|
136
|
+
'// the driven roots above (per-variable classification in report.json).',
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
const byFamily = new Map();
|
|
140
|
+
for (const e of emits) {
|
|
141
|
+
if (e.value === undefined) continue; // unbindable (source absent) — coverage row only
|
|
142
|
+
if (!byFamily.has(e.v.family)) byFamily.set(e.v.family, []);
|
|
143
|
+
byFamily.get(e.v.family).push(e);
|
|
144
|
+
}
|
|
145
|
+
for (const family of [...byFamily.keys()].sort()) {
|
|
146
|
+
lines.push(`// ${family}`);
|
|
147
|
+
for (const e of byFamily.get(family)) {
|
|
148
|
+
lines.push(
|
|
149
|
+
`$${e.v.name}: ${e.value}; // ${e.slot}${e.cls === 'approximated' ? ' (approximated)' : ''}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Coverage: one row per in-inventory variable — the AL3 completeness bar.
|
|
155
|
+
for (const v of INVENTORY.variables) {
|
|
156
|
+
if (v.scope !== 'component') continue;
|
|
157
|
+
const c = coverageForVariable(v);
|
|
158
|
+
const emit = emits.find((e) => e.v.name === v.name);
|
|
159
|
+
if (emit) {
|
|
160
|
+
coverage.push({
|
|
161
|
+
variable: `$${v.name}`,
|
|
162
|
+
slot: emit.slot,
|
|
163
|
+
class: emit.cls,
|
|
164
|
+
...(emit.note && { note: emit.note }),
|
|
165
|
+
});
|
|
166
|
+
} else if (c.drop) {
|
|
167
|
+
coverage.push({ variable: `$${v.name}`, slot: '—', class: c.drop.cls, note: c.drop.note });
|
|
168
|
+
} else {
|
|
169
|
+
const m = MECH[c.mech];
|
|
170
|
+
coverage.push({
|
|
171
|
+
variable: `$${v.name}`,
|
|
172
|
+
slot: m.slot,
|
|
173
|
+
class: m.class,
|
|
174
|
+
note: c.from ? `${m.note} ${c.from}` : m.note,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { lines, coverage };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Families with structural CSS-var counterparts → the selector Bootstrap sets them on (verified against the 5.3.8 partials). */
|
|
182
|
+
const FAMILY_SELECTOR = {
|
|
183
|
+
accordion: '.accordion',
|
|
184
|
+
alert: '.alert',
|
|
185
|
+
badge: '.badge',
|
|
186
|
+
breadcrumb: '.breadcrumb',
|
|
187
|
+
btn: '.btn',
|
|
188
|
+
carousel: '.carousel',
|
|
189
|
+
dropdown: '.dropdown-menu',
|
|
190
|
+
'form-check': '.form-check',
|
|
191
|
+
'form-range': '.form-range',
|
|
192
|
+
'input-group': '.input-group',
|
|
193
|
+
'list-group': '.list-group',
|
|
194
|
+
modal: '.modal',
|
|
195
|
+
nav: '.nav',
|
|
196
|
+
'nav-link': '.nav',
|
|
197
|
+
navbar: '.navbar',
|
|
198
|
+
pagination: '.pagination',
|
|
199
|
+
popover: '.popover',
|
|
200
|
+
progress: '.progress, .progress-stacked',
|
|
201
|
+
spinner: '.spinner-border, .spinner-grow',
|
|
202
|
+
table: '.table',
|
|
203
|
+
toast: '.toast',
|
|
204
|
+
tooltip: '.tooltip',
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Where a specific CSS var is declared on a different selector than its
|
|
209
|
+
* family's root (verified against the partials): Bootstrap's nav-underline
|
|
210
|
+
* vars live on .nav-underline, and $navbar-nav-link-padding-x is interpolated
|
|
211
|
+
* into --bs-nav-link-padding-x on .navbar-nav.
|
|
212
|
+
*/
|
|
213
|
+
const CSSVAR_SELECTOR = {
|
|
214
|
+
'nav:nav-underline-gap': '.nav-underline',
|
|
215
|
+
'nav:nav-underline-border-width': '.nav-underline',
|
|
216
|
+
'navbar:nav-link-padding-x': '.navbar-nav',
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* CSS-path-only extras: $btn-padding-* are chained from the shared
|
|
221
|
+
* $input-btn-* root on the Sass path, but stock-CSS users get the baked
|
|
222
|
+
* defaults — these entries make the component.button.* tokens reach the
|
|
223
|
+
* CSS path directly.
|
|
224
|
+
*/
|
|
225
|
+
const CSS_EXTRAS = [
|
|
226
|
+
{ selector: '.btn', cssVar: 'btn-padding-y', comp: 'button.padding-y' },
|
|
227
|
+
{ selector: '.btn', cssVar: 'btn-padding-x', comp: 'button.padding-x' },
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* CSS-path structural component blocks (AL1.4): every bound variable that has
|
|
232
|
+
* a direct --bs-* counterpart, emitted selector-scoped so it overrides the
|
|
233
|
+
* value Bootstrap's build baked into the component rule. Returns { lines, coverage }.
|
|
234
|
+
* Where Bootstrap re-declares the same var responsively (--bs-modal-margin),
|
|
235
|
+
* the base declaration wins and the responsive re-set stays Sass-path-only.
|
|
236
|
+
*/
|
|
237
|
+
export function componentCssBlocks(light, ctx) {
|
|
238
|
+
const emits = resolveEmits(light, ctx).filter(
|
|
239
|
+
(e) => e.value !== undefined && e.v.cssVars.length > 0 && FAMILY_SELECTOR[e.v.family],
|
|
240
|
+
);
|
|
241
|
+
const bySelector = new Map();
|
|
242
|
+
const seen = new Set();
|
|
243
|
+
const push = (sel, cssVar, value, slot) => {
|
|
244
|
+
if (seen.has(`${sel} ${cssVar}`)) return;
|
|
245
|
+
seen.add(`${sel} ${cssVar}`);
|
|
246
|
+
if (!bySelector.has(sel)) bySelector.set(sel, []);
|
|
247
|
+
bySelector.get(sel).push(` --bs-${cssVar}: ${value}; /* ${slot} */`);
|
|
248
|
+
};
|
|
249
|
+
for (const x of CSS_EXTRAS) {
|
|
250
|
+
const v = light.get(`component.${x.comp}`)?.value; // may be absent — see resolveEmits
|
|
251
|
+
if (v !== undefined) push(x.selector, x.cssVar, v, `component.${x.comp}`);
|
|
252
|
+
}
|
|
253
|
+
for (const e of emits) {
|
|
254
|
+
for (const cssVar of e.v.cssVars) {
|
|
255
|
+
push(
|
|
256
|
+
CSSVAR_SELECTOR[`${e.v.family}:${cssVar}`] ?? FAMILY_SELECTOR[e.v.family],
|
|
257
|
+
cssVar,
|
|
258
|
+
e.value,
|
|
259
|
+
e.slot,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const lines = [];
|
|
264
|
+
for (const sel of [...bySelector.keys()].sort()) {
|
|
265
|
+
lines.push(`${sel} {`, ...bySelector.get(sel), '}', '');
|
|
266
|
+
}
|
|
267
|
+
const coverage = emits.length
|
|
268
|
+
? [
|
|
269
|
+
{
|
|
270
|
+
variable: `--bs-* component structure (${seen.size} slots, CSS path)`,
|
|
271
|
+
slot: 'see the per-$variable rows',
|
|
272
|
+
class: 'derived',
|
|
273
|
+
note: 'selector-scoped overrides of the values Bootstrap baked into component rules — reaches part of what F13 said the token tier alone could not',
|
|
274
|
+
},
|
|
275
|
+
]
|
|
276
|
+
: [];
|
|
277
|
+
return { lines, coverage };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* CSS-path button variant state colors (AL1.4): the N_PARAM promise from the
|
|
282
|
+
* cross-walk — Bootstrap's shade/tint knobs are dropped on the Sass path
|
|
283
|
+
* because the grid's state cells ARE the derived results; here they reach
|
|
284
|
+
* stock-CSS users per variant. `roles` = the exporter's ROLES list; `cell`
|
|
285
|
+
* reads `semantic.color.<role>.<cell>` hexes for one mode.
|
|
286
|
+
*/
|
|
287
|
+
export function buttonVariantBlocks(roles, cell, rgbTriplet, { darkPrefix = '' } = {}) {
|
|
288
|
+
const lines = [];
|
|
289
|
+
for (const role of roles) {
|
|
290
|
+
const solid = cell(role, 'solid'),
|
|
291
|
+
on = cell(role, 'on-solid');
|
|
292
|
+
const hover = cell(role, 'solid-hover'),
|
|
293
|
+
active = cell(role, 'solid-active');
|
|
294
|
+
if (!solid || !on || !hover || !active) continue;
|
|
295
|
+
lines.push(`${darkPrefix}.btn-${role} {`);
|
|
296
|
+
lines.push(
|
|
297
|
+
` --bs-btn-color: ${on}; --bs-btn-bg: ${solid}; --bs-btn-border-color: ${solid};`,
|
|
298
|
+
);
|
|
299
|
+
lines.push(
|
|
300
|
+
` --bs-btn-hover-color: ${on}; --bs-btn-hover-bg: ${hover}; --bs-btn-hover-border-color: ${hover};`,
|
|
301
|
+
);
|
|
302
|
+
lines.push(
|
|
303
|
+
` --bs-btn-active-color: ${on}; --bs-btn-active-bg: ${active}; --bs-btn-active-border-color: ${active};`,
|
|
304
|
+
);
|
|
305
|
+
lines.push(
|
|
306
|
+
` --bs-btn-disabled-color: ${on}; --bs-btn-disabled-bg: ${solid}; --bs-btn-disabled-border-color: ${solid};`,
|
|
307
|
+
);
|
|
308
|
+
lines.push(` --bs-btn-focus-shadow-rgb: ${rgbTriplet(solid)};`);
|
|
309
|
+
lines.push('}');
|
|
310
|
+
// Outline variant: Bootstrap's own semantics (text+border = the role color,
|
|
311
|
+
// fill on hover) — exporter convention: solid/on-solid cells, not the grid's
|
|
312
|
+
// outline cell, which is calibrated for subtle borders, not button text.
|
|
313
|
+
lines.push(`${darkPrefix}.btn-outline-${role} {`);
|
|
314
|
+
lines.push(` --bs-btn-color: ${solid}; --bs-btn-border-color: ${solid};`);
|
|
315
|
+
lines.push(
|
|
316
|
+
` --bs-btn-hover-color: ${on}; --bs-btn-hover-bg: ${solid}; --bs-btn-hover-border-color: ${solid};`,
|
|
317
|
+
);
|
|
318
|
+
lines.push(
|
|
319
|
+
` --bs-btn-active-color: ${on}; --bs-btn-active-bg: ${active}; --bs-btn-active-border-color: ${active};`,
|
|
320
|
+
);
|
|
321
|
+
lines.push(` --bs-btn-disabled-color: ${solid}; --bs-btn-disabled-border-color: ${solid};`);
|
|
322
|
+
lines.push(` --bs-btn-focus-shadow-rgb: ${rgbTriplet(solid)};`);
|
|
323
|
+
lines.push('}');
|
|
324
|
+
}
|
|
325
|
+
return lines;
|
|
326
|
+
}
|