-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgenerate-agent-docs.cjs
More file actions
1050 lines (967 loc) · 39.8 KB
/
Copy pathgenerate-agent-docs.cjs
File metadata and controls
1050 lines (967 loc) · 39.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Generates the agent-facing skill (skills/harmonia/) from the VitePress docs
// and the utility-class safelist.
//
// The docs/ tree is the source of truth for components: this script transcribes
// each directive-bearing doc into an agent-optimized reference file, builds the
// SKILL.md router (with a generated component index) and an llms.txt index.
// Each reference carries the doc's Usage prose, API tables, Keyboard Handling
// and Accessibility sections, and the FULL Examples section verbatim (every
// ```html block with its headings and surrounding prose - the examples are
// already written in the exact syntax a consumer types), plus a link to the
// component's page on the docs site. VitePress-only markup is normalized:
// example wrappers (<LiveExample> / <IconGallery> / <TemplateShowcase>) and
// internal links are stripped, and ::: info/warning containers become plain
// markdown blockquotes. Content wrapped in a <!-- skill:ignore --> ... <!--
// /skill:ignore --> region is docs-site-only and dropped entirely (the prose
// counterpart of a <LiveExample data-exclude="skill"> block).
//
// The utility-class reference (references/utility-classes.md) is generated from
// src/styles/harmonia.css - the authoritative Tailwind safelist - so agents get
// the exact set of available classes rather than assuming all of Tailwind.
//
// Run with `npm run agent-docs:generate`; it also runs automatically on build.
// The pure transform() is exported so tests/agent-docs.test.js can assert the
// committed output is not stale without touching the filesystem.
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const DOCS_DIR = path.join(ROOT, 'docs');
const OUT_DIR = path.join(ROOT, 'skills', 'harmonia');
const REF_DIR = path.join(OUT_DIR, 'references');
// The authoritative safelist of available utility classes. Only what this file
// compiles into harmonia.css exists at runtime, so the utility-class reference
// is generated from it (not the docs, which can drift, nor dist/harmonia.css,
// which also contains undocumented internal classes).
const HARMONIA_CSS = path.join(ROOT, 'src', 'styles', 'harmonia.css');
const UTILITY_LABEL = 'Utility classes';
// Canonical public docs site. Referenced from every generated reference file
// (per-component "Full docs" link) and from the SKILL.md / llms.txt headers.
const DOCS_URL = 'https://www.codbex.com/harmonia';
// Breaking changes only, extracted from the root CHANGELOG.md, so a coding
// agent migrating between versions gets signal without wading through every
// feature and bug fix. Requires every breaking-change bullet in the changelog
// to use the exact `- **Breaking: ...**` prefix (see references/migration.md
// generation below).
const CHANGELOG_PATH = path.join(ROOT, 'CHANGELOG.md');
const CHANGELOG_URL = 'https://github.com/codbex/harmonia/blob/main/CHANGELOG.md';
const MIGRATION_LABEL = 'Migration';
// Doc subdirectories to transcribe, in index order. `requireDirectives` marks
// the directive-bearing groups where a missing directive block signals drift.
const SOURCES = [
{ dir: 'components', label: 'Components', requireDirectives: true },
{ dir: 'charts', label: 'Charts', requireDirectives: true },
{ dir: 'layouts', label: 'Layouts', requireDirectives: true },
{ dir: 'utilities', label: 'Utilities', requireDirectives: false },
{ dir: 'plugins', label: 'Plugins', requireDirectives: false },
];
// ---------------------------------------------------------------------------
// Markdown parsing helpers (fence-aware, section-driven; never line-count based)
// ---------------------------------------------------------------------------
function computeFenceMask(lines) {
const mask = new Array(lines.length).fill(false);
let inFence = false;
for (let i = 0; i < lines.length; i++) {
if (/^\s*```/.test(lines[i])) {
mask[i] = true; // the fence toggle line itself is not a heading
inFence = !inFence;
continue;
}
mask[i] = inFence;
}
return mask;
}
function headingAt(lines, mask, i) {
if (mask[i]) return null;
const m = /^(#{1,6})\s+(.*)$/.exec(lines[i]);
return m ? { level: m[1].length, text: m[2].trim() } : null;
}
// End index (exclusive) of the section opened by the heading at startIdx:
// the next heading whose level is <= the start heading's level.
function sectionEnd(lines, mask, startIdx) {
const start = headingAt(lines, mask, startIdx);
for (let i = startIdx + 1; i < lines.length; i++) {
const h = headingAt(lines, mask, i);
if (h && h.level <= start.level) return i;
}
return lines.length;
}
function findHeading(lines, mask, { level, match, from = 0, to = lines.length }) {
for (let i = from; i < to; i++) {
const h = headingAt(lines, mask, i);
if (!h) continue;
if (level && h.level !== level) continue;
if (match && !match.test(h.text)) continue;
return i;
}
return -1;
}
// Converts VitePress-internal markdown links to plain text: `[Menu](/components/menu)`
// and `[range mode](#anchor)` become broken pointers in a standalone reference,
// so keep the label and drop the target. Real http(s) links are left intact.
function stripInternalLinks(md) {
return md.replace(/\[([^\]]+)\]\((\/|\.\/|\.\.\/|#)[^)]*\)/g, '$1');
}
// Runnable examples in the docs are wrapped in Vue components (<LiveExample>,
// <IconGallery>, <TemplateShowcase>) that exist only in the VitePress site. When a
// wrapper sits inside a section we transcribe verbatim (e.g. an API Reference
// subsection), drop the wrapper's own tag lines but keep the fenced example they
// contain. Fence-aware, so a wrapper name shown inside a code block is left intact.
const WRAPPER_TAG = /^<\/?(?:LiveExample|IconGallery|TemplateShowcase)(?:\s[^>]*)?\/?>$/;
function stripComponentWrappers(md) {
const lines = md.split('\n');
let inFence = false;
const out = [];
for (const line of lines) {
if (/^\s*```/.test(line)) {
inFence = !inFence;
out.push(line);
continue;
}
if (!inFence && WRAPPER_TAG.test(line.trim())) continue;
out.push(line);
}
return out.join('\n');
}
// A doc example marked data-exclude="skill" (or "all") is site-only: drop the
// whole <LiveExample> block (wrapper tag, fence and any prose inside it) before
// transcription. Counterpart of the theming generator's data-exclude="generator"
// handling in scripts/generate-theming-fragments.cjs. Fence-aware.
function removeExcludedExamples(md) {
const lines = md.split('\n');
let inFence = false;
let skipping = false;
const out = [];
for (const line of lines) {
if (/^\s*```/.test(line)) inFence = !inFence;
if (!inFence && !skipping) {
const open = /^<LiveExample\b([^>]*)>/.exec(line.trim());
const exclude = open ? (open[1].match(/data-exclude="([^"]*)"/) || [])[1] : undefined;
if (exclude && exclude.split(/\s+/).some((token) => token === 'skill' || token === 'all')) {
skipping = true;
continue;
}
}
if (skipping) {
if (!inFence && /^<\/LiveExample>$/.test(line.trim())) skipping = false;
continue;
}
out.push(line);
}
return out.join('\n');
}
// A <!-- skill:ignore --> ... <!-- /skill:ignore --> region is docs-site-only
// (e.g. a photo-attribution note): drop it entirely before transcription so it
// never reaches an agent reference. The markers are HTML comments, invisible in
// the rendered docs; the wrapped markdown stays visible on the site. The prose
// counterpart of the <LiveExample data-exclude="skill"> hook above. Fence-aware.
function removeSkillIgnored(md) {
const lines = md.split('\n');
let inFence = false;
let skipping = false;
const out = [];
for (const line of lines) {
if (/^\s*```/.test(line)) inFence = !inFence;
if (!inFence && !skipping && /^\s*<!--\s*skill:ignore\s*-->\s*$/.test(line)) {
skipping = true;
continue;
}
if (skipping) {
if (!inFence && /^\s*<!--\s*\/skill:ignore\s*-->\s*$/.test(line)) skipping = false;
continue;
}
out.push(line);
}
return out.join('\n');
}
// VitePress `::: info|tip|warning|...` custom containers are site-only syntax;
// left as-is they leak raw `:::` lines into the references. Convert each to a
// plain markdown blockquote: the opening line becomes `> **Label:** Title`,
// body lines get a `> ` prefix, and the closing `:::` is dropped. Fence-aware.
// Unknown container types fall back to the capitalized type name so no
// container can ever pass through unconverted.
const CONTAINER_LABELS = { info: 'Note', tip: 'Tip', warning: 'Warning', danger: 'Caution', details: 'Details' };
function convertContainers(md) {
const lines = md.split('\n');
let inFence = false;
let inContainer = false;
const out = [];
for (const line of lines) {
if (/^\s*```/.test(line)) {
inFence = !inFence;
out.push(inContainer ? `> ${line}` : line);
continue;
}
if (inFence) {
out.push(inContainer ? `> ${line}` : line);
continue;
}
const open = /^:{3,}\s*(\S+)\s*(.*)$/.exec(line);
if (open && !inContainer) {
inContainer = true;
const type = open[1].toLowerCase();
const label = CONTAINER_LABELS[type] || type.charAt(0).toUpperCase() + type.slice(1);
out.push(open[2] ? `> **${label}:** ${open[2].trim()}` : `> **${label}:**`);
continue;
}
if (inContainer && /^:{3,}\s*$/.test(line)) {
inContainer = false;
continue;
}
out.push(inContainer ? (line.trim() ? `> ${line}` : '>') : line);
}
return out.join('\n');
}
function extractTitleAndDescription(lines, mask) {
const ti = findHeading(lines, mask, { level: 1 });
const title = ti >= 0 ? headingAt(lines, mask, ti).text : null;
let i = ti + 1;
while (i < lines.length && lines[i].trim() === '') i++;
const buf = [];
while (i < lines.length && lines[i].trim() !== '' && !headingAt(lines, mask, i)) {
buf.push(lines[i].trim());
i++;
}
return { title, description: stripInternalLinks(buf.join(' ').trim()) };
}
function extractDirectives(lines, headingIdx) {
let i = headingIdx + 1;
while (i < lines.length && lines[i].trim() === '') i++;
if (i >= lines.length || !/^\s*```/.test(lines[i])) return [];
i++;
const out = [];
while (i < lines.length && !/^\s*```/.test(lines[i])) {
const t = lines[i].trim();
if (t) out.push(t);
i++;
}
return out;
}
// Returns { directives, apiDetails } from every "API Reference" section, at any
// heading level (a few docs, e.g. calendar, nest one per variant). Directives
// are unioned across all "Component attribute(s)" blocks; apiDetails is those
// sections verbatim MINUS the directive blocks (which we render separately).
function extractApi(lines, mask) {
const directives = [];
const parts = [];
for (let i = 0; i < lines.length; i++) {
const h = headingAt(lines, mask, i);
if (!h || !/component attribute/i.test(h.text)) continue;
for (const d of extractDirectives(lines, i)) {
if (!directives.includes(d)) directives.push(d);
}
}
for (let i = 0; i < lines.length; i++) {
const h = headingAt(lines, mask, i);
if (!h || !/api reference/i.test(h.text)) continue;
const end = sectionEnd(lines, mask, i);
const excludes = [];
for (let j = i + 1; j < end; j++) {
const hh = headingAt(lines, mask, j);
if (hh && /component attribute/i.test(hh.text)) excludes.push([j, sectionEnd(lines, mask, j)]);
}
const detail = [];
for (let j = i + 1; j < end; j++) {
if (excludes.some(([s, e]) => j >= s && j < e)) continue;
detail.push(lines[j]);
}
const trimmed = detail.join('\n').trim();
if (trimmed) parts.push(trimmed);
i = end - 1;
}
return { directives, apiDetails: stripComponentWrappers(stripInternalLinks(parts.join('\n\n'))) };
}
// Verbatim body of the H2 section with the given name (`## Usage`,
// `## Behavior`, `## Keyboard Handling`, `## Accessibility`, `## Examples` -
// the canonical section set enforced by tests/docs-structure.test.js),
// normalized for a standalone reference (internal links and VitePress wrappers
// removed). Returns null when the section is missing or empty.
function extractSection(lines, mask, name) {
const match = new RegExp(`^${name}$`, 'i');
const start = findHeading(lines, mask, { level: 2, match });
if (start < 0) return null;
const end = sectionEnd(lines, mask, start);
const body = stripComponentWrappers(stripInternalLinks(lines.slice(start + 1, end).join('\n'))).trim();
return body || null;
}
// Collects every ```html block in the doc (fence-aware). Used only to detect
// `x-model` usage across all examples (including ones inside API Reference).
function extractExampleBlocks(lines) {
const blocks = [];
let i = 0;
while (i < lines.length) {
if (/^\s*```html\s*$/.test(lines[i])) {
i++;
const buf = [];
while (i < lines.length && !/^\s*```/.test(lines[i])) {
buf.push(lines[i]);
i++;
}
blocks.push({ code: buf.join('\n').replace(/\s+$/, '') });
}
i++;
}
return blocks;
}
function parseDoc(text) {
const lines = convertContainers(removeExcludedExamples(removeSkillIgnored(text.replace(/\r\n/g, '\n')))).split('\n');
const mask = computeFenceMask(lines);
const { title, description } = extractTitleAndDescription(lines, mask);
const { directives, apiDetails } = extractApi(lines, mask);
const hasModel = extractExampleBlocks(lines).some((b) => /x-model/.test(b.code));
return {
title,
description,
directives,
apiDetails,
usage: extractSection(lines, mask, 'Usage'),
behavior: extractSection(lines, mask, 'Behavior'),
keyboard: extractSection(lines, mask, 'Keyboard Handling'),
accessibility: extractSection(lines, mask, 'Accessibility'),
examples: extractSection(lines, mask, 'Examples'),
hasModel,
};
}
// Fence-aware heading outline of a doc: [{ level, text }] in document order.
// Exported for tests/docs-structure.test.js so it shares this file's parsing.
function outline(text) {
const lines = text.replace(/\r\n/g, '\n').split('\n');
const mask = computeFenceMask(lines);
const out = [];
for (let i = 0; i < lines.length; i++) {
const h = headingAt(lines, mask, i);
if (h) out.push(h);
}
return out;
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
function firstSentence(text) {
if (!text) return '';
const m = /^(.*?[.!?])(\s|$)/.exec(text);
return (m ? m[1] : text).trim();
}
function renderReference(doc, docUrl) {
const out = [];
out.push(`# ${doc.title}`);
out.push('');
if (doc.description) {
out.push(doc.description);
out.push('');
}
out.push('Part of the Harmonia Alpine.js component library. Every directive uses the `x-h-` prefix.');
out.push('');
if (doc.usage) {
out.push('## Usage');
out.push('');
out.push(doc.usage);
out.push('');
}
if (doc.behavior) {
out.push('## Behavior');
out.push('');
out.push(doc.behavior);
out.push('');
}
if (doc.directives.length === 1) {
out.push('## Directive');
out.push('');
out.push(`- \`${doc.directives[0]}\``);
out.push('');
} else if (doc.directives.length > 1) {
out.push('## Directives');
out.push('');
out.push(`\`${doc.directives[0]}\` is the root. The directives compose one component and must be nested as shown in the Examples below (the library throws at runtime when a required ancestor is missing):`);
out.push('');
for (const d of doc.directives) out.push(`- \`${d}\``);
out.push('');
}
if (doc.apiDetails) {
out.push('## API');
out.push('');
out.push(doc.apiDetails);
out.push('');
}
if (doc.keyboard) {
out.push('## Keyboard Handling');
out.push('');
out.push(doc.keyboard);
out.push('');
}
if (doc.accessibility) {
out.push('## Accessibility');
out.push('');
out.push(doc.accessibility);
out.push('');
}
if (doc.hasModel) {
out.push('## Binding');
out.push('');
out.push('Binds through Alpine `x-model`. See the Examples for the expected value shape.');
out.push('');
}
if (doc.examples) {
out.push('## Examples');
out.push('');
out.push(doc.examples);
out.push('');
}
if (docUrl) {
out.push(`Full docs: ${docUrl}`);
out.push('');
}
out.push('## Notes');
out.push('');
out.push('- Directive values are Alpine expressions, so quote string literals: `x-h-...="\'Label\'"`.');
out.push('- Components render only after Alpine has registered Harmonia. See SKILL.md for setup.');
out.push('');
return (
out
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trimEnd() + '\n'
);
}
const SKILL_FRONTMATTER = `---
name: harmonia
description: How to build UIs with the Harmonia Alpine.js component library (@codbex/harmonia). Use when adding, wiring, or styling Harmonia UI components (x-h-* directives such as buttons, dialogs, selects, tables, date pickers) in a project that depends on @codbex/harmonia.
---`;
function renderSkill(index) {
const out = [];
out.push(SKILL_FRONTMATTER);
out.push('');
out.push('<!-- AUTO-GENERATED by scripts/generate-agent-docs.cjs from docs/. Edit that script (or the source docs), then run `npm run build`. -->');
out.push('');
out.push('# Harmonia');
out.push('');
out.push(
'Harmonia is a UI component library for [Alpine.js](https://alpinejs.dev/), built with Tailwind CSS. Components are Alpine directives: you add `x-h-*` attributes to plain HTML elements and the library upgrades them. There is no JSX and no component-tag syntax.'
);
out.push('');
out.push(`Full documentation: ${DOCS_URL}/`);
out.push('');
out.push('## How to use this skill');
out.push('');
out.push(
'Find the component in the index below and open its file under `references/`. Each reference lists the directive set, its attributes, whether it binds with `x-model`, and working examples you can adapt. Load only the reference(s) you need.'
);
out.push('');
out.push('## Setup');
out.push('');
out.push('Harmonia requires Alpine.js as a peer dependency and ships a CSS file that must be linked.');
out.push('');
out.push('### Script tag (auto-registers on `alpine:init`)');
out.push('');
out.push('```html');
out.push('<script src="/path/to/node_modules/@codbex/harmonia/dist/harmonia.min.js"></script>');
out.push('<link href="/path/to/node_modules/@codbex/harmonia/dist/harmonia.css" rel="stylesheet" />');
out.push('<script defer src="/path/to/node_modules/alpinejs/dist/cdn.min.js"></script>');
out.push('```');
out.push('');
out.push('### ES module (register manually)');
out.push('');
out.push('```js');
out.push("import Alpine from 'alpinejs';");
out.push("import registerComponents from '@codbex/harmonia';");
out.push('');
out.push('registerComponents(Alpine.plugin); // register every component');
out.push('Alpine.start();');
out.push('```');
out.push('');
out.push("Import the CSS (`@codbex/harmonia/dist/harmonia.css`) too. For selective registration, import named exports (`import { Button, Card } from '@codbex/harmonia'`) and call `Alpine.plugin(Button)` per component.");
out.push('');
out.push('## Conventions that apply to every component');
out.push('');
out.push('- **Directive prefix.** Alpine directives registered as `h-<name>` are written as `x-h-<name>` in HTML (for example `x-h-button`, `x-h-date-picker`).');
out.push(
'- **Values are Alpine expressions.** A string literal must be quoted inside the attribute value: `x-h-accordion-trigger="\'Section title\'"`, not `x-h-accordion-trigger="Section title"`. A bare word is read as a variable reference.'
);
out.push(
'- **Compound components nest.** Many components are a set of directives (root plus children). They must be nested as the reference example shows; the library throws a descriptive error at runtime if a required ancestor is missing.'
);
out.push('- **Modifiers are dot suffixes.** For example `x-h-accordion.single`, `x-h-accordion-item.default`.');
out.push(
'- **Styling is attribute-driven.** Common attributes are `data-size` (for example `sm` / `md`), `data-variant` (for example `primary` / `negative`), and `data-align` for popovers/menus. See each reference for the exact values.'
);
out.push(
'- **Utility classes are a curated subset, NOT all of Tailwind.** Only the classes compiled into `harmonia.css` exist; an arbitrary Tailwind class that is not shipped (for example `h-80`, `gap-20`, `bg-red-450`) silently does nothing. Before using any utility class, confirm it is in the [Utility classes](references/utility-classes.md) reference, and for a one-off value with no matching class use an inline `style`.'
);
out.push('- **Form controls use `x-model`.** Inputs, selects, checkboxes, radios, ranges, switches, and the date/time pickers bind their value with Alpine `x-model`.');
out.push('- **Light and dark modes** are handled automatically.');
out.push('- **Accessibility.** Components set sensible ARIA roles and a default `aria-label` only when the author has not set one; provide your own labels where the content is not self-describing.');
out.push('');
out.push('## Component index');
out.push('');
out.push(index.trim());
out.push('');
return (
out
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trimEnd() + '\n'
);
}
function renderIndex(groups) {
const out = [];
for (const group of groups) {
if (!group.entries.length) continue;
out.push(`### ${group.label}`);
out.push('');
out.push('| Name | Description | Reference |');
out.push('| ---- | ----------- | --------- |');
for (const e of group.entries) {
out.push(`| ${e.title} | ${e.summary} | [${e.slug}](${e.href}) |`);
}
out.push('');
}
return out.join('\n');
}
function renderLlms(groups) {
const out = [];
out.push('# Harmonia');
out.push('');
out.push(`> A UI component library for Alpine.js (@codbex/harmonia). Components are \`x-h-*\` directives added to plain HTML. Full usage per component is in the linked reference files; start from SKILL.md. Docs site: ${DOCS_URL}/`);
out.push('');
for (const group of groups) {
if (!group.entries.length) continue;
out.push(`## ${group.label}`);
out.push('');
for (const e of group.entries) {
out.push(`- [${e.title}](${e.href}): ${e.summary}`);
}
out.push('');
}
return (
out
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trimEnd() + '\n'
);
}
// ---------------------------------------------------------------------------
// Migration reference, parsed from CHANGELOG.md
// ---------------------------------------------------------------------------
// Splits CHANGELOG.md into per-version blocks (newest first, matching the
// file), then keeps only the `- **Breaking: ...**` bullets in each block -
// every other bullet is a feature or a fix, irrelevant to migrating between
// versions. A bullet immediately followed by a fenced code block (a before/
// after snippet) keeps that block, since it is part of the same explanation.
// Returns [{ version, warning, bullets: [string] }], versions with zero
// bullets omitted from the reference but still checked for the `breaking`
// keyword so a mislabeled future entry produces a build warning instead of
// silently vanishing from the generated reference.
function extractBreakingChanges(text) {
const lines = text.replace(/\r\n/g, '\n').split('\n');
const versionStarts = [];
lines.forEach((line, i) => {
const m = line.match(/^## (v\S+)/);
if (m) versionStarts.push({ version: m[1], start: i });
});
const versions = [];
for (let v = 0; v < versionStarts.length; v++) {
const { version, start } = versionStarts[v];
const end = v + 1 < versionStarts.length ? versionStarts[v + 1].start : lines.length;
const body = lines.slice(start, end);
const bullets = [];
for (let i = 0; i < body.length; i++) {
if (!/^-\s+\*\*Breaking:/.test(body[i])) continue;
let bullet = body[i];
let next = i + 1;
while (next < body.length && body[next].trim() === '') next++;
if (next < body.length && /^\s*```/.test(body[next])) {
const fenceLines = [body[next]];
let j = next + 1;
while (j < body.length && !/^\s*```/.test(body[j])) fenceLines.push(body[j++]);
if (j < body.length) fenceLines.push(body[j]);
bullet += '\n\n' + fenceLines.join('\n');
}
bullets.push(bullet);
}
const mentionsBreaking = /\bbreaking\b/i.test(body.join('\n')) && !/\bno breaking\b/i.test(body.join('\n'));
const warning = bullets.length === 0 && mentionsBreaking ? `CHANGELOG.md ${version} mentions "breaking" but has no "- **Breaking:**" bullet - migration.md will miss it` : null;
if (bullets.length) versions.push({ version, bullets });
else if (warning) versions.push({ version, bullets: [], warning });
}
return versions;
}
function renderMigration(versions) {
const out = [];
out.push('# Migration');
out.push('');
out.push(`Breaking changes only, grouped by version (newest first). For the full history including features and fixes, see [CHANGELOG.md](${CHANGELOG_URL}).`);
out.push('');
for (const v of versions) {
if (!v.bullets.length) continue;
out.push(`## ${v.version}`);
out.push('');
for (const bullet of v.bullets) {
out.push(bullet);
out.push('');
}
}
return (
out
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trimEnd() + '\n'
);
}
// ---------------------------------------------------------------------------
// Utility-class allowlist, parsed from src/styles/harmonia.css
// ---------------------------------------------------------------------------
// Split a brace group's inner text on top-level commas, ignoring commas inside
// an arbitrary-value bracket (e.g. `[opacity,scale]` stays one option).
function splitOptions(inner) {
const out = [];
let cur = '';
let depth = 0;
for (const ch of inner) {
if (ch === '[') depth++;
else if (ch === ']') depth--;
if (ch === ',' && depth === 0) {
out.push(cur);
cur = '';
} else {
cur += ch;
}
}
out.push(cur);
return out;
}
// Expand a brace group's options, turning numeric ranges like `0..12` into each
// number and leaving everything else literal.
function expandRange(options) {
const out = [];
for (const o of options) {
const range = /^(\d+)\.\.(\d+)$/.exec(o);
if (range) {
for (let n = Number(range[1]); n <= Number(range[2]); n++) out.push(String(n));
} else {
out.push(o);
}
}
return out;
}
// Expand a Tailwind `@source inline(...)` pattern into `{ prefixSet, baseClasses,
// important }`. A pure variant-prefix group (every non-empty option ends with `:`,
// e.g. `{sm:,md:,lg:,xl:}` or `{max-sm:,...}`) is pulled OUT as `prefixSet` rather
// than folded into the class strings, and the `{!,}` important group collapses to
// the base class while flagging `important`. The remaining groups form the
// `baseClasses` those modifiers apply to, so callers can report exactly which
// classes are responsive-enabled or `!`-enabled instead of losing that association.
function expandPattern(pattern) {
const groups = [];
let prefixSet = null;
let important = false;
let i = 0;
while (i < pattern.length) {
if (pattern[i] === '{') {
let j = i + 1;
let inner = '';
while (j < pattern.length && pattern[j] !== '}') {
inner += pattern[j];
j++;
}
const options = splitOptions(inner);
const nonEmpty = options.filter((o) => o !== '');
if (nonEmpty.length && nonEmpty.every((o) => o.endsWith(':'))) {
prefixSet = nonEmpty;
groups.push(['']);
} else if (options.length === 2 && options.includes('!') && options.includes('')) {
important = true;
groups.push(['']);
} else {
groups.push(expandRange(options));
}
i = j + 1;
} else {
let lit = '';
while (i < pattern.length && pattern[i] !== '{') {
lit += pattern[i];
i++;
}
groups.push([lit]);
}
}
let baseClasses = [''];
for (const opts of groups) {
const next = [];
for (const prefix of baseClasses) for (const o of opts) next.push(prefix + o);
baseClasses = next;
}
return { prefixSet, baseClasses, important };
}
// Parse harmonia.css into the available class names: `tailwind` (from
// `@source inline`), `custom` (Harmonia-only, from `@utility` and `.class`
// rules), and `prefixGroups` (each variant-prefix family mapped to the exact set
// of classes it may be applied to, so the reference can say which classes are
// responsive-enabled rather than leaving it vague).
function parseUtilityCss(cssText) {
const tailwind = new Set();
const custom = new Set();
const prefixGroups = new Map(); // key: prefixes joined -> { prefixes: string[], classes: Set }
const important = new Set(); // classes that accept a trailing `!`
let m;
const sourceRe = /@source\s+inline\(\s*"([^"]*)"\s*\)/g;
while ((m = sourceRe.exec(cssText))) {
const { prefixSet, baseClasses, important: isImportant } = expandPattern(m[1]);
for (const cls of baseClasses) if (cls) tailwind.add(cls);
if (prefixSet) {
const key = prefixSet.join(' ');
if (!prefixGroups.has(key)) prefixGroups.set(key, { prefixes: prefixSet, classes: new Set() });
const group = prefixGroups.get(key);
for (const cls of baseClasses) if (cls) group.classes.add(cls);
}
if (isImportant) for (const cls of baseClasses) if (cls) important.add(cls);
}
// A functional utility (`@utility fade-x-*`) is not a class name itself. The
// classes that exist are its safelisted expansions, which belong with the
// Harmonia-specific names rather than the Tailwind subset.
const utilRe = /@utility\s+([A-Za-z0-9_-]+\*?)/g;
const functionalPrefixes = [];
while ((m = utilRe.exec(cssText))) {
if (m[1].endsWith('*')) functionalPrefixes.push(m[1].slice(0, -1));
else custom.add(m[1]);
}
for (const prefix of functionalPrefixes) {
for (const cls of [...tailwind]) {
if (cls.startsWith(prefix)) {
tailwind.delete(cls);
custom.add(cls);
}
}
}
for (const line of cssText.split('\n')) {
const trimmed = line.trim();
const mm = /^\.([A-Za-z0-9_-]+)/.exec(trimmed);
if (mm) custom.add(mm[1]);
// A `.class\!` rule (e.g. `.leading-tight\!`) declares an important variant.
const im = /^\.([A-Za-z0-9_-]+)\\!/.exec(trimmed);
if (im) important.add(im[1]);
}
for (const c of custom) tailwind.delete(c);
return { tailwind, custom, prefixGroups, important };
}
function renderUtilityClasses(cssText) {
const { tailwind, custom, prefixGroups, important } = parseUtilityCss(cssText);
const out = [];
out.push('# Utility classes');
out.push('');
out.push(
'Harmonia ships a curated subset of Tailwind utility classes. **Only the classes listed on this page are compiled into `harmonia.css` and exist at runtime.** A Tailwind class that is not listed here - for example `h-80`, `gap-20`, or `bg-red-450` - is NOT available and does nothing. When you need a value that has no matching class, use an inline `style` instead of guessing a class name.'
);
out.push('');
out.push('This list is generated from `src/styles/harmonia.css` (the Tailwind safelist), so it is exhaustive and authoritative.');
out.push('');
out.push('## Modifiers');
out.push('');
out.push('- **Negative:** the negated forms that ship are listed explicitly in the sections below with the `-` already shown (for example `-translate-x-4`). No other class accepts a leading `-`.');
out.push('');
out.push('### Important (`!`)');
out.push('');
out.push('Append `!` to raise the specificity so the class wins (for example `w-full!`, `p-4!`). The `!` suffix is available on exactly these classes and no others:');
out.push('');
out.push('```');
out.push([...important].sort().join(' '));
out.push('```');
out.push('');
out.push('### Responsive prefixes');
out.push('');
out.push(
'A breakpoint prefix works ONLY on the classes listed under it - prefixing any other class does nothing. Write it before the class (for example `md:grid-cols-3`, `lg:w-1/2`, `max-md:rounded-none`). Responsive variants are available for exactly these classes and no others.'
);
out.push('');
// Min-width families (sm:/md:/...) before max-width families (max-sm:/...).
const groups = [...prefixGroups.values()].sort((a, b) => {
const am = a.prefixes[0].startsWith('max-') ? 1 : 0;
const bm = b.prefixes[0].startsWith('max-') ? 1 : 0;
return am - bm || a.prefixes[0].localeCompare(b.prefixes[0]);
});
for (const group of groups) {
const label = group.prefixes.map((p) => `\`${p}\``).join(' ');
const kind = group.prefixes[0].startsWith('max-') ? 'apply up to that breakpoint (max-width)' : 'apply at that breakpoint and up (min-width)';
out.push(`${label} ${kind}:`);
out.push('');
out.push('```');
out.push([...group.classes].sort().join(' '));
out.push('```');
out.push('');
}
out.push('## Harmonia-specific utilities');
out.push('');
out.push('Unique to Harmonia (not Tailwind):');
out.push('');
out.push('```');
out.push([...custom].sort().join(' '));
out.push('```');
out.push('');
out.push('## Tailwind utility subset');
out.push('');
out.push('```');
out.push([...tailwind].sort().join(' '));
out.push('```');
out.push('');
return (
out
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trimEnd() + '\n'
);
}
// ---------------------------------------------------------------------------
// Pure transform: inputs -> { files, warnings }
// inputs: [{ category, label, requireDirectives, slug, text }]
// ---------------------------------------------------------------------------
function transform(inputs) {
const files = {};
const warnings = [];
const seen = new Map();
const groupsByLabel = new Map();
const componentInputs = inputs.filter((i) => i.category !== 'utility-css' && i.category !== 'changelog');
const ordered = [...componentInputs].sort((a, b) => (a.category === b.category ? a.slug.localeCompare(b.slug) : 0));
for (const input of ordered) {
const doc = parseDoc(input.text);
if (!doc.title) {
throw new Error(`generate-agent-docs: ${input.category}/${input.slug}.md has no H1 title`);
}
if (seen.has(input.slug)) {
throw new Error(`generate-agent-docs: duplicate slug "${input.slug}" (${input.category} vs ${seen.get(input.slug)})`);
}
seen.set(input.slug, input.category);
if (input.requireDirectives && doc.directives.length === 0) {
warnings.push(`${input.category}/${input.slug}.md is missing a "Component attribute(s)" block (expected directives)`);
}
files[`references/${input.slug}.md`] = renderReference(doc, `${DOCS_URL}/${input.category}/${input.slug}.html`);
if (!groupsByLabel.has(input.label)) groupsByLabel.set(input.label, { label: input.label, entries: [] });
groupsByLabel.get(input.label).entries.push({
slug: input.slug,
title: doc.title,
summary: firstSentence(doc.description) || doc.title,
href: `references/${input.slug}.md`,
});
}
// Preserve SOURCES order for the groups.
const groups = [];
for (const src of SOURCES) {
const g = groupsByLabel.get(src.label);
if (g) groups.push(g);
}
// Utility classes: generated from the harmonia.css safelist (see readInputs).
const utilityCss = inputs.find((i) => i.category === 'utility-css');
if (utilityCss) {
files['references/utility-classes.md'] = renderUtilityClasses(utilityCss.text);
groups.push({
label: UTILITY_LABEL,
entries: [
{
slug: 'utility-classes',
title: 'Utility classes',
summary: 'The complete allowlist of Tailwind utility classes available in harmonia.css (not the full Tailwind set).',
href: 'references/utility-classes.md',
},
],
});
}
// Migration reference: breaking changes only, parsed from CHANGELOG.md.
const changelog = inputs.find((i) => i.category === 'changelog');
if (changelog) {
const versions = extractBreakingChanges(changelog.text);
files['references/migration.md'] = renderMigration(versions);
for (const v of versions) {
if (v.warning) warnings.push(v.warning);
}
groups.push({
label: MIGRATION_LABEL,
entries: [
{
slug: 'migration',
title: 'Migration',
summary: 'Breaking changes only, grouped by version - read before upgrading to a newer Harmonia version.',
href: 'references/migration.md',
},
],
});
}
files['SKILL.md'] = renderSkill(renderIndex(groups));
files['llms.txt'] = renderLlms(groups);
return { files, warnings };
}
// ---------------------------------------------------------------------------
// I/O wrapper
// ---------------------------------------------------------------------------
// Map a code-snippet file extension to its markdown fence language.
const FENCE_LANG = { html: 'html', js: 'js', mjs: 'js', cjs: 'js', ts: 'ts', css: 'css', json: 'json' };
// VitePress imports example code with a `<<< @/path` snippet line so the live
// demo and the shown code share one source file. The generator does not run
// through VitePress, so expand those lines into an equivalent fenced block
// (reading the referenced file) before parsing, keeping transform() pure.
function expandSnippets(text, mdPath) {
const lines = text.replace(/\r\n/g, '\n').split('\n');
let inFence = false;
const out = [];
for (const line of lines) {
if (/^\s*```/.test(line)) inFence = !inFence;
const m = !inFence && line.match(/^\s*<<<\s+(\S+)/);
if (!m) {
out.push(line);
continue;
}
// Strip an optional region (#name) and line-highlight/lang ({...}) suffix.
const token = m[1].replace(/[#{].*$/, '');
const rel = token.startsWith('@/') ? token.slice(2) : token.replace(/^\//, '');
const file = token.startsWith('@/') || token.startsWith('/') ? path.join(DOCS_DIR, rel) : path.join(path.dirname(mdPath), token);
if (!fs.existsSync(file)) {
throw new Error(`generate-agent-docs: snippet not found: ${token} (referenced by ${path.relative(ROOT, mdPath)})`);
}
const lang = FENCE_LANG[path.extname(file).slice(1)] || '';
out.push('```' + lang);
out.push(fs.readFileSync(file, 'utf8').replace(/\n+$/, ''));
out.push('```');
}
return out.join('\n');
}
function readInputs() {