@se-studio/project-build 1.0.118 → 1.0.120
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/CHANGELOG.md +12 -0
- package/dist/cms-generate-html-style-guide.d.ts +4 -0
- package/dist/cms-generate-html-style-guide.d.ts.map +1 -0
- package/dist/cms-generate-html-style-guide.js +1006 -0
- package/dist/cms-generate-html-style-guide.js.map +1 -0
- package/dist/cms-merge-guidelines.js +10 -3
- package/dist/cms-merge-guidelines.js.map +1 -1
- package/package.json +7 -6
- package/skills/contentful-cms/cms-guidelines/README.md +22 -3
- package/skills/contentful-cms/cms-guidelines/html-component-authoring.md +401 -0
- package/skills/contentful-cms/core/SKILL.md +14 -4
- package/skills/contentful-cms/rich-text/SKILL.md +7 -1
- package/skills/contentful-cms/update-cms-guidelines/SKILL.md +16 -3
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** biome-ignore-all lint/suspicious/noConsole: Console output is intentional in CLI */
|
|
3
|
+
/**
|
|
4
|
+
* cms-generate-html-style-guide
|
|
5
|
+
*
|
|
6
|
+
* Generates a comprehensive HTML authoring style guide for HtmlComponent entries.
|
|
7
|
+
* Reads the project's design tokens from tailwind.config.json and globals.css and
|
|
8
|
+
* produces a self-contained markdown reference document.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* cms-generate-html-style-guide [--app-dir <path>]
|
|
12
|
+
*
|
|
13
|
+
* Output:
|
|
14
|
+
* <app-dir>/docs/cms-guidelines/html-component-style-guide.md
|
|
15
|
+
*
|
|
16
|
+
* The output file is picked up automatically by cms-merge-guidelines and included
|
|
17
|
+
* as the "Design System Reference" preamble in COMPONENT_GUIDELINES_FOR_LLM.md.
|
|
18
|
+
*
|
|
19
|
+
* Run this whenever tailwind.config.json or globals.css changes, or as part of
|
|
20
|
+
* the update-cms-guidelines workflow.
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { basename, join } from 'node:path';
|
|
24
|
+
import { loadTailwindConfig } from './loadTailwindConfig.js';
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// CLI parsing
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
function parseAppDir() {
|
|
29
|
+
const args = process.argv.slice(2);
|
|
30
|
+
const idx = args.indexOf('--app-dir');
|
|
31
|
+
return idx !== -1 && args[idx + 1] ? args[idx + 1] : process.cwd();
|
|
32
|
+
}
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// CSS parsing helpers
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
/**
|
|
37
|
+
* Extracts all @utility names from a globals.css string.
|
|
38
|
+
* Returns a list of { name, body } where body is the raw CSS inside the braces.
|
|
39
|
+
*/
|
|
40
|
+
function extractUtilities(css) {
|
|
41
|
+
const results = [];
|
|
42
|
+
// Match @utility <name> { ... } — handle nested braces via depth counting
|
|
43
|
+
const utilityRe = /@utility\s+([\w-]+)\s*\{/g;
|
|
44
|
+
let match = utilityRe.exec(css);
|
|
45
|
+
while (match !== null) {
|
|
46
|
+
const name = match[1];
|
|
47
|
+
const start = utilityRe.lastIndex;
|
|
48
|
+
let depth = 1;
|
|
49
|
+
let i = start;
|
|
50
|
+
while (i < css.length && depth > 0) {
|
|
51
|
+
if (css[i] === '{')
|
|
52
|
+
depth++;
|
|
53
|
+
else if (css[i] === '}')
|
|
54
|
+
depth--;
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
const body = css.slice(start, i - 1).trim();
|
|
58
|
+
results.push({ name, body });
|
|
59
|
+
match = utilityRe.exec(css);
|
|
60
|
+
}
|
|
61
|
+
return results;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Extracts .rtf-* class names present in the CSS.
|
|
65
|
+
*/
|
|
66
|
+
function extractRtfClasses(css) {
|
|
67
|
+
const names = new Set();
|
|
68
|
+
const rtfRe = /\.(rtf-[\w-]+)/g;
|
|
69
|
+
let m = rtfRe.exec(css);
|
|
70
|
+
while (m !== null) {
|
|
71
|
+
names.add(m[1]);
|
|
72
|
+
m = rtfRe.exec(css);
|
|
73
|
+
}
|
|
74
|
+
return [...names].sort();
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Extracts CSS custom property declarations from :root blocks.
|
|
78
|
+
*/
|
|
79
|
+
function extractRootVars(css) {
|
|
80
|
+
const results = [];
|
|
81
|
+
// Find :root { ... } blocks
|
|
82
|
+
const rootRe = /:root\s*\{([^}]+)\}/g;
|
|
83
|
+
let m = rootRe.exec(css);
|
|
84
|
+
while (m !== null) {
|
|
85
|
+
const block = m[1];
|
|
86
|
+
const varRe = /--([\w-]+)\s*:\s*([^;]+);/g;
|
|
87
|
+
let v = varRe.exec(block);
|
|
88
|
+
while (v !== null) {
|
|
89
|
+
results.push({ name: `--${v[1]}`, value: v[2].trim() });
|
|
90
|
+
v = varRe.exec(block);
|
|
91
|
+
}
|
|
92
|
+
m = rootRe.exec(css);
|
|
93
|
+
}
|
|
94
|
+
return results;
|
|
95
|
+
}
|
|
96
|
+
function resolveSizePx(value) {
|
|
97
|
+
if (typeof value === 'number')
|
|
98
|
+
return value;
|
|
99
|
+
if (value !== null && typeof value === 'object') {
|
|
100
|
+
const v = value;
|
|
101
|
+
if (typeof v['sizePixels'] === 'number')
|
|
102
|
+
return v['sizePixels'];
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
function buildBreakpointsSection(config) {
|
|
107
|
+
const sizes = config.sizes ?? {};
|
|
108
|
+
const lines = ['## Breakpoints', ''];
|
|
109
|
+
lines.push('All breakpoints are **mobile-first**. Default styles apply to all sizes; use prefixes to override at larger breakpoints.');
|
|
110
|
+
lines.push('');
|
|
111
|
+
lines.push('| Prefix | Min-width | Columns |');
|
|
112
|
+
lines.push('|--------|-----------|---------|');
|
|
113
|
+
lines.push(`| *(none)* | 0px (mobile) | ${sizes['mobile']?.cols ?? '—'} |`);
|
|
114
|
+
for (const [name, s] of Object.entries(sizes)) {
|
|
115
|
+
if (name === 'mobile' || s.breakpoint === undefined)
|
|
116
|
+
continue;
|
|
117
|
+
lines.push(`| \`${name}:\` | ${s.breakpoint}px | ${s.cols ?? '—'} |`);
|
|
118
|
+
}
|
|
119
|
+
lines.push('');
|
|
120
|
+
lines.push('```html');
|
|
121
|
+
lines.push('<!-- Example: stacked on mobile, side-by-side on laptop -->');
|
|
122
|
+
lines.push('<div class="flex flex-col laptop:flex-row gap-6">...</div>');
|
|
123
|
+
lines.push('```');
|
|
124
|
+
return lines.join('\n');
|
|
125
|
+
}
|
|
126
|
+
function buildColourSection(config) {
|
|
127
|
+
const colorOptions = config.colorOptions ?? {};
|
|
128
|
+
const gradients = config.gradients ?? {};
|
|
129
|
+
const foregroundColors = new Set(config.foregroundColors ?? []);
|
|
130
|
+
const colorOpposites = config.colorOpposites ?? {};
|
|
131
|
+
const lines = ['## Colour Palette', ''];
|
|
132
|
+
lines.push('Use **lowercase** colour names in Tailwind classes (e.g. colour `Dark` → class `text-dark`).');
|
|
133
|
+
lines.push('');
|
|
134
|
+
if (Object.keys(colorOptions).length > 0) {
|
|
135
|
+
lines.push('| Name | Hex | `bg-*` class | `text-*` class | `border-*` class | Contrast pair |');
|
|
136
|
+
lines.push('|------|-----|-------------|----------------|-----------------|---------------|');
|
|
137
|
+
for (const [name, hex] of Object.entries(colorOptions)) {
|
|
138
|
+
const lc = name.toLowerCase();
|
|
139
|
+
const opposite = colorOpposites[name] ?? '—';
|
|
140
|
+
const foreground = foregroundColors.has(name) ? '*(text colour)*' : '';
|
|
141
|
+
lines.push(`| **${name}** ${foreground} | \`${hex}\` | \`bg-${lc}\` | \`text-${lc}\` | \`border-${lc}\` | ${opposite} |`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (Object.keys(gradients).length > 0) {
|
|
145
|
+
lines.push('');
|
|
146
|
+
lines.push('**Gradients:**');
|
|
147
|
+
lines.push('');
|
|
148
|
+
lines.push('| Name | Value | `bg-*` class |');
|
|
149
|
+
lines.push('|------|-------|-------------|');
|
|
150
|
+
for (const [name, value] of Object.entries(gradients)) {
|
|
151
|
+
const lc = name.toLowerCase();
|
|
152
|
+
lines.push(`| ${name} | \`${value}\` | \`bg-${lc}\` |`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
lines.push('');
|
|
156
|
+
lines.push('**Rules:**');
|
|
157
|
+
lines.push('- Never use hardcoded hex values. Always use the named classes above.');
|
|
158
|
+
lines.push('- For body copy on a dark background use the contrast pair (e.g. `bg-dark text-light`).');
|
|
159
|
+
lines.push('- Accent colours (Orange, Blue, Yellow) work as backgrounds and highlights.');
|
|
160
|
+
return lines.join('\n');
|
|
161
|
+
}
|
|
162
|
+
function buildTypographySection(config) {
|
|
163
|
+
const fontTable = config.fontTable;
|
|
164
|
+
const styles = fontTable?.styles;
|
|
165
|
+
if (!styles || Object.keys(styles).length === 0) {
|
|
166
|
+
return '## Typography\n\nNo typography styles defined in tailwind.config.json.';
|
|
167
|
+
}
|
|
168
|
+
// After the guard above, styles is guaranteed defined. Alias for closure access.
|
|
169
|
+
const definedStyles = styles;
|
|
170
|
+
const baseFont = fontTable?.font ?? 'sans-serif';
|
|
171
|
+
const baseWeight = fontTable?.weight ?? 400;
|
|
172
|
+
const lines = ['## Typography', ''];
|
|
173
|
+
lines.push(`Base font: **${baseFont}** | Base weight: **${baseWeight}**`);
|
|
174
|
+
lines.push('');
|
|
175
|
+
lines.push('Use the class name directly on any HTML element — the class controls font-size, weight, line-height, and letter-spacing across breakpoints.');
|
|
176
|
+
lines.push('');
|
|
177
|
+
// Categorise by approximate usage
|
|
178
|
+
const displayClasses = [];
|
|
179
|
+
const headingClasses = [];
|
|
180
|
+
const bodyClasses = [];
|
|
181
|
+
const specialClasses = [];
|
|
182
|
+
for (const name of Object.keys(styles)) {
|
|
183
|
+
if (name.startsWith('h1'))
|
|
184
|
+
displayClasses.push(name);
|
|
185
|
+
else if (name.startsWith('h2') || name.startsWith('h3') || name.startsWith('h4'))
|
|
186
|
+
headingClasses.push(name);
|
|
187
|
+
else if (name.startsWith('p'))
|
|
188
|
+
bodyClasses.push(name);
|
|
189
|
+
else
|
|
190
|
+
specialClasses.push(name);
|
|
191
|
+
}
|
|
192
|
+
function buildTypographyTable(names) {
|
|
193
|
+
const rows = [
|
|
194
|
+
'| Class | Mobile | Tablet | Laptop | Desktop | Weight | Notes |',
|
|
195
|
+
'|-------|--------|--------|--------|---------|--------|-------|',
|
|
196
|
+
];
|
|
197
|
+
for (const name of names) {
|
|
198
|
+
const e = definedStyles[name];
|
|
199
|
+
const defaultPx = resolveSizePx(e['default']);
|
|
200
|
+
const tabletPx = resolveSizePx(e['tablet']);
|
|
201
|
+
const laptopVal = e['laptop'];
|
|
202
|
+
const laptopPx = laptopVal !== undefined ? resolveSizePx(laptopVal) : null;
|
|
203
|
+
const desktopVal = e['desktop'];
|
|
204
|
+
const desktopPx = desktopVal !== undefined ? resolveSizePx(desktopVal) : null;
|
|
205
|
+
const weight = typeof e['weight'] === 'number' ? e['weight'] : (baseWeight ?? '—');
|
|
206
|
+
const additional = e['additional'];
|
|
207
|
+
const notes = [];
|
|
208
|
+
if (additional?.['text-transform'] === 'uppercase')
|
|
209
|
+
notes.push('UPPERCASE');
|
|
210
|
+
if (typeof e['letterSpacing'] === 'number' && e['letterSpacing'] !== 0)
|
|
211
|
+
notes.push(`tracking ${e['letterSpacing']}px`);
|
|
212
|
+
rows.push(`| \`${name}\` | ${defaultPx !== null ? `${defaultPx}px` : '—'} | ${tabletPx !== null ? `${tabletPx}px` : '—'} | ${laptopPx !== null ? `${laptopPx}px` : '—'} | ${desktopPx !== null ? `${desktopPx}px` : '—'} | ${weight} | ${notes.join(', ')} |`);
|
|
213
|
+
}
|
|
214
|
+
return rows.join('\n');
|
|
215
|
+
}
|
|
216
|
+
if (displayClasses.length > 0) {
|
|
217
|
+
lines.push('### Display Headings');
|
|
218
|
+
lines.push('');
|
|
219
|
+
lines.push(buildTypographyTable(displayClasses));
|
|
220
|
+
lines.push('');
|
|
221
|
+
}
|
|
222
|
+
if (headingClasses.length > 0) {
|
|
223
|
+
lines.push('### Section & Sub-Headings');
|
|
224
|
+
lines.push('');
|
|
225
|
+
lines.push(buildTypographyTable(headingClasses));
|
|
226
|
+
lines.push('');
|
|
227
|
+
}
|
|
228
|
+
if (bodyClasses.length > 0) {
|
|
229
|
+
lines.push('### Body Text');
|
|
230
|
+
lines.push('');
|
|
231
|
+
lines.push(buildTypographyTable(bodyClasses));
|
|
232
|
+
lines.push('');
|
|
233
|
+
}
|
|
234
|
+
if (specialClasses.length > 0) {
|
|
235
|
+
lines.push('### Special Styles');
|
|
236
|
+
lines.push('');
|
|
237
|
+
lines.push(buildTypographyTable(specialClasses));
|
|
238
|
+
lines.push('');
|
|
239
|
+
}
|
|
240
|
+
lines.push('**Rules:**');
|
|
241
|
+
lines.push('- Apply the class directly: `<h2 class="h2">Heading</h2>`');
|
|
242
|
+
lines.push('- Never use `text-[Npx]`, `font-size`, or `font-weight` inline styles.');
|
|
243
|
+
lines.push('- For body copy, use `p1` (large) or `p2` (standard). Reserve `h*` classes for actual headings.');
|
|
244
|
+
return lines.join('\n');
|
|
245
|
+
}
|
|
246
|
+
function buildGridSection(config) {
|
|
247
|
+
const sizes = config.sizes ?? {};
|
|
248
|
+
const lines = ['## Grid & Layout', ''];
|
|
249
|
+
lines.push('Content is placed on a CSS grid. The grid column count changes per breakpoint; gaps and margins are fixed.');
|
|
250
|
+
lines.push('');
|
|
251
|
+
lines.push('| Breakpoint | Columns | Gap | Margin |');
|
|
252
|
+
lines.push('|------------|---------|-----|--------|');
|
|
253
|
+
for (const [name, s] of Object.entries(sizes)) {
|
|
254
|
+
const bp = s.breakpoint !== undefined ? `${s.breakpoint}px` : 'default';
|
|
255
|
+
const cols = s.cols !== undefined ? String(s.cols) : '—';
|
|
256
|
+
const gap = s.gap !== undefined ? `${s.gap}px` : '—';
|
|
257
|
+
const margin = s.margin !== undefined ? `${s.margin}px` : '—';
|
|
258
|
+
lines.push(`| ${name} (${bp}) | ${cols} | ${gap} | ${margin} |`);
|
|
259
|
+
}
|
|
260
|
+
lines.push('');
|
|
261
|
+
lines.push('### Grid Container Classes');
|
|
262
|
+
lines.push('');
|
|
263
|
+
lines.push('The author is responsible for adding both grid layers. Without them, content has no page margins');
|
|
264
|
+
lines.push('and stretches edge-to-edge. **Outer layer** classes go on the section wrapper; **inner layer** classes');
|
|
265
|
+
lines.push('go on the div that receives `content-cols-grid`. Content spans go inside the inner layer.');
|
|
266
|
+
lines.push('');
|
|
267
|
+
lines.push('| Class | Layer | Description |');
|
|
268
|
+
lines.push('|-------|-------|-------------|');
|
|
269
|
+
lines.push('| `container-cols-grid` | Outer | 3-column grid: `[margin \\| content \\| margin]`. Background colour goes here. |');
|
|
270
|
+
lines.push('| `container-rows-grid` | Outer | Adds vertical spacing rows. Required when using `container-row-*`. |');
|
|
271
|
+
lines.push('| `container-row-6-12` | Outer | Vertical spacing (48px bottom — standard default). Replace with desired value. |');
|
|
272
|
+
lines.push('| `col-start-2 col-span-1` | Inner | Places inner div in the content column (skips margin columns). |');
|
|
273
|
+
lines.push('| `row-start-2 row-span-4` | Inner | Places inner div in the content rows (skips spacing rows). |');
|
|
274
|
+
lines.push('| `content-cols-grid` | Inner | Creates the 12-column content grid inside the inner div. |');
|
|
275
|
+
lines.push('| `col-span-full` | Content | Full width (all 12 columns) |');
|
|
276
|
+
lines.push('| `laptop:col-start-2 laptop:col-span-10` | Content | Centered with one-column margin each side (laptop+) |');
|
|
277
|
+
lines.push('| `laptop:col-start-2 laptop:col-span-4` | Content | Left column (text side in two-column layout) |');
|
|
278
|
+
lines.push('| `laptop:col-start-7 laptop:col-span-5` | Content | Right column (visual side in two-column layout) |');
|
|
279
|
+
lines.push('| `laptop:col-start-2 laptop:col-span-5` | Content | Narrow left half |');
|
|
280
|
+
lines.push('| `laptop:col-start-7 laptop:col-span-6` | Content | Wide right half |');
|
|
281
|
+
lines.push('');
|
|
282
|
+
lines.push('### Layout Notes');
|
|
283
|
+
lines.push('');
|
|
284
|
+
lines.push('- On mobile, all content columns are `col-span-full` by default.');
|
|
285
|
+
lines.push('- For edge-to-edge (full-bleed) content, set `fullWidth: true` on the HtmlComponent entry — the outer grid is removed and you control the full viewport width.');
|
|
286
|
+
return lines.join('\n');
|
|
287
|
+
}
|
|
288
|
+
function buildSpacingSection() {
|
|
289
|
+
const lines = ['## Spacing Conventions', ''];
|
|
290
|
+
lines.push('Use Tailwind spacing utilities. Multiples of 4 (the Tailwind scale) are preferred.');
|
|
291
|
+
lines.push('');
|
|
292
|
+
lines.push('| Purpose | Classes |');
|
|
293
|
+
lines.push('|---------|---------|');
|
|
294
|
+
lines.push('| Section vertical padding (large) | `py-16 laptop:py-24` |');
|
|
295
|
+
lines.push('| Section vertical padding (medium) | `py-12 laptop:py-16` |');
|
|
296
|
+
lines.push('| Section vertical padding (small) | `py-8 laptop:py-12` |');
|
|
297
|
+
lines.push('| Between stacked elements | `space-y-4 laptop:space-y-8` |');
|
|
298
|
+
lines.push('| Flex/grid gap | `gap-4 laptop:gap-8` |');
|
|
299
|
+
lines.push('| Card padding | `p-6 laptop:p-10` |');
|
|
300
|
+
lines.push('');
|
|
301
|
+
lines.push('> **Note:** HtmlComponent renders inside the Section component which already provides top/bottom padding. Avoid adding extra outer padding that doubles the section gap.');
|
|
302
|
+
lines.push('');
|
|
303
|
+
lines.push('> All classes in the table above are included in the generated `src/generated/cms-rawhtml-safelist.css`', '> and are guaranteed to be compiled once that file is imported into `globals.css`.', '> See the Confirmed Safe Tailwind Utilities section for details.');
|
|
304
|
+
return lines.join('\n');
|
|
305
|
+
}
|
|
306
|
+
function buildUtilitiesSection(utilities) {
|
|
307
|
+
// Filter out internal/layout utilities unlikely to be useful in raw HTML
|
|
308
|
+
const skipList = new Set([
|
|
309
|
+
'unknown-component',
|
|
310
|
+
'button',
|
|
311
|
+
'outlined-button',
|
|
312
|
+
'visual-cols',
|
|
313
|
+
'visual-width',
|
|
314
|
+
'image-item-h',
|
|
315
|
+
'row-1-pageHalf',
|
|
316
|
+
'row-6-pageHalf',
|
|
317
|
+
]);
|
|
318
|
+
const useful = utilities.filter((u) => !skipList.has(u.name));
|
|
319
|
+
if (useful.length === 0) {
|
|
320
|
+
return '## Custom Tailwind Utilities\n\n*None defined in globals.css.*';
|
|
321
|
+
}
|
|
322
|
+
const lines = ['## Custom Tailwind Utilities', ''];
|
|
323
|
+
lines.push('These utilities are available in addition to the standard Tailwind library:');
|
|
324
|
+
lines.push('');
|
|
325
|
+
lines.push('| Class | Description |');
|
|
326
|
+
lines.push('|-------|-------------|');
|
|
327
|
+
const descriptions = {
|
|
328
|
+
'animate-ticker': 'Horizontal ticker/marquee scroll animation (20s loop)',
|
|
329
|
+
'animate-fade-in-grow': 'Fade in + scale up entrance animation (0.5s)',
|
|
330
|
+
'animate-shape-shift': 'Animated shape morphing (clip-path + rotation, 9s loop)',
|
|
331
|
+
'animate-card-rotate': 'Subtle 3D rotation on hover (0.4s)',
|
|
332
|
+
'animate-clip-reveal': 'Left-to-right clip-path reveal animation (0.6s)',
|
|
333
|
+
'film-grain': 'Adds a subtle animated film grain overlay (fixed, z-index 9998)',
|
|
334
|
+
'section-spacing': 'Standard section top/bottom padding',
|
|
335
|
+
};
|
|
336
|
+
for (const { name } of useful) {
|
|
337
|
+
const desc = descriptions[name] ?? 'Custom utility — see globals.css for details';
|
|
338
|
+
lines.push(`| \`${name}\` | ${desc} |`);
|
|
339
|
+
}
|
|
340
|
+
return lines.join('\n');
|
|
341
|
+
}
|
|
342
|
+
function buildRtfSection(rtfClasses) {
|
|
343
|
+
const lines = ['## Rich Text Classes', ''];
|
|
344
|
+
lines.push('Apply these classes to an element that contains HTML produced from rich text or Markdown:');
|
|
345
|
+
lines.push('');
|
|
346
|
+
const rtfDescriptions = {
|
|
347
|
+
'rtf-standard': 'Default rich text styling. Handles headings, paragraphs, lists, blockquotes, and links with standard spacing. Use for most body content.',
|
|
348
|
+
'rtf-article': 'Editorial / blog styling with wider paragraph spacing and emphasis on readability. Use for long-form articles.',
|
|
349
|
+
'rtf-article-right': 'Article styling with paragraphs pinned to the right column (laptop+). Use for editorial layouts with a wide left margin.',
|
|
350
|
+
'rtf-legal': 'Legal document styling with compact spacing. Headings map to smaller sizes (h1→h3, h2→h4, etc.). Use for terms, privacy, and compliance content.',
|
|
351
|
+
};
|
|
352
|
+
if (rtfClasses.length === 0) {
|
|
353
|
+
lines.push('*No `.rtf-*` classes found in globals.css.*');
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
lines.push('| Class | Use when… |');
|
|
357
|
+
lines.push('|-------|-----------|');
|
|
358
|
+
for (const cls of rtfClasses) {
|
|
359
|
+
const desc = rtfDescriptions[cls] ?? 'Custom rich text variant — see globals.css for details.';
|
|
360
|
+
lines.push(`| \`${cls}\` | ${desc} |`);
|
|
361
|
+
}
|
|
362
|
+
lines.push('');
|
|
363
|
+
lines.push('```html');
|
|
364
|
+
lines.push('<div class="rtf-standard">');
|
|
365
|
+
lines.push(' <h2>Heading</h2>');
|
|
366
|
+
lines.push(' <p>Paragraph with <strong>bold</strong> and <em>italic</em>.</p>');
|
|
367
|
+
lines.push(' <ul><li>List item</li></ul>');
|
|
368
|
+
lines.push('</div>');
|
|
369
|
+
lines.push('```');
|
|
370
|
+
}
|
|
371
|
+
return lines.join('\n');
|
|
372
|
+
}
|
|
373
|
+
function buildFontsSection(config) {
|
|
374
|
+
const fontTable = config.fontTable;
|
|
375
|
+
const baseFont = fontTable?.font;
|
|
376
|
+
const lines = ['## Fonts', ''];
|
|
377
|
+
if (baseFont) {
|
|
378
|
+
lines.push(`The primary font is **${baseFont}**, loaded via Next.js font optimisation.`);
|
|
379
|
+
lines.push('');
|
|
380
|
+
lines.push('| Tailwind property | CSS variable |');
|
|
381
|
+
lines.push('|-------------------|--------------|');
|
|
382
|
+
lines.push('| `font-sans` | `var(--font-sans)` — use for all body and heading text |');
|
|
383
|
+
lines.push('| `font-mono` | `var(--font-mono)` — use for code snippets |');
|
|
384
|
+
lines.push('');
|
|
385
|
+
lines.push('> **Do not** reference the font family by name in CSS. Use `font-sans` or rely on the typography classes which already apply the correct font.');
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
lines.push('Font information not available in tailwind.config.json.');
|
|
389
|
+
}
|
|
390
|
+
return lines.join('\n');
|
|
391
|
+
}
|
|
392
|
+
function buildPatternsSection(config) {
|
|
393
|
+
const colors = Object.keys(config.colorOptions ?? {});
|
|
394
|
+
const darkColor = colors.find((c) => c.toLowerCase() === 'dark') ?? colors[0] ?? 'dark';
|
|
395
|
+
const lightColor = colors.find((c) => c.toLowerCase() === 'light') ?? colors[1] ?? 'light';
|
|
396
|
+
const accentColor = colors.find((c) => !['dark', 'light', 'gray', 'grey'].includes(c.toLowerCase())) ??
|
|
397
|
+
colors[2] ??
|
|
398
|
+
'orange';
|
|
399
|
+
const dc = darkColor.toLowerCase();
|
|
400
|
+
const lc = lightColor.toLowerCase();
|
|
401
|
+
const ac = accentColor.toLowerCase();
|
|
402
|
+
const lines = ['## Common Layout Patterns', ''];
|
|
403
|
+
lines.push('Copy-paste these patterns into `rawHtml`. Replace placeholder text with real content.');
|
|
404
|
+
lines.push('');
|
|
405
|
+
lines.push('> **All patterns use the mandatory two-layer grid structure.** The outer `container-cols-grid` provides');
|
|
406
|
+
lines.push('> page margin columns; the inner `content-cols-grid` provides the 12-column content grid.');
|
|
407
|
+
lines.push('> Never use `content-cols-grid` as the outermost element — content will have no page margins.');
|
|
408
|
+
lines.push('');
|
|
409
|
+
lines.push('### Centered Text Block');
|
|
410
|
+
lines.push('');
|
|
411
|
+
lines.push('```html');
|
|
412
|
+
lines.push('<div class="w-full container-cols-grid container-rows-grid container-row-6-12">');
|
|
413
|
+
lines.push(' <div class="col-start-2 col-span-1 row-start-2 row-span-4 content-cols-grid">');
|
|
414
|
+
lines.push(' <div class="col-span-full laptop:col-start-2 laptop:col-span-10 flex flex-col gap-6">');
|
|
415
|
+
lines.push(' <h2 class="h2">Section Heading</h2>');
|
|
416
|
+
lines.push(' <div class="rtf-standard">');
|
|
417
|
+
lines.push(' <p>Body copy goes here.</p>');
|
|
418
|
+
lines.push(' </div>');
|
|
419
|
+
lines.push(' </div>');
|
|
420
|
+
lines.push(' </div>');
|
|
421
|
+
lines.push('</div>');
|
|
422
|
+
lines.push('```');
|
|
423
|
+
lines.push('');
|
|
424
|
+
lines.push('### Two-Column Layout (Text + Visual)');
|
|
425
|
+
lines.push('');
|
|
426
|
+
lines.push('```html');
|
|
427
|
+
lines.push('<div class="w-full container-cols-grid container-rows-grid container-row-6-12">');
|
|
428
|
+
lines.push(' <div class="col-start-2 col-span-1 row-start-2 row-span-4 content-cols-grid">');
|
|
429
|
+
lines.push(' <div class="col-span-full laptop:col-start-2 laptop:col-span-4 flex flex-col gap-6">');
|
|
430
|
+
lines.push(' <h2 class="h2">Heading</h2>');
|
|
431
|
+
lines.push(' <p class="p1">Descriptive text alongside the visual.</p>');
|
|
432
|
+
lines.push(' </div>');
|
|
433
|
+
lines.push(' <div class="col-span-full laptop:col-start-7 laptop:col-span-5">');
|
|
434
|
+
lines.push(' <img src="..." alt="..." class="w-full rounded-lg" />');
|
|
435
|
+
lines.push(' </div>');
|
|
436
|
+
lines.push(' </div>');
|
|
437
|
+
lines.push('</div>');
|
|
438
|
+
lines.push('<!-- For tablet column layout, use customCss @media instead of tablet: prefix -->');
|
|
439
|
+
lines.push('```');
|
|
440
|
+
lines.push('');
|
|
441
|
+
lines.push('### Coloured Card');
|
|
442
|
+
lines.push('');
|
|
443
|
+
lines.push('```html');
|
|
444
|
+
lines.push('<div class="w-full container-cols-grid container-rows-grid container-row-6-12">');
|
|
445
|
+
lines.push(' <div class="col-start-2 col-span-1 row-start-2 row-span-4 content-cols-grid">');
|
|
446
|
+
lines.push(' <div class="col-span-full laptop:col-start-2 laptop:col-span-10">');
|
|
447
|
+
lines.push(` <div class="bg-${ac} text-${dc} p-8 rounded-lg flex flex-col gap-4">`);
|
|
448
|
+
lines.push(' <h3 class="h3">Card Title</h3>');
|
|
449
|
+
lines.push(' <p class="p2">Supporting description text.</p>');
|
|
450
|
+
lines.push(' </div>');
|
|
451
|
+
lines.push(' </div>');
|
|
452
|
+
lines.push(' </div>');
|
|
453
|
+
lines.push('</div>');
|
|
454
|
+
lines.push('```');
|
|
455
|
+
lines.push('');
|
|
456
|
+
lines.push('### Card Grid');
|
|
457
|
+
lines.push('');
|
|
458
|
+
lines.push('```html');
|
|
459
|
+
lines.push('<div class="w-full container-cols-grid container-rows-grid container-row-6-12">');
|
|
460
|
+
lines.push(' <div class="col-start-2 col-span-1 row-start-2 row-span-4 content-cols-grid gap-y-8">');
|
|
461
|
+
lines.push(' <div class="col-span-full laptop:col-start-2 laptop:col-span-10">');
|
|
462
|
+
lines.push(' <h2 class="h2">Grid Title</h2>');
|
|
463
|
+
lines.push(' </div>');
|
|
464
|
+
lines.push(' <div class="col-span-full laptop:col-start-2 laptop:col-span-10">');
|
|
465
|
+
lines.push(' <!-- Use customCss for responsive grid columns (tablet:/laptop: not reliable in rawHtml) -->');
|
|
466
|
+
lines.push(' <div class="my-card-grid">');
|
|
467
|
+
lines.push(' <div class="flex flex-col gap-4 p-6 border rounded-lg">');
|
|
468
|
+
lines.push(' <h3 class="h3">Card 1</h3>');
|
|
469
|
+
lines.push(' <p class="p2">Description.</p>');
|
|
470
|
+
lines.push(' </div>');
|
|
471
|
+
lines.push(' </div>');
|
|
472
|
+
lines.push(' </div>');
|
|
473
|
+
lines.push(' </div>');
|
|
474
|
+
lines.push('</div>');
|
|
475
|
+
lines.push('<!-- In customCss: -->');
|
|
476
|
+
lines.push('<!-- @media (min-width: 768px) { .my-card-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; } } -->');
|
|
477
|
+
lines.push('<!-- @media (min-width: 1024px) { .my-card-grid { grid-template-columns: repeat(3, 1fr); } } -->');
|
|
478
|
+
lines.push('```');
|
|
479
|
+
lines.push('');
|
|
480
|
+
lines.push('### Hero with Dark Background');
|
|
481
|
+
lines.push('');
|
|
482
|
+
lines.push('```html');
|
|
483
|
+
lines.push('<!-- Set isHero: true and fullWidth: true on this HtmlComponent entry -->');
|
|
484
|
+
lines.push('<!-- fullWidth: true removes the outer grid — you own the full width -->');
|
|
485
|
+
lines.push('<!-- py-16 is used here because fullWidth: true removes the outer grid/row spacing. -->');
|
|
486
|
+
lines.push('<!-- In a standard (non-fullWidth) section, use container-row-6-* for vertical spacing. -->');
|
|
487
|
+
lines.push(`<div class="bg-${dc} text-${lc} w-full">`);
|
|
488
|
+
lines.push(' <div class="content-cols-grid py-16">');
|
|
489
|
+
lines.push(' <div class="col-span-full laptop:col-start-2 laptop:col-span-10 flex flex-col gap-8">');
|
|
490
|
+
lines.push(' <h1 class="h1">Hero Heading</h1>');
|
|
491
|
+
lines.push(' <p class="p1">Subtitle or intro text.</p>');
|
|
492
|
+
lines.push(' </div>');
|
|
493
|
+
lines.push(' </div>');
|
|
494
|
+
lines.push('</div>');
|
|
495
|
+
lines.push('```');
|
|
496
|
+
return lines.join('\n');
|
|
497
|
+
}
|
|
498
|
+
function buildHtmlComponentRules() {
|
|
499
|
+
return `## HtmlComponent Field Reference
|
|
500
|
+
|
|
501
|
+
When authoring HTML for a \`HtmlComponent\` entry in Contentful, fill these fields:
|
|
502
|
+
|
|
503
|
+
| Field | Required | Notes |
|
|
504
|
+
|-------|----------|-------|
|
|
505
|
+
| \`rawHtml\` | ✓ | The HTML markup. Use Tailwind classes; avoid inline \`style=\` attributes for layout/colour/typography. |
|
|
506
|
+
| \`customCss\` | — | Scoped CSS. Rules are automatically prefixed with \`[data-hc-id="<id>"]\` so they cannot leak. **Use for all responsive layout** (\`@media\` queries) and any utility class not in the Confirmed Safe list. |
|
|
507
|
+
| \`customJs\` | — | JavaScript loaded via Next.js \`<Script>\`. A \`hcRoot\` variable pointing to this component's root element is automatically injected — use it to scope all queries (see below). |
|
|
508
|
+
| \`markdownContent\` | — | Plain text summary for site search indexing. **Strongly recommended** — leaving it empty makes the component invisible to search. |
|
|
509
|
+
| \`isHero\` | — | Set \`true\` if the HTML contains an \`<h1>\`. Ensures this block appears first in the page content flow. |
|
|
510
|
+
| \`fullWidth\` | — | Set \`true\` for edge-to-edge (full-bleed) layouts that break out of the column grid. |
|
|
511
|
+
| \`excludeFromSearch\` | — | Set \`true\` to exclude from search indexing even if \`markdownContent\` is filled. |
|
|
512
|
+
| \`scriptStrategy\` | — | \`afterInteractive\` (default, safe), \`lazyOnload\` (deferred), \`beforeInteractive\` (constrained). |
|
|
513
|
+
|
|
514
|
+
### Authoring Rules
|
|
515
|
+
|
|
516
|
+
1. **Classes, not inline styles.** Use Tailwind utility classes. Never write \`style="color: #FF5C00"\` — use \`class="text-orange"\` instead.
|
|
517
|
+
2. **Use the two-layer grid.** Every section needs an outer \`container-cols-grid\` wrapper (page margins) and an inner \`col-start-2 col-span-1 content-cols-grid\` wrapper (12-column content grid). See the Layout Patterns section.
|
|
518
|
+
3. **Only use responsive prefixes from the Confirmed Safe list.** Tailwind only compiles classes found in source files — classes in \`rawHtml\` are invisible to the scanner. Only use \`tablet:\` / \`laptop:\` prefixes that appear in the **Confirmed Safe Tailwind Utilities** section (they are proven to be in the compiled bundle). For any responsive behaviour not listed there, use \`customCss\` with \`@media\` queries instead.
|
|
519
|
+
4. **Semantic HTML.** Use correct heading levels (\`<h1>\` only once per page, \`<h2>\` for section heads, \`<h3>\` for cards).
|
|
520
|
+
5. **Accessible images.** Always include \`alt\` attributes. Decorative images use \`alt=""\`.
|
|
521
|
+
6. **No custom font stacks.** Use \`font-sans\` or rely on typography classes. The project font is loaded by Next.js automatically.
|
|
522
|
+
7. **Fill \`markdownContent\`.** This is strongly recommended — it is the only way site search can index the component's content. Leave it empty and the component is invisible to search.
|
|
523
|
+
8. **Set \`cmsLabel\` immediately.** Use a descriptive value — the Contentful UI shows only this name.
|
|
524
|
+
|
|
525
|
+
### Custom JavaScript (\`customJs\`)
|
|
526
|
+
|
|
527
|
+
The renderer automatically injects \`hcRoot\` — a reference to this component's root \`[data-hc-id]\` element — before your script runs. **Always use \`hcRoot\` instead of \`document.querySelector('[data-hc-id]')\`**, which would match the first component on the page, not this one.
|
|
528
|
+
|
|
529
|
+
\`\`\`js
|
|
530
|
+
// ✓ Correct — hcRoot is automatically scoped to this component
|
|
531
|
+
var btns = hcRoot.querySelectorAll('.my-btn');
|
|
532
|
+
btns.forEach(function (btn) {
|
|
533
|
+
btn.addEventListener('click', function () { /* ... */ });
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
// ✗ Wrong — matches the first [data-hc-id] on the page, not necessarily this component
|
|
537
|
+
var section = document.querySelector('[data-hc-id]');
|
|
538
|
+
\`\`\`
|
|
539
|
+
|
|
540
|
+
**Rules:**
|
|
541
|
+
- Use \`hcRoot\` as the root for all \`querySelector\` / \`querySelectorAll\` calls.
|
|
542
|
+
- Keep scripts small and self-contained — no module imports, no global state.
|
|
543
|
+
- Avoid \`document.getElementById\` unless the ID is unique across the entire page.
|
|
544
|
+
- Use \`afterInteractive\` (default) for toggle/tab interactions. Use \`lazyOnload\` for non-critical analytics or third-party widgets.`;
|
|
545
|
+
}
|
|
546
|
+
// ---------------------------------------------------------------------------
|
|
547
|
+
// Safe Tailwind utilities scanner
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
/** Responsive / state prefixes to strip before category matching. */
|
|
550
|
+
const VARIANT_PREFIXES = [
|
|
551
|
+
'tablet:',
|
|
552
|
+
'laptop:',
|
|
553
|
+
'desktop:',
|
|
554
|
+
'wide:',
|
|
555
|
+
'hover:',
|
|
556
|
+
'focus:',
|
|
557
|
+
'active:',
|
|
558
|
+
'group-hover:',
|
|
559
|
+
'dark:',
|
|
560
|
+
'motion-safe:',
|
|
561
|
+
'motion-reduce:',
|
|
562
|
+
];
|
|
563
|
+
/**
|
|
564
|
+
* Per-category patterns matched against the BASE class (after stripping variants).
|
|
565
|
+
* Order matters — first match wins.
|
|
566
|
+
*/
|
|
567
|
+
const SAFE_UTILITY_CATEGORIES = [
|
|
568
|
+
{
|
|
569
|
+
label: 'Display & Visibility',
|
|
570
|
+
pattern: /^(block|inline-block|inline|flex|inline-flex|grid|inline-grid|hidden|contents|flow-root|visible|invisible|sr-only|not-sr-only)$/,
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
label: 'Position',
|
|
574
|
+
pattern: /^(relative|absolute|fixed|sticky|static)$|^(inset(-[xy])?|top|right|bottom|left|z)-/,
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
label: 'Flexbox',
|
|
578
|
+
pattern: /^(flex(-col|-row|-wrap|-nowrap|-1|-auto|-initial|-none|-shrink|-shrink-0|-grow|-grow-0)?|items|justify|self|content|place-items|place-content|place-self|order|grow|shrink|basis)(-|$)/,
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
label: 'Grid',
|
|
582
|
+
pattern: /^(grid(-cols|-rows|-flow)?|col(-span|-start|-end)?|row(-span|-start|-end)?|auto-cols|auto-rows)(-|\d|$)/,
|
|
583
|
+
},
|
|
584
|
+
{
|
|
585
|
+
label: 'Gap',
|
|
586
|
+
pattern: /^gap(-[xy])?-/,
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
label: 'Padding',
|
|
590
|
+
pattern: /^(p|px|py|pt|pr|pb|pl|ps|pe)-/,
|
|
591
|
+
},
|
|
592
|
+
{
|
|
593
|
+
label: 'Margin',
|
|
594
|
+
pattern: /^-?(m|mx|my|mt|mr|mb|ml|ms|me)-/,
|
|
595
|
+
},
|
|
596
|
+
{
|
|
597
|
+
label: 'Space Between',
|
|
598
|
+
pattern: /^-?space-(x|y)-/,
|
|
599
|
+
},
|
|
600
|
+
{
|
|
601
|
+
label: 'Sizing',
|
|
602
|
+
pattern: /^(w|h|min-w|min-h|max-w|max-h|size)-/,
|
|
603
|
+
},
|
|
604
|
+
{
|
|
605
|
+
label: 'Typography Utilities',
|
|
606
|
+
pattern: /^(text-(center|left|right|justify|wrap|nowrap|balance|pretty)|font-(bold|semibold|medium|normal|light|thin|black|extrabold)|leading|tracking|uppercase|lowercase|capitalize|normal-case|truncate|break|whitespace|underline|no-underline|line-through|decoration|align|indent)(-|$)/,
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
label: 'Borders & Radius',
|
|
610
|
+
pattern: /^(border(-[0-9]|-(t|b|l|r|x|y|s|e|dashed|solid|dotted|none))?$|rounded)(-|$)/,
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
label: 'Opacity',
|
|
614
|
+
pattern: /^opacity-/,
|
|
615
|
+
},
|
|
616
|
+
{
|
|
617
|
+
label: 'Shadow & Ring',
|
|
618
|
+
pattern: /^(shadow|ring)(-|$)/,
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
label: 'Overflow',
|
|
622
|
+
pattern: /^overflow(-[xy])?-/,
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
label: 'Object Fit',
|
|
626
|
+
pattern: /^object-/,
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
label: 'Aspect Ratio',
|
|
630
|
+
pattern: /^aspect-/,
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
label: 'Transition & Animation',
|
|
634
|
+
pattern: /^(transition|duration|ease|delay|animate)(-|$)/,
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
label: 'Transform',
|
|
638
|
+
pattern: /^-?(translate|rotate|scale|skew|transform)(-|$)/,
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
label: 'Interactivity',
|
|
642
|
+
pattern: /^(cursor|pointer-events|select|resize|scroll|touch)(-|$)/,
|
|
643
|
+
},
|
|
644
|
+
];
|
|
645
|
+
/** Characters that are valid in a Tailwind class token. */
|
|
646
|
+
const VALID_CLASS_RE = /^[a-z0-9_-]+([:/[\].!a-z0-9_-])*$/i;
|
|
647
|
+
/**
|
|
648
|
+
* For spacing/sizing categories the value portion (after the last prefix hyphen)
|
|
649
|
+
* must look like a Tailwind scale — a number, fraction, keyword, or arbitrary value.
|
|
650
|
+
* This prevents descriptive classnames like `my-warning` from being included.
|
|
651
|
+
*/
|
|
652
|
+
const SPACING_CATEGORY_LABELS = new Set(['Padding', 'Margin', 'Gap', 'Space Between', 'Sizing']);
|
|
653
|
+
const VALID_SPACING_VALUE_RE = /^(\[.*\]|-?\d[\d.]*(?:\/\d+)?|px|auto|full|screen|fit|min|max|svh|dvh|unset|inherit|cols-\S+|gap-\S+)$/;
|
|
654
|
+
/** Patterns for strings we always exclude even if they match a category. */
|
|
655
|
+
const EXCLUDE_RE = /^(bg-|text-|border-|ring-|from-|to-|via-|fill-|stroke-|shadow-|outline-|accent-|caret-|divide-color|placeholder-)(?!opacity|none|current|inherit|transparent|clip|left|right|center|justify|auto|full|screen|fit|max|min|px|0|[0-9]|\.)/;
|
|
656
|
+
/** Custom design-system class names that should never appear in utility output. */
|
|
657
|
+
const CUSTOM_CLASS_RE = /^(content-cols-grid|container-cols-grid|container-rows-grid|container-row|section-spacing|col-start-2|rtf-|animate-ticker|animate-fade|animate-shape|animate-card|animate-clip|film-grain|h[1-4](Med|plus|l|lbold|MedYellow)?|p[1-3](Med)?|hHome|large-button|nav)(-|$)/;
|
|
658
|
+
function collectSourceFiles(dir) {
|
|
659
|
+
const results = [];
|
|
660
|
+
if (!existsSync(dir))
|
|
661
|
+
return results;
|
|
662
|
+
for (const entry of readdirSync(dir)) {
|
|
663
|
+
const full = join(dir, entry);
|
|
664
|
+
const st = statSync(full);
|
|
665
|
+
if (st.isDirectory()) {
|
|
666
|
+
if (entry === 'node_modules' || entry === '.next' || entry === 'dist')
|
|
667
|
+
continue;
|
|
668
|
+
results.push(...collectSourceFiles(full));
|
|
669
|
+
}
|
|
670
|
+
else if (/\.(tsx?|css)$/.test(entry)) {
|
|
671
|
+
results.push(full);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return results;
|
|
675
|
+
}
|
|
676
|
+
function extractClassTokens(files) {
|
|
677
|
+
// Match quoted strings that are plausibly class lists (contain spaces or look like single classes)
|
|
678
|
+
const QUOTED_RE = /(?:className|class)=(?:"([^"]+)"|'([^']+)'|`([^`]+)`)/g;
|
|
679
|
+
const CN_RE = /\bcn\(([^)]{1,500})\)/g;
|
|
680
|
+
const tokens = new Set();
|
|
681
|
+
for (const file of files) {
|
|
682
|
+
const src = readFileSync(file, 'utf-8');
|
|
683
|
+
for (const match of src.matchAll(QUOTED_RE)) {
|
|
684
|
+
const val = match[1] ?? match[2] ?? match[3] ?? '';
|
|
685
|
+
for (const tok of val.split(/\s+/)) {
|
|
686
|
+
if (tok)
|
|
687
|
+
tokens.add(tok);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
for (const match of src.matchAll(CN_RE)) {
|
|
691
|
+
for (const quoted of (match[1] ?? '').matchAll(/"([^"]+)"|'([^']+)'/g)) {
|
|
692
|
+
const val = quoted[1] ?? quoted[2] ?? '';
|
|
693
|
+
for (const tok of val.split(/\s+/)) {
|
|
694
|
+
if (tok)
|
|
695
|
+
tokens.add(tok);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return tokens;
|
|
701
|
+
}
|
|
702
|
+
function stripVariantPrefix(cls) {
|
|
703
|
+
for (const prefix of VARIANT_PREFIXES) {
|
|
704
|
+
if (cls.startsWith(prefix))
|
|
705
|
+
return cls.slice(prefix.length);
|
|
706
|
+
}
|
|
707
|
+
return cls;
|
|
708
|
+
}
|
|
709
|
+
function categorizeSafeClasses(tokens) {
|
|
710
|
+
const result = new Map();
|
|
711
|
+
for (const { label } of SAFE_UTILITY_CATEGORIES) {
|
|
712
|
+
result.set(label, new Set());
|
|
713
|
+
}
|
|
714
|
+
for (const cls of tokens) {
|
|
715
|
+
// Must look like a valid class token
|
|
716
|
+
if (!VALID_CLASS_RE.test(cls))
|
|
717
|
+
continue;
|
|
718
|
+
// Exclude design-system colour classes (already in Colours section)
|
|
719
|
+
if (EXCLUDE_RE.test(stripVariantPrefix(cls)))
|
|
720
|
+
continue;
|
|
721
|
+
if (CUSTOM_CLASS_RE.test(stripVariantPrefix(cls)))
|
|
722
|
+
continue;
|
|
723
|
+
const base = stripVariantPrefix(cls);
|
|
724
|
+
for (const { label, pattern } of SAFE_UTILITY_CATEGORIES) {
|
|
725
|
+
if (pattern.test(base)) {
|
|
726
|
+
// For spacing/sizing categories validate the value portion looks like a
|
|
727
|
+
// Tailwind scale value, not a descriptive word (e.g. reject `my-warning`)
|
|
728
|
+
if (SPACING_CATEGORY_LABELS.has(label)) {
|
|
729
|
+
const valuePart = base.split('-').at(-1) ?? '';
|
|
730
|
+
if (!VALID_SPACING_VALUE_RE.test(valuePart))
|
|
731
|
+
break;
|
|
732
|
+
}
|
|
733
|
+
result.get(label)?.add(cls);
|
|
734
|
+
break;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return result;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Expands a single brace-expansion pattern into all concrete strings.
|
|
742
|
+
* E.g. "{,laptop:}py-{4,8}" → ["py-4", "py-8", "laptop:py-4", "laptop:py-8"]
|
|
743
|
+
*/
|
|
744
|
+
function braceExpand(pattern) {
|
|
745
|
+
const start = pattern.indexOf('{');
|
|
746
|
+
if (start === -1)
|
|
747
|
+
return [pattern];
|
|
748
|
+
const end = pattern.indexOf('}', start);
|
|
749
|
+
if (end === -1)
|
|
750
|
+
return [pattern];
|
|
751
|
+
const pre = pattern.slice(0, start);
|
|
752
|
+
const suf = pattern.slice(end + 1);
|
|
753
|
+
return pattern
|
|
754
|
+
.slice(start + 1, end)
|
|
755
|
+
.split(',')
|
|
756
|
+
.flatMap((alt) => braceExpand(pre + alt + suf));
|
|
757
|
+
}
|
|
758
|
+
/** Parse @source inline("...") entries from a CSS string and expand to class tokens. */
|
|
759
|
+
function expandInlineSources(css) {
|
|
760
|
+
const tokens = new Set();
|
|
761
|
+
const re = /@source\s+inline\("([^"]+)"\)/g;
|
|
762
|
+
let m = re.exec(css);
|
|
763
|
+
while (m !== null) {
|
|
764
|
+
for (const cls of braceExpand(m[1])) {
|
|
765
|
+
const t = cls.trim();
|
|
766
|
+
if (t)
|
|
767
|
+
tokens.add(t);
|
|
768
|
+
}
|
|
769
|
+
m = re.exec(css);
|
|
770
|
+
}
|
|
771
|
+
return tokens;
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Build the cms-rawhtml-safelist.css content for this project.
|
|
775
|
+
* Uses project breakpoints from tailwind.config.json to generate the correct
|
|
776
|
+
* variant prefixes, and unions the baseline values with scanner-found values
|
|
777
|
+
* to cover project-specific spacing usage.
|
|
778
|
+
*/
|
|
779
|
+
function buildSafelistCss(config, scannerTokens) {
|
|
780
|
+
const sizes = config.sizes ?? {};
|
|
781
|
+
// Breakpoint names in config order, skipping 'mobile' (no prefix)
|
|
782
|
+
const bpNames = Object.entries(sizes)
|
|
783
|
+
.filter(([name, s]) => name !== 'mobile' && s.breakpoint !== undefined)
|
|
784
|
+
.map(([name]) => name);
|
|
785
|
+
// e.g. "{,tablet:,laptop:}" or "{,laptop:}" — empty string = base class (no prefix)
|
|
786
|
+
const vp = bpNames.length > 0 ? `{,${bpNames.map((n) => `${n}:`).join(',')}}` : '';
|
|
787
|
+
// Extract numeric values the scanner found for a given property prefix
|
|
788
|
+
function scannerValuesFor(propPrefix) {
|
|
789
|
+
const vals = new Set();
|
|
790
|
+
for (const tok of scannerTokens) {
|
|
791
|
+
const base = stripVariantPrefix(tok);
|
|
792
|
+
if (base.startsWith(`${propPrefix}-`)) {
|
|
793
|
+
const val = base.slice(propPrefix.length + 1);
|
|
794
|
+
if (/^\d+(\.\d+)?$/.test(val))
|
|
795
|
+
vals.add(val);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return [...vals];
|
|
799
|
+
}
|
|
800
|
+
// Curated baseline values per property (Tailwind default spacing scale subset)
|
|
801
|
+
const BASELINE = {
|
|
802
|
+
py: ['2', '4', '6', '8', '10', '12', '16', '20', '24'],
|
|
803
|
+
px: ['2', '4', '6', '8', '10', '12', '16', '20'],
|
|
804
|
+
pt: ['2', '4', '6', '8', '10', '12', '16', '20', '24'],
|
|
805
|
+
pb: ['2', '4', '6', '8', '10', '12', '16', '20', '24'],
|
|
806
|
+
p: ['2', '4', '6', '8', '10', '12'],
|
|
807
|
+
gap: ['1', '2', '3', '4', '5', '6', '8', '10', '12', '16'],
|
|
808
|
+
'gap-y': ['2', '4', '6', '8', '10', '12', '16'],
|
|
809
|
+
'gap-x': ['2', '4', '6', '8', '10', '12'],
|
|
810
|
+
'space-y': ['2', '4', '6', '8', '10', '12', '16'],
|
|
811
|
+
'space-x': ['2', '4', '6', '8', '10', '12'],
|
|
812
|
+
};
|
|
813
|
+
// Properties where responsive variants are meaningful for layout
|
|
814
|
+
const RESPONSIVE = new Set(['py', 'px', 'gap', 'gap-y', 'gap-x', 'space-y', 'space-x']);
|
|
815
|
+
const lines = [
|
|
816
|
+
'/* Auto-generated by cms-generate-html-style-guide — do not edit by hand */',
|
|
817
|
+
'/* Regenerate: pnpm cms-generate-html-style-guide */',
|
|
818
|
+
'',
|
|
819
|
+
'/*',
|
|
820
|
+
' * Forces common spacing/gap utilities into the CSS bundle so they can be',
|
|
821
|
+
' * safely used in HtmlComponent rawHtml fields (which Tailwind JIT never scans).',
|
|
822
|
+
' *',
|
|
823
|
+
' * Setup (one time per app):',
|
|
824
|
+
` * Add to src/app/globals.css: @import "../generated/cms-rawhtml-safelist.css"`,
|
|
825
|
+
' */',
|
|
826
|
+
'',
|
|
827
|
+
];
|
|
828
|
+
for (const [prop, baseline] of Object.entries(BASELINE)) {
|
|
829
|
+
// Union baseline with scanner-found values, sort numerically
|
|
830
|
+
const all = new Set([...baseline, ...scannerValuesFor(prop)]);
|
|
831
|
+
const values = [...all].sort((a, b) => Number(a) - Number(b));
|
|
832
|
+
const valBrace = `{${values.join(',')}}`;
|
|
833
|
+
const prefix = RESPONSIVE.has(prop) && vp ? vp : '';
|
|
834
|
+
lines.push(`@source inline("${prefix}${prop}-${valBrace}");`);
|
|
835
|
+
}
|
|
836
|
+
return `${lines.join('\n')}\n`;
|
|
837
|
+
}
|
|
838
|
+
function buildSafeUtilitiesSection(allTokens) {
|
|
839
|
+
const grouped = categorizeSafeClasses(allTokens);
|
|
840
|
+
const lines = [
|
|
841
|
+
'## Confirmed Safe Tailwind Utilities',
|
|
842
|
+
'',
|
|
843
|
+
'These classes are **guaranteed to be in the CSS bundle** — either found in compiled source',
|
|
844
|
+
'files or explicitly included in the generated `src/generated/cms-rawhtml-safelist.css`.',
|
|
845
|
+
'Use them freely in `rawHtml`.',
|
|
846
|
+
'',
|
|
847
|
+
'> **Setup required (one time):** Add `@import "../generated/cms-rawhtml-safelist.css"` to',
|
|
848
|
+
'> `src/app/globals.css`. Without this import the safelist classes are not compiled.',
|
|
849
|
+
'',
|
|
850
|
+
'> **Any class NOT listed here may not be compiled.** For responsive layout not in this list,',
|
|
851
|
+
'> use `customCss` with `@media` queries instead.',
|
|
852
|
+
'',
|
|
853
|
+
];
|
|
854
|
+
let totalCount = 0;
|
|
855
|
+
for (const { label } of SAFE_UTILITY_CATEGORIES) {
|
|
856
|
+
const classes = grouped.get(label);
|
|
857
|
+
if (!classes || classes.size === 0)
|
|
858
|
+
continue;
|
|
859
|
+
const sorted = [...classes].sort();
|
|
860
|
+
totalCount += sorted.length;
|
|
861
|
+
lines.push(`### ${label}`);
|
|
862
|
+
lines.push('');
|
|
863
|
+
lines.push(sorted.map((c) => `\`${c}\``).join(' '));
|
|
864
|
+
lines.push('');
|
|
865
|
+
}
|
|
866
|
+
if (totalCount === 0) {
|
|
867
|
+
lines.push('_No confirmed utility classes found — ensure source files are accessible._');
|
|
868
|
+
}
|
|
869
|
+
return lines.join('\n');
|
|
870
|
+
}
|
|
871
|
+
// ---------------------------------------------------------------------------
|
|
872
|
+
// Main
|
|
873
|
+
// ---------------------------------------------------------------------------
|
|
874
|
+
function main() {
|
|
875
|
+
const appRoot = parseAppDir();
|
|
876
|
+
const appName = basename(appRoot);
|
|
877
|
+
console.log(`Generating HTML style guide for: ${appRoot}`);
|
|
878
|
+
// Load tailwind config
|
|
879
|
+
const config = loadTailwindConfig(appRoot);
|
|
880
|
+
if (!config) {
|
|
881
|
+
console.error(`ERROR: Could not load tailwind.config.json from ${appRoot}`);
|
|
882
|
+
console.error(' Run from or pass --app-dir pointing to an app directory.');
|
|
883
|
+
process.exit(1);
|
|
884
|
+
}
|
|
885
|
+
// Load globals.css
|
|
886
|
+
const globalsCssPath = join(appRoot, 'src/app/globals.css');
|
|
887
|
+
let globalsCss = '';
|
|
888
|
+
if (existsSync(globalsCssPath)) {
|
|
889
|
+
globalsCss = readFileSync(globalsCssPath, 'utf8');
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
console.warn(` WARNING: globals.css not found at ${globalsCssPath} — skipping CSS parsing`);
|
|
893
|
+
}
|
|
894
|
+
const utilities = extractUtilities(globalsCss);
|
|
895
|
+
const rtfClasses = extractRtfClasses(globalsCss);
|
|
896
|
+
const rootVars = extractRootVars(globalsCss);
|
|
897
|
+
// Scan source files for confirmed-safe class tokens
|
|
898
|
+
const monorepoRoot = join(appRoot, '../..');
|
|
899
|
+
const scanDirs = [join(appRoot, 'src'), join(monorepoRoot, 'packages/core-ui/src')];
|
|
900
|
+
const sourceFiles = scanDirs.flatMap(collectSourceFiles);
|
|
901
|
+
const scannerTokens = extractClassTokens(sourceFiles);
|
|
902
|
+
// Generate the per-project safelist CSS (uses project breakpoints + scanner values)
|
|
903
|
+
const safelistCss = buildSafelistCss(config, scannerTokens);
|
|
904
|
+
const safelistDir = join(appRoot, 'src/generated');
|
|
905
|
+
const safelistPath = join(safelistDir, 'cms-rawhtml-safelist.css');
|
|
906
|
+
mkdirSync(safelistDir, { recursive: true });
|
|
907
|
+
writeFileSync(safelistPath, safelistCss);
|
|
908
|
+
// Expand safelist tokens and merge with scanner tokens
|
|
909
|
+
const safelistTokens = expandInlineSources(safelistCss);
|
|
910
|
+
const allTokens = new Set([...scannerTokens, ...safelistTokens]);
|
|
911
|
+
console.log(` Colours: ${Object.keys(config.colorOptions ?? {}).length}`);
|
|
912
|
+
console.log(` Typography styles: ${Object.keys(config.fontTable?.styles ?? {}).length}`);
|
|
913
|
+
console.log(` Utilities: ${utilities.length}`);
|
|
914
|
+
console.log(` RTF classes: ${rtfClasses.length}`);
|
|
915
|
+
console.log(` CSS variables: ${rootVars.length}`);
|
|
916
|
+
console.log(` Safelist classes: ${safelistTokens.size} (wrote ${safelistPath})`);
|
|
917
|
+
console.log(` ℹ One-time setup: add @import "../generated/cms-rawhtml-safelist.css" to src/app/globals.css`);
|
|
918
|
+
// Build sections
|
|
919
|
+
const now = new Date().toISOString().split('T')[0];
|
|
920
|
+
const sections = [
|
|
921
|
+
`# HTML Component Style Guide — ${appName}`,
|
|
922
|
+
'',
|
|
923
|
+
`> Auto-generated on ${now} from \`tailwind.config.json\` and \`src/app/globals.css\`.`,
|
|
924
|
+
'> Regenerate with: `pnpm cms-generate-html-style-guide`',
|
|
925
|
+
'',
|
|
926
|
+
'This guide defines every design token available for use in `HtmlComponent` `rawHtml` fields.',
|
|
927
|
+
'Read this before authoring any custom HTML for the site — it ensures visual consistency.',
|
|
928
|
+
'',
|
|
929
|
+
'---',
|
|
930
|
+
'',
|
|
931
|
+
buildHtmlComponentRules(),
|
|
932
|
+
'',
|
|
933
|
+
'---',
|
|
934
|
+
'',
|
|
935
|
+
buildBreakpointsSection(config),
|
|
936
|
+
'',
|
|
937
|
+
'---',
|
|
938
|
+
'',
|
|
939
|
+
buildColourSection(config),
|
|
940
|
+
'',
|
|
941
|
+
'---',
|
|
942
|
+
'',
|
|
943
|
+
buildTypographySection(config),
|
|
944
|
+
'',
|
|
945
|
+
'---',
|
|
946
|
+
'',
|
|
947
|
+
buildGridSection(config),
|
|
948
|
+
'',
|
|
949
|
+
'---',
|
|
950
|
+
'',
|
|
951
|
+
buildSpacingSection(),
|
|
952
|
+
'',
|
|
953
|
+
'---',
|
|
954
|
+
'',
|
|
955
|
+
buildFontsSection(config),
|
|
956
|
+
'',
|
|
957
|
+
'---',
|
|
958
|
+
'',
|
|
959
|
+
buildUtilitiesSection(utilities),
|
|
960
|
+
'',
|
|
961
|
+
'---',
|
|
962
|
+
'',
|
|
963
|
+
buildRtfSection(rtfClasses),
|
|
964
|
+
'',
|
|
965
|
+
'---',
|
|
966
|
+
'',
|
|
967
|
+
buildSafeUtilitiesSection(allTokens),
|
|
968
|
+
'',
|
|
969
|
+
'---',
|
|
970
|
+
'',
|
|
971
|
+
buildPatternsSection(config),
|
|
972
|
+
];
|
|
973
|
+
// Include CSS variables if any interesting ones found
|
|
974
|
+
const interestingVars = rootVars.filter((v) => !v.name.startsWith('--color-') && !v.name.startsWith('--animate-'));
|
|
975
|
+
if (interestingVars.length > 0) {
|
|
976
|
+
sections.push('', '---', '', '## CSS Custom Properties', '');
|
|
977
|
+
sections.push('These tokens are available via `var(--name)` in `customCss` fields:');
|
|
978
|
+
sections.push('');
|
|
979
|
+
sections.push('| Variable | Default value |');
|
|
980
|
+
sections.push('|----------|---------------|');
|
|
981
|
+
// De-duplicate by name — keep the first (default/mobile) value; note responsive overrides
|
|
982
|
+
const seenVars = new Map();
|
|
983
|
+
const multipleVars = new Set();
|
|
984
|
+
for (const { name, value } of interestingVars) {
|
|
985
|
+
if (seenVars.has(name)) {
|
|
986
|
+
multipleVars.add(name);
|
|
987
|
+
}
|
|
988
|
+
else {
|
|
989
|
+
seenVars.set(name, value);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
for (const [name, value] of seenVars) {
|
|
993
|
+
const note = multipleVars.has(name) ? ' *(has responsive override)*' : '';
|
|
994
|
+
sections.push(`| \`${name}\` | \`${value}\`${note} |`);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
const output = `${sections.join('\n')}\n`;
|
|
998
|
+
// Write output
|
|
999
|
+
const outputDir = join(appRoot, 'docs/cms-guidelines');
|
|
1000
|
+
mkdirSync(outputDir, { recursive: true });
|
|
1001
|
+
const outputPath = join(outputDir, 'html-component-style-guide.md');
|
|
1002
|
+
writeFileSync(outputPath, output);
|
|
1003
|
+
console.log(`\nWrote ${outputPath}`);
|
|
1004
|
+
}
|
|
1005
|
+
main();
|
|
1006
|
+
//# sourceMappingURL=cms-generate-html-style-guide.js.map
|