@integr8-media/config 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +280 -17
- package/biome/biome.json +52 -1
- package/dist/cli-DIsoGTED.mjs +2644 -0
- package/dist/registrySite.d.mts +959 -0
- package/dist/registrySite.mjs +3 -0
- package/dist/registrySiteBin.d.mts +1 -0
- package/dist/registrySiteBin.mjs +5 -0
- package/dist/tsdown.d.mts +16 -3
- package/dist/tsdown.mjs +17 -4
- package/dist/vitest.d.mts +10 -3
- package/dist/vitest.mjs +18 -3
- package/lefthook/base.yml +0 -3
- package/package.json +9 -2
|
@@ -0,0 +1,2644 @@
|
|
|
1
|
+
import { t as ConfigError } from "./configError-Cap5gwBB.mjs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { createReadStream, existsSync } from "node:fs";
|
|
4
|
+
import { cp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import ts from "typescript";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { createServer } from "node:http";
|
|
10
|
+
import { brotliCompressSync, constants } from "node:zlib";
|
|
11
|
+
// src/registrySite/siteCss.ts
|
|
12
|
+
/** Styles shared by every page: the integr8-media.com look. */
|
|
13
|
+
const SITE_CSS = `
|
|
14
|
+
:root {
|
|
15
|
+
--bg: #07060b;
|
|
16
|
+
--soft: #100e18;
|
|
17
|
+
--raise: #15121f;
|
|
18
|
+
--fg: #f4f2ff;
|
|
19
|
+
--muted: #9a96b0;
|
|
20
|
+
--line: rgba(255, 255, 255, 0.08);
|
|
21
|
+
--line-strong: rgba(255, 255, 255, 0.16);
|
|
22
|
+
--violet: #7c5cff;
|
|
23
|
+
--cyan: #22d3ee;
|
|
24
|
+
--lime: #c6ff3d;
|
|
25
|
+
--pink: #ff4fd8;
|
|
26
|
+
--font-sans: "Inter Variable", "Inter", ui-sans-serif, system-ui, sans-serif;
|
|
27
|
+
--font-display: "Unbounded", "Inter Variable", "Inter", ui-sans-serif, system-ui, sans-serif;
|
|
28
|
+
--font-mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
29
|
+
color-scheme: dark;
|
|
30
|
+
}
|
|
31
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
32
|
+
[hidden] { display: none !important; }
|
|
33
|
+
html { scroll-behavior: smooth; scroll-padding-top: 5rem; }
|
|
34
|
+
body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.6 var(--font-sans); -webkit-font-smoothing: antialiased; overflow-x: hidden; }
|
|
35
|
+
::selection { background: var(--lime); color: #07060b; }
|
|
36
|
+
a { color: inherit; }
|
|
37
|
+
:focus-visible { outline: 2px solid var(--violet); outline-offset: 3px; }
|
|
38
|
+
h1, h2, h3, h4 { font-family: var(--font-display); font-weight: 600; line-height: 1.15; margin: 0; letter-spacing: -0.01em; }
|
|
39
|
+
p { margin: 0; }
|
|
40
|
+
code, pre { font-family: var(--font-mono); font-size: 0.875rem; }
|
|
41
|
+
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
42
|
+
.skip { position: absolute; left: 1rem; top: -4rem; z-index: 60; padding: 0.5rem 1rem; border-radius: 999px; background: var(--lime); color: #07060b; font-weight: 600; }
|
|
43
|
+
.skip:focus { top: 1rem; }
|
|
44
|
+
.wrap { width: 100%; max-width: 76rem; margin: 0 auto; padding: 0 1rem; }
|
|
45
|
+
@media (min-width: 48rem) { .wrap { padding: 0 2rem; } }
|
|
46
|
+
|
|
47
|
+
/* Film grain and background glow, as on integr8-media.com. */
|
|
48
|
+
.grain { position: fixed; inset: 0; z-index: 50; pointer-events: none; opacity: 0.06; mix-blend-mode: overlay;
|
|
49
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); }
|
|
50
|
+
.glow { position: absolute; inset: 0 0 auto; height: 44rem; z-index: -1; pointer-events: none;
|
|
51
|
+
background: radial-gradient(40rem 22rem at 18% 8%, rgba(124, 92, 255, 0.28), transparent 70%),
|
|
52
|
+
radial-gradient(34rem 20rem at 85% 18%, rgba(34, 211, 238, 0.16), transparent 70%),
|
|
53
|
+
radial-gradient(30rem 16rem at 55% 60%, rgba(198, 255, 61, 0.07), transparent 70%); }
|
|
54
|
+
.gradient { background: linear-gradient(90deg, var(--violet), var(--cyan), var(--lime)); -webkit-background-clip: text; background-clip: text; color: transparent; -webkit-text-fill-color: transparent; }
|
|
55
|
+
|
|
56
|
+
/* Header */
|
|
57
|
+
.nav { position: sticky; top: 0; z-index: 40; border-bottom: 1px solid var(--line); background: rgba(7, 6, 11, 0.72); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); }
|
|
58
|
+
.nav .wrap { display: flex; align-items: center; gap: 1.5rem; height: 4rem; }
|
|
59
|
+
.brand { display: flex; align-items: center; gap: 0.6rem; text-decoration: none; font-family: var(--font-display); font-weight: 600; font-size: 1.05rem; }
|
|
60
|
+
.brand-mark { display: grid; place-items: center; width: 2rem; height: 2rem; border-radius: 0.6rem; background: linear-gradient(135deg, var(--violet), var(--cyan)); color: #07060b; font-size: 0.75rem; font-weight: 800; }
|
|
61
|
+
.brand .slash { color: var(--lime); margin: 0 0.15rem; }
|
|
62
|
+
.nav nav { display: flex; align-items: center; gap: 1.25rem; margin-left: auto; font-size: 0.925rem; }
|
|
63
|
+
.nav nav a { text-decoration: none; color: var(--muted); transition: color 0.2s; }
|
|
64
|
+
.nav nav a:hover { color: var(--fg); }
|
|
65
|
+
@media (max-width: 40rem) { .nav-hide { display: none; } }
|
|
66
|
+
|
|
67
|
+
/* Buttons */
|
|
68
|
+
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; min-height: 3rem; padding: 0 1.5rem; border-radius: 999px; font-weight: 600; text-decoration: none; border: 1px solid transparent; transition: transform 0.2s, background 0.2s, border-color 0.2s; }
|
|
69
|
+
.btn:hover { transform: translateY(-1px); }
|
|
70
|
+
.btn.primary { background: var(--lime); color: #07060b; }
|
|
71
|
+
.btn.ghost { border-color: var(--line-strong); color: var(--fg); background: rgba(255, 255, 255, 0.03); }
|
|
72
|
+
.btn.ghost:hover { border-color: var(--violet); }
|
|
73
|
+
.pill-sm { display: inline-flex; align-items: center; min-height: 2rem; padding: 0 0.85rem; border-radius: 999px; border: 1px solid transparent; background: var(--lime); color: #07060b; font: 600 0.8rem var(--font-sans); text-decoration: none; cursor: pointer; }
|
|
74
|
+
.pill-sm.ghost { background: transparent; color: var(--fg); border-color: var(--line-strong); }
|
|
75
|
+
.pill-sm.ghost:hover { border-color: var(--violet); }
|
|
76
|
+
.chip { display: inline-flex; align-items: center; gap: 0.4rem; min-height: 2.25rem; padding: 0 0.9rem; border-radius: 999px; border: 1px solid var(--line-strong); background: transparent; color: var(--muted); font: 500 0.85rem var(--font-sans); cursor: pointer; transition: all 0.2s; }
|
|
77
|
+
.chip span { color: inherit; opacity: 0.6; font-size: 0.75rem; }
|
|
78
|
+
.chip:hover { color: var(--fg); border-color: var(--violet); }
|
|
79
|
+
.chip[aria-pressed="true"] { background: var(--violet); border-color: var(--violet); color: #fff; }
|
|
80
|
+
.chips { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
|
81
|
+
|
|
82
|
+
/* Hero */
|
|
83
|
+
.hero { position: relative; padding: 5rem 0 4rem; }
|
|
84
|
+
@media (min-width: 48rem) { .hero { padding: 7.5rem 0 5.5rem; } }
|
|
85
|
+
.eyebrow { display: inline-flex; align-items: center; gap: 0.5rem; color: var(--muted); font: 500 0.8rem var(--font-mono); text-transform: uppercase; letter-spacing: 0.12em; }
|
|
86
|
+
.eyebrow::before { content: ""; width: 0.5rem; height: 0.5rem; border-radius: 999px; background: var(--lime); box-shadow: 0 0 12px var(--lime); }
|
|
87
|
+
.hero h1 { margin-top: 1.25rem; font-size: clamp(2.4rem, 7vw, 5rem); font-weight: 800; max-width: 15ch; }
|
|
88
|
+
.lead { color: var(--muted); font-size: 1.1rem; max-width: 42rem; }
|
|
89
|
+
.hero .lead { margin-top: 1.5rem; font-size: clamp(1.05rem, 2vw, 1.25rem); }
|
|
90
|
+
.cta { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 2.25rem; }
|
|
91
|
+
.hero .cmd { margin-top: 2rem; max-width: 46rem; }
|
|
92
|
+
.stats { display: flex; flex-wrap: wrap; gap: 2.5rem; margin: 3.5rem 0 0; padding: 0; list-style: none; }
|
|
93
|
+
.stats strong { display: block; font: 700 2rem/1 var(--font-display); }
|
|
94
|
+
.stats span { color: var(--muted); font-size: 0.875rem; }
|
|
95
|
+
|
|
96
|
+
/* Command lines and code */
|
|
97
|
+
.cmd { display: flex; align-items: center; gap: 0.5rem; min-width: 0; padding: 0.4rem 0.4rem 0.4rem 1rem; border: 1px solid var(--line-strong); border-radius: 999px; background: rgba(255, 255, 255, 0.03); }
|
|
98
|
+
.cmd code { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg); }
|
|
99
|
+
.cmd code::before { content: "$ "; color: var(--lime); }
|
|
100
|
+
.copy { flex: none; min-height: 2.25rem; padding: 0 1rem; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--raise); color: var(--fg); font: 600 0.8rem var(--font-sans); cursor: pointer; transition: all 0.2s; }
|
|
101
|
+
.copy:hover { border-color: var(--violet); }
|
|
102
|
+
.copy[data-copied] { background: var(--lime); border-color: var(--lime); color: #07060b; }
|
|
103
|
+
.code { position: relative; margin-top: 1rem; border: 1px solid var(--line); border-radius: 1rem; background: rgba(0, 0, 0, 0.35); min-width: 0; }
|
|
104
|
+
.code pre { margin: 0; padding: 1rem 1.25rem; overflow-x: auto; color: #d9d5f5; }
|
|
105
|
+
.code-caption { padding: 0.6rem 6rem 0.6rem 1.25rem; border-bottom: 1px solid var(--line); color: var(--muted); font: 500 0.8rem var(--font-mono); overflow-wrap: anywhere; }
|
|
106
|
+
.code .copy { position: absolute; top: 0.45rem; right: 0.5rem; min-height: 2rem; }
|
|
107
|
+
|
|
108
|
+
/* Sections */
|
|
109
|
+
.section { padding: 4.5rem 0; border-top: 1px solid var(--line); }
|
|
110
|
+
.section-head { max-width: 46rem; margin-bottom: 2.5rem; }
|
|
111
|
+
.section-head h2 { margin-top: 1rem; font-size: clamp(1.8rem, 4.5vw, 3rem); }
|
|
112
|
+
.section-head .lead { margin-top: 1rem; }
|
|
113
|
+
|
|
114
|
+
/* Steps */
|
|
115
|
+
.steps { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr)); margin: 0; padding: 0; list-style: none; counter-reset: step; }
|
|
116
|
+
.step { position: relative; display: flex; flex-direction: column; gap: 0.75rem; padding: 1.5rem; border: 1px solid var(--line); border-radius: 2rem; background: var(--soft); min-width: 0; }
|
|
117
|
+
.step::before { counter-increment: step; content: "0" counter(step); font: 700 0.85rem var(--font-mono); color: var(--lime); }
|
|
118
|
+
.step h3 { font-size: 1.15rem; }
|
|
119
|
+
.step p { color: var(--muted); font-size: 0.95rem; }
|
|
120
|
+
.step .cmd { margin-top: auto; }
|
|
121
|
+
.extras { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(min(100%, 26rem), 1fr)); margin-top: 1rem; }
|
|
122
|
+
.extra { padding: 1.5rem; border: 1px solid var(--line); border-radius: 2rem; background: var(--soft); min-width: 0; }
|
|
123
|
+
.extra h3 { font-size: 1.05rem; }
|
|
124
|
+
.extra p { margin-top: 0.5rem; color: var(--muted); font-size: 0.95rem; }
|
|
125
|
+
|
|
126
|
+
/* Live preview */
|
|
127
|
+
.viewer { border: 1px solid var(--line-strong); border-radius: 2rem; overflow: hidden; background: var(--soft); }
|
|
128
|
+
.viewer-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; padding: 0.9rem; border-bottom: 1px solid var(--line); }
|
|
129
|
+
.viewer-bar .field { flex: 1 1 16rem; min-width: 0; }
|
|
130
|
+
.viewer-bar .open-full { margin-left: auto; }
|
|
131
|
+
select, input[type="search"] { width: 100%; min-height: 2.5rem; padding: 0 1rem; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--bg); color: var(--fg); font: 500 0.9rem var(--font-sans); }
|
|
132
|
+
select:focus-visible, input[type="search"]:focus-visible { outline: 2px solid var(--violet); outline-offset: 1px; }
|
|
133
|
+
.viewer iframe { display: block; width: 100%; height: min(72vh, 42rem); border: 0; background: var(--bg); }
|
|
134
|
+
/* Full screen: the viewer covers the window; its bar keeps the close button. */
|
|
135
|
+
.viewer.is-expanded { position: fixed; inset: 0; z-index: 70; display: flex; flex-direction: column; border: 0; border-radius: 0; }
|
|
136
|
+
.viewer.is-expanded iframe { flex: 1; height: auto; }
|
|
137
|
+
body.viewer-open { overflow: hidden; }
|
|
138
|
+
.note { padding: 1rem 1.25rem; color: var(--muted); }
|
|
139
|
+
|
|
140
|
+
/* Component list */
|
|
141
|
+
.toolbar { display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2rem; }
|
|
142
|
+
.search { max-width: 28rem; }
|
|
143
|
+
.group + .group { margin-top: 3rem; }
|
|
144
|
+
.group h3 { font-size: 1.25rem; margin-bottom: 1.25rem; }
|
|
145
|
+
.cards { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(min(100%, 17rem), 1fr)); margin: 0; padding: 0; list-style: none; }
|
|
146
|
+
.card { position: relative; display: flex; flex-direction: column; gap: 0.75rem; padding: 1.25rem; border: 1px solid var(--line); border-radius: 1.25rem; background: var(--soft); transition: border-color 0.2s, transform 0.2s; min-width: 0; }
|
|
147
|
+
.card:hover { border-color: rgba(124, 92, 255, 0.55); transform: translateY(-2px); }
|
|
148
|
+
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
|
|
149
|
+
.card h4 { font-size: 0.95rem; overflow-wrap: anywhere; }
|
|
150
|
+
.card h4 a { text-decoration: none; }
|
|
151
|
+
.card h4 a:hover { color: var(--lime); }
|
|
152
|
+
.card p { flex: 1; color: var(--muted); font-size: 0.875rem; }
|
|
153
|
+
.tag { flex: none; padding: 0.15rem 0.55rem; border-radius: 999px; border: 1px solid var(--line-strong); color: var(--muted); font: 500 0.7rem var(--font-mono); }
|
|
154
|
+
.card-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
|
155
|
+
.card .copy { min-height: 2rem; background: var(--lime); color: #07060b; border-color: var(--lime); }
|
|
156
|
+
.empty { padding: 3rem 0; text-align: center; color: var(--muted); }
|
|
157
|
+
|
|
158
|
+
/* Item pages: sidebar + article */
|
|
159
|
+
.docs { display: grid; gap: 2rem; padding-top: 2rem; padding-bottom: 4rem; }
|
|
160
|
+
@media (min-width: 64rem) {
|
|
161
|
+
.docs { grid-template-columns: 15rem minmax(0, 1fr); gap: 3rem; padding-top: 3rem; }
|
|
162
|
+
.sidebar { position: sticky; top: 5rem; align-self: start; max-height: calc(100vh - 6rem); overflow-y: auto; padding-right: 0.5rem; }
|
|
163
|
+
.side-nav[open] > summary { display: none; }
|
|
164
|
+
}
|
|
165
|
+
.side-nav { border: 1px solid var(--line); border-radius: 1.25rem; background: var(--soft); padding: 0.25rem 1rem; }
|
|
166
|
+
@media (min-width: 64rem) { .side-nav[open] { border: 0; background: none; padding: 0; } }
|
|
167
|
+
.side-nav summary { cursor: pointer; padding: 0.75rem 0; font-weight: 600; }
|
|
168
|
+
.side-nav h2 { margin: 1.25rem 0 0.4rem; color: var(--muted); font: 600 0.72rem var(--font-mono); text-transform: uppercase; letter-spacing: 0.12em; }
|
|
169
|
+
.side-nav ul { margin: 0; padding: 0; list-style: none; }
|
|
170
|
+
.side-nav a { display: block; padding: 0.3rem 0.75rem; border-radius: 0.6rem; color: var(--muted); text-decoration: none; font-size: 0.9rem; overflow-wrap: anywhere; }
|
|
171
|
+
.side-nav a:hover { color: var(--fg); background: rgba(255, 255, 255, 0.04); }
|
|
172
|
+
.side-nav a[aria-current="page"] { color: var(--fg); background: rgba(124, 92, 255, 0.18); box-shadow: inset 2px 0 0 var(--violet); }
|
|
173
|
+
.doc { min-width: 0; }
|
|
174
|
+
.crumbs { display: flex; flex-wrap: wrap; gap: 0.5rem; color: var(--muted); font-size: 0.875rem; }
|
|
175
|
+
.crumbs a { text-decoration: none; }
|
|
176
|
+
.crumbs a:hover { color: var(--fg); }
|
|
177
|
+
.doc-head { position: relative; margin-top: 1rem; padding-bottom: 2rem; }
|
|
178
|
+
.doc-head .tags { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
|
179
|
+
.doc-head h1 { margin-top: 0.75rem; font-size: clamp(2rem, 5vw, 3.25rem); overflow-wrap: anywhere; }
|
|
180
|
+
.doc-head .lead { margin-top: 1rem; }
|
|
181
|
+
.doc-head .cmd { margin-top: 1.5rem; max-width: 46rem; }
|
|
182
|
+
.toc { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1.5rem; }
|
|
183
|
+
.toc a { padding: 0.25rem 0.75rem; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: 0.8rem; text-decoration: none; }
|
|
184
|
+
.toc a:hover { color: var(--fg); border-color: var(--violet); }
|
|
185
|
+
.doc-section { padding: 2.5rem 0 0; }
|
|
186
|
+
.doc-section > h2 { font-size: 1.5rem; margin-bottom: 1rem; }
|
|
187
|
+
.doc-section > p, .prose p { color: var(--muted); }
|
|
188
|
+
.prose { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
189
|
+
.prose ul { margin: 0; padding-left: 1.25rem; color: var(--muted); }
|
|
190
|
+
.prose code, .doc-section p code, td code, .facts code { padding: 0.1em 0.35em; border-radius: 0.35rem; background: rgba(255, 255, 255, 0.06); color: var(--fg); overflow-wrap: anywhere; }
|
|
191
|
+
.prose a, .doc-section a:not(.pill-sm):not(.chip) { color: var(--fg); text-decoration-color: var(--violet); text-underline-offset: 3px; }
|
|
192
|
+
.facts { display: grid; gap: 0.75rem; margin: 1.25rem 0 0; padding: 0; list-style: none; }
|
|
193
|
+
.facts li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.4rem; color: var(--muted); font-size: 0.925rem; }
|
|
194
|
+
.facts strong { color: var(--fg); font-weight: 600; margin-right: 0.25rem; }
|
|
195
|
+
.api { margin-top: 1.5rem; padding: 1.5rem; border: 1px solid var(--line); border-radius: 1.5rem; background: var(--soft); min-width: 0; }
|
|
196
|
+
.api-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; margin-bottom: 0.75rem; }
|
|
197
|
+
.api-head h3 { font: 600 1.05rem var(--font-mono); overflow-wrap: anywhere; }
|
|
198
|
+
.api-head .deprecated { border-color: #ff5470; color: #ff5470; }
|
|
199
|
+
.api h4 { margin: 1.25rem 0 0.5rem; color: var(--muted); font: 600 0.72rem var(--font-mono); text-transform: uppercase; letter-spacing: 0.12em; }
|
|
200
|
+
.sig { margin: 0.75rem 0 0; padding: 0.75rem 1rem; border-radius: 0.75rem; background: rgba(0, 0, 0, 0.35); overflow-x: auto; color: #d9d5f5; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
201
|
+
.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: 1rem; }
|
|
202
|
+
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
|
203
|
+
th, td { padding: 0.65rem 0.85rem; text-align: left; vertical-align: top; border-bottom: 1px solid var(--line); }
|
|
204
|
+
tr:last-child td { border-bottom: 0; }
|
|
205
|
+
th { color: var(--muted); font: 600 0.72rem var(--font-mono); text-transform: uppercase; letter-spacing: 0.08em; background: rgba(255, 255, 255, 0.02); }
|
|
206
|
+
td:first-child { white-space: nowrap; }
|
|
207
|
+
td:first-child code { color: var(--lime); }
|
|
208
|
+
td .muted { color: var(--muted); }
|
|
209
|
+
details.source { margin-top: 1rem; border: 1px solid var(--line); border-radius: 1rem; background: var(--soft); }
|
|
210
|
+
details.source > summary { cursor: pointer; padding: 0.9rem 1.25rem; font-weight: 600; }
|
|
211
|
+
details.source .code { margin: 0 1rem 1rem; }
|
|
212
|
+
details.source pre { max-height: 36rem; }
|
|
213
|
+
.pager { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); margin-top: 3.5rem; }
|
|
214
|
+
.pager a { display: flex; flex-direction: column; gap: 0.25rem; padding: 1rem 1.25rem; border: 1px solid var(--line); border-radius: 1.25rem; text-decoration: none; }
|
|
215
|
+
.pager a:hover { border-color: var(--violet); }
|
|
216
|
+
.pager span { color: var(--muted); font-size: 0.8rem; }
|
|
217
|
+
.pager .next { text-align: right; }
|
|
218
|
+
|
|
219
|
+
/* Footer */
|
|
220
|
+
footer { border-top: 1px solid var(--line); padding: 2.5rem 0 3rem; color: var(--muted); font-size: 0.875rem; }
|
|
221
|
+
footer .wrap { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 1rem; }
|
|
222
|
+
|
|
223
|
+
@media (prefers-reduced-motion: reduce) {
|
|
224
|
+
html { scroll-behavior: auto; }
|
|
225
|
+
*, *::before, *::after { transition-duration: 0.01ms !important; }
|
|
226
|
+
.btn:hover, .card:hover { transform: none; }
|
|
227
|
+
}
|
|
228
|
+
`;
|
|
229
|
+
// src/registrySite/layout.ts
|
|
230
|
+
/** Short labels for registry types. */
|
|
231
|
+
const TYPE_LABELS = {
|
|
232
|
+
"registry:ui": "component",
|
|
233
|
+
"registry:hook": "hook",
|
|
234
|
+
"registry:lib": "helper",
|
|
235
|
+
"registry:item": "setup"
|
|
236
|
+
};
|
|
237
|
+
/** Escapes text for HTML content and attribute values. */
|
|
238
|
+
function escapeHtml(text) {
|
|
239
|
+
return String(text).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
240
|
+
}
|
|
241
|
+
/** The short label of a registry type: `registry:ui` → `component`. */
|
|
242
|
+
function typeLabel(type) {
|
|
243
|
+
return TYPE_LABELS[type] ?? String(type).replace("registry:", "");
|
|
244
|
+
}
|
|
245
|
+
function labelOfId(id) {
|
|
246
|
+
const spaced = id.replace(/[-_]+/g, " ").trim();
|
|
247
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Groups registry items by their first category, in the order of `categories`. Items without a
|
|
251
|
+
* known category go last, under "Other". Without `categories`, the groups are the categories
|
|
252
|
+
* found in the items, in order of first appearance (labelled from their ids).
|
|
253
|
+
*
|
|
254
|
+
* @param items - Registry items.
|
|
255
|
+
* @param categories - Ordered `[id, label]` pairs, from the site config.
|
|
256
|
+
* @returns Only the groups that have items.
|
|
257
|
+
*/
|
|
258
|
+
function groupItems(items, categories) {
|
|
259
|
+
const found = /* @__PURE__ */ new Set();
|
|
260
|
+
for (const item of items) {
|
|
261
|
+
const category = item.categories?.[0];
|
|
262
|
+
if (category) found.add(category);
|
|
263
|
+
}
|
|
264
|
+
const entries = categories ?? [...found].map((id) => [id, labelOfId(id)]);
|
|
265
|
+
const groups = new Map(entries.map(([id, label]) => [id, {
|
|
266
|
+
id,
|
|
267
|
+
label,
|
|
268
|
+
items: []
|
|
269
|
+
}]));
|
|
270
|
+
const other = {
|
|
271
|
+
id: "other",
|
|
272
|
+
label: "Other",
|
|
273
|
+
items: []
|
|
274
|
+
};
|
|
275
|
+
for (const item of items) {
|
|
276
|
+
const category = item.categories?.[0];
|
|
277
|
+
((category === void 0 ? void 0 : groups.get(category)) ?? other).items.push(item);
|
|
278
|
+
}
|
|
279
|
+
return [...groups.values(), other].filter((group) => group.items.length > 0);
|
|
280
|
+
}
|
|
281
|
+
/** A command line with a copy button. `display` defaults to the command itself. */
|
|
282
|
+
function commandBlock(command, label, display = command) {
|
|
283
|
+
return `<div class="cmd">
|
|
284
|
+
<code>${escapeHtml(display)}</code>
|
|
285
|
+
<button type="button" class="copy" data-copy="${escapeHtml(command)}" aria-label="${escapeHtml(label)}">Copy</button>
|
|
286
|
+
</div>`;
|
|
287
|
+
}
|
|
288
|
+
/** A code sample with a copy button and an optional caption (for example a file path). */
|
|
289
|
+
function codeBlock(code, label, caption) {
|
|
290
|
+
return `<div class="code">
|
|
291
|
+
${caption ? `<div class="code-caption">${escapeHtml(caption)}</div>` : ""}
|
|
292
|
+
<button type="button" class="copy" data-copy="${escapeHtml(code)}" aria-label="${escapeHtml(label)}">Copy</button>
|
|
293
|
+
<pre><code>${escapeHtml(code)}</code></pre>
|
|
294
|
+
</div>`;
|
|
295
|
+
}
|
|
296
|
+
/** `{@link X}` and `{@link X | label}` → inline code (`X`) or the label. */
|
|
297
|
+
function replaceLinks(text) {
|
|
298
|
+
return String(text).replace(/\{@link(?:code|plain)?\s+([^}|\s]+)(?:\s*\|\s*([^}]+))?\s*\}/g, (_all, target, label) => label ? label.trim() : `\`${target}\``);
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Inline Markdown: `code`, **bold** and [links](url). Inline code that names a documented
|
|
302
|
+
* export becomes a link to it, through `linkFor`.
|
|
303
|
+
*
|
|
304
|
+
* @param text - Markdown text on one line.
|
|
305
|
+
* @param options - `linkFor`, to link documented exports.
|
|
306
|
+
* @returns HTML.
|
|
307
|
+
*/
|
|
308
|
+
function inlineMarkdown(text, options = {}) {
|
|
309
|
+
return replaceLinks(text).split(/(`[^`\n]+`)/).map((part) => {
|
|
310
|
+
if (part.startsWith("`") && part.endsWith("`") && part.length > 1) {
|
|
311
|
+
const code = part.slice(1, -1);
|
|
312
|
+
const href = options.linkFor?.(code.replace(/\(\)$/, ""));
|
|
313
|
+
const html = `<code>${escapeHtml(code)}</code>`;
|
|
314
|
+
return href ? `<a href="${escapeHtml(href)}">${html}</a>` : html;
|
|
315
|
+
}
|
|
316
|
+
return escapeHtml(part).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\[([^\]]+)\]\(((?:https?:\/\/|\.|\/|#)[^)\s]*)\)/g, (_all, label, href) => `<a href="${href}">${label}</a>`);
|
|
317
|
+
}).join("");
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Block Markdown for TSDoc text: paragraphs, `-` lists and fenced code blocks.
|
|
321
|
+
*
|
|
322
|
+
* @param markdown - Markdown text.
|
|
323
|
+
* @param options - `linkFor`, to link documented exports.
|
|
324
|
+
* @returns HTML.
|
|
325
|
+
*/
|
|
326
|
+
function renderMarkdown(markdown, options = {}) {
|
|
327
|
+
const lines = String(markdown ?? "").replace(/\r/g, "").split("\n");
|
|
328
|
+
const out = [];
|
|
329
|
+
let paragraph = [];
|
|
330
|
+
let list = [];
|
|
331
|
+
const flushParagraph = () => {
|
|
332
|
+
if (paragraph.length > 0) {
|
|
333
|
+
out.push(`<p>${inlineMarkdown(paragraph.join(" "), options)}</p>`);
|
|
334
|
+
paragraph = [];
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
const flushList = () => {
|
|
338
|
+
if (list.length > 0) {
|
|
339
|
+
out.push(`<ul>${list.map((item) => `<li>${inlineMarkdown(item, options)}</li>`).join("")}</ul>`);
|
|
340
|
+
list = [];
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
for (let index = 0; index < lines.length; index++) {
|
|
344
|
+
const line = lines[index] ?? "";
|
|
345
|
+
if (/^\s*```\s*([\w-]*)/.test(line)) {
|
|
346
|
+
flushParagraph();
|
|
347
|
+
flushList();
|
|
348
|
+
const code = [];
|
|
349
|
+
index++;
|
|
350
|
+
while (index < lines.length && !/^\s*```/.test(lines[index] ?? "")) {
|
|
351
|
+
code.push(lines[index] ?? "");
|
|
352
|
+
index++;
|
|
353
|
+
}
|
|
354
|
+
out.push(codeBlock(code.join("\n"), "Copy the example"));
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const bullet = /^\s*[-*]\s+(.*)$/.exec(line);
|
|
358
|
+
if (bullet) {
|
|
359
|
+
flushParagraph();
|
|
360
|
+
list.push(bullet[1] ?? "");
|
|
361
|
+
} else if (line.trim() === "") {
|
|
362
|
+
flushParagraph();
|
|
363
|
+
flushList();
|
|
364
|
+
} else if (list.length > 0 && /^\s+/.test(line)) {
|
|
365
|
+
const last = list.length - 1;
|
|
366
|
+
list[last] = `${list[last] ?? ""} ${line.trim()}`;
|
|
367
|
+
} else {
|
|
368
|
+
flushList();
|
|
369
|
+
paragraph.push(line.trim());
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
flushParagraph();
|
|
373
|
+
flushList();
|
|
374
|
+
return out.join("\n");
|
|
375
|
+
}
|
|
376
|
+
/** Copy buttons: any element with `data-copy` copies that text and says so. */
|
|
377
|
+
const COPY_SCRIPT = `
|
|
378
|
+
(function () {
|
|
379
|
+
var status = document.getElementById('copy-status');
|
|
380
|
+
document.addEventListener('click', function (event) {
|
|
381
|
+
var button = event.target.closest('[data-copy]');
|
|
382
|
+
if (!button || button.hasAttribute('data-copied')) return;
|
|
383
|
+
var text = button.getAttribute('data-copy');
|
|
384
|
+
var done = function () {
|
|
385
|
+
var label = button.textContent;
|
|
386
|
+
button.setAttribute('data-copied', '');
|
|
387
|
+
button.textContent = 'Copied';
|
|
388
|
+
status.textContent = 'Copied to the clipboard.';
|
|
389
|
+
setTimeout(function () {
|
|
390
|
+
button.removeAttribute('data-copied');
|
|
391
|
+
button.textContent = label;
|
|
392
|
+
}, 1600);
|
|
393
|
+
};
|
|
394
|
+
if (navigator.clipboard) navigator.clipboard.writeText(text).then(done, function () {});
|
|
395
|
+
});
|
|
396
|
+
})();
|
|
397
|
+
`;
|
|
398
|
+
/**
|
|
399
|
+
* Full-screen preview: a `[data-expand]` button makes its `.viewer` cover the window and
|
|
400
|
+
* turns into "Close full screen". Escape closes it too.
|
|
401
|
+
*/
|
|
402
|
+
const VIEWER_SCRIPT = `
|
|
403
|
+
(function () {
|
|
404
|
+
function setExpanded(viewer, expanded) {
|
|
405
|
+
viewer.classList.toggle('is-expanded', expanded);
|
|
406
|
+
document.body.classList.toggle('viewer-open', expanded);
|
|
407
|
+
var button = viewer.querySelector('[data-expand]');
|
|
408
|
+
if (!button) return;
|
|
409
|
+
button.setAttribute('aria-pressed', String(expanded));
|
|
410
|
+
button.textContent = expanded ? 'Close full screen' : 'Full screen';
|
|
411
|
+
button.focus();
|
|
412
|
+
}
|
|
413
|
+
document.addEventListener('click', function (event) {
|
|
414
|
+
var button = event.target.closest('[data-expand]');
|
|
415
|
+
if (!button) return;
|
|
416
|
+
var viewer = button.closest('.viewer');
|
|
417
|
+
if (viewer) setExpanded(viewer, !viewer.classList.contains('is-expanded'));
|
|
418
|
+
});
|
|
419
|
+
document.addEventListener('keydown', function (event) {
|
|
420
|
+
if (event.key !== 'Escape') return;
|
|
421
|
+
var open = document.querySelector('.viewer.is-expanded');
|
|
422
|
+
if (open) setExpanded(open, false);
|
|
423
|
+
});
|
|
424
|
+
})();
|
|
425
|
+
`;
|
|
426
|
+
/**
|
|
427
|
+
* The URL of a story in the embedded playground.
|
|
428
|
+
*
|
|
429
|
+
* @param playground - Playground folder (relative to the page, ends with `/`).
|
|
430
|
+
* @param story - The Ladle story id.
|
|
431
|
+
* @param theme - The preview theme id; leave out when the site has no theme switcher.
|
|
432
|
+
* @returns The iframe `src` (not HTML-escaped).
|
|
433
|
+
*/
|
|
434
|
+
function storyUrl(playground, story, theme) {
|
|
435
|
+
const themeParam = theme ? `&integr8Theme=${encodeURIComponent(theme)}` : "";
|
|
436
|
+
return `${playground}?story=${encodeURIComponent(story)}${themeParam}&mode=preview&integr8Embed=1`;
|
|
437
|
+
}
|
|
438
|
+
/** The theme buttons of a live preview, the first one pressed. Empty without themes. */
|
|
439
|
+
function themeButtons(themes, indent) {
|
|
440
|
+
if (themes.length === 0) return "";
|
|
441
|
+
return `<div class="chips" role="group" aria-label="Preview theme">
|
|
442
|
+
${indent}${themes.map(([name, label], position) => `<button type="button" class="chip" data-theme="${escapeHtml(name)}" aria-pressed="${position === 0}">${escapeHtml(label)}</button>`).join(`\n${indent}`)}
|
|
443
|
+
${indent.slice(2)}</div>`;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* The page shell shared by every page.
|
|
447
|
+
*
|
|
448
|
+
* @param options - Title, brand, links, body and script of the page.
|
|
449
|
+
* @returns The full HTML document.
|
|
450
|
+
*/
|
|
451
|
+
function renderShell(options) {
|
|
452
|
+
const root = options.root ?? "";
|
|
453
|
+
const home = root || "./";
|
|
454
|
+
const { brand } = options;
|
|
455
|
+
const fonts = options.fontsHref ? `<link rel="stylesheet" href="${escapeHtml(root + options.fontsHref)}">` : "";
|
|
456
|
+
const repository = options.repositoryUrl ? `<a class="nav-hide" href="${escapeHtml(options.repositoryUrl)}">GitHub</a>` : "";
|
|
457
|
+
const playground = options.playgroundHref ? `<a class="nav-hide" href="${escapeHtml(root + options.playgroundHref)}">Playground</a>` : "";
|
|
458
|
+
return `<!doctype html>
|
|
459
|
+
<html lang="en">
|
|
460
|
+
<head>
|
|
461
|
+
<meta charset="utf-8">
|
|
462
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
463
|
+
<title>${escapeHtml(options.title)}</title>
|
|
464
|
+
<meta name="description" content="${escapeHtml(options.description)}">
|
|
465
|
+
<meta name="theme-color" content="#07060b">
|
|
466
|
+
${fonts}
|
|
467
|
+
<style>${SITE_CSS}</style>
|
|
468
|
+
</head>
|
|
469
|
+
<body>
|
|
470
|
+
<a class="skip" href="#main">Skip to content</a>
|
|
471
|
+
<div class="grain" aria-hidden="true"></div>
|
|
472
|
+
|
|
473
|
+
<header class="nav">
|
|
474
|
+
<div class="wrap">
|
|
475
|
+
<a class="brand" href="${escapeHtml(home)}" aria-label="${escapeHtml(brand.name)} home"><span class="brand-mark" aria-hidden="true">${escapeHtml(brand.mark)}</span><span>integr8<span class="slash">/</span>${escapeHtml(brand.slug)}</span></a>
|
|
476
|
+
<nav aria-label="Main">
|
|
477
|
+
<a class="nav-hide" href="${escapeHtml(root)}#start">Get started</a>
|
|
478
|
+
<a class="nav-hide" href="${escapeHtml(root)}#components">Components</a>
|
|
479
|
+
${playground}
|
|
480
|
+
${repository}
|
|
481
|
+
</nav>
|
|
482
|
+
</div>
|
|
483
|
+
</header>
|
|
484
|
+
|
|
485
|
+
${options.body}
|
|
486
|
+
|
|
487
|
+
<footer>
|
|
488
|
+
<div class="wrap">
|
|
489
|
+
<span>${escapeHtml(options.footerNote)}</span>
|
|
490
|
+
<span>Built for <a href="https://integr8-media.com">integr8-media.com</a></span>
|
|
491
|
+
</div>
|
|
492
|
+
</footer>
|
|
493
|
+
|
|
494
|
+
<p id="copy-status" class="sr-only" aria-live="polite"></p>
|
|
495
|
+
|
|
496
|
+
<script>${COPY_SCRIPT}${VIEWER_SCRIPT}${options.script ?? ""}<\/script>
|
|
497
|
+
</body>
|
|
498
|
+
</html>
|
|
499
|
+
`;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Fills `{registryUrl}`, `{uiRegistryUrl}`, `{version}` and `{packageName}` in a setup text.
|
|
503
|
+
* Any other `{...}` is kept (a `components.json` sample contains `{name}`).
|
|
504
|
+
*
|
|
505
|
+
* @param text - The template.
|
|
506
|
+
* @param values - The values by placeholder name.
|
|
507
|
+
* @returns The text with the known placeholders replaced.
|
|
508
|
+
*/
|
|
509
|
+
function fillTemplate(text, values) {
|
|
510
|
+
return text.replace(/\{(\w+)\}/g, (all, key) => Object.hasOwn(values, key) ? values[key] ?? all : all);
|
|
511
|
+
}
|
|
512
|
+
// src/registrySite/registryNames.ts
|
|
513
|
+
/** `DataTableColumnHeader` → `data-table-column-header`, `floatingStyles` → `floating-styles`. */
|
|
514
|
+
function toKebabCase(name) {
|
|
515
|
+
return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").toLowerCase();
|
|
516
|
+
}
|
|
517
|
+
/** The `@/...` import path of a file inside a shadcn project. */
|
|
518
|
+
function aliasFor(file) {
|
|
519
|
+
const withoutExtension = file.replace(/\.(tsx?|mjs|js)$/, "");
|
|
520
|
+
if (withoutExtension.startsWith("ui/")) return `@/components/${withoutExtension}`;
|
|
521
|
+
return `@/${withoutExtension}`;
|
|
522
|
+
}
|
|
523
|
+
/** Item name from a registry dependency URL: `https://x/r/button.json` → `button`. */
|
|
524
|
+
function itemNameFromUrl(url) {
|
|
525
|
+
return /([^/]+)\.json$/.exec(url)?.[1] ?? url;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* The slash-terminated path a site is served from: `''` and `/` → `/`, `ui` → `/ui/`.
|
|
529
|
+
*
|
|
530
|
+
* @param path - A path such as `/ui/` or the `SITE_BASE_PATH` variable.
|
|
531
|
+
* @returns A path that starts and ends with `/`.
|
|
532
|
+
*/
|
|
533
|
+
function normalizeSitePath(path) {
|
|
534
|
+
const trimmed = (path ?? "").trim().replace(/^\/+|\/+$/g, "");
|
|
535
|
+
return trimmed === "" ? "/" : `/${trimmed}/`;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* The `--base` the Ladle playground must be built with so that its assets load from where the
|
|
539
|
+
* site serves it: `<site path>playground/`.
|
|
540
|
+
*
|
|
541
|
+
* @param sitePath - The path the site is served from (`/` locally, `/repo/` on GitHub Pages).
|
|
542
|
+
* @returns For example `/playground/` or `/integr8-ui/playground/`.
|
|
543
|
+
*/
|
|
544
|
+
function playgroundBase(sitePath) {
|
|
545
|
+
return `${normalizeSitePath(sitePath)}playground/`;
|
|
546
|
+
}
|
|
547
|
+
// src/registrySite/sourceDocs.ts
|
|
548
|
+
/**
|
|
549
|
+
* Splits a raw doc comment into its summary and tags. Tags inside fenced code blocks are left
|
|
550
|
+
* alone.
|
|
551
|
+
*
|
|
552
|
+
* @param raw - The comment text, from `/**` to the closing marker.
|
|
553
|
+
* @returns The summary and the tags.
|
|
554
|
+
*/
|
|
555
|
+
function parseDocComment(raw) {
|
|
556
|
+
const lines = raw.replace(/^\/\*\*/, "").replace(/\*\/$/, "").replace(/\r/g, "").split("\n").map((line) => line.replace(/^\s*\*? ?/, ""));
|
|
557
|
+
const summary = [];
|
|
558
|
+
const tags = [];
|
|
559
|
+
let inFence = false;
|
|
560
|
+
for (const line of lines) {
|
|
561
|
+
if (/^\s*```/.test(line)) inFence = !inFence;
|
|
562
|
+
const match = inFence ? null : /^@(\w+)\s?(.*)$/.exec(line);
|
|
563
|
+
const last = tags.at(-1);
|
|
564
|
+
if (match) tags.push({
|
|
565
|
+
tag: match[1] ?? "",
|
|
566
|
+
text: match[2] ?? ""
|
|
567
|
+
});
|
|
568
|
+
else if (last) last.text += `\n${line}`;
|
|
569
|
+
else summary.push(line);
|
|
570
|
+
}
|
|
571
|
+
for (const tag of tags) tag.text = tag.text.trim();
|
|
572
|
+
return {
|
|
573
|
+
summary: summary.join("\n").trim(),
|
|
574
|
+
tags
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
/** The `/** ... *\/` comment right above a node, parsed. Empty when there is none. */
|
|
578
|
+
function leadingDoc(node, sourceFile) {
|
|
579
|
+
const text = sourceFile.getFullText();
|
|
580
|
+
const last = (ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []).filter((range) => text.startsWith("/**", range.pos)).at(-1);
|
|
581
|
+
if (!last) return {
|
|
582
|
+
summary: "",
|
|
583
|
+
tags: []
|
|
584
|
+
};
|
|
585
|
+
return parseDocComment(text.slice(last.pos, last.end));
|
|
586
|
+
}
|
|
587
|
+
/** Source text of a node with whitespace collapsed. */
|
|
588
|
+
function textOf(node, sourceFile) {
|
|
589
|
+
return node ? node.getText(sourceFile).replace(/\s+/g, " ").trim() : "";
|
|
590
|
+
}
|
|
591
|
+
const tagsNamed = (doc, name) => doc.tags.filter((tag) => tag.tag === name).map((tag) => tag.text);
|
|
592
|
+
const hasTag = (doc, name) => doc.tags.some((tag) => tag.tag === name);
|
|
593
|
+
/** `@example` text as Markdown: wrapped in a tsx fence unless it already has one. */
|
|
594
|
+
function exampleMarkdown(text) {
|
|
595
|
+
return text.includes("```") ? text : `\`\`\`tsx\n${text}\n\`\`\``;
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* The default value of a documented member: `@default` / `@defaultValue`, else a
|
|
599
|
+
* "Default `x`" phrase in the description.
|
|
600
|
+
*
|
|
601
|
+
* @param doc - The parsed comment of the member.
|
|
602
|
+
* @returns The default as written, or `undefined`.
|
|
603
|
+
*/
|
|
604
|
+
function defaultFromDoc(doc) {
|
|
605
|
+
const tag = doc.tags.find((item) => item.tag === "default" || item.tag === "defaultValue");
|
|
606
|
+
if (tag) return tag.text.replace(/^`|`$/g, "").trim() || void 0;
|
|
607
|
+
return /\bdefault(?: is)?:?\s+(`[^`]+`|'[^']*'|"[^"]*"|-?\d[\d.]*\b)/i.exec(doc.summary)?.[1]?.replace(/^`|`$/g, "");
|
|
608
|
+
}
|
|
609
|
+
/** Props or fields of an interface or object type. */
|
|
610
|
+
function membersOf(members, sourceFile) {
|
|
611
|
+
const result = [];
|
|
612
|
+
for (const member of members) {
|
|
613
|
+
if (!ts.isPropertySignature(member) && !ts.isMethodSignature(member)) continue;
|
|
614
|
+
const doc = leadingDoc(member, sourceFile);
|
|
615
|
+
if (hasTag(doc, "internal")) continue;
|
|
616
|
+
const type = ts.isMethodSignature(member) ? `(${member.parameters.map((p) => textOf(p, sourceFile)).join(", ")}) => ${textOf(member.type, sourceFile) || "void"}` : textOf(member.type, sourceFile) || "unknown";
|
|
617
|
+
result.push({
|
|
618
|
+
name: textOf(member.name, sourceFile),
|
|
619
|
+
type,
|
|
620
|
+
optional: Boolean(member.questionToken),
|
|
621
|
+
defaultValue: defaultFromDoc(doc),
|
|
622
|
+
description: doc.summary,
|
|
623
|
+
...hasTag(doc, "deprecated") ? { deprecated: true } : {}
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
return result;
|
|
627
|
+
}
|
|
628
|
+
/** Public properties of a class (not private, protected or `#private`). */
|
|
629
|
+
function classMembersOf(node, sourceFile) {
|
|
630
|
+
const result = [];
|
|
631
|
+
for (const member of node.members) {
|
|
632
|
+
if (!ts.isPropertyDeclaration(member) || ts.isPrivateIdentifier(member.name)) continue;
|
|
633
|
+
const modifiers = ts.getModifiers(member) ?? [];
|
|
634
|
+
if (modifiers.some((m) => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword)) continue;
|
|
635
|
+
const doc = leadingDoc(member, sourceFile);
|
|
636
|
+
if (hasTag(doc, "internal")) continue;
|
|
637
|
+
const readonly = modifiers.some((m) => m.kind === ts.SyntaxKind.ReadonlyKeyword);
|
|
638
|
+
result.push({
|
|
639
|
+
name: `${readonly ? "readonly " : ""}${textOf(member.name, sourceFile)}`,
|
|
640
|
+
type: textOf(member.type, sourceFile) || "unknown",
|
|
641
|
+
optional: Boolean(member.questionToken),
|
|
642
|
+
description: doc.summary
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
return result;
|
|
646
|
+
}
|
|
647
|
+
function typeParametersOf(node, sourceFile) {
|
|
648
|
+
return node.typeParameters?.length ? `<${node.typeParameters.map((p) => textOf(p, sourceFile)).join(", ")}>` : "";
|
|
649
|
+
}
|
|
650
|
+
/** Kind of a function export from its name and return type. */
|
|
651
|
+
function functionKind(name, returns, fileName) {
|
|
652
|
+
if (/^use[A-Z]/.test(name)) return "hook";
|
|
653
|
+
if (/^[A-Z]/.test(name) && (/JSX|ReactNode|Element/.test(returns) || fileName.endsWith(".tsx"))) return "component";
|
|
654
|
+
return "function";
|
|
655
|
+
}
|
|
656
|
+
/** Docs of a function declaration, arrow function, function expression or constructor. */
|
|
657
|
+
function functionDoc(name, fn, doc, sourceFile) {
|
|
658
|
+
const paramTags = doc.tags.filter((tag) => tag.tag === "param");
|
|
659
|
+
const propDefaults = {};
|
|
660
|
+
const found = {};
|
|
661
|
+
const params = fn.parameters.map((param) => {
|
|
662
|
+
const paramName = !ts.isIdentifier(param.name) ? "props" : textOf(param.name, sourceFile);
|
|
663
|
+
if (ts.isObjectBindingPattern(param.name)) {
|
|
664
|
+
found.propsType = textOf(param.type, sourceFile) || void 0;
|
|
665
|
+
for (const element of param.name.elements) if (element.initializer && ts.isIdentifier(element.name)) propDefaults[element.name.text] = textOf(element.initializer, sourceFile);
|
|
666
|
+
}
|
|
667
|
+
const tag = paramTags.find((item) => item.text.split(/\s/)[0] === paramName);
|
|
668
|
+
return {
|
|
669
|
+
name: `${param.dotDotDotToken ? "..." : ""}${paramName}`,
|
|
670
|
+
type: textOf(param.type, sourceFile) || "unknown",
|
|
671
|
+
optional: Boolean(param.questionToken || param.initializer),
|
|
672
|
+
defaultValue: param.initializer ? textOf(param.initializer, sourceFile) : void 0,
|
|
673
|
+
description: tag ? tag.text.slice(paramName.length).replace(/^\s*-?\s*/, "") : ""
|
|
674
|
+
};
|
|
675
|
+
});
|
|
676
|
+
const returns = textOf(fn.type, sourceFile);
|
|
677
|
+
const paramList = params.map((p) => `${p.name}${p.optional && !p.defaultValue ? "?" : ""}: ${p.type}`).join(", ");
|
|
678
|
+
return {
|
|
679
|
+
kind: functionKind(name, returns, sourceFile.fileName),
|
|
680
|
+
signature: `${name}${typeParametersOf(fn, sourceFile)}(${paramList})${returns ? `: ${returns}` : ""}`,
|
|
681
|
+
params,
|
|
682
|
+
...returns ? { returns } : {},
|
|
683
|
+
...found.propsType ? { propsType: found.propsType } : {},
|
|
684
|
+
...Object.keys(propDefaults).length > 0 ? { propDefaults } : {}
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
/** Docs of a type alias: object types and intersections list their members. */
|
|
688
|
+
function typeAliasDoc(name, node, sourceFile) {
|
|
689
|
+
const typeParams = typeParametersOf(node, sourceFile);
|
|
690
|
+
const type = node.type;
|
|
691
|
+
if (ts.isTypeLiteralNode(type)) return {
|
|
692
|
+
signature: `type ${name}${typeParams} = { … }`,
|
|
693
|
+
members: membersOf(type.members, sourceFile)
|
|
694
|
+
};
|
|
695
|
+
if (ts.isIntersectionTypeNode(type)) {
|
|
696
|
+
const literals = type.types.filter(ts.isTypeLiteralNode);
|
|
697
|
+
const others = type.types.filter((part) => !ts.isTypeLiteralNode(part));
|
|
698
|
+
if (literals.length > 0) return {
|
|
699
|
+
signature: `type ${name}${typeParams} = ${[...others.map((part) => textOf(part, sourceFile)), "{ … }"].join(" & ")}`,
|
|
700
|
+
members: literals.flatMap((literal) => membersOf(literal.members, sourceFile)),
|
|
701
|
+
extends: others.map((part) => textOf(part, sourceFile))
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
return { signature: `type ${name}${typeParams} = ${textOf(type, sourceFile)}` };
|
|
705
|
+
}
|
|
706
|
+
const isExported = (node) => ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((m) => m.kind === ts.SyntaxKind.ExportKeyword) && !(ts.getModifiers(node) ?? []).some((m) => m.kind === ts.SyntaxKind.DefaultKeyword);
|
|
707
|
+
function heritageOf(node, sourceFile) {
|
|
708
|
+
return (node.heritageClauses ?? []).flatMap((clause) => clause.types.map((type) => textOf(type, sourceFile)));
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* The documentation of every exported declaration in a file, in source order. Components get
|
|
712
|
+
* the defaults from their props destructuring copied onto their props type's members.
|
|
713
|
+
*
|
|
714
|
+
* @param fileName - Used for the parser (`.tsx` enables JSX).
|
|
715
|
+
* @param content - The file's text.
|
|
716
|
+
* @returns One entry per documented export.
|
|
717
|
+
*/
|
|
718
|
+
function extractDocs(fileName, content) {
|
|
719
|
+
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true, fileName.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
720
|
+
const docs = [];
|
|
721
|
+
const add = (name, doc, details) => {
|
|
722
|
+
if (hasTag(doc, "internal")) return;
|
|
723
|
+
const deprecated = doc.tags.find((tag) => tag.tag === "deprecated");
|
|
724
|
+
const remarks = tagsNamed(doc, "remarks");
|
|
725
|
+
const returnsDescription = [...tagsNamed(doc, "returns"), ...tagsNamed(doc, "return")][0];
|
|
726
|
+
docs.push({
|
|
727
|
+
name,
|
|
728
|
+
summary: [doc.summary, ...remarks].filter(Boolean).join("\n\n"),
|
|
729
|
+
examples: tagsNamed(doc, "example").map(exampleMarkdown),
|
|
730
|
+
throws: tagsNamed(doc, "throws"),
|
|
731
|
+
...deprecated ? { deprecated: deprecated.text || "Deprecated." } : {},
|
|
732
|
+
...returnsDescription ? { returnsDescription } : {},
|
|
733
|
+
...details
|
|
734
|
+
});
|
|
735
|
+
};
|
|
736
|
+
for (const statement of sourceFile.statements) {
|
|
737
|
+
if (!isExported(statement)) continue;
|
|
738
|
+
const doc = leadingDoc(statement, sourceFile);
|
|
739
|
+
if (ts.isFunctionDeclaration(statement) && statement.name) {
|
|
740
|
+
const name = statement.name.text;
|
|
741
|
+
add(name, doc, functionDoc(name, statement, doc, sourceFile));
|
|
742
|
+
} else if (ts.isVariableStatement(statement)) for (const declaration of statement.declarationList.declarations) {
|
|
743
|
+
if (!ts.isIdentifier(declaration.name)) continue;
|
|
744
|
+
const name = declaration.name.text;
|
|
745
|
+
const init = declaration.initializer;
|
|
746
|
+
if (init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) {
|
|
747
|
+
add(name, doc, functionDoc(name, init, doc, sourceFile));
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
const type = textOf(declaration.type, sourceFile);
|
|
751
|
+
const initText = textOf(init, sourceFile);
|
|
752
|
+
add(name, doc, {
|
|
753
|
+
kind: "constant",
|
|
754
|
+
signature: `const ${name}${type ? `: ${type}` : initText && initText.length <= 80 ? ` = ${initText}` : ""}`
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
else if (ts.isInterfaceDeclaration(statement)) {
|
|
758
|
+
const name = statement.name.text;
|
|
759
|
+
const heritage = heritageOf(statement, sourceFile);
|
|
760
|
+
add(name, doc, {
|
|
761
|
+
kind: "interface",
|
|
762
|
+
signature: `interface ${name}${typeParametersOf(statement, sourceFile)}${heritage.length > 0 ? ` extends ${heritage.join(", ")}` : ""}`,
|
|
763
|
+
members: membersOf(statement.members, sourceFile),
|
|
764
|
+
...heritage.length > 0 ? { extends: heritage } : {}
|
|
765
|
+
});
|
|
766
|
+
} else if (ts.isTypeAliasDeclaration(statement)) {
|
|
767
|
+
const name = statement.name.text;
|
|
768
|
+
add(name, doc, {
|
|
769
|
+
kind: "type",
|
|
770
|
+
...typeAliasDoc(name, statement, sourceFile)
|
|
771
|
+
});
|
|
772
|
+
} else if (ts.isClassDeclaration(statement) && statement.name) {
|
|
773
|
+
const name = statement.name.text;
|
|
774
|
+
const heritage = heritageOf(statement, sourceFile);
|
|
775
|
+
const ctor = statement.members.find(ts.isConstructorDeclaration);
|
|
776
|
+
const params = ctor ? functionDoc(name, ctor, leadingDoc(ctor, sourceFile), sourceFile).params : [];
|
|
777
|
+
add(name, doc, {
|
|
778
|
+
kind: "class",
|
|
779
|
+
signature: `class ${name}${typeParametersOf(statement, sourceFile)}${heritage.length > 0 ? ` extends ${heritage.join(", ")}` : ""}`,
|
|
780
|
+
members: classMembersOf(statement, sourceFile),
|
|
781
|
+
...params.length > 0 ? { params } : {}
|
|
782
|
+
});
|
|
783
|
+
} else if (ts.isEnumDeclaration(statement)) {
|
|
784
|
+
const name = statement.name.text;
|
|
785
|
+
add(name, doc, {
|
|
786
|
+
kind: "enum",
|
|
787
|
+
signature: `enum ${name}`,
|
|
788
|
+
members: statement.members.map((member) => ({
|
|
789
|
+
name: textOf(member.name, sourceFile),
|
|
790
|
+
type: member.initializer ? textOf(member.initializer, sourceFile) : "",
|
|
791
|
+
optional: false,
|
|
792
|
+
description: leadingDoc(member, sourceFile).summary
|
|
793
|
+
}))
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
for (const doc of docs) {
|
|
798
|
+
if (!doc.propsType || !doc.propDefaults) continue;
|
|
799
|
+
const target = docs.find((other) => other.name === doc.propsType && other.members);
|
|
800
|
+
for (const member of target?.members ?? []) {
|
|
801
|
+
const fallback = doc.propDefaults[member.name];
|
|
802
|
+
if (member.defaultValue === void 0 && fallback !== void 0) member.defaultValue = fallback;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return docs;
|
|
806
|
+
}
|
|
807
|
+
// src/registrySite/itemPage.ts
|
|
808
|
+
/** Kinds that can be imported as values (the rest are types). */
|
|
809
|
+
const VALUE_KINDS = /* @__PURE__ */ new Set([
|
|
810
|
+
"component",
|
|
811
|
+
"hook",
|
|
812
|
+
"function",
|
|
813
|
+
"constant",
|
|
814
|
+
"class",
|
|
815
|
+
"enum"
|
|
816
|
+
]);
|
|
817
|
+
/** Anchor id of an export on its item page. */
|
|
818
|
+
const apiAnchor = (name) => `api-${name}`;
|
|
819
|
+
/**
|
|
820
|
+
* Every documented export of an item, the main one first (the export whose kebab-case name is
|
|
821
|
+
* the item name, for example `Button` for `button`), then in file and source order.
|
|
822
|
+
*
|
|
823
|
+
* @param item - A full registry item (with file contents).
|
|
824
|
+
* @returns The exports with their docs.
|
|
825
|
+
*/
|
|
826
|
+
function itemDocs(item) {
|
|
827
|
+
const docs = (item.files ?? []).flatMap((file) => file.content && /\.(tsx?|mts)$/.test(file.path) ? extractDocs(file.path, file.content).map((doc) => ({
|
|
828
|
+
...doc,
|
|
829
|
+
file: file.path
|
|
830
|
+
})) : []);
|
|
831
|
+
const main = docs.findIndex((doc) => toKebabCase(doc.name) === item.name);
|
|
832
|
+
if (main > 0) docs.unshift(...docs.splice(main, 1));
|
|
833
|
+
return docs;
|
|
834
|
+
}
|
|
835
|
+
/** `<code>` of a type, linked when it names a documented export. */
|
|
836
|
+
function typeCode(type, linkFor) {
|
|
837
|
+
const href = linkFor(type);
|
|
838
|
+
const html = `<code>${escapeHtml(type)}</code>`;
|
|
839
|
+
return href ? `<a href="${escapeHtml(href)}">${html}</a>` : html;
|
|
840
|
+
}
|
|
841
|
+
/** A props, parameters or members table. */
|
|
842
|
+
function membersTable(caption, rows, linkFor) {
|
|
843
|
+
const body = rows.map((row) => `<tr>
|
|
844
|
+
<td><code>${escapeHtml(row.name)}${row.optional && !row.name.startsWith("...") ? "?" : ""}</code>${row.deprecated ? " <span class=\"tag deprecated\">deprecated</span>" : ""}</td>
|
|
845
|
+
<td>${row.type ? typeCode(row.type, linkFor) : "<span class=\"muted\">—</span>"}</td>
|
|
846
|
+
<td>${row.defaultValue !== void 0 ? `<code>${escapeHtml(row.defaultValue)}</code>` : "<span class=\"muted\">—</span>"}</td>
|
|
847
|
+
<td>${row.description ? inlineMarkdown(row.description.replace(/\s*\n\s*/g, " "), { linkFor }) : ""}</td>
|
|
848
|
+
</tr>`).join("\n ");
|
|
849
|
+
return `<h4>${escapeHtml(caption)}</h4>
|
|
850
|
+
<div class="table-wrap">
|
|
851
|
+
<table>
|
|
852
|
+
<thead><tr><th scope="col">Name</th><th scope="col">Type</th><th scope="col">Default</th><th scope="col">Description</th></tr></thead>
|
|
853
|
+
<tbody>
|
|
854
|
+
${body}
|
|
855
|
+
</tbody>
|
|
856
|
+
</table>
|
|
857
|
+
</div>`;
|
|
858
|
+
}
|
|
859
|
+
/** One export in the API reference. */
|
|
860
|
+
function apiEntry(doc, linkFor) {
|
|
861
|
+
const markdown = (text) => renderMarkdown(text, { linkFor });
|
|
862
|
+
const parts = [];
|
|
863
|
+
if (doc.deprecated) parts.push(`<div class="prose"><p><strong>Deprecated.</strong></p>${markdown(doc.deprecated)}</div>`);
|
|
864
|
+
if (doc.summary) parts.push(`<div class="prose">${markdown(doc.summary)}</div>`);
|
|
865
|
+
parts.push(`<pre class="sig"><code>${escapeHtml(doc.signature)}</code></pre>`);
|
|
866
|
+
if (doc.extends?.length) parts.push(`<h4>Also accepts</h4>
|
|
867
|
+
<p>${doc.extends.map((type) => typeCode(type, linkFor)).join(", ")}</p>`);
|
|
868
|
+
if (doc.params?.length && doc.kind !== "interface" && doc.kind !== "type") parts.push(membersTable(doc.kind === "class" ? "Constructor" : "Parameters", doc.params, linkFor));
|
|
869
|
+
if (doc.members?.length) {
|
|
870
|
+
const caption = doc.kind === "enum" ? "Members" : /Props$/.test(doc.name) ? "Props" : "Properties";
|
|
871
|
+
parts.push(membersTable(caption, doc.members, linkFor));
|
|
872
|
+
}
|
|
873
|
+
if (doc.returnsDescription || doc.returns && doc.kind !== "component") parts.push(`<h4>Returns</h4>
|
|
874
|
+
<p>${doc.returns ? typeCode(doc.returns, linkFor) : ""} ${doc.returnsDescription ? inlineMarkdown(doc.returnsDescription, { linkFor }) : ""}</p>`);
|
|
875
|
+
if (doc.throws.length > 0) parts.push(`<h4>Throws</h4>\n <div class="prose">${doc.throws.map(markdown).join("\n")}</div>`);
|
|
876
|
+
for (const example of doc.examples) parts.push(`<h4>Example</h4>\n ${markdown(example)}`);
|
|
877
|
+
const anchor = escapeHtml(apiAnchor(doc.name));
|
|
878
|
+
return `<section class="api" id="${anchor}" aria-labelledby="${anchor}-title">
|
|
879
|
+
<div class="api-head">
|
|
880
|
+
<h3 id="${anchor}-title">${escapeHtml(doc.name)}</h3>
|
|
881
|
+
<span class="tag">${escapeHtml(doc.kind)}</span>
|
|
882
|
+
${doc.deprecated ? "<span class=\"tag deprecated\">deprecated</span>" : ""}
|
|
883
|
+
</div>
|
|
884
|
+
${parts.join("\n ")}
|
|
885
|
+
</section>`;
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* A shadcn item's `css` object as CSS text: selectors and at-rules become blocks, strings
|
|
889
|
+
* become declarations, empty objects become block-less at-rules (`@import "x";`).
|
|
890
|
+
*
|
|
891
|
+
* @param css - The item's `css` object.
|
|
892
|
+
* @param indent - Indentation of the first level.
|
|
893
|
+
* @returns CSS text.
|
|
894
|
+
*/
|
|
895
|
+
function cssToText(css, indent = "") {
|
|
896
|
+
return Object.entries(css).map(([key, value]) => {
|
|
897
|
+
if (typeof value === "string") return `${indent}${key}: ${value};`;
|
|
898
|
+
const nested = value ?? {};
|
|
899
|
+
if (Object.keys(nested).length === 0) return `${indent}${key};`;
|
|
900
|
+
return `${indent}${key} {\n${cssToText(nested, `${indent} `)}\n${indent}}`;
|
|
901
|
+
}).join("\n");
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Import lines for the value exports of each file:
|
|
905
|
+
* `import { Button } from '@/components/ui/button';`.
|
|
906
|
+
*
|
|
907
|
+
* @param docs - The exports of an item, from {@link itemDocs}.
|
|
908
|
+
* @returns One line per file.
|
|
909
|
+
*/
|
|
910
|
+
function importLines(docs) {
|
|
911
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
912
|
+
for (const doc of docs) {
|
|
913
|
+
if (!VALUE_KINDS.has(doc.kind)) continue;
|
|
914
|
+
const names = byFile.get(doc.file) ?? [];
|
|
915
|
+
names.push(doc.name);
|
|
916
|
+
byFile.set(doc.file, names);
|
|
917
|
+
}
|
|
918
|
+
return [...byFile.entries()].map(([file, names]) => `import { ${names.join(", ")} } from '${aliasFor(file)}';`);
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* How a registry dependency is shown: a link to this site's page when the item belongs to this
|
|
922
|
+
* registry, a link to the ui site (marked "ui") when it is a ui item, plain code otherwise.
|
|
923
|
+
*
|
|
924
|
+
* @param name - The dependency's item name.
|
|
925
|
+
* @param known - Names of the items of this registry.
|
|
926
|
+
* @param pageOf - Relative URL of an item page of this site.
|
|
927
|
+
* @param uiDocsUrl - Where the ui item pages are (ends with `/`), when the registry uses ui.
|
|
928
|
+
* @returns HTML.
|
|
929
|
+
*/
|
|
930
|
+
function dependencyLink(name, known, pageOf, uiDocsUrl) {
|
|
931
|
+
const label = `<code>${escapeHtml(name)}</code>`;
|
|
932
|
+
if (known.has(name)) return `<a href="${escapeHtml(pageOf(name))}">${label}</a>`;
|
|
933
|
+
return uiDocsUrl ? `<a href="${escapeHtml(`${uiDocsUrl}${name}/`)}">${label}</a> <span class="muted">(ui)</span>` : label;
|
|
934
|
+
}
|
|
935
|
+
const ITEM_SCRIPT = `
|
|
936
|
+
(function () {
|
|
937
|
+
// Sidebar: open on wide screens, with the current item in view.
|
|
938
|
+
var nav = document.querySelector('.side-nav');
|
|
939
|
+
if (nav && window.matchMedia('(min-width: 64rem)').matches) {
|
|
940
|
+
nav.open = true;
|
|
941
|
+
var current = nav.querySelector('[aria-current="page"]');
|
|
942
|
+
var sidebar = document.querySelector('.sidebar');
|
|
943
|
+
if (current && sidebar) sidebar.scrollTop = current.offsetTop - sidebar.clientHeight / 3;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Live preview: switch story and theme.
|
|
947
|
+
var preview = document.getElementById('preview');
|
|
948
|
+
if (!preview) return;
|
|
949
|
+
var base = preview.getAttribute('data-playground');
|
|
950
|
+
var story = preview.getAttribute('data-story');
|
|
951
|
+
var theme = preview.getAttribute('data-default-theme') || '';
|
|
952
|
+
var select = document.getElementById('story-select');
|
|
953
|
+
var frame = document.getElementById('story-frame');
|
|
954
|
+
var themeButtons = Array.prototype.slice.call(preview.querySelectorAll('[data-theme]'));
|
|
955
|
+
function show() {
|
|
956
|
+
frame.src = base + '?story=' + encodeURIComponent(story) + (theme ? '&integr8Theme=' + encodeURIComponent(theme) : '') + '&mode=preview&integr8Embed=1';
|
|
957
|
+
}
|
|
958
|
+
if (select) select.addEventListener('change', function () { story = select.value; show(); });
|
|
959
|
+
themeButtons.forEach(function (button) {
|
|
960
|
+
button.addEventListener('click', function () {
|
|
961
|
+
theme = button.getAttribute('data-theme');
|
|
962
|
+
themeButtons.forEach(function (other) {
|
|
963
|
+
other.setAttribute('aria-pressed', String(other === button));
|
|
964
|
+
});
|
|
965
|
+
show();
|
|
966
|
+
});
|
|
967
|
+
});
|
|
968
|
+
})();
|
|
969
|
+
`;
|
|
970
|
+
/**
|
|
971
|
+
* Renders the documentation page of one registry item.
|
|
972
|
+
*
|
|
973
|
+
* @param page - The item, its stories and the items that use it.
|
|
974
|
+
* @param site - Config, URLs, the item list and the optional playground and fonts.
|
|
975
|
+
* @returns The full HTML document.
|
|
976
|
+
*/
|
|
977
|
+
function renderItemPage(page, site) {
|
|
978
|
+
const { item } = page;
|
|
979
|
+
const { config } = site;
|
|
980
|
+
const { brand, ui } = config;
|
|
981
|
+
const stories = page.stories ?? [];
|
|
982
|
+
const usedBy = page.usedBy ?? [];
|
|
983
|
+
const docsPath = site.docsPath ?? "components/";
|
|
984
|
+
const root = "../".repeat(docsPath.split("/").filter(Boolean).length + 1);
|
|
985
|
+
const pageOf = (name) => `${root}${docsPath}${name}/`;
|
|
986
|
+
const title = item.title ?? item.name;
|
|
987
|
+
const install = `npx shadcn@latest add ${site.baseUrl}/${item.name}.json`;
|
|
988
|
+
const known = new Set(site.order.map((other) => other.name));
|
|
989
|
+
const hasThemes = config.previewThemes.length > 0;
|
|
990
|
+
const firstTheme = config.previewThemes[0]?.[0];
|
|
991
|
+
const linkFor = (name) => {
|
|
992
|
+
const owner = site.exportOwners.get(name);
|
|
993
|
+
if (!owner) return null;
|
|
994
|
+
return owner === item.name ? `#${apiAnchor(name)}` : `${pageOf(owner)}#${apiAnchor(name)}`;
|
|
995
|
+
};
|
|
996
|
+
const docs = itemDocs(item);
|
|
997
|
+
const group = site.groups.find((candidate) => candidate.items.some((other) => other.name === item.name));
|
|
998
|
+
const sidebar = site.groups.map((candidate) => `<h2>${escapeHtml(candidate.label)}</h2>
|
|
999
|
+
<ul>
|
|
1000
|
+
${candidate.items.map((other) => `<li><a href="${escapeHtml(pageOf(other.name))}"${other.name === item.name ? " aria-current=\"page\"" : ""}>${escapeHtml(other.title ?? other.name)}</a></li>`).join("\n ")}
|
|
1001
|
+
</ul>`).join("\n ");
|
|
1002
|
+
const toc = [];
|
|
1003
|
+
let preview = "";
|
|
1004
|
+
const first = stories[0];
|
|
1005
|
+
if (site.playgroundPath && first) {
|
|
1006
|
+
toc.push(["preview", "Preview"]);
|
|
1007
|
+
const playground = root + site.playgroundPath;
|
|
1008
|
+
const select = stories.length > 1 ? `<label class="field">
|
|
1009
|
+
<span class="sr-only">Story</span>
|
|
1010
|
+
<select id="story-select">
|
|
1011
|
+
${stories.map((story) => `<option value="${escapeHtml(story.id)}">${escapeHtml(story.name)}</option>`).join("\n ")}
|
|
1012
|
+
</select>
|
|
1013
|
+
</label>` : "";
|
|
1014
|
+
preview = `<section id="preview" class="doc-section" aria-labelledby="preview-title" data-playground="${escapeHtml(playground)}" data-story="${escapeHtml(first.id)}" data-default-theme="${escapeHtml(firstTheme ?? "")}">
|
|
1015
|
+
<h2 id="preview-title">Preview</h2>
|
|
1016
|
+
<div class="viewer">
|
|
1017
|
+
<div class="viewer-bar">
|
|
1018
|
+
${select}
|
|
1019
|
+
${themeButtons(config.previewThemes, " ")}
|
|
1020
|
+
<button type="button" class="pill-sm ghost open-full" data-expand aria-pressed="false">Full screen</button>
|
|
1021
|
+
</div>
|
|
1022
|
+
<iframe id="story-frame" title="${escapeHtml(title)} preview" src="${escapeHtml(storyUrl(playground, first.id, hasThemes ? firstTheme : void 0))}" loading="lazy"></iframe>
|
|
1023
|
+
</div>
|
|
1024
|
+
</section>`;
|
|
1025
|
+
}
|
|
1026
|
+
toc.push(["installation", "Installation"]);
|
|
1027
|
+
const facts = [];
|
|
1028
|
+
if (item.dependencies?.length) facts.push(`<li><strong>npm packages</strong>${item.dependencies.map((dep) => `<code>${escapeHtml(dep)}</code>`).join(" ")}</li>`);
|
|
1029
|
+
if (item.registryDependencies?.length) facts.push(`<li><strong>Also adds</strong>${item.registryDependencies.map(itemNameFromUrl).map((name) => dependencyLink(name, known, pageOf, ui?.docsUrl)).join(" ")}</li>`);
|
|
1030
|
+
if (item.files?.length) facts.push(`<li><strong>Files</strong>${item.files.map((file) => `<code>${escapeHtml(file.target ?? file.path)}</code>`).join(" ")}</li>`);
|
|
1031
|
+
const note = config.themeNote;
|
|
1032
|
+
let themeNote = "";
|
|
1033
|
+
if (note && !note.skip.includes(item.name)) themeNote = note.external ? `<p>Uses the Integr8 theme tokens: add the <a href="${escapeHtml(ui ? `${ui.docsUrl}${note.item}/` : "")}">ui theme</a> once per project first (<code>npx shadcn@latest add ${escapeHtml(ui?.registryUrl ?? "")}/${escapeHtml(note.item)}.json</code>).</p>` : item.name === note.item ? "" : `<p>Uses the theme tokens: add <a href="${escapeHtml(pageOf(note.item))}">${escapeHtml(note.item)}</a> once per project first.</p>`;
|
|
1034
|
+
const installation = `<section id="installation" class="doc-section" aria-labelledby="installation-title">
|
|
1035
|
+
<h2 id="installation-title">Installation</h2>
|
|
1036
|
+
${themeNote}
|
|
1037
|
+
${commandBlock(install, `Copy the install command for ${item.name}`)}
|
|
1038
|
+
${facts.length > 0 ? `<ul class="facts">\n ${facts.join("\n ")}\n </ul>` : ""}
|
|
1039
|
+
</section>`;
|
|
1040
|
+
const imports = importLines(docs);
|
|
1041
|
+
const mainExamples = docs.find((doc) => doc.examples.length > 0)?.examples ?? [];
|
|
1042
|
+
let usage = "";
|
|
1043
|
+
if (imports.length > 0 || mainExamples.length > 0) {
|
|
1044
|
+
toc.push(["usage", "Usage"]);
|
|
1045
|
+
usage = `<section id="usage" class="doc-section" aria-labelledby="usage-title">
|
|
1046
|
+
<h2 id="usage-title">Usage</h2>
|
|
1047
|
+
${imports.length > 0 ? codeBlock(imports.join("\n"), "Copy the import") : ""}
|
|
1048
|
+
${mainExamples.map((example) => renderMarkdown(example, { linkFor })).join("\n ")}
|
|
1049
|
+
</section>`;
|
|
1050
|
+
}
|
|
1051
|
+
let styles = "";
|
|
1052
|
+
if (item.cssVars || item.css) {
|
|
1053
|
+
toc.push(["styles", "Styles"]);
|
|
1054
|
+
const light = item.cssVars?.light ?? {};
|
|
1055
|
+
const dark = item.cssVars?.dark ?? {};
|
|
1056
|
+
const tokens = [.../* @__PURE__ */ new Set([...Object.keys(light), ...Object.keys(dark)])];
|
|
1057
|
+
const swatch = (value) => value === void 0 ? "<span class=\"muted\">—</span>" : `<code>${escapeHtml(value)}</code>`;
|
|
1058
|
+
const tokenTable = tokens.length > 0 ? `<h3 class="sr-only">Tokens</h3>
|
|
1059
|
+
<div class="table-wrap">
|
|
1060
|
+
<table>
|
|
1061
|
+
<thead><tr><th scope="col">Token</th><th scope="col">Light (<code>:root</code>)</th><th scope="col">Dark (<code>.dark</code>)</th></tr></thead>
|
|
1062
|
+
<tbody>
|
|
1063
|
+
${tokens.map((token) => `<tr><td><code>--${escapeHtml(token)}</code></td><td>${swatch(light[token])}</td><td>${swatch(dark[token])}</td></tr>`).join("\n ")}
|
|
1064
|
+
</tbody>
|
|
1065
|
+
</table>
|
|
1066
|
+
</div>` : "";
|
|
1067
|
+
const cssText = item.css ? cssToText(item.css) : "";
|
|
1068
|
+
styles = `<section id="styles" class="doc-section" aria-labelledby="styles-title">
|
|
1069
|
+
<h2 id="styles-title">Styles</h2>
|
|
1070
|
+
<p>What <code>shadcn add</code> writes into your CSS file${tokens.length > 0 ? ": the theme tokens below, plus the rules for the other themes and the Integr8 extras" : ""}.</p>
|
|
1071
|
+
${tokenTable}
|
|
1072
|
+
${cssText ? `<details class="source"><summary>View the CSS</summary>${codeBlock(cssText, "Copy the CSS", "CSS")}</details>` : ""}
|
|
1073
|
+
</section>`;
|
|
1074
|
+
}
|
|
1075
|
+
const hasCode = (item.files ?? []).some((file) => typeof file.content === "string");
|
|
1076
|
+
if (hasCode) toc.push(["api", "API reference"]);
|
|
1077
|
+
const api = !hasCode ? "" : `<section id="api" class="doc-section" aria-labelledby="api-title">
|
|
1078
|
+
<h2 id="api-title">API reference</h2>
|
|
1079
|
+
${docs.length > 0 ? docs.map((doc) => apiEntry(doc, linkFor)).join("\n ") : "<p>This item has no public API of its own: other components use it. See the source below.</p>"}
|
|
1080
|
+
</section>`;
|
|
1081
|
+
const sourceFiles = (item.files ?? []).filter((file) => typeof file.content === "string");
|
|
1082
|
+
let source = "";
|
|
1083
|
+
if (sourceFiles.length > 0) {
|
|
1084
|
+
toc.push(["source", "Source"]);
|
|
1085
|
+
source = `<section id="source" class="doc-section" aria-labelledby="source-title">
|
|
1086
|
+
<h2 id="source-title">Source</h2>
|
|
1087
|
+
<p>The code <code>shadcn add</code> copies into your project. It's yours to change.</p>
|
|
1088
|
+
<details class="source">
|
|
1089
|
+
<summary>View the source (${sourceFiles.length} ${sourceFiles.length === 1 ? "file" : "files"})</summary>
|
|
1090
|
+
${sourceFiles.map((file) => codeBlock(file.content, `Copy ${file.target ?? file.path}`, file.target ?? file.path)).join("\n ")}
|
|
1091
|
+
</details>
|
|
1092
|
+
</section>`;
|
|
1093
|
+
}
|
|
1094
|
+
const usedBySection = usedBy.length > 0 ? `<section id="used-by" class="doc-section" aria-labelledby="used-by-title">
|
|
1095
|
+
<h2 id="used-by-title">Used by</h2>
|
|
1096
|
+
<div class="chips">
|
|
1097
|
+
${usedBy.map((name) => `<a class="chip" href="${escapeHtml(pageOf(name))}">${escapeHtml(name)}</a>`).join("\n ")}
|
|
1098
|
+
</div>
|
|
1099
|
+
</section>` : "";
|
|
1100
|
+
const position = site.order.findIndex((other) => other.name === item.name);
|
|
1101
|
+
const previous = position > 0 ? site.order[position - 1] : void 0;
|
|
1102
|
+
const next = position >= 0 ? site.order[position + 1] : void 0;
|
|
1103
|
+
const pager = `<nav class="pager" aria-label="Previous and next item">
|
|
1104
|
+
${previous ? `<a href="${escapeHtml(pageOf(previous.name))}"><span>Previous</span>${escapeHtml(previous.title ?? previous.name)}</a>` : "<span></span>"}
|
|
1105
|
+
${next ? `<a class="next" href="${escapeHtml(pageOf(next.name))}"><span>Next</span>${escapeHtml(next.title ?? next.name)}</a>` : ""}
|
|
1106
|
+
</nav>`;
|
|
1107
|
+
const body = `<main id="main">
|
|
1108
|
+
<div class="wrap docs">
|
|
1109
|
+
<aside class="sidebar">
|
|
1110
|
+
<details class="side-nav">
|
|
1111
|
+
<summary>Browse all items</summary>
|
|
1112
|
+
<nav aria-label="Registry items">
|
|
1113
|
+
${sidebar}
|
|
1114
|
+
</nav>
|
|
1115
|
+
</details>
|
|
1116
|
+
</aside>
|
|
1117
|
+
<article class="doc">
|
|
1118
|
+
<nav class="crumbs" aria-label="Breadcrumb">
|
|
1119
|
+
<a href="${escapeHtml(root)}#components">Registry</a><span aria-hidden="true">/</span>
|
|
1120
|
+
<span>${escapeHtml(group?.label ?? "Other")}</span>
|
|
1121
|
+
</nav>
|
|
1122
|
+
<header class="doc-head">
|
|
1123
|
+
<div class="tags">
|
|
1124
|
+
<span class="tag">${escapeHtml(typeLabel(item.type))}</span>
|
|
1125
|
+
<span class="tag">${escapeHtml(item.name)}</span>
|
|
1126
|
+
</div>
|
|
1127
|
+
<h1>${escapeHtml(title)}</h1>
|
|
1128
|
+
${item.description ? `<p class="lead">${inlineMarkdown(item.description, { linkFor })}</p>` : ""}
|
|
1129
|
+
${commandBlock(install, `Copy the install command for ${item.name}`)}
|
|
1130
|
+
<nav class="toc" aria-label="On this page">
|
|
1131
|
+
${toc.map(([id, label]) => `<a href="#${id}">${label}</a>`).join("\n ")}
|
|
1132
|
+
</nav>
|
|
1133
|
+
</header>
|
|
1134
|
+
${preview}
|
|
1135
|
+
${installation}
|
|
1136
|
+
${usage}
|
|
1137
|
+
${styles}
|
|
1138
|
+
${api}
|
|
1139
|
+
${source}
|
|
1140
|
+
${usedBySection}
|
|
1141
|
+
${pager}
|
|
1142
|
+
</article>
|
|
1143
|
+
</div>
|
|
1144
|
+
</main>`;
|
|
1145
|
+
return renderShell({
|
|
1146
|
+
title: `${title} · ${brand.name}`,
|
|
1147
|
+
description: item.description ?? `${title}: a ${brand.name} registry item.`,
|
|
1148
|
+
brand,
|
|
1149
|
+
root,
|
|
1150
|
+
fontsHref: site.fontsHref,
|
|
1151
|
+
repositoryUrl: brand.repositoryUrl,
|
|
1152
|
+
...brand.playgroundLink && site.playgroundPath ? { playgroundHref: site.playgroundPath } : {},
|
|
1153
|
+
footerNote: site.config.brand.footer.replaceAll("{packageName}", site.registryName).replaceAll("{version}", site.version),
|
|
1154
|
+
body,
|
|
1155
|
+
script: ITEM_SCRIPT
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
// src/registrySite/landingPage.ts
|
|
1159
|
+
const LANDING_SCRIPT = `
|
|
1160
|
+
(function () {
|
|
1161
|
+
// Search and group filters
|
|
1162
|
+
var search = document.getElementById('search');
|
|
1163
|
+
var groups = Array.prototype.slice.call(document.querySelectorAll('.group'));
|
|
1164
|
+
var filterButtons = Array.prototype.slice.call(document.querySelectorAll('[data-filter]'));
|
|
1165
|
+
var count = document.getElementById('result-count');
|
|
1166
|
+
var empty = document.getElementById('no-results');
|
|
1167
|
+
var filter = 'all';
|
|
1168
|
+
function apply() {
|
|
1169
|
+
var query = search.value.trim().toLowerCase();
|
|
1170
|
+
var shown = 0;
|
|
1171
|
+
groups.forEach(function (group) {
|
|
1172
|
+
var inFilter = filter === 'all' || group.getAttribute('data-group') === filter;
|
|
1173
|
+
var visible = 0;
|
|
1174
|
+
group.querySelectorAll('.card').forEach(function (card) {
|
|
1175
|
+
var match = inFilter && (!query || card.getAttribute('data-search').indexOf(query) !== -1);
|
|
1176
|
+
card.hidden = !match;
|
|
1177
|
+
if (match) visible++;
|
|
1178
|
+
});
|
|
1179
|
+
group.hidden = visible === 0;
|
|
1180
|
+
shown += visible;
|
|
1181
|
+
});
|
|
1182
|
+
empty.hidden = shown !== 0;
|
|
1183
|
+
count.textContent = shown + (shown === 1 ? ' item' : ' items') + ' shown.';
|
|
1184
|
+
}
|
|
1185
|
+
search.addEventListener('input', apply);
|
|
1186
|
+
filterButtons.forEach(function (button) {
|
|
1187
|
+
button.addEventListener('click', function () {
|
|
1188
|
+
filter = button.getAttribute('data-filter');
|
|
1189
|
+
filterButtons.forEach(function (other) {
|
|
1190
|
+
other.setAttribute('aria-pressed', String(other === button));
|
|
1191
|
+
});
|
|
1192
|
+
apply();
|
|
1193
|
+
});
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
// Live preview from the Ladle playground
|
|
1197
|
+
var preview = document.getElementById('preview');
|
|
1198
|
+
if (!preview) return;
|
|
1199
|
+
var base = preview.getAttribute('data-playground');
|
|
1200
|
+
var select = document.getElementById('story-select');
|
|
1201
|
+
var frame = document.getElementById('story-frame');
|
|
1202
|
+
var themeButtons = Array.prototype.slice.call(preview.querySelectorAll('[data-theme]'));
|
|
1203
|
+
var theme = preview.getAttribute('data-default-theme') || '';
|
|
1204
|
+
function storyUrl(id) {
|
|
1205
|
+
return base + '?story=' + encodeURIComponent(id) + (theme ? '&integr8Theme=' + encodeURIComponent(theme) : '') + '&mode=preview&integr8Embed=1';
|
|
1206
|
+
}
|
|
1207
|
+
function show(id) {
|
|
1208
|
+
if (!id) return;
|
|
1209
|
+
select.value = id;
|
|
1210
|
+
frame.src = storyUrl(id);
|
|
1211
|
+
}
|
|
1212
|
+
themeButtons.forEach(function (button) {
|
|
1213
|
+
button.addEventListener('click', function () {
|
|
1214
|
+
theme = button.getAttribute('data-theme');
|
|
1215
|
+
themeButtons.forEach(function (other) {
|
|
1216
|
+
other.setAttribute('aria-pressed', String(other === button));
|
|
1217
|
+
});
|
|
1218
|
+
show(select.value);
|
|
1219
|
+
});
|
|
1220
|
+
});
|
|
1221
|
+
select.addEventListener('change', function () { show(select.value); });
|
|
1222
|
+
|
|
1223
|
+
fetch(base + 'meta.json')
|
|
1224
|
+
.then(function (response) {
|
|
1225
|
+
if (!response.ok) throw new Error(response.statusText);
|
|
1226
|
+
return response.json();
|
|
1227
|
+
})
|
|
1228
|
+
.then(function (meta) {
|
|
1229
|
+
var stories = meta.stories || {};
|
|
1230
|
+
var ids = Object.keys(stories);
|
|
1231
|
+
if (ids.length === 0) throw new Error('No stories');
|
|
1232
|
+
select.textContent = '';
|
|
1233
|
+
var byGroup = {};
|
|
1234
|
+
ids.forEach(function (id) {
|
|
1235
|
+
var story = stories[id];
|
|
1236
|
+
var level = (story.levels && story.levels[0]) || 'Stories';
|
|
1237
|
+
if (!byGroup[level]) {
|
|
1238
|
+
byGroup[level] = document.createElement('optgroup');
|
|
1239
|
+
byGroup[level].label = level;
|
|
1240
|
+
select.appendChild(byGroup[level]);
|
|
1241
|
+
}
|
|
1242
|
+
var option = document.createElement('option');
|
|
1243
|
+
option.value = id;
|
|
1244
|
+
option.textContent = (story.levels || []).slice(1).concat(story.name).join(' / ');
|
|
1245
|
+
byGroup[level].appendChild(option);
|
|
1246
|
+
});
|
|
1247
|
+
select.disabled = false;
|
|
1248
|
+
|
|
1249
|
+
// Each item's story id is set at build time (data-story); keep only the known ones.
|
|
1250
|
+
document.querySelectorAll('[data-story]').forEach(function (button) {
|
|
1251
|
+
var id = button.getAttribute('data-story');
|
|
1252
|
+
if (!stories[id]) return;
|
|
1253
|
+
button.hidden = false;
|
|
1254
|
+
button.addEventListener('click', function () {
|
|
1255
|
+
show(id);
|
|
1256
|
+
preview.scrollIntoView();
|
|
1257
|
+
frame.focus();
|
|
1258
|
+
});
|
|
1259
|
+
});
|
|
1260
|
+
var first = preview.getAttribute('data-default-story');
|
|
1261
|
+
show(stories[first] ? first : ids[0]);
|
|
1262
|
+
})
|
|
1263
|
+
.catch(function () {
|
|
1264
|
+
select.closest('.viewer-bar').hidden = true;
|
|
1265
|
+
frame.hidden = true;
|
|
1266
|
+
document.getElementById('preview-error').hidden = false;
|
|
1267
|
+
});
|
|
1268
|
+
})();
|
|
1269
|
+
`;
|
|
1270
|
+
const NUMBER_WORDS = [
|
|
1271
|
+
"One",
|
|
1272
|
+
"Two",
|
|
1273
|
+
"Three",
|
|
1274
|
+
"Four",
|
|
1275
|
+
"Five",
|
|
1276
|
+
"Six",
|
|
1277
|
+
"Seven",
|
|
1278
|
+
"Eight",
|
|
1279
|
+
"Nine"
|
|
1280
|
+
];
|
|
1281
|
+
/**
|
|
1282
|
+
* Renders the landing page.
|
|
1283
|
+
*
|
|
1284
|
+
* @param index - The registry index (`registry.json`).
|
|
1285
|
+
* @param options - Config, URLs and the optional playground, fonts and item pages.
|
|
1286
|
+
* @returns The full HTML document.
|
|
1287
|
+
*/
|
|
1288
|
+
function renderRegistryPage(index, options) {
|
|
1289
|
+
const { config, baseUrl, version, playgroundPath, docsPath } = options;
|
|
1290
|
+
const { brand, ui } = config;
|
|
1291
|
+
const previewStories = options.previewStories ?? {};
|
|
1292
|
+
const url = (name) => `${baseUrl}/${name}.json`;
|
|
1293
|
+
const add = (name) => `npx shadcn@latest add ${url(name)}`;
|
|
1294
|
+
const docsHref = (name) => docsPath ? `${docsPath}${name}/` : null;
|
|
1295
|
+
const componentCount = index.items.filter((item) => item.type === "registry:ui").length;
|
|
1296
|
+
const groups = groupItems(index.items, config.categories);
|
|
1297
|
+
const hasThemes = config.previewThemes.length > 0;
|
|
1298
|
+
const firstItem = (index.items.find((item) => item.type === "registry:ui") ?? index.items[0])?.name;
|
|
1299
|
+
const values = {
|
|
1300
|
+
registryUrl: baseUrl,
|
|
1301
|
+
uiRegistryUrl: ui?.registryUrl ?? "",
|
|
1302
|
+
version,
|
|
1303
|
+
packageName: index.name
|
|
1304
|
+
};
|
|
1305
|
+
const fill = (text) => fillTemplate(text, values);
|
|
1306
|
+
const steps = config.setup.steps ?? [
|
|
1307
|
+
{
|
|
1308
|
+
title: "Set up shadcn",
|
|
1309
|
+
text: "Skip this if your project already has a `components.json`.",
|
|
1310
|
+
command: "npx shadcn@latest init"
|
|
1311
|
+
},
|
|
1312
|
+
...ui ? [{
|
|
1313
|
+
title: "Add the theme",
|
|
1314
|
+
text: "Once per project: the Integr8 theme tokens from the ui registry.",
|
|
1315
|
+
command: `npx shadcn@latest add ${ui.registryUrl}/theme.json`
|
|
1316
|
+
}] : [],
|
|
1317
|
+
...firstItem ? [{
|
|
1318
|
+
title: "Add items",
|
|
1319
|
+
text: "Each one brings the parts it needs. The code is yours to change.",
|
|
1320
|
+
command: add(firstItem)
|
|
1321
|
+
}] : []
|
|
1322
|
+
];
|
|
1323
|
+
const heroCommand = brand.heroCommand ?? steps.at(-1)?.command;
|
|
1324
|
+
const registries = ui ? {
|
|
1325
|
+
"@integr8": `${ui.registryUrl}/{name}.json`,
|
|
1326
|
+
[config.setup.namespace]: `${baseUrl}/{name}.json`
|
|
1327
|
+
} : { [config.setup.namespace]: `${baseUrl}/{name}.json` };
|
|
1328
|
+
const extras = [...config.setup.shortNames ? [{
|
|
1329
|
+
title: "Shorter names",
|
|
1330
|
+
text: `Add the registry to \`components.json\`, then run \`npx shadcn@latest add ${config.setup.namespace}/${firstItem ?? "item"}\`.`,
|
|
1331
|
+
code: JSON.stringify({ registries }, null, 2)
|
|
1332
|
+
}] : [], ...config.setup.extras];
|
|
1333
|
+
const stepItems = steps.map((step) => `<li class="step">
|
|
1334
|
+
<h3>${escapeHtml(step.title)}</h3>
|
|
1335
|
+
${step.text ? `<p>${inlineMarkdown(fill(step.text))}</p>` : ""}
|
|
1336
|
+
${commandBlock(fill(step.command), `Copy the command: ${step.title}`)}
|
|
1337
|
+
</li>`).join("\n ");
|
|
1338
|
+
const extraItems = extras.map((extra) => `<div class="extra">
|
|
1339
|
+
<h3>${escapeHtml(extra.title)}</h3>
|
|
1340
|
+
${extra.text ? `<p>${inlineMarkdown(fill(extra.text))}</p>` : ""}
|
|
1341
|
+
${codeBlock(fill(extra.code), `Copy the example: ${extra.title}`, extra.caption)}
|
|
1342
|
+
</div>`).join("\n ");
|
|
1343
|
+
const filters = groups.map((group) => `<button type="button" class="chip" data-filter="${escapeHtml(group.id)}" aria-pressed="false">${escapeHtml(group.label)} <span>${group.items.length}</span></button>`).join("\n ");
|
|
1344
|
+
const sections = groups.map((group) => {
|
|
1345
|
+
const cards = group.items.map((item) => {
|
|
1346
|
+
const search = [
|
|
1347
|
+
item.name,
|
|
1348
|
+
item.title,
|
|
1349
|
+
item.description,
|
|
1350
|
+
group.label
|
|
1351
|
+
].filter(Boolean).join(" ").toLowerCase();
|
|
1352
|
+
const title = escapeHtml(item.title ?? item.name);
|
|
1353
|
+
const href = docsHref(item.name);
|
|
1354
|
+
const previewStory = previewStories[item.name];
|
|
1355
|
+
return `<li class="card" data-name="${escapeHtml(item.name)}" data-search="${escapeHtml(search)}">
|
|
1356
|
+
<div class="card-head">
|
|
1357
|
+
<h4>${href ? `<a href="${escapeHtml(href)}">${title}</a>` : title}</h4>
|
|
1358
|
+
<span class="tag">${escapeHtml(typeLabel(item.type))}</span>
|
|
1359
|
+
</div>
|
|
1360
|
+
<p>${escapeHtml(item.description ?? "")}</p>
|
|
1361
|
+
<div class="card-actions">
|
|
1362
|
+
<button type="button" class="copy pill-sm" data-copy="${escapeHtml(add(item.name))}" aria-label="Copy the install command for ${escapeHtml(item.name)}">Copy install</button>
|
|
1363
|
+
${href ? `<a class="pill-sm ghost" href="${escapeHtml(href)}" aria-label="Documentation for ${escapeHtml(item.name)}">Docs</a>` : ""}
|
|
1364
|
+
${previewStory ? `<button type="button" class="preview pill-sm ghost" data-story="${escapeHtml(previewStory)}" hidden>Preview</button>` : ""}
|
|
1365
|
+
${href ? "" : `<a class="pill-sm ghost" href="${escapeHtml(url(item.name))}">JSON</a>`}
|
|
1366
|
+
</div>
|
|
1367
|
+
</li>`;
|
|
1368
|
+
}).join("\n ");
|
|
1369
|
+
return `<section class="group" data-group="${escapeHtml(group.id)}" aria-labelledby="group-${escapeHtml(group.id)}">
|
|
1370
|
+
<h3 id="group-${escapeHtml(group.id)}">${escapeHtml(group.label)}</h3>
|
|
1371
|
+
<ul class="cards">
|
|
1372
|
+
${cards}
|
|
1373
|
+
</ul>
|
|
1374
|
+
</section>`;
|
|
1375
|
+
}).join("\n ");
|
|
1376
|
+
const previewLead = config.copy.previewLead ?? `Every item runs live from the Ladle playground. Pick a story${hasThemes ? " and a theme" : ""}.`;
|
|
1377
|
+
const previewSection = playgroundPath ? `<section id="preview" class="section" aria-labelledby="preview-title" data-playground="${escapeHtml(playgroundPath)}" data-default-story="${escapeHtml(options.defaultStory ?? "")}" data-default-theme="${escapeHtml(config.previewThemes[0]?.[0] ?? "")}">
|
|
1378
|
+
<div class="wrap">
|
|
1379
|
+
<div class="section-head">
|
|
1380
|
+
<p class="eyebrow">Live</p>
|
|
1381
|
+
<h2 id="preview-title">See it <span class="gradient">before you copy it.</span></h2>
|
|
1382
|
+
<p class="lead">${escapeHtml(previewLead)}</p>
|
|
1383
|
+
</div>
|
|
1384
|
+
<div class="viewer">
|
|
1385
|
+
<div class="viewer-bar">
|
|
1386
|
+
<label class="field">
|
|
1387
|
+
<span class="sr-only">Story</span>
|
|
1388
|
+
<select id="story-select" disabled>
|
|
1389
|
+
<option>Loading stories…</option>
|
|
1390
|
+
</select>
|
|
1391
|
+
</label>
|
|
1392
|
+
${themeButtons(config.previewThemes, " ")}
|
|
1393
|
+
<button type="button" class="pill-sm ghost open-full" data-expand aria-pressed="false">Full screen</button>
|
|
1394
|
+
</div>
|
|
1395
|
+
<iframe id="story-frame" title="Component preview" loading="lazy"></iframe>
|
|
1396
|
+
<p id="preview-error" class="note" hidden>The preview could not be loaded. Try again later.</p>
|
|
1397
|
+
</div>
|
|
1398
|
+
</div>
|
|
1399
|
+
</section>` : "";
|
|
1400
|
+
const stats = [
|
|
1401
|
+
{
|
|
1402
|
+
value: String(componentCount),
|
|
1403
|
+
label: "components"
|
|
1404
|
+
},
|
|
1405
|
+
...brand.extraStats,
|
|
1406
|
+
{
|
|
1407
|
+
value: String(index.items.length),
|
|
1408
|
+
label: "registry items"
|
|
1409
|
+
}
|
|
1410
|
+
].map((stat) => `<li><strong>${escapeHtml(stat.value)}</strong><span>${escapeHtml(stat.label)}</span></li>`).join("\n ");
|
|
1411
|
+
const stepCount = steps.length;
|
|
1412
|
+
const startTitle = config.setup.title ?? `${NUMBER_WORDS[stepCount - 1] ?? String(stepCount)} commands to`;
|
|
1413
|
+
const body = `<main id="main">
|
|
1414
|
+
<section class="hero">
|
|
1415
|
+
<div class="glow" aria-hidden="true"></div>
|
|
1416
|
+
<div class="wrap">
|
|
1417
|
+
<p class="eyebrow">${escapeHtml(brand.eyebrow)} · v${escapeHtml(version)}</p>
|
|
1418
|
+
<h1>${escapeHtml(brand.heroTitle)}${brand.heroAccent ? ` <span class="gradient">${escapeHtml(brand.heroAccent)}</span>` : ""}</h1>
|
|
1419
|
+
${brand.lead ? `<p class="lead">${escapeHtml(brand.lead)}</p>` : ""}
|
|
1420
|
+
<div class="cta">
|
|
1421
|
+
<a class="btn primary" href="#start">Get started</a>
|
|
1422
|
+
<a class="btn ghost" href="#components">Browse components</a>
|
|
1423
|
+
</div>
|
|
1424
|
+
${heroCommand ? commandBlock(fill(heroCommand), "Copy the install command") : ""}
|
|
1425
|
+
<ul class="stats">
|
|
1426
|
+
${stats}
|
|
1427
|
+
</ul>
|
|
1428
|
+
</div>
|
|
1429
|
+
</section>
|
|
1430
|
+
|
|
1431
|
+
<section id="start" class="section" aria-labelledby="start-title">
|
|
1432
|
+
<div class="wrap">
|
|
1433
|
+
<div class="section-head">
|
|
1434
|
+
<p class="eyebrow">Get started</p>
|
|
1435
|
+
<h2 id="start-title">${escapeHtml(startTitle)} <span class="gradient">${escapeHtml(config.setup.accent)}</span></h2>
|
|
1436
|
+
<p class="lead">${escapeHtml(fill(config.setup.lead))}</p>
|
|
1437
|
+
</div>
|
|
1438
|
+
<ol class="steps">
|
|
1439
|
+
${stepItems}
|
|
1440
|
+
</ol>
|
|
1441
|
+
${extras.length > 0 ? `<div class="extras">\n ${extraItems}\n </div>` : ""}
|
|
1442
|
+
</div>
|
|
1443
|
+
</section>
|
|
1444
|
+
|
|
1445
|
+
${previewSection}
|
|
1446
|
+
|
|
1447
|
+
<section id="components" class="section" aria-labelledby="components-title">
|
|
1448
|
+
<div class="wrap">
|
|
1449
|
+
<div class="section-head">
|
|
1450
|
+
<p class="eyebrow">Registry</p>
|
|
1451
|
+
<h2 id="components-title">Everything in <span class="gradient">the kit.</span></h2>
|
|
1452
|
+
<p class="lead">${escapeHtml(config.copy.componentsLead)}</p>
|
|
1453
|
+
</div>
|
|
1454
|
+
<div class="toolbar">
|
|
1455
|
+
<label class="search">
|
|
1456
|
+
<span class="sr-only">Search components</span>
|
|
1457
|
+
<input id="search" type="search" placeholder="Search ${index.items.length} items…" autocomplete="off">
|
|
1458
|
+
</label>
|
|
1459
|
+
<div class="chips" role="group" aria-label="Filter by group">
|
|
1460
|
+
<button type="button" class="chip" data-filter="all" aria-pressed="true">All <span>${index.items.length}</span></button>
|
|
1461
|
+
${filters}
|
|
1462
|
+
</div>
|
|
1463
|
+
<p id="result-count" class="sr-only" aria-live="polite"></p>
|
|
1464
|
+
</div>
|
|
1465
|
+
${sections}
|
|
1466
|
+
<p id="no-results" class="empty" hidden>No items match your search.</p>
|
|
1467
|
+
</div>
|
|
1468
|
+
</section>
|
|
1469
|
+
</main>`;
|
|
1470
|
+
return renderShell({
|
|
1471
|
+
title: brand.title,
|
|
1472
|
+
description: brand.description,
|
|
1473
|
+
brand,
|
|
1474
|
+
root: "",
|
|
1475
|
+
fontsHref: options.fontsHref,
|
|
1476
|
+
repositoryUrl: brand.repositoryUrl,
|
|
1477
|
+
...brand.playgroundLink && playgroundPath ? { playgroundHref: playgroundPath } : {},
|
|
1478
|
+
footerNote: fillTemplate(brand.footer, values),
|
|
1479
|
+
body,
|
|
1480
|
+
script: LANDING_SCRIPT
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
// src/registrySite/registrySiteError.ts
|
|
1484
|
+
/**
|
|
1485
|
+
* Thrown while building the registry site, for problems with the repo's files rather than with
|
|
1486
|
+
* the options (those are a `ConfigError` with code `INVALID_OPTION`).
|
|
1487
|
+
*
|
|
1488
|
+
* @example
|
|
1489
|
+
* ```ts
|
|
1490
|
+
* try {
|
|
1491
|
+
* await buildRegistrySite();
|
|
1492
|
+
* } catch (error) {
|
|
1493
|
+
* if (error instanceof RegistrySiteError && error.code === 'REGISTRY_NOT_BUILT') {
|
|
1494
|
+
* // run the registry build first
|
|
1495
|
+
* }
|
|
1496
|
+
* }
|
|
1497
|
+
* ```
|
|
1498
|
+
*/
|
|
1499
|
+
var RegistrySiteError = class extends Error {
|
|
1500
|
+
/** Always `'RegistrySiteError'`. */
|
|
1501
|
+
name = "RegistrySiteError";
|
|
1502
|
+
/** Machine-readable reason for the error. */
|
|
1503
|
+
code;
|
|
1504
|
+
/**
|
|
1505
|
+
* @param code - Machine-readable reason for the error.
|
|
1506
|
+
* @param message - Human-readable description.
|
|
1507
|
+
* @param options - Standard error options, such as `cause`.
|
|
1508
|
+
*/
|
|
1509
|
+
constructor(code, message, options) {
|
|
1510
|
+
super(message, options);
|
|
1511
|
+
this.code = code;
|
|
1512
|
+
}
|
|
1513
|
+
};
|
|
1514
|
+
// src/registrySite/siteConfig.ts
|
|
1515
|
+
const TOP_LEVEL_KEYS = [
|
|
1516
|
+
"brand",
|
|
1517
|
+
"categories",
|
|
1518
|
+
"stories",
|
|
1519
|
+
"defaultStory",
|
|
1520
|
+
"ui",
|
|
1521
|
+
"fonts",
|
|
1522
|
+
"setup",
|
|
1523
|
+
"copy",
|
|
1524
|
+
"previewThemes",
|
|
1525
|
+
"themeNote",
|
|
1526
|
+
"bundleSizes",
|
|
1527
|
+
"commands",
|
|
1528
|
+
"registryUrl",
|
|
1529
|
+
"playground"
|
|
1530
|
+
];
|
|
1531
|
+
const BRAND_KEYS = [
|
|
1532
|
+
"name",
|
|
1533
|
+
"slug",
|
|
1534
|
+
"mark",
|
|
1535
|
+
"eyebrow",
|
|
1536
|
+
"heroTitle",
|
|
1537
|
+
"heroAccent",
|
|
1538
|
+
"lead",
|
|
1539
|
+
"title",
|
|
1540
|
+
"description",
|
|
1541
|
+
"footer",
|
|
1542
|
+
"repositoryUrl",
|
|
1543
|
+
"playgroundLink",
|
|
1544
|
+
"extraStats",
|
|
1545
|
+
"heroCommand"
|
|
1546
|
+
];
|
|
1547
|
+
const SETUP_KEYS = [
|
|
1548
|
+
"title",
|
|
1549
|
+
"accent",
|
|
1550
|
+
"lead",
|
|
1551
|
+
"steps",
|
|
1552
|
+
"extras",
|
|
1553
|
+
"namespace",
|
|
1554
|
+
"shortNames"
|
|
1555
|
+
];
|
|
1556
|
+
const UI_KEYS = [
|
|
1557
|
+
"registryUrl",
|
|
1558
|
+
"docsUrl",
|
|
1559
|
+
"packageName"
|
|
1560
|
+
];
|
|
1561
|
+
const COPY_KEYS = ["previewLead", "componentsLead"];
|
|
1562
|
+
const BUNDLE_KEYS = [
|
|
1563
|
+
"entry",
|
|
1564
|
+
"ownCodeLabel",
|
|
1565
|
+
"external"
|
|
1566
|
+
];
|
|
1567
|
+
const COMMAND_KEYS = ["assets", "registry"];
|
|
1568
|
+
const THEME_NOTE_KEYS = ["item", "skip"];
|
|
1569
|
+
function fail(path, expectation, value) {
|
|
1570
|
+
const got = value === void 0 ? "" : `; got ${describe(value)}`;
|
|
1571
|
+
throw new ConfigError("INVALID_OPTION", `registry-site config: "${path}" ${expectation}${got}.`);
|
|
1572
|
+
}
|
|
1573
|
+
function describe(value) {
|
|
1574
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
1575
|
+
if (typeof value === "function") return "a function";
|
|
1576
|
+
if (Array.isArray(value)) return "an array";
|
|
1577
|
+
return String(value);
|
|
1578
|
+
}
|
|
1579
|
+
function isPlain(value) {
|
|
1580
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1581
|
+
}
|
|
1582
|
+
function plain(value, path) {
|
|
1583
|
+
if (!isPlain(value)) fail(path, "must be an object", value);
|
|
1584
|
+
return value;
|
|
1585
|
+
}
|
|
1586
|
+
function noUnknownKeys(value, known, path) {
|
|
1587
|
+
for (const key of Object.keys(value)) if (!known.includes(key)) fail(path === "config" ? key : `${path}.${key}`, `is not an option (known options: ${known.join(", ")})`);
|
|
1588
|
+
}
|
|
1589
|
+
function optionalString(parent, key, path, allowEmpty = false) {
|
|
1590
|
+
const value = parent[key];
|
|
1591
|
+
if (value === void 0) return;
|
|
1592
|
+
if (typeof value !== "string" || !allowEmpty && value.trim() === "") fail(`${path}.${key}`, `must be a${allowEmpty ? "" : " non-empty"} string`, value);
|
|
1593
|
+
}
|
|
1594
|
+
function optionalBoolean(parent, key, path) {
|
|
1595
|
+
const value = parent[key];
|
|
1596
|
+
if (value !== void 0 && typeof value !== "boolean") fail(`${path}.${key}`, "must be true or false", value);
|
|
1597
|
+
}
|
|
1598
|
+
function entries(value, path, unique) {
|
|
1599
|
+
if (!Array.isArray(value) || value.length === 0) fail(path, "must be a non-empty list of [id, label] pairs", value);
|
|
1600
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1601
|
+
value.forEach((entry, position) => {
|
|
1602
|
+
const here = `${path}[${position}]`;
|
|
1603
|
+
if (!Array.isArray(entry) || entry.length !== 2 || !entry.every((part) => typeof part === "string" && part.trim() !== "")) fail(here, "must be an [id, label] pair of non-empty strings", entry);
|
|
1604
|
+
const id = entry[0];
|
|
1605
|
+
if (unique && seen.has(id)) fail(here, `repeats the id ${JSON.stringify(id)}`);
|
|
1606
|
+
seen.add(id);
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
function validateUrl(value, path) {
|
|
1610
|
+
if (typeof value !== "string" || !/^https?:\/\/\S+$/.test(value)) fail(path, "must be an http(s) URL", value);
|
|
1611
|
+
}
|
|
1612
|
+
function validateSetup(value) {
|
|
1613
|
+
const setup = plain(value, "setup");
|
|
1614
|
+
noUnknownKeys(setup, SETUP_KEYS, "setup");
|
|
1615
|
+
for (const key of [
|
|
1616
|
+
"title",
|
|
1617
|
+
"accent",
|
|
1618
|
+
"lead",
|
|
1619
|
+
"namespace"
|
|
1620
|
+
]) optionalString(setup, key, "setup", key !== "namespace");
|
|
1621
|
+
optionalBoolean(setup, "shortNames", "setup");
|
|
1622
|
+
const { steps, extras } = setup;
|
|
1623
|
+
if (steps !== void 0) {
|
|
1624
|
+
if (!Array.isArray(steps) || steps.length === 0) fail("setup.steps", "must be a non-empty list of steps", steps);
|
|
1625
|
+
steps.forEach((step, position) => {
|
|
1626
|
+
const here = `setup.steps[${position}]`;
|
|
1627
|
+
const item = plain(step, here);
|
|
1628
|
+
noUnknownKeys(item, [
|
|
1629
|
+
"title",
|
|
1630
|
+
"text",
|
|
1631
|
+
"command"
|
|
1632
|
+
], here);
|
|
1633
|
+
for (const key of ["title", "command"]) if (typeof item[key] !== "string" || item[key].trim() === "") fail(`${here}.${key}`, "must be a non-empty string", item[key]);
|
|
1634
|
+
optionalString(item, "text", here, true);
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
if (extras !== void 0) {
|
|
1638
|
+
if (!Array.isArray(extras)) fail("setup.extras", "must be a list", extras);
|
|
1639
|
+
extras.forEach((extra, position) => {
|
|
1640
|
+
const here = `setup.extras[${position}]`;
|
|
1641
|
+
const item = plain(extra, here);
|
|
1642
|
+
noUnknownKeys(item, [
|
|
1643
|
+
"title",
|
|
1644
|
+
"text",
|
|
1645
|
+
"code",
|
|
1646
|
+
"caption"
|
|
1647
|
+
], here);
|
|
1648
|
+
for (const key of ["title", "code"]) if (typeof item[key] !== "string" || item[key].trim() === "") fail(`${here}.${key}`, "must be a non-empty string", item[key]);
|
|
1649
|
+
optionalString(item, "text", here, true);
|
|
1650
|
+
optionalString(item, "caption", here);
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* Checks a site config and returns it. Use it in `registry/site.config.mjs` to get early,
|
|
1656
|
+
* readable errors and editor hints.
|
|
1657
|
+
*
|
|
1658
|
+
* @param config - The config object.
|
|
1659
|
+
* @returns The same config, typed.
|
|
1660
|
+
* @throws {@link ConfigError} with code `INVALID_OPTION`, naming the option and what is wrong
|
|
1661
|
+
* (a missing `brand.name`, an unknown option, `fonts: 'copy-from-ui'` without `ui`, a repeated
|
|
1662
|
+
* category id, and so on).
|
|
1663
|
+
*
|
|
1664
|
+
* @example
|
|
1665
|
+
* ```js
|
|
1666
|
+
* export default defineRegistrySite({ brand: { name: 'Integr8 Forms' } });
|
|
1667
|
+
* ```
|
|
1668
|
+
*/
|
|
1669
|
+
function defineRegistrySite(config) {
|
|
1670
|
+
const root = plain(config, "config");
|
|
1671
|
+
noUnknownKeys(root, TOP_LEVEL_KEYS, "config");
|
|
1672
|
+
const brand = plain(root.brand, "brand");
|
|
1673
|
+
noUnknownKeys(brand, BRAND_KEYS, "brand");
|
|
1674
|
+
if (typeof brand.name !== "string" || brand.name.trim() === "") fail("brand.name", "is required and must be a non-empty string", brand.name);
|
|
1675
|
+
for (const key of [
|
|
1676
|
+
"slug",
|
|
1677
|
+
"mark",
|
|
1678
|
+
"eyebrow",
|
|
1679
|
+
"title",
|
|
1680
|
+
"description",
|
|
1681
|
+
"footer",
|
|
1682
|
+
"heroCommand"
|
|
1683
|
+
]) optionalString(brand, key, "brand");
|
|
1684
|
+
for (const key of [
|
|
1685
|
+
"heroTitle",
|
|
1686
|
+
"heroAccent",
|
|
1687
|
+
"lead"
|
|
1688
|
+
]) optionalString(brand, key, "brand", true);
|
|
1689
|
+
optionalBoolean(brand, "playgroundLink", "brand");
|
|
1690
|
+
if (brand.repositoryUrl !== void 0) validateUrl(brand.repositoryUrl, "brand.repositoryUrl");
|
|
1691
|
+
if (brand.extraStats !== void 0) {
|
|
1692
|
+
const stats = brand.extraStats;
|
|
1693
|
+
if (!Array.isArray(stats) || !stats.every((stat) => isPlain(stat) && typeof stat.value === "string" && typeof stat.label === "string")) fail("brand.extraStats", "must be a list of { value, label } strings", stats);
|
|
1694
|
+
}
|
|
1695
|
+
if (root.categories !== void 0) entries(root.categories, "categories", true);
|
|
1696
|
+
if (root.previewThemes !== void 0) entries(root.previewThemes, "previewThemes", true);
|
|
1697
|
+
const { stories } = root;
|
|
1698
|
+
if (stories !== void 0 && typeof stories !== "function") {
|
|
1699
|
+
const map = plain(stories, "stories");
|
|
1700
|
+
for (const [item, parts] of Object.entries(map)) if (!Array.isArray(parts) || parts.length === 0 || !parts.every((part) => typeof part === "string" && part !== "")) fail(`stories.${item}`, "must be a non-empty list of story id parts (non-empty strings)", parts);
|
|
1701
|
+
}
|
|
1702
|
+
optionalString(root, "defaultStory", "config");
|
|
1703
|
+
if (root.ui !== void 0) {
|
|
1704
|
+
const ui = plain(root.ui, "ui");
|
|
1705
|
+
noUnknownKeys(ui, UI_KEYS, "ui");
|
|
1706
|
+
validateUrl(ui.registryUrl, "ui.registryUrl");
|
|
1707
|
+
if (ui.docsUrl !== void 0) validateUrl(ui.docsUrl, "ui.docsUrl");
|
|
1708
|
+
optionalString(ui, "packageName", "ui");
|
|
1709
|
+
}
|
|
1710
|
+
const { fonts } = root;
|
|
1711
|
+
if (fonts !== void 0 && fonts !== false && fonts !== "build" && fonts !== "copy-from-ui") fail("fonts", "must be 'copy-from-ui', 'build' or false", fonts);
|
|
1712
|
+
if (fonts === "copy-from-ui" && root.ui === void 0) fail("fonts", "is 'copy-from-ui', which needs the `ui` option (the ui registry to copy from)");
|
|
1713
|
+
if (root.setup !== void 0) validateSetup(root.setup);
|
|
1714
|
+
if (root.copy !== void 0) {
|
|
1715
|
+
const copy = plain(root.copy, "copy");
|
|
1716
|
+
noUnknownKeys(copy, COPY_KEYS, "copy");
|
|
1717
|
+
for (const key of COPY_KEYS) optionalString(copy, key, "copy");
|
|
1718
|
+
}
|
|
1719
|
+
const { themeNote } = root;
|
|
1720
|
+
if (themeNote !== void 0 && themeNote !== false) {
|
|
1721
|
+
const note = plain(themeNote, "themeNote");
|
|
1722
|
+
noUnknownKeys(note, THEME_NOTE_KEYS, "themeNote");
|
|
1723
|
+
optionalString(note, "item", "themeNote");
|
|
1724
|
+
if (typeof note.item !== "string") fail("themeNote.item", "is required and must be a non-empty string", note.item);
|
|
1725
|
+
if (note.skip !== void 0 && (!Array.isArray(note.skip) || !note.skip.every((name) => typeof name === "string"))) fail("themeNote.skip", "must be a list of item names", note.skip);
|
|
1726
|
+
}
|
|
1727
|
+
const { bundleSizes } = root;
|
|
1728
|
+
if (bundleSizes !== void 0 && bundleSizes !== false) {
|
|
1729
|
+
const sizes = plain(bundleSizes, "bundleSizes");
|
|
1730
|
+
noUnknownKeys(sizes, BUNDLE_KEYS, "bundleSizes");
|
|
1731
|
+
optionalString(sizes, "entry", "bundleSizes");
|
|
1732
|
+
optionalString(sizes, "ownCodeLabel", "bundleSizes");
|
|
1733
|
+
if (sizes.external !== void 0 && (!Array.isArray(sizes.external) || !sizes.external.every((name) => typeof name === "string"))) fail("bundleSizes.external", "must be a list of package names", sizes.external);
|
|
1734
|
+
}
|
|
1735
|
+
if (root.commands !== void 0) {
|
|
1736
|
+
const commands = plain(root.commands, "commands");
|
|
1737
|
+
noUnknownKeys(commands, COMMAND_KEYS, "commands");
|
|
1738
|
+
for (const key of COMMAND_KEYS) optionalString(commands, key, "commands");
|
|
1739
|
+
}
|
|
1740
|
+
if (root.registryUrl !== void 0) validateUrl(root.registryUrl, "registryUrl");
|
|
1741
|
+
optionalBoolean(root, "playground", "config");
|
|
1742
|
+
return config;
|
|
1743
|
+
}
|
|
1744
|
+
/** `Integr8 Window Manager` → `window-manager`. */
|
|
1745
|
+
function slugOf(name) {
|
|
1746
|
+
const slug = name.toLowerCase().replace(/^integr8[\s-]+/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1747
|
+
return slug === "" ? "site" : slug;
|
|
1748
|
+
}
|
|
1749
|
+
function repositoryUrlOf(pkg, env) {
|
|
1750
|
+
const fromEnv = env.GITHUB_REPOSITORY;
|
|
1751
|
+
if (fromEnv !== void 0 && fromEnv !== "") return `https://github.com/${fromEnv}`;
|
|
1752
|
+
const repository = typeof pkg.repository === "string" ? pkg.repository : pkg.repository?.url;
|
|
1753
|
+
if (repository === void 0 || repository === "") return;
|
|
1754
|
+
const cleaned = repository.replace(/^git\+/, "").replace(/^git:\/\//, "https://").replace(/^github:/, "https://github.com/").replace(/\.git$/, "");
|
|
1755
|
+
return /^https?:\/\//.test(cleaned) ? cleaned : void 0;
|
|
1756
|
+
}
|
|
1757
|
+
/**
|
|
1758
|
+
* The URL the registry JSON files are served from, without a trailing slash. The
|
|
1759
|
+
* `REGISTRY_BASE_URL` variable wins over `registryUrl` in the config; without either, it is the
|
|
1760
|
+
* GitHub Pages URL of the `repository` in `package.json`.
|
|
1761
|
+
*
|
|
1762
|
+
* @param configured - `registryUrl` from the site config.
|
|
1763
|
+
* @param pkg - Name, version and repository of the package.
|
|
1764
|
+
* @param env - Environment variables.
|
|
1765
|
+
* @returns The folder URL, for example `https://owner.github.io/repo/r`.
|
|
1766
|
+
* @throws {@link ConfigError} with code `INVALID_OPTION` when none of the three gives a URL.
|
|
1767
|
+
*/
|
|
1768
|
+
function resolveRegistryUrl(configured, pkg, env) {
|
|
1769
|
+
const fromEnv = env.REGISTRY_BASE_URL;
|
|
1770
|
+
let url = fromEnv !== void 0 && fromEnv !== "" ? fromEnv : configured;
|
|
1771
|
+
if (url === void 0) {
|
|
1772
|
+
const repository = repositoryUrlOf(pkg, {});
|
|
1773
|
+
const match = repository ? /^https:\/\/github\.com\/([^/]+)\/([^/]+)$/.exec(repository) : null;
|
|
1774
|
+
const owner = match?.[1];
|
|
1775
|
+
const repo = match?.[2];
|
|
1776
|
+
if (owner === void 0 || repo === void 0) throw new ConfigError("INVALID_OPTION", "registry-site config: cannot tell where the registry is served. Set \"registryUrl\" in the config, the REGISTRY_BASE_URL variable, or \"repository\" in package.json.");
|
|
1777
|
+
url = `https://${owner.toLowerCase()}.github.io/${repo}/r`;
|
|
1778
|
+
}
|
|
1779
|
+
return url.replace("{name}", pkg.name).replace("{version}", pkg.version).replace(/\/+$/, "");
|
|
1780
|
+
}
|
|
1781
|
+
/**
|
|
1782
|
+
* The folder where the ui item pages are: the ui registry URL without a trailing `/r`, then
|
|
1783
|
+
* `components/`.
|
|
1784
|
+
*
|
|
1785
|
+
* @param registryUrl - The ui registry URL.
|
|
1786
|
+
* @returns A URL ending with `/`.
|
|
1787
|
+
*/
|
|
1788
|
+
function uiDocsUrlOf(registryUrl) {
|
|
1789
|
+
return `${registryUrl.replace(/\/+$/, "").replace(/\/r$/, "")}/components/`;
|
|
1790
|
+
}
|
|
1791
|
+
/**
|
|
1792
|
+
* Validates a site config and fills in every default.
|
|
1793
|
+
*
|
|
1794
|
+
* @param config - The repo's config.
|
|
1795
|
+
* @param context - `package.json` facts and the environment (default: the process environment).
|
|
1796
|
+
* @returns The full config the renderers use.
|
|
1797
|
+
* @throws {@link ConfigError} with code `INVALID_OPTION` for bad input.
|
|
1798
|
+
*/
|
|
1799
|
+
function resolveSiteConfig(config, context) {
|
|
1800
|
+
defineRegistrySite(config);
|
|
1801
|
+
const { pkg } = context;
|
|
1802
|
+
const env = context.env ?? process.env;
|
|
1803
|
+
const brand = config.brand;
|
|
1804
|
+
const ui = config.ui ? {
|
|
1805
|
+
registryUrl: config.ui.registryUrl.replace(/\/+$/, ""),
|
|
1806
|
+
docsUrl: config.ui.docsUrl ?? uiDocsUrlOf(config.ui.registryUrl),
|
|
1807
|
+
packageName: config.ui.packageName ?? "@integr8-media/ui"
|
|
1808
|
+
} : void 0;
|
|
1809
|
+
const slug = brand.slug ?? slugOf(brand.name);
|
|
1810
|
+
const themeNote = config.themeNote === false ? void 0 : config.themeNote ? {
|
|
1811
|
+
item: config.themeNote.item,
|
|
1812
|
+
skip: [...config.themeNote.skip ?? []],
|
|
1813
|
+
external: false
|
|
1814
|
+
} : ui ? {
|
|
1815
|
+
item: "theme",
|
|
1816
|
+
skip: [],
|
|
1817
|
+
external: true
|
|
1818
|
+
} : void 0;
|
|
1819
|
+
return {
|
|
1820
|
+
brand: {
|
|
1821
|
+
name: brand.name,
|
|
1822
|
+
slug,
|
|
1823
|
+
mark: brand.mark ?? "i8",
|
|
1824
|
+
eyebrow: brand.eyebrow ?? "Registry",
|
|
1825
|
+
heroTitle: brand.heroTitle ?? brand.name,
|
|
1826
|
+
heroAccent: brand.heroAccent ?? "",
|
|
1827
|
+
lead: brand.lead ?? pkg.description ?? "",
|
|
1828
|
+
title: brand.title ?? brand.name,
|
|
1829
|
+
description: brand.description ?? pkg.description ?? `${brand.name} as a shadcn registry.`,
|
|
1830
|
+
footer: brand.footer ?? "{packageName} · v{version}",
|
|
1831
|
+
repositoryUrl: brand.repositoryUrl ?? repositoryUrlOf(pkg, env),
|
|
1832
|
+
playgroundLink: brand.playgroundLink ?? false,
|
|
1833
|
+
extraStats: brand.extraStats ?? [],
|
|
1834
|
+
heroCommand: brand.heroCommand
|
|
1835
|
+
},
|
|
1836
|
+
categories: config.categories,
|
|
1837
|
+
stories: config.stories,
|
|
1838
|
+
defaultStory: config.defaultStory,
|
|
1839
|
+
ui,
|
|
1840
|
+
fonts: config.fonts ?? (ui ? "copy-from-ui" : false),
|
|
1841
|
+
setup: {
|
|
1842
|
+
title: config.setup?.title,
|
|
1843
|
+
accent: config.setup?.accent ?? "your first screen.",
|
|
1844
|
+
lead: config.setup?.lead ?? "Works in any React 19 app set up for shadcn/ui with Tailwind v4: Next.js, Vite, React Router.",
|
|
1845
|
+
steps: config.setup?.steps,
|
|
1846
|
+
extras: config.setup?.extras ?? [],
|
|
1847
|
+
namespace: config.setup?.namespace ?? (ui ? `@integr8-${slug}` : "@integr8"),
|
|
1848
|
+
shortNames: config.setup?.shortNames ?? true
|
|
1849
|
+
},
|
|
1850
|
+
copy: {
|
|
1851
|
+
previewLead: config.copy?.previewLead,
|
|
1852
|
+
componentsLead: config.copy?.componentsLead ?? "Search, preview and copy. Every item installs with one command and has its own page with docs."
|
|
1853
|
+
},
|
|
1854
|
+
previewThemes: config.previewThemes ?? [],
|
|
1855
|
+
themeNote,
|
|
1856
|
+
bundleSizes: config.bundleSizes ? {
|
|
1857
|
+
entry: config.bundleSizes.entry ?? "dist/index.mjs",
|
|
1858
|
+
ownCodeLabel: config.bundleSizes.ownCodeLabel ?? `${pkg.name} (dist)`,
|
|
1859
|
+
external: config.bundleSizes.external ?? [
|
|
1860
|
+
"react",
|
|
1861
|
+
"react-dom",
|
|
1862
|
+
"react/*",
|
|
1863
|
+
"react-dom/*"
|
|
1864
|
+
]
|
|
1865
|
+
} : void 0,
|
|
1866
|
+
commands: {
|
|
1867
|
+
assets: config.commands?.assets,
|
|
1868
|
+
registry: config.commands?.registry ?? "node scripts/buildRegistry.mjs"
|
|
1869
|
+
},
|
|
1870
|
+
registryUrl: config.registryUrl,
|
|
1871
|
+
playground: config.playground ?? true
|
|
1872
|
+
};
|
|
1873
|
+
}
|
|
1874
|
+
// src/registrySite/siteSwitch.ts
|
|
1875
|
+
/**
|
|
1876
|
+
* Whether the registry site is on, from `integr8.site` in a parsed `package.json`.
|
|
1877
|
+
*
|
|
1878
|
+
* - key missing: on (a repo that calls the site workflow wants a site)
|
|
1879
|
+
* - `"integr8": { "site": false }`: off
|
|
1880
|
+
* - `"integr8": { "site": true }`: on
|
|
1881
|
+
*
|
|
1882
|
+
* This is the only on/off switch. The reusable workflow `registry-site.yml` reads the same key,
|
|
1883
|
+
* and the `integr8-registry-site` commands exit with code 0 without building when it is `false`.
|
|
1884
|
+
*
|
|
1885
|
+
* @param pkg - The parsed `package.json`.
|
|
1886
|
+
* @returns `false` only when `integr8.site` is `false`.
|
|
1887
|
+
* @throws {@link RegistrySiteError} with code `INVALID_SWITCH` for any other value, so a typo
|
|
1888
|
+
* such as `"site": "false"` never silently builds (or skips) the site.
|
|
1889
|
+
*
|
|
1890
|
+
* @example
|
|
1891
|
+
* ```ts
|
|
1892
|
+
* readSiteSwitch({ name: 'x', integr8: { site: false } }); // false
|
|
1893
|
+
* readSiteSwitch({ name: 'x' }); // true
|
|
1894
|
+
* ```
|
|
1895
|
+
*/
|
|
1896
|
+
function readSiteSwitch(pkg) {
|
|
1897
|
+
if (typeof pkg !== "object" || pkg === null || !("integr8" in pkg)) return true;
|
|
1898
|
+
const { integr8 } = pkg;
|
|
1899
|
+
if (typeof integr8 !== "object" || integr8 === null || Array.isArray(integr8)) throw new RegistrySiteError("INVALID_SWITCH", "package.json \"integr8\" must be an object such as { \"site\": false }.");
|
|
1900
|
+
if (!("site" in integr8)) return true;
|
|
1901
|
+
const { site } = integr8;
|
|
1902
|
+
if (typeof site !== "boolean") throw new RegistrySiteError("INVALID_SWITCH", `package.json "integr8.site" must be true or false (a JSON boolean, not a string); got ${JSON.stringify(site)}.`);
|
|
1903
|
+
return site;
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* Reads `package.json` in a folder and returns the parsed file with its switch.
|
|
1907
|
+
*
|
|
1908
|
+
* @param root - The repo root.
|
|
1909
|
+
* @returns The parsed `package.json` and `enabled`, the result of {@link readSiteSwitch}.
|
|
1910
|
+
* @throws {@link RegistrySiteError} with code `INVALID_SWITCH` for a bad switch value.
|
|
1911
|
+
*/
|
|
1912
|
+
async function readPackageAndSwitch(root) {
|
|
1913
|
+
const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
|
|
1914
|
+
return {
|
|
1915
|
+
pkg,
|
|
1916
|
+
enabled: readSiteSwitch(pkg)
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
// src/registrySite/stories.ts
|
|
1920
|
+
/**
|
|
1921
|
+
* The stories of a parsed Ladle `meta.json`. Anything that is not a story map gives `{}`.
|
|
1922
|
+
*
|
|
1923
|
+
* @param meta - The parsed `playground/dist/meta.json`.
|
|
1924
|
+
* @returns Story id → details.
|
|
1925
|
+
*/
|
|
1926
|
+
function readLadleStories(meta) {
|
|
1927
|
+
if (typeof meta !== "object" || meta === null || !("stories" in meta)) return {};
|
|
1928
|
+
const { stories } = meta;
|
|
1929
|
+
return typeof stories === "object" && stories !== null && !Array.isArray(stories) ? stories : {};
|
|
1930
|
+
}
|
|
1931
|
+
/**
|
|
1932
|
+
* The Ladle stories that preview an item.
|
|
1933
|
+
*
|
|
1934
|
+
* - `stories` not set: the stories whose id starts with `<item>--` (the id of `Button.stories.tsx`
|
|
1935
|
+
* starts with `button--`).
|
|
1936
|
+
* - a map or a function: the stories whose id contains one of the item's parts. An item the map
|
|
1937
|
+
* does not list has no stories, so no preview.
|
|
1938
|
+
*
|
|
1939
|
+
* @param itemName - The registry item name.
|
|
1940
|
+
* @param mapping - The `stories` option of the site config.
|
|
1941
|
+
* @param ladle - Every story in the playground.
|
|
1942
|
+
* @returns The matching stories in playground order.
|
|
1943
|
+
*/
|
|
1944
|
+
function storiesOf(itemName, mapping, ladle) {
|
|
1945
|
+
let matches;
|
|
1946
|
+
if (mapping === void 0) matches = (id) => id.startsWith(`${itemName}--`);
|
|
1947
|
+
else {
|
|
1948
|
+
const parts = typeof mapping === "function" ? mapping(itemName) : Object.hasOwn(mapping, itemName) ? mapping[itemName] : void 0;
|
|
1949
|
+
if (!parts || parts.length === 0) return [];
|
|
1950
|
+
matches = (id) => parts.some((part) => id.includes(part));
|
|
1951
|
+
}
|
|
1952
|
+
return Object.entries(ladle).filter(([id]) => matches(id)).map(([id, story]) => ({
|
|
1953
|
+
id,
|
|
1954
|
+
name: story.name ?? id
|
|
1955
|
+
}));
|
|
1956
|
+
}
|
|
1957
|
+
/**
|
|
1958
|
+
* The story the landing page's "Preview" button opens: `<item>--default` when it is one of the
|
|
1959
|
+
* item's stories, else its first one.
|
|
1960
|
+
*
|
|
1961
|
+
* @param itemName - The registry item name.
|
|
1962
|
+
* @param stories - The item's stories, from {@link storiesOf}.
|
|
1963
|
+
* @returns A story id, or `undefined` for an item without stories.
|
|
1964
|
+
*/
|
|
1965
|
+
function previewStoryOf(itemName, stories) {
|
|
1966
|
+
return (stories.find((story) => story.id === `${itemName}--default`) ?? stories[0])?.id;
|
|
1967
|
+
}
|
|
1968
|
+
/**
|
|
1969
|
+
* The story the live preview shows first: `defaultStory` as an item name (its preview story), or
|
|
1970
|
+
* as a story id. `undefined` when it names neither, so the page falls back to its first story.
|
|
1971
|
+
*
|
|
1972
|
+
* @param defaultStory - The `defaultStory` option.
|
|
1973
|
+
* @param previewStories - Item name → preview story id.
|
|
1974
|
+
* @param ladle - Every story in the playground.
|
|
1975
|
+
* @returns A story id, or `undefined`.
|
|
1976
|
+
*/
|
|
1977
|
+
function defaultStoryOf(defaultStory, previewStories, ladle) {
|
|
1978
|
+
if (defaultStory === void 0) return;
|
|
1979
|
+
if (Object.hasOwn(previewStories, defaultStory)) return previewStories[defaultStory];
|
|
1980
|
+
return Object.hasOwn(ladle, defaultStory) ? defaultStory : void 0;
|
|
1981
|
+
}
|
|
1982
|
+
// src/registrySite/buildRegistrySite.ts
|
|
1983
|
+
const DOCS_PATH = "components/";
|
|
1984
|
+
/**
|
|
1985
|
+
* Renders every page of the site, without touching the file system.
|
|
1986
|
+
*
|
|
1987
|
+
* @param input - Config, the registry and what the site contains.
|
|
1988
|
+
* @returns The landing page and one page per item.
|
|
1989
|
+
*/
|
|
1990
|
+
function renderSiteFiles(input) {
|
|
1991
|
+
const { config, index, items, baseUrl, version, ladle } = input;
|
|
1992
|
+
const playgroundPath = input.playground ? "playground/" : void 0;
|
|
1993
|
+
const fontsHref = input.fonts ? "assets/fonts.css" : void 0;
|
|
1994
|
+
const storiesByItem = new Map(index.items.map((item) => [item.name, storiesOf(item.name, config.stories, ladle)]));
|
|
1995
|
+
const previewStories = {};
|
|
1996
|
+
for (const [name, stories] of storiesByItem) {
|
|
1997
|
+
const id = previewStoryOf(name, stories);
|
|
1998
|
+
if (id) previewStories[name] = id;
|
|
1999
|
+
}
|
|
2000
|
+
const exportOwners = /* @__PURE__ */ new Map();
|
|
2001
|
+
const usedBy = /* @__PURE__ */ new Map();
|
|
2002
|
+
for (const item of items) {
|
|
2003
|
+
for (const doc of itemDocs(item)) if (!exportOwners.has(doc.name)) exportOwners.set(doc.name, item.name);
|
|
2004
|
+
for (const url of item.registryDependencies ?? []) {
|
|
2005
|
+
const dependency = itemNameFromUrl(url);
|
|
2006
|
+
usedBy.set(dependency, [...usedBy.get(dependency) ?? [], item.name]);
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
const groups = groupItems(index.items, config.categories);
|
|
2010
|
+
const site = {
|
|
2011
|
+
config,
|
|
2012
|
+
baseUrl,
|
|
2013
|
+
version,
|
|
2014
|
+
registryName: index.name,
|
|
2015
|
+
groups,
|
|
2016
|
+
order: groups.flatMap((group) => group.items),
|
|
2017
|
+
exportOwners,
|
|
2018
|
+
docsPath: DOCS_PATH,
|
|
2019
|
+
playgroundPath,
|
|
2020
|
+
fontsHref
|
|
2021
|
+
};
|
|
2022
|
+
const pages = [{
|
|
2023
|
+
path: "index.html",
|
|
2024
|
+
html: renderRegistryPage(index, {
|
|
2025
|
+
config,
|
|
2026
|
+
baseUrl,
|
|
2027
|
+
version,
|
|
2028
|
+
docsPath: DOCS_PATH,
|
|
2029
|
+
playgroundPath,
|
|
2030
|
+
fontsHref,
|
|
2031
|
+
previewStories,
|
|
2032
|
+
defaultStory: defaultStoryOf(config.defaultStory, previewStories, ladle)
|
|
2033
|
+
})
|
|
2034
|
+
}];
|
|
2035
|
+
for (const item of items) pages.push({
|
|
2036
|
+
path: `${DOCS_PATH}${item.name}/index.html`,
|
|
2037
|
+
html: renderItemPage({
|
|
2038
|
+
item,
|
|
2039
|
+
stories: storiesByItem.get(item.name) ?? [],
|
|
2040
|
+
usedBy: [...usedBy.get(item.name) ?? []].sort()
|
|
2041
|
+
}, site)
|
|
2042
|
+
});
|
|
2043
|
+
return pages;
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
2046
|
+
* Checks that a built playground loads its assets from where the site serves it.
|
|
2047
|
+
*
|
|
2048
|
+
* A Ladle build made without `--base <site path>playground/` asks for `/assets/...`, which is a
|
|
2049
|
+
* 404 under `site/playground/`, so every preview iframe stays blank.
|
|
2050
|
+
*
|
|
2051
|
+
* @param html - The text of `playground/dist/index.html`.
|
|
2052
|
+
* @param base - The expected base, from {@link playgroundBase}.
|
|
2053
|
+
* @throws {@link RegistrySiteError} with code `PLAYGROUND_BASE_MISMATCH` when an asset URL is
|
|
2054
|
+
* absolute but outside `base`.
|
|
2055
|
+
*/
|
|
2056
|
+
function checkPlaygroundBase(html, base) {
|
|
2057
|
+
const wrong = [...html.matchAll(/\b(?:src|href)="(\/[^"]*\/assets\/[^"]*|\/assets\/[^"]*)"/g)].map((match) => match[1] ?? "").find((url) => !url.startsWith(base));
|
|
2058
|
+
if (wrong !== void 0) throw new RegistrySiteError("PLAYGROUND_BASE_MISMATCH", `The playground in playground/dist was built for a different base: it loads ${wrong}, but the site serves it from ${base}. Rebuild it with \`ladle build --base ${base}\`, or run \`integr8-registry-site build\`, which always passes the right base.`);
|
|
2059
|
+
}
|
|
2060
|
+
/**
|
|
2061
|
+
* Loads and validates a site config file.
|
|
2062
|
+
*
|
|
2063
|
+
* @param root - The repo root.
|
|
2064
|
+
* @param file - The config file, relative to `root`. Defaults to `registry/site.config.mjs`.
|
|
2065
|
+
* @returns The default export of the file.
|
|
2066
|
+
* @throws {@link RegistrySiteError} with code `CONFIG_NOT_FOUND` when the file is missing.
|
|
2067
|
+
*/
|
|
2068
|
+
async function loadSiteConfig(root, file = "registry/site.config.mjs") {
|
|
2069
|
+
const path = isAbsolute(file) ? file : join(root, file);
|
|
2070
|
+
if (!existsSync(path)) throw new RegistrySiteError("CONFIG_NOT_FOUND", `The site config ${file} does not exist. Create it with defineRegistrySite({ brand: { name: '...' } }), or set "integr8": { "site": false } in package.json if this package has no site.`);
|
|
2071
|
+
const loaded = await import(pathToFileURL(path).href);
|
|
2072
|
+
return loaded.default ?? loaded;
|
|
2073
|
+
}
|
|
2074
|
+
async function readJson(path) {
|
|
2075
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
2076
|
+
}
|
|
2077
|
+
async function copyFonts(config, root, siteDir) {
|
|
2078
|
+
let fromDir;
|
|
2079
|
+
if (config.fonts === "copy-from-ui") {
|
|
2080
|
+
const packageName = config.ui?.packageName ?? "@integr8-media/ui";
|
|
2081
|
+
try {
|
|
2082
|
+
fromDir = dirname(createRequire(join(root, "package.json")).resolve(`${packageName}/fonts.css`));
|
|
2083
|
+
} catch (cause) {
|
|
2084
|
+
throw new RegistrySiteError("UI_PACKAGE_NOT_FOUND", `fonts is 'copy-from-ui', but ${packageName} is not installed (it must export ./fonts.css). Install it as a devDependency, or set fonts: false.`, { cause });
|
|
2085
|
+
}
|
|
2086
|
+
} else if (config.fonts === "build") fromDir = join(root, "dist");
|
|
2087
|
+
else return false;
|
|
2088
|
+
if (!existsSync(join(fromDir, "fonts.css")) || !existsSync(join(fromDir, "fonts"))) {
|
|
2089
|
+
if (config.fonts === "copy-from-ui") throw new RegistrySiteError("UI_PACKAGE_NOT_FOUND", `Expected fonts.css and fonts/ in ${fromDir}, but they are missing. Build the ui package, or set fonts: false.`);
|
|
2090
|
+
return false;
|
|
2091
|
+
}
|
|
2092
|
+
await mkdir(join(siteDir, "assets"), { recursive: true });
|
|
2093
|
+
await cp(join(fromDir, "fonts.css"), join(siteDir, "assets", "fonts.css"));
|
|
2094
|
+
await cp(join(fromDir, "fonts"), join(siteDir, "assets", "fonts"), { recursive: true });
|
|
2095
|
+
return true;
|
|
2096
|
+
}
|
|
2097
|
+
/**
|
|
2098
|
+
* Builds the registry site into `site/`: the landing page, one page per item, the registry JSON
|
|
2099
|
+
* (`r/`), the fonts and the Ladle playground. Needs `dist/registry/` (the registry builder's
|
|
2100
|
+
* output) and, for live previews, `playground/dist/` built with the base from
|
|
2101
|
+
* {@link playgroundBase}.
|
|
2102
|
+
*
|
|
2103
|
+
* Does nothing (and does not fail) when `"integr8": { "site": false }` is set in `package.json`.
|
|
2104
|
+
*
|
|
2105
|
+
* @param options - Root, config, environment and site path; all optional.
|
|
2106
|
+
* @returns What was built.
|
|
2107
|
+
* @throws {@link ConfigError} with code `INVALID_OPTION` for a bad config,
|
|
2108
|
+
* {@link RegistrySiteError} for a missing config, registry or fonts, a bad switch value, or a
|
|
2109
|
+
* playground built with the wrong `--base`.
|
|
2110
|
+
*/
|
|
2111
|
+
async function buildRegistrySite(options = {}) {
|
|
2112
|
+
const root = resolve(options.root ?? process.cwd());
|
|
2113
|
+
const env = options.env ?? process.env;
|
|
2114
|
+
const log = options.log ?? ((line) => void process.stdout.write(`${line}\n`));
|
|
2115
|
+
const siteDir = join(root, "site");
|
|
2116
|
+
const { pkg, enabled } = await readPackageAndSwitch(root);
|
|
2117
|
+
if (!enabled) {
|
|
2118
|
+
log("registry site is off (\"integr8\": { \"site\": false } in package.json): nothing to build.");
|
|
2119
|
+
return {
|
|
2120
|
+
enabled,
|
|
2121
|
+
siteDir,
|
|
2122
|
+
pages: 0,
|
|
2123
|
+
fonts: false,
|
|
2124
|
+
playground: false,
|
|
2125
|
+
baseUrl: ""
|
|
2126
|
+
};
|
|
2127
|
+
}
|
|
2128
|
+
const sitePackage = pkg;
|
|
2129
|
+
const config = resolveSiteConfig(typeof options.config === "object" ? options.config : await loadSiteConfig(root, options.config), {
|
|
2130
|
+
pkg: sitePackage,
|
|
2131
|
+
env
|
|
2132
|
+
});
|
|
2133
|
+
const baseUrl = resolveRegistryUrl(config.registryUrl, sitePackage, env);
|
|
2134
|
+
const registryDir = join(root, "dist", "registry");
|
|
2135
|
+
const indexPath = join(registryDir, "registry.json");
|
|
2136
|
+
if (!existsSync(indexPath)) throw new RegistrySiteError("REGISTRY_NOT_BUILT", `${indexPath} is missing. Build the registry first (config "commands.registry", by default node scripts/buildRegistry.mjs).`);
|
|
2137
|
+
const index = await readJson(indexPath);
|
|
2138
|
+
const items = await Promise.all(index.items.map(({ name }) => readJson(join(registryDir, `${name}.json`))));
|
|
2139
|
+
const playgroundDir = join(root, "playground", "dist");
|
|
2140
|
+
const withPlayground = options.playground !== false && config.playground && existsSync(join(playgroundDir, "index.html"));
|
|
2141
|
+
let ladle = {};
|
|
2142
|
+
if (withPlayground) {
|
|
2143
|
+
const base = playgroundBase(options.siteBasePath ?? env.SITE_BASE_PATH ?? normalizeSitePath(""));
|
|
2144
|
+
checkPlaygroundBase(await readFile(join(playgroundDir, "index.html"), "utf8"), base);
|
|
2145
|
+
const metaPath = join(playgroundDir, "meta.json");
|
|
2146
|
+
if (existsSync(metaPath)) ladle = readLadleStories(await readJson(metaPath));
|
|
2147
|
+
}
|
|
2148
|
+
await rm(siteDir, {
|
|
2149
|
+
recursive: true,
|
|
2150
|
+
force: true
|
|
2151
|
+
});
|
|
2152
|
+
await mkdir(siteDir, { recursive: true });
|
|
2153
|
+
await cp(registryDir, join(siteDir, "r"), { recursive: true });
|
|
2154
|
+
const withFonts = await copyFonts(config, root, siteDir);
|
|
2155
|
+
if (withPlayground) await cp(playgroundDir, join(siteDir, "playground"), { recursive: true });
|
|
2156
|
+
const pages = renderSiteFiles({
|
|
2157
|
+
config,
|
|
2158
|
+
index,
|
|
2159
|
+
items,
|
|
2160
|
+
baseUrl,
|
|
2161
|
+
version: sitePackage.version,
|
|
2162
|
+
ladle,
|
|
2163
|
+
playground: withPlayground,
|
|
2164
|
+
fonts: withFonts
|
|
2165
|
+
});
|
|
2166
|
+
for (const page of pages) {
|
|
2167
|
+
const file = join(siteDir, page.path);
|
|
2168
|
+
await mkdir(dirname(file), { recursive: true });
|
|
2169
|
+
await writeFile(file, page.html);
|
|
2170
|
+
}
|
|
2171
|
+
await writeFile(join(siteDir, ".nojekyll"), "");
|
|
2172
|
+
log(`site/ (index.html + ${items.length} item pages + r/${withFonts ? ", fonts" : ""}${withPlayground ? ", playground" : ""}) for ${baseUrl}`);
|
|
2173
|
+
return {
|
|
2174
|
+
enabled,
|
|
2175
|
+
siteDir,
|
|
2176
|
+
pages: pages.length,
|
|
2177
|
+
fonts: withFonts,
|
|
2178
|
+
playground: withPlayground,
|
|
2179
|
+
baseUrl
|
|
2180
|
+
};
|
|
2181
|
+
}
|
|
2182
|
+
// src/registrySite/bundleSizes.ts
|
|
2183
|
+
/** Label for this package's own built files, when none is given. */
|
|
2184
|
+
const OWN_CODE = "this package (dist)";
|
|
2185
|
+
/**
|
|
2186
|
+
* The package a bundled module belongs to: the name after the last `node_modules/` (with its
|
|
2187
|
+
* scope), `ownCode` for `dist/`, and `(other)` for anything else (such as `<stdin>`).
|
|
2188
|
+
*
|
|
2189
|
+
* @param path - The module path.
|
|
2190
|
+
* @param ownCode - Label for this package's own code.
|
|
2191
|
+
* @returns A package name or label.
|
|
2192
|
+
*/
|
|
2193
|
+
function packageOf(path, ownCode = OWN_CODE) {
|
|
2194
|
+
const normalized = path.replaceAll("\\", "/");
|
|
2195
|
+
const at = normalized.lastIndexOf("node_modules/");
|
|
2196
|
+
if (at !== -1) {
|
|
2197
|
+
const parts = normalized.slice(at + 13).split("/");
|
|
2198
|
+
return parts[0]?.startsWith("@") ? `${parts[0]}/${parts[1]}` : parts[0] ?? "(other)";
|
|
2199
|
+
}
|
|
2200
|
+
if (normalized.startsWith("dist/") || normalized.includes("/dist/")) return ownCode;
|
|
2201
|
+
return "(other)";
|
|
2202
|
+
}
|
|
2203
|
+
/**
|
|
2204
|
+
* Groups the inputs of one esbuild output by package, largest first. Modules that add no bytes
|
|
2205
|
+
* (removed by tree shaking) are left out.
|
|
2206
|
+
*
|
|
2207
|
+
* @param inputs - `metafile.outputs[file].inputs`.
|
|
2208
|
+
* @param ownCode - Label for this package's own code.
|
|
2209
|
+
* @returns One entry per package.
|
|
2210
|
+
*/
|
|
2211
|
+
function groupByPackage(inputs, ownCode = OWN_CODE) {
|
|
2212
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2213
|
+
for (const [rawPath, { bytesInOutput }] of Object.entries(inputs)) {
|
|
2214
|
+
if (bytesInOutput <= 0) continue;
|
|
2215
|
+
const path = rawPath.replaceAll("\\", "/");
|
|
2216
|
+
const name = packageOf(path, ownCode);
|
|
2217
|
+
const group = groups.get(name) ?? {
|
|
2218
|
+
name,
|
|
2219
|
+
bytes: 0,
|
|
2220
|
+
modules: []
|
|
2221
|
+
};
|
|
2222
|
+
group.bytes += bytesInOutput;
|
|
2223
|
+
group.modules.push({
|
|
2224
|
+
path,
|
|
2225
|
+
bytes: bytesInOutput
|
|
2226
|
+
});
|
|
2227
|
+
groups.set(name, group);
|
|
2228
|
+
}
|
|
2229
|
+
const list = [...groups.values()];
|
|
2230
|
+
for (const group of list) group.modules.sort((a, b) => b.bytes - a.bytes);
|
|
2231
|
+
return list.sort((a, b) => b.bytes - a.bytes || a.name.localeCompare(b.name));
|
|
2232
|
+
}
|
|
2233
|
+
/** `12345` → `12.35 kB` (1 kB = 1000 bytes, like size-limit). */
|
|
2234
|
+
function formatKb(bytes) {
|
|
2235
|
+
return `${(bytes / 1e3).toFixed(2)} kB`;
|
|
2236
|
+
}
|
|
2237
|
+
/** The part of a module path after its package folder (or after `dist/`), for short lines. */
|
|
2238
|
+
function shortPath(path) {
|
|
2239
|
+
const normalized = path.replaceAll("\\", "/");
|
|
2240
|
+
const at = normalized.lastIndexOf("node_modules/");
|
|
2241
|
+
if (at !== -1) {
|
|
2242
|
+
const parts = normalized.slice(at + 13).split("/");
|
|
2243
|
+
const skip = parts[0]?.startsWith("@") ? 2 : 1;
|
|
2244
|
+
return parts.slice(skip).join("/") || normalized;
|
|
2245
|
+
}
|
|
2246
|
+
const dist = normalized.indexOf("dist/");
|
|
2247
|
+
return dist === -1 ? normalized : normalized.slice(dist);
|
|
2248
|
+
}
|
|
2249
|
+
/**
|
|
2250
|
+
* The text report for one import: totals, then one line per package with its minified size, an
|
|
2251
|
+
* estimate of its brotli share, and its share of the bundle, then its largest modules.
|
|
2252
|
+
*
|
|
2253
|
+
* The brotli share of a package is estimated from its minified share: compression works on the
|
|
2254
|
+
* whole bundle, so an exact per-package brotli size does not exist.
|
|
2255
|
+
*
|
|
2256
|
+
* @param report - The measured import.
|
|
2257
|
+
* @returns Text, one line per row.
|
|
2258
|
+
*/
|
|
2259
|
+
function formatReport(report) {
|
|
2260
|
+
const { label, groups, minifiedBytes, brotliBytes, topModules = 3 } = report;
|
|
2261
|
+
const lines = [
|
|
2262
|
+
label,
|
|
2263
|
+
` Total: ${formatKb(minifiedBytes)} minified, ${formatKb(brotliBytes)} brotli`,
|
|
2264
|
+
"",
|
|
2265
|
+
` ${"Package".padEnd(36)}${"Minified".padStart(12)}${"~Brotli".padStart(12)}${"Share".padStart(8)}`
|
|
2266
|
+
];
|
|
2267
|
+
for (const group of groups) {
|
|
2268
|
+
const share = minifiedBytes > 0 ? group.bytes / minifiedBytes : 0;
|
|
2269
|
+
lines.push(` ${group.name.padEnd(36)}${formatKb(group.bytes).padStart(12)}${formatKb(share * brotliBytes).padStart(12)}${`${(share * 100).toFixed(1)}%`.padStart(8)}`);
|
|
2270
|
+
for (const item of group.modules.slice(0, topModules)) lines.push(` ${formatKb(item.bytes).padStart(10)} ${shortPath(item.path)}`);
|
|
2271
|
+
}
|
|
2272
|
+
return lines.join("\n");
|
|
2273
|
+
}
|
|
2274
|
+
/**
|
|
2275
|
+
* One line per export, largest brotli size first: the overview printed when no export is named.
|
|
2276
|
+
*
|
|
2277
|
+
* @param rows - The measured exports.
|
|
2278
|
+
* @returns Text, one line per export.
|
|
2279
|
+
*/
|
|
2280
|
+
function formatSummary(rows) {
|
|
2281
|
+
const sorted = [...rows].sort((a, b) => b.brotliBytes - a.brotliBytes || a.name.localeCompare(b.name));
|
|
2282
|
+
const width = Math.max(6, ...sorted.map((row) => row.name.length)) + 2;
|
|
2283
|
+
const lines = [` ${"Export".padEnd(width)}${"Minified".padStart(12)}${"Brotli".padStart(12)} Largest package`];
|
|
2284
|
+
for (const row of sorted) lines.push(` ${row.name.padEnd(width)}${formatKb(row.minifiedBytes).padStart(12)}${formatKb(row.brotliBytes).padStart(12)} ${row.largest ?? ""}`);
|
|
2285
|
+
return lines.join("\n");
|
|
2286
|
+
}
|
|
2287
|
+
/**
|
|
2288
|
+
* The runtime export names of a module, from esbuild's metafile (`outputs[file].exports`),
|
|
2289
|
+
* sorted. Names starting with `_` are left out.
|
|
2290
|
+
*
|
|
2291
|
+
* @param exportNames - The names esbuild reports.
|
|
2292
|
+
* @returns Unique public names.
|
|
2293
|
+
*/
|
|
2294
|
+
function publicExports(exportNames) {
|
|
2295
|
+
return [...new Set(exportNames)].filter((name) => name !== "default" && !name.startsWith("_")).sort((a, b) => a.localeCompare(b));
|
|
2296
|
+
}
|
|
2297
|
+
// src/registrySite/serveSite.ts
|
|
2298
|
+
const TYPES = {
|
|
2299
|
+
".html": "text/html; charset=utf-8",
|
|
2300
|
+
".js": "text/javascript; charset=utf-8",
|
|
2301
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
2302
|
+
".css": "text/css; charset=utf-8",
|
|
2303
|
+
".json": "application/json; charset=utf-8",
|
|
2304
|
+
".svg": "image/svg+xml",
|
|
2305
|
+
".png": "image/png",
|
|
2306
|
+
".ico": "image/x-icon",
|
|
2307
|
+
".woff": "font/woff",
|
|
2308
|
+
".woff2": "font/woff2",
|
|
2309
|
+
".txt": "text/plain; charset=utf-8"
|
|
2310
|
+
};
|
|
2311
|
+
/**
|
|
2312
|
+
* The file for a URL path: folders serve their `index.html`; `null` for paths outside the root
|
|
2313
|
+
* or without a file.
|
|
2314
|
+
*
|
|
2315
|
+
* @param root - The folder to serve.
|
|
2316
|
+
* @param urlPath - The path of the request URL.
|
|
2317
|
+
* @returns An absolute file path, or `null`.
|
|
2318
|
+
*/
|
|
2319
|
+
async function resolveSiteFile(root, urlPath) {
|
|
2320
|
+
const base = resolve(root);
|
|
2321
|
+
let path;
|
|
2322
|
+
try {
|
|
2323
|
+
path = resolve(base, `.${decodeURIComponent(urlPath)}`);
|
|
2324
|
+
} catch {
|
|
2325
|
+
return null;
|
|
2326
|
+
}
|
|
2327
|
+
if (path !== base && !path.startsWith(base + sep)) return null;
|
|
2328
|
+
try {
|
|
2329
|
+
const info = await stat(path);
|
|
2330
|
+
if (info.isFile()) return path;
|
|
2331
|
+
if (info.isDirectory()) {
|
|
2332
|
+
const index = join(path, "index.html");
|
|
2333
|
+
if ((await stat(index)).isFile()) return index;
|
|
2334
|
+
}
|
|
2335
|
+
} catch {}
|
|
2336
|
+
return null;
|
|
2337
|
+
}
|
|
2338
|
+
/**
|
|
2339
|
+
* Creates (does not start) the static server for a folder.
|
|
2340
|
+
*
|
|
2341
|
+
* @param root - The folder to serve, normally `site/`.
|
|
2342
|
+
* @returns A Node HTTP server. Folders without a trailing slash are redirected, so relative
|
|
2343
|
+
* links on the page resolve; responses are never cached.
|
|
2344
|
+
*/
|
|
2345
|
+
function createSiteServer(root) {
|
|
2346
|
+
const base = resolve(root);
|
|
2347
|
+
return createServer(async (request, response) => {
|
|
2348
|
+
const { pathname } = new URL(request.url ?? "/", "http://localhost");
|
|
2349
|
+
const file = await resolveSiteFile(base, pathname);
|
|
2350
|
+
if (file?.endsWith(`${sep}index.html`) && !pathname.endsWith("/") && !pathname.endsWith(".html")) {
|
|
2351
|
+
response.writeHead(301, { location: `${pathname}/` }).end();
|
|
2352
|
+
return;
|
|
2353
|
+
}
|
|
2354
|
+
if (!file) {
|
|
2355
|
+
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("Not found");
|
|
2356
|
+
return;
|
|
2357
|
+
}
|
|
2358
|
+
response.writeHead(200, {
|
|
2359
|
+
"content-type": TYPES[extname(file).toLowerCase()] ?? "application/octet-stream",
|
|
2360
|
+
"cache-control": "no-store"
|
|
2361
|
+
});
|
|
2362
|
+
createReadStream(file).pipe(response);
|
|
2363
|
+
});
|
|
2364
|
+
}
|
|
2365
|
+
/**
|
|
2366
|
+
* Serves a folder on a port and resolves when it is listening.
|
|
2367
|
+
*
|
|
2368
|
+
* @param root - The folder to serve.
|
|
2369
|
+
* @param port - The port (`0` picks a free one).
|
|
2370
|
+
* @returns The running server.
|
|
2371
|
+
*/
|
|
2372
|
+
function serveSite(root, port) {
|
|
2373
|
+
const server = createSiteServer(root);
|
|
2374
|
+
return new Promise((resolveListening, reject) => {
|
|
2375
|
+
server.once("error", reject);
|
|
2376
|
+
server.listen(port, () => resolveListening(server));
|
|
2377
|
+
});
|
|
2378
|
+
}
|
|
2379
|
+
// src/registrySite/sizeBreakdown.ts
|
|
2380
|
+
/**
|
|
2381
|
+
* Prints the bundle size of each export (or of the named ones) with the per-package breakdown.
|
|
2382
|
+
*
|
|
2383
|
+
* @param options - Root, entry, labels and arguments.
|
|
2384
|
+
* @throws {@link RegistrySiteError} with code `BUNDLE_SIZES_UNAVAILABLE` when the entry is not
|
|
2385
|
+
* built, esbuild is missing, or an export cannot be bundled.
|
|
2386
|
+
*/
|
|
2387
|
+
async function runSizeBreakdown(options) {
|
|
2388
|
+
const { root, entry, ownCodeLabel, args, log } = options;
|
|
2389
|
+
const entryPath = join(root, entry);
|
|
2390
|
+
const modulesArg = args.find((arg) => arg.startsWith("--modules="));
|
|
2391
|
+
const parsedModules = modulesArg ? Number(modulesArg.slice(10)) : 3;
|
|
2392
|
+
const topModules = Number.isFinite(parsedModules) && parsedModules >= 0 ? parsedModules : 3;
|
|
2393
|
+
const names = args.filter((arg) => !arg.startsWith("--"));
|
|
2394
|
+
const details = names.length > 0 || args.includes("--details");
|
|
2395
|
+
if (!existsSync(entryPath)) throw new RegistrySiteError("BUNDLE_SIZES_UNAVAILABLE", `${entry} is missing. Run \`npm run build\` first.`);
|
|
2396
|
+
const specifier = "esbuild";
|
|
2397
|
+
let esbuild;
|
|
2398
|
+
try {
|
|
2399
|
+
esbuild = await import(specifier);
|
|
2400
|
+
} catch (cause) {
|
|
2401
|
+
throw new RegistrySiteError("BUNDLE_SIZES_UNAVAILABLE", "esbuild was not found. It is installed with @size-limit/esbuild; run `npm install` first.", { cause });
|
|
2402
|
+
}
|
|
2403
|
+
const shared = {
|
|
2404
|
+
bundle: true,
|
|
2405
|
+
minify: true,
|
|
2406
|
+
format: "esm",
|
|
2407
|
+
platform: "browser",
|
|
2408
|
+
write: false,
|
|
2409
|
+
metafile: true,
|
|
2410
|
+
logLevel: "silent",
|
|
2411
|
+
external: [...options.external],
|
|
2412
|
+
absWorkingDir: root
|
|
2413
|
+
};
|
|
2414
|
+
async function listExports() {
|
|
2415
|
+
const result = await esbuild.build({
|
|
2416
|
+
...shared,
|
|
2417
|
+
entryPoints: [entryPath]
|
|
2418
|
+
});
|
|
2419
|
+
return publicExports(Object.values(result.metafile.outputs)[0]?.exports ?? []);
|
|
2420
|
+
}
|
|
2421
|
+
async function measure(name) {
|
|
2422
|
+
const result = await esbuild.build({
|
|
2423
|
+
...shared,
|
|
2424
|
+
stdin: {
|
|
2425
|
+
contents: `export { ${name} } from './${entry.replaceAll("\\", "/")}';`,
|
|
2426
|
+
resolveDir: root,
|
|
2427
|
+
loader: "js"
|
|
2428
|
+
}
|
|
2429
|
+
});
|
|
2430
|
+
const output = result.outputFiles?.[0];
|
|
2431
|
+
const meta = Object.values(result.metafile.outputs)[0];
|
|
2432
|
+
if (!output || !meta) throw new Error("esbuild produced no output");
|
|
2433
|
+
const brotliBytes = brotliCompressSync(output.contents, { params: { [constants.BROTLI_PARAM_QUALITY]: 11 } }).length;
|
|
2434
|
+
const groups = groupByPackage(meta.inputs, ownCodeLabel);
|
|
2435
|
+
return {
|
|
2436
|
+
name,
|
|
2437
|
+
minifiedBytes: output.contents.length,
|
|
2438
|
+
brotliBytes,
|
|
2439
|
+
groups,
|
|
2440
|
+
largest: groups[0]?.name
|
|
2441
|
+
};
|
|
2442
|
+
}
|
|
2443
|
+
const exportsToCheck = names.length > 0 ? names : await listExports();
|
|
2444
|
+
if (exportsToCheck.length === 0) throw new RegistrySiteError("BUNDLE_SIZES_UNAVAILABLE", `No exports found in ${entry}.`);
|
|
2445
|
+
if (names.length === 0) log(`Measuring ${exportsToCheck.length} exports of ${entry}...\n`);
|
|
2446
|
+
const batchSize = 8;
|
|
2447
|
+
const rows = [];
|
|
2448
|
+
const failures = [];
|
|
2449
|
+
for (let start = 0; start < exportsToCheck.length; start += batchSize) {
|
|
2450
|
+
const batch = exportsToCheck.slice(start, start + batchSize);
|
|
2451
|
+
(await Promise.allSettled(batch.map((name) => measure(name)))).forEach((settled, position) => {
|
|
2452
|
+
if (settled.status === "fulfilled") rows.push(settled.value);
|
|
2453
|
+
else {
|
|
2454
|
+
const reason = settled.reason;
|
|
2455
|
+
failures.push(`Could not bundle "${batch[position]}": ${reason instanceof Error ? reason.message : String(reason)}`);
|
|
2456
|
+
}
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2459
|
+
if (details) for (const row of rows) {
|
|
2460
|
+
log(formatReport({
|
|
2461
|
+
label: `import { ${row.name} }`,
|
|
2462
|
+
groups: row.groups,
|
|
2463
|
+
minifiedBytes: row.minifiedBytes,
|
|
2464
|
+
brotliBytes: row.brotliBytes,
|
|
2465
|
+
topModules
|
|
2466
|
+
}));
|
|
2467
|
+
log("");
|
|
2468
|
+
}
|
|
2469
|
+
else {
|
|
2470
|
+
log(formatSummary(rows));
|
|
2471
|
+
log("\nFor the per-package breakdown: npm run size:why -- <Export> [<Export> ...]");
|
|
2472
|
+
}
|
|
2473
|
+
if (failures.length > 0) throw new RegistrySiteError("BUNDLE_SIZES_UNAVAILABLE", failures.join("\n"));
|
|
2474
|
+
}
|
|
2475
|
+
// src/registrySite/cli.ts
|
|
2476
|
+
/** The usage text. */
|
|
2477
|
+
const USAGE = `Usage: integr8-registry-site <command> [options]
|
|
2478
|
+
|
|
2479
|
+
Commands:
|
|
2480
|
+
build Build site/ (assets, Ladle playground, registry JSON, pages).
|
|
2481
|
+
serve Serve site/ on http://localhost:4173 (PORT=5000 to change).
|
|
2482
|
+
dev build, then serve. The playground is always built for http://localhost:<port>/.
|
|
2483
|
+
enabled Print true or false: the value of "integr8.site" in package.json.
|
|
2484
|
+
size Bundle-size breakdown of the package's exports (needs "bundleSizes" in the config).
|
|
2485
|
+
|
|
2486
|
+
Options:
|
|
2487
|
+
--config <file> Site config. Default: registry/site.config.mjs
|
|
2488
|
+
--site-base-path <path> Path the site is served from. Default: $SITE_BASE_PATH, else /
|
|
2489
|
+
--no-playground Build without the Ladle playground (no live previews)
|
|
2490
|
+
|
|
2491
|
+
The site is off when package.json has "integr8": { "site": false }: every command then
|
|
2492
|
+
prints a note and exits with code 0.`;
|
|
2493
|
+
function runShell(command, cwd, env) {
|
|
2494
|
+
return new Promise((resolveExit, reject) => {
|
|
2495
|
+
const child = spawn(command, {
|
|
2496
|
+
cwd,
|
|
2497
|
+
shell: true,
|
|
2498
|
+
stdio: "inherit",
|
|
2499
|
+
env: {
|
|
2500
|
+
...process.env,
|
|
2501
|
+
...env
|
|
2502
|
+
}
|
|
2503
|
+
});
|
|
2504
|
+
child.once("error", reject);
|
|
2505
|
+
child.once("exit", (code) => resolveExit(code ?? 1));
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
2508
|
+
/** The default dependencies: the real process, shell and server. */
|
|
2509
|
+
function defaultDeps() {
|
|
2510
|
+
return {
|
|
2511
|
+
cwd: process.cwd(),
|
|
2512
|
+
env: process.env,
|
|
2513
|
+
log: (line) => void process.stdout.write(`${line}\n`),
|
|
2514
|
+
error: (line) => void process.stderr.write(`${line}\n`),
|
|
2515
|
+
run: runShell,
|
|
2516
|
+
serve: serveSite
|
|
2517
|
+
};
|
|
2518
|
+
}
|
|
2519
|
+
function parseFlags(args) {
|
|
2520
|
+
const flags = {
|
|
2521
|
+
config: void 0,
|
|
2522
|
+
siteBasePath: void 0,
|
|
2523
|
+
playground: true,
|
|
2524
|
+
rest: []
|
|
2525
|
+
};
|
|
2526
|
+
for (let position = 0; position < args.length; position++) {
|
|
2527
|
+
const arg = args[position] ?? "";
|
|
2528
|
+
if (arg === "--config" || arg === "--site-base-path") {
|
|
2529
|
+
const value = args[position + 1];
|
|
2530
|
+
if (value === void 0 || value.startsWith("--")) throw new ConfigError("INVALID_OPTION", `integr8-registry-site: ${arg} needs a value.`);
|
|
2531
|
+
position++;
|
|
2532
|
+
if (arg === "--config") flags.config = value;
|
|
2533
|
+
else flags.siteBasePath = value;
|
|
2534
|
+
} else if (arg === "--no-playground") flags.playground = false;
|
|
2535
|
+
else flags.rest.push(arg);
|
|
2536
|
+
}
|
|
2537
|
+
return flags;
|
|
2538
|
+
}
|
|
2539
|
+
/** Only characters that are safe in a URL path and in a shell argument. */
|
|
2540
|
+
function checkSitePath(path) {
|
|
2541
|
+
if (!/^[\w.~/-]*$/.test(path)) throw new ConfigError("INVALID_OPTION", `integr8-registry-site: the site base path ${JSON.stringify(path)} may only contain letters, digits and . _ ~ - /`);
|
|
2542
|
+
return normalizeSitePath(path);
|
|
2543
|
+
}
|
|
2544
|
+
async function run(command, deps, step) {
|
|
2545
|
+
deps.log(`> ${command}`);
|
|
2546
|
+
const code = await deps.run(command, deps.cwd, deps.env);
|
|
2547
|
+
if (code !== 0) throw new RegistrySiteError("STEP_FAILED", `${step} failed (exit code ${code}): ${command}`);
|
|
2548
|
+
}
|
|
2549
|
+
async function build(flags, deps, forcedSitePath) {
|
|
2550
|
+
const root = resolve(deps.cwd);
|
|
2551
|
+
const { pkg, enabled } = await readPackageAndSwitch(root);
|
|
2552
|
+
if (!enabled) {
|
|
2553
|
+
deps.log("registry site is off (\"integr8\": { \"site\": false } in package.json): nothing to build.");
|
|
2554
|
+
return false;
|
|
2555
|
+
}
|
|
2556
|
+
const input = await loadSiteConfig(root, flags.config);
|
|
2557
|
+
const config = resolveSiteConfig(input, {
|
|
2558
|
+
pkg,
|
|
2559
|
+
env: deps.env
|
|
2560
|
+
});
|
|
2561
|
+
const sitePath = checkSitePath(forcedSitePath ?? flags.siteBasePath ?? deps.env.SITE_BASE_PATH ?? "/");
|
|
2562
|
+
if (config.commands.assets) await run(config.commands.assets, deps, "The assets step");
|
|
2563
|
+
const withPlayground = flags.playground && config.playground && existsSync(join(root, "playground"));
|
|
2564
|
+
if (withPlayground) await run(`npx ladle build --config playground --outDir playground/dist --base "${playgroundBase(sitePath)}"`, deps, "The Ladle playground build");
|
|
2565
|
+
await run(config.commands.registry, deps, "The registry build");
|
|
2566
|
+
await buildRegistrySite({
|
|
2567
|
+
root,
|
|
2568
|
+
config: input,
|
|
2569
|
+
env: deps.env,
|
|
2570
|
+
siteBasePath: sitePath,
|
|
2571
|
+
playground: withPlayground,
|
|
2572
|
+
log: deps.log
|
|
2573
|
+
});
|
|
2574
|
+
return true;
|
|
2575
|
+
}
|
|
2576
|
+
/**
|
|
2577
|
+
* Runs the `integr8-registry-site` command.
|
|
2578
|
+
*
|
|
2579
|
+
* @param argv - The arguments after the program name.
|
|
2580
|
+
* @param deps - Working folder, environment, output and process runner (default: the real ones).
|
|
2581
|
+
* @returns The exit code: `0` on success and when the site is switched off, `1` on an error.
|
|
2582
|
+
*/
|
|
2583
|
+
async function runCli(argv, deps = defaultDeps()) {
|
|
2584
|
+
const [command, ...args] = argv;
|
|
2585
|
+
try {
|
|
2586
|
+
switch (command) {
|
|
2587
|
+
case "build":
|
|
2588
|
+
await build(parseFlags(args), deps);
|
|
2589
|
+
return 0;
|
|
2590
|
+
case "dev":
|
|
2591
|
+
case "serve": {
|
|
2592
|
+
const flags = parseFlags(args);
|
|
2593
|
+
if (command === "dev" && !await build(flags, deps, "/")) return 0;
|
|
2594
|
+
if (command === "serve") {
|
|
2595
|
+
const { enabled } = await readPackageAndSwitch(deps.cwd);
|
|
2596
|
+
if (!enabled) {
|
|
2597
|
+
deps.log("registry site is off (\"integr8\": { \"site\": false } in package.json): nothing to serve.");
|
|
2598
|
+
return 0;
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
const siteDir = join(resolve(deps.cwd), "site");
|
|
2602
|
+
if (!existsSync(join(siteDir, "index.html"))) throw new RegistrySiteError("REGISTRY_NOT_BUILT", "site/index.html is missing. Run `integr8-registry-site build` first.");
|
|
2603
|
+
const port = Number(deps.env.PORT ?? 4173);
|
|
2604
|
+
await deps.serve(siteDir, port);
|
|
2605
|
+
deps.log(`Registry site: http://localhost:${port}/ (Ctrl+C to stop)`);
|
|
2606
|
+
return await new Promise(() => void 0);
|
|
2607
|
+
}
|
|
2608
|
+
case "enabled": {
|
|
2609
|
+
const { enabled } = await readPackageAndSwitch(deps.cwd);
|
|
2610
|
+
deps.log(String(enabled));
|
|
2611
|
+
return 0;
|
|
2612
|
+
}
|
|
2613
|
+
case "size": {
|
|
2614
|
+
const flags = parseFlags(args);
|
|
2615
|
+
const root = resolve(deps.cwd);
|
|
2616
|
+
const { pkg } = await readPackageAndSwitch(root);
|
|
2617
|
+
const config = resolveSiteConfig(await loadSiteConfig(root, flags.config), {
|
|
2618
|
+
pkg,
|
|
2619
|
+
env: deps.env
|
|
2620
|
+
});
|
|
2621
|
+
if (!config.bundleSizes) throw new RegistrySiteError("BUNDLE_SIZES_UNAVAILABLE", "The bundle-size breakdown is off. Add \"bundleSizes: {}\" to registry/site.config.mjs to turn it on.");
|
|
2622
|
+
await runSizeBreakdown({
|
|
2623
|
+
root,
|
|
2624
|
+
...config.bundleSizes,
|
|
2625
|
+
args: flags.rest,
|
|
2626
|
+
log: deps.log
|
|
2627
|
+
});
|
|
2628
|
+
return 0;
|
|
2629
|
+
}
|
|
2630
|
+
default: {
|
|
2631
|
+
const known = command === void 0 || command === "help" || command === "--help";
|
|
2632
|
+
(known ? deps.log : deps.error)(USAGE);
|
|
2633
|
+
return known ? 0 : 1;
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
} catch (error) {
|
|
2637
|
+
if (error instanceof ConfigError || error instanceof RegistrySiteError) {
|
|
2638
|
+
deps.error(`integr8-registry-site: ${error.message}`);
|
|
2639
|
+
return 1;
|
|
2640
|
+
}
|
|
2641
|
+
throw error;
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
export { cssToText as A, renderMarkdown as B, readSiteSwitch as C, uiDocsUrlOf as D, resolveSiteConfig as E, normalizeSitePath as F, typeLabel as H, playgroundBase as I, escapeHtml as L, itemDocs as M, renderItemPage as N, RegistrySiteError as O, extractDocs as P, groupItems as R, readPackageAndSwitch as S, resolveRegistryUrl as T, renderShell as V, renderSiteFiles as _, serveSite as a, readLadleStories as b, formatReport as c, packageOf as d, publicExports as f, loadSiteConfig as g, checkPlaygroundBase as h, resolveSiteFile as i, importLines as j, renderRegistryPage as k, formatSummary as l, buildRegistrySite as m, runCli as n, OWN_CODE as o, shortPath as p, createSiteServer as r, formatKb as s, USAGE as t, groupByPackage as u, defaultStoryOf as v, defineRegistrySite as w, storiesOf as x, previewStoryOf as y, inlineMarkdown as z };
|