@supertype.ai/foundations 0.1.24
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/LICENSE +21 -0
- package/README.md +369 -0
- package/bin/foundations.mjs +713 -0
- package/dist/blocks/accordion.d.ts +23 -0
- package/dist/blocks/accordion.js +59 -0
- package/dist/blocks/callout.d.ts +57 -0
- package/dist/blocks/callout.js +61 -0
- package/dist/blocks/card.d.ts +34 -0
- package/dist/blocks/card.js +56 -0
- package/dist/blocks/index.d.ts +7 -0
- package/dist/blocks/index.js +7 -0
- package/dist/blocks/interactive-accordion.d.ts +13 -0
- package/dist/blocks/interactive-accordion.js +27 -0
- package/dist/blocks/segment.d.ts +37 -0
- package/dist/blocks/segment.js +37 -0
- package/dist/blocks/steps.d.ts +10 -0
- package/dist/blocks/steps.js +13 -0
- package/dist/blocks/tabs.d.ts +32 -0
- package/dist/blocks/tabs.js +69 -0
- package/dist/cjs/eslint.js +146 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cn.d.ts +2 -0
- package/dist/cn.js +5 -0
- package/dist/contrast.d.ts +47 -0
- package/dist/contrast.js +255 -0
- package/dist/eslint.d.ts +74 -0
- package/dist/eslint.js +138 -0
- package/dist/essay/contents.d.ts +10 -0
- package/dist/essay/contents.js +17 -0
- package/dist/essay/essay.d.ts +125 -0
- package/dist/essay/essay.js +92 -0
- package/dist/essay/index.d.ts +7 -0
- package/dist/essay/index.js +9 -0
- package/dist/essay/layout.d.ts +72 -0
- package/dist/essay/layout.js +77 -0
- package/dist/essay/rail.d.ts +15 -0
- package/dist/essay/rail.js +26 -0
- package/dist/essay/reading.d.ts +17 -0
- package/dist/essay/reading.js +31 -0
- package/dist/essay/scroll.d.ts +8 -0
- package/dist/essay/scroll.js +78 -0
- package/dist/essay/toc.d.ts +23 -0
- package/dist/essay/toc.js +50 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +33 -0
- package/dist/injection.d.ts +8 -0
- package/dist/injection.js +1 -0
- package/dist/mdx.d.ts +47 -0
- package/dist/mdx.js +68 -0
- package/dist/og.d.ts +18 -0
- package/dist/og.js +50 -0
- package/dist/rehype.d.ts +18 -0
- package/dist/rehype.js +41 -0
- package/dist/seo.d.ts +174 -0
- package/dist/seo.js +152 -0
- package/dist/typography/as.d.ts +15 -0
- package/dist/typography/as.js +8 -0
- package/dist/typography/header.d.ts +44 -0
- package/dist/typography/header.js +119 -0
- package/dist/typography/highlight.d.ts +33 -0
- package/dist/typography/highlight.js +98 -0
- package/dist/typography/index.d.ts +4 -0
- package/dist/typography/index.js +3 -0
- package/dist/typography/paragraph.d.ts +157 -0
- package/dist/typography/paragraph.js +229 -0
- package/llms.txt +125 -0
- package/package.json +140 -0
- package/src/prose.css +12 -0
- package/src/shiki.css +23 -0
- package/src/theme.css +272 -0
- package/src/tokens.css +43 -0
- package/src/type.css +73 -0
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The consumer-side CLI: `foundations doctor` and `foundations init`.
|
|
4
|
+
*
|
|
5
|
+
* Everything this package needs from an app fails quietly when it is missing. A
|
|
6
|
+
* missing `@source` line purges every class the package ships, so the
|
|
7
|
+
* components render unstyled. A skipped `theme.css` leaves every colour role
|
|
8
|
+
* unpainted, so `bg-background` resolves to nothing. A font bound with
|
|
9
|
+
* `.className` instead of
|
|
10
|
+
* `.variable` renders one typeface on <html> and another on every utility that
|
|
11
|
+
* asks for a role. None of them throw an error, and all of them are easy enough
|
|
12
|
+
* to check mechanically, which is what this file does.
|
|
13
|
+
*
|
|
14
|
+
* npx foundations doctor check this app against the contract
|
|
15
|
+
* npx foundations init write the CSS block, print the font binding
|
|
16
|
+
* npx foundations init --dry-run show the patch without writing it
|
|
17
|
+
* npx foundations doctor --cwd ../other-app
|
|
18
|
+
*
|
|
19
|
+
* The checks read what to expect from the installed package instead of
|
|
20
|
+
* hardcoding it: CSS entry points come from `exports` in package.json, font
|
|
21
|
+
* variables from type.css, peer ranges from peerDependencies. That way a rule
|
|
22
|
+
* that changes in the package changes here on the next release.
|
|
23
|
+
*/
|
|
24
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
|
|
28
|
+
const PKG_NAME = "@supertype.ai/foundations";
|
|
29
|
+
|
|
30
|
+
/** The installed copy of this package — where `import.meta.url` already is. */
|
|
31
|
+
const pkgRoot = fileURLToPath(new URL("..", import.meta.url));
|
|
32
|
+
const pkgJson = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8"));
|
|
33
|
+
|
|
34
|
+
/* ------------------------------------------------------------------ output */
|
|
35
|
+
|
|
36
|
+
const color = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
37
|
+
const paint = (code, s) => (color ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
38
|
+
const dim = (s) => paint(2, s);
|
|
39
|
+
const bold = (s) => paint(1, s);
|
|
40
|
+
|
|
41
|
+
const LEVELS = {
|
|
42
|
+
ok: { mark: () => paint(32, "✔"), rank: 0 },
|
|
43
|
+
info: { mark: () => dim("·"), rank: 1 },
|
|
44
|
+
warn: { mark: () => paint(33, "!"), rank: 2 },
|
|
45
|
+
error: { mark: () => paint(31, "✖"), rank: 3 },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** One finding. `fix` is the line you can act on without leaving the terminal. */
|
|
49
|
+
const finding = (level, title, detail, fix) => ({ level, title, detail, fix });
|
|
50
|
+
|
|
51
|
+
const report = (sections) => {
|
|
52
|
+
for (const [heading, findings] of sections) {
|
|
53
|
+
if (!findings.length) continue;
|
|
54
|
+
console.log(`\n${bold(heading)}`);
|
|
55
|
+
for (const f of findings) {
|
|
56
|
+
console.log(` ${LEVELS[f.level].mark()} ${f.title}`);
|
|
57
|
+
if (f.detail) console.log(` ${dim(f.detail)}`);
|
|
58
|
+
if (f.fix) console.log(` ${dim("→")} ${f.fix}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/* ------------------------------------------------------------- small utils */
|
|
64
|
+
|
|
65
|
+
const read = (file) => {
|
|
66
|
+
try {
|
|
67
|
+
return readFileSync(file, "utf8");
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const readJson = (file) => {
|
|
74
|
+
const raw = read(file);
|
|
75
|
+
if (!raw) return null;
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(raw);
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Files under `dir` with one of `exts`, depth-limited and blind to build output. */
|
|
84
|
+
const walk = (dir, exts, depth = 4, out = []) => {
|
|
85
|
+
if (depth < 0 || !existsSync(dir)) return out;
|
|
86
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
87
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
88
|
+
const full = join(dir, entry.name);
|
|
89
|
+
if (entry.isDirectory()) walk(full, exts, depth - 1, out);
|
|
90
|
+
else if (exts.some((ext) => entry.name.endsWith(ext))) out.push(full);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Leading numeric triple of a version or a range: ">=19" and "^19.1.0" both give 19.0.0. */
|
|
96
|
+
const versionParts = (spec) => {
|
|
97
|
+
const m = /(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(spec ?? "");
|
|
98
|
+
return m ? [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)] : null;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const satisfiesMin = (installed, range) => {
|
|
102
|
+
const have = versionParts(installed);
|
|
103
|
+
const want = versionParts(range);
|
|
104
|
+
if (!have || !want) return true; // Unparseable: not our place to fail the run.
|
|
105
|
+
for (let i = 0; i < 3; i += 1) {
|
|
106
|
+
if (have[i] > want[i]) return true;
|
|
107
|
+
if (have[i] < want[i]) return false;
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Nearest ancestor of `from` holding a package.json — the consumer's root. */
|
|
113
|
+
const findAppRoot = (from) => {
|
|
114
|
+
let dir = resolve(from);
|
|
115
|
+
for (;;) {
|
|
116
|
+
if (existsSync(join(dir, "package.json"))) return dir;
|
|
117
|
+
const up = dirname(dir);
|
|
118
|
+
if (up === dir) return null;
|
|
119
|
+
dir = up;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/* -------------------------------------------------- what the package wants */
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The CSS entry points, in the order they have to be imported: tokens, then
|
|
127
|
+
* theme, then type. `exports` is already in that order, which is also the order
|
|
128
|
+
* the README documents.
|
|
129
|
+
*/
|
|
130
|
+
const cssEntries = Object.keys(pkgJson.exports)
|
|
131
|
+
.filter((key) => key.endsWith(".css"))
|
|
132
|
+
.map((key) => `${PKG_NAME}/${key.slice(2)}`);
|
|
133
|
+
|
|
134
|
+
/** Only needed if the app renders code fences. */
|
|
135
|
+
const OPTIONAL_CSS = new Set([`${PKG_NAME}/shiki.css`]);
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Skippable only by an app that paints the roles itself. tokens.css registers
|
|
139
|
+
* them and theme.css is the palette; with neither, every colour utility
|
|
140
|
+
* generates and resolves to nothing.
|
|
141
|
+
*/
|
|
142
|
+
const PALETTE_CSS = `${PKG_NAME}/theme.css`;
|
|
143
|
+
|
|
144
|
+
/** The roles tokens.css registers, which something has to give a value to. */
|
|
145
|
+
const roles = (() => {
|
|
146
|
+
const css = read(join(pkgRoot, "src/tokens.css")) ?? "";
|
|
147
|
+
return [...css.matchAll(/--color-[a-z0-9-]+:\s*var\((--[a-z0-9-]+)\)/gi)].map((m) => m[1]);
|
|
148
|
+
})();
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The font variables the app has to supply. In type.css, a `var(--font-x,
|
|
152
|
+
* fallback)` is a typeface the package expects from next/font; a
|
|
153
|
+
* `var(--font-sans)` with no fallback is a role the package defines itself.
|
|
154
|
+
*/
|
|
155
|
+
const fontVars = (() => {
|
|
156
|
+
const css = read(join(pkgRoot, "src/type.css")) ?? "";
|
|
157
|
+
const found = new Set();
|
|
158
|
+
for (const [, name, tail] of css.matchAll(/var\(\s*(--font-[\w-]+)\s*(,[^)]*)?\)/g)) {
|
|
159
|
+
if (tail) found.add(name);
|
|
160
|
+
}
|
|
161
|
+
// `--font-average` only carries the `.editorial` heading role.
|
|
162
|
+
return [...found].map((name) => ({ name, editorialOnly: name.includes("average") }));
|
|
163
|
+
})();
|
|
164
|
+
|
|
165
|
+
/* -------------------------------------------------------- consumer probing */
|
|
166
|
+
|
|
167
|
+
/** The CSS file that starts the cascade, i.e. the one importing Tailwind. */
|
|
168
|
+
const findCssEntry = (appRoot) => {
|
|
169
|
+
const roots = ["app", "src", "styles"].map((d) => join(appRoot, d));
|
|
170
|
+
const files = roots.flatMap((dir) => walk(dir, [".css"]));
|
|
171
|
+
return files.find((file) => /@import\s+["']tailwindcss["']/.test(read(file) ?? "")) ?? null;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const LAYOUTS = ["app/layout.tsx", "src/app/layout.tsx", "app/layout.jsx", "src/app/layout.jsx"];
|
|
175
|
+
const findLayout = (appRoot) => LAYOUTS.map((rel) => join(appRoot, rel)).find((f) => existsSync(f)) ?? null;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* CSS with its comments removed. The checks below ask whether a directive is
|
|
179
|
+
* present, and a commented-out import or a mention of `@custom-variant` in a
|
|
180
|
+
* comment is neither present nor a mistake. Without this, the doctor warns
|
|
181
|
+
* about the comment explaining why it should not warn.
|
|
182
|
+
*/
|
|
183
|
+
const stripComments = (css) => {
|
|
184
|
+
let out = "";
|
|
185
|
+
let quote = null;
|
|
186
|
+
for (let i = 0; i < css.length; ) {
|
|
187
|
+
const c = css[i];
|
|
188
|
+
if (quote) {
|
|
189
|
+
// Inside a string, `/**/` is part of a glob rather than an empty comment.
|
|
190
|
+
if (c === "\\") {
|
|
191
|
+
out += c + (css[i + 1] ?? "");
|
|
192
|
+
i += 2;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (c === quote) quote = null;
|
|
196
|
+
out += c;
|
|
197
|
+
i += 1;
|
|
198
|
+
} else if (c === '"' || c === "'") {
|
|
199
|
+
quote = c;
|
|
200
|
+
out += c;
|
|
201
|
+
i += 1;
|
|
202
|
+
} else if (c === "/" && css[i + 1] === "*") {
|
|
203
|
+
const end = css.indexOf("*/", i + 2);
|
|
204
|
+
i = end === -1 ? css.length : end + 2;
|
|
205
|
+
} else {
|
|
206
|
+
out += c;
|
|
207
|
+
i += 1;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/** `@import` targets in source order, so one parse gives presence and ordering. */
|
|
214
|
+
const importsOf = (css) => [...css.matchAll(/@import\s+["']([^"']+)["']/g)].map((m) => m[1]);
|
|
215
|
+
|
|
216
|
+
const sourceDirectives = (css) => [...css.matchAll(/@source\s+["']([^"']+)["']/g)].map((m) => m[1]);
|
|
217
|
+
|
|
218
|
+
/** Consts assigned from a next/font call, so we can check how they are used. */
|
|
219
|
+
const fontConsts = (src) => {
|
|
220
|
+
const fns = new Set();
|
|
221
|
+
for (const [, names] of src.matchAll(/import\s*\{([^}]+)\}\s*from\s*["']next\/font\/[^"']+["']/g)) {
|
|
222
|
+
for (const part of names.split(",")) fns.add(part.trim().split(/\s+as\s+/).pop());
|
|
223
|
+
}
|
|
224
|
+
for (const [, name] of src.matchAll(/import\s+(\w+)\s+from\s*["']next\/font\/local["']/g)) fns.add(name);
|
|
225
|
+
|
|
226
|
+
const consts = [];
|
|
227
|
+
for (const [, name, fn] of src.matchAll(/const\s+(\w+)\s*=\s*(\w+)\s*\(/g)) {
|
|
228
|
+
if (fns.has(fn)) consts.push(name);
|
|
229
|
+
}
|
|
230
|
+
return consts;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/* ---------------------------------------------------------- the @source line */
|
|
234
|
+
|
|
235
|
+
/** The @source line for this app, relative to the CSS file wherever yarn put us. */
|
|
236
|
+
const sourceGlob = (appRoot, cssFile) => {
|
|
237
|
+
const installed = join(appRoot, "node_modules", ...PKG_NAME.split("/"));
|
|
238
|
+
let path = relative(dirname(cssFile), join(installed, "dist"));
|
|
239
|
+
path = path.split(sep).join("/");
|
|
240
|
+
if (!path.startsWith(".")) path = `./${path}`;
|
|
241
|
+
return `${path}/**/*.js`;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/* -------------------------------------------------------------- the checks */
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* A git dependency, as opposed to a registry range. Only the former can move
|
|
248
|
+
* under a consumer: `#main` and untagged specs re-resolve on a fresh install,
|
|
249
|
+
* while `^0.1.23` is pinned by the lockfile. The check below only has something
|
|
250
|
+
* to say about the first kind.
|
|
251
|
+
*/
|
|
252
|
+
const isGitSpec = (spec) =>
|
|
253
|
+
/^(git\+|git:|git@)/.test(spec) || /github\.com|gitlab\.com|bitbucket\.org/.test(spec);
|
|
254
|
+
|
|
255
|
+
const checkInstall = (appRoot) => {
|
|
256
|
+
const out = [];
|
|
257
|
+
const appPkg = readJson(join(appRoot, "package.json"));
|
|
258
|
+
const installed = join(appRoot, "node_modules", ...PKG_NAME.split("/"));
|
|
259
|
+
|
|
260
|
+
const spec = appPkg?.dependencies?.[PKG_NAME] ?? appPkg?.devDependencies?.[PKG_NAME] ?? null;
|
|
261
|
+
|
|
262
|
+
if (!spec) {
|
|
263
|
+
out.push(
|
|
264
|
+
finding(
|
|
265
|
+
"error",
|
|
266
|
+
`${PKG_NAME} is not a dependency of this app`,
|
|
267
|
+
`checked ${join(appRoot, "package.json")}`,
|
|
268
|
+
`yarn add ${PKG_NAME}`,
|
|
269
|
+
),
|
|
270
|
+
);
|
|
271
|
+
} else if (!isGitSpec(spec)) {
|
|
272
|
+
// A registry range is resolved once and recorded in the lockfile, so it
|
|
273
|
+
// does not have the re-resolution problem a git spec has. Nothing to say.
|
|
274
|
+
out.push(finding("ok", `${PKG_NAME}@${pkgJson.version}`, spec));
|
|
275
|
+
} else if (/#main\b/.test(spec) || !spec.includes("#")) {
|
|
276
|
+
out.push(
|
|
277
|
+
finding(
|
|
278
|
+
"warn",
|
|
279
|
+
"the dependency is not pinned to a tag",
|
|
280
|
+
spec,
|
|
281
|
+
`pin it: ...foundations.git#v${pkgJson.version}, or install from the registry: yarn add ${PKG_NAME}. An unpinned git dependency re-resolves to a different commit on any fresh install.`,
|
|
282
|
+
),
|
|
283
|
+
);
|
|
284
|
+
} else {
|
|
285
|
+
const tag = versionParts(spec.split("#").pop());
|
|
286
|
+
const here = versionParts(pkgJson.version);
|
|
287
|
+
if (tag && here && tag.join(".") !== here.join(".")) {
|
|
288
|
+
out.push(
|
|
289
|
+
finding(
|
|
290
|
+
"warn",
|
|
291
|
+
"the installed copy does not match the pinned tag",
|
|
292
|
+
`package.json asks for v${tag.join(".")}, node_modules holds ${pkgJson.version}`,
|
|
293
|
+
"Expected after a `yarn sync`, and fine while you iterate. Run `yarn install` to restore the tag before you ship.",
|
|
294
|
+
),
|
|
295
|
+
);
|
|
296
|
+
} else {
|
|
297
|
+
out.push(finding("ok", `${PKG_NAME}@${pkgJson.version}`, spec));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (existsSync(installed)) {
|
|
302
|
+
if (lstatSync(installed).isSymbolicLink()) {
|
|
303
|
+
out.push(
|
|
304
|
+
finding(
|
|
305
|
+
"error",
|
|
306
|
+
"the installed package is a symlink",
|
|
307
|
+
"yarn link, or a linked workspace",
|
|
308
|
+
"Unlink it and install the tag. Turbopack resolves the symlink to a path outside the project root and fails on the CSS import, and you end up with two copies of React, which shows up as an invalid hook call.",
|
|
309
|
+
),
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
if (!existsSync(join(installed, "dist/index.js"))) {
|
|
313
|
+
out.push(
|
|
314
|
+
finding("error", "the installed copy has no dist/", "dist/index.js is missing", "Reinstall. The package ships built, so there is no compile step that could have failed."),
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
if (existsSync(join(installed, "node_modules/react"))) {
|
|
318
|
+
out.push(
|
|
319
|
+
finding(
|
|
320
|
+
"error",
|
|
321
|
+
"a second React is nested inside the package",
|
|
322
|
+
join(installed, "node_modules/react"),
|
|
323
|
+
"Dedupe it. React is a peer dependency here, and two copies show up as an invalid hook call at runtime.",
|
|
324
|
+
),
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
for (const [peer, range] of Object.entries(pkgJson.peerDependencies ?? {})) {
|
|
330
|
+
const soft = peer === "@base-ui/react"; // Only Accordion and Tabs need it.
|
|
331
|
+
const meta = readJson(join(appRoot, "node_modules", ...peer.split("/"), "package.json"));
|
|
332
|
+
if (!meta) {
|
|
333
|
+
out.push(
|
|
334
|
+
finding(
|
|
335
|
+
soft ? "warn" : "error",
|
|
336
|
+
`${peer} is not installed`,
|
|
337
|
+
`peer range ${range}`,
|
|
338
|
+
soft ? "Only Accordion and Tabs need it. Install it before you import either." : `yarn add ${peer}`,
|
|
339
|
+
),
|
|
340
|
+
);
|
|
341
|
+
} else if (!satisfiesMin(meta.version, range)) {
|
|
342
|
+
out.push(finding("error", `${peer}@${meta.version} is below the peer range`, `wants ${range}`, `yarn add ${peer}@latest`));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const checkStyles = (appRoot) => {
|
|
349
|
+
const out = [];
|
|
350
|
+
const cssFile = findCssEntry(appRoot);
|
|
351
|
+
if (!cssFile) {
|
|
352
|
+
out.push(
|
|
353
|
+
finding("error", "no CSS entry importing tailwindcss", "looked under app/, src/, styles/", "Create one (app/global.css), then run `foundations init`."),
|
|
354
|
+
);
|
|
355
|
+
return out;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const css = stripComments(read(cssFile) ?? "");
|
|
359
|
+
const rel = relative(appRoot, cssFile);
|
|
360
|
+
const order = importsOf(css);
|
|
361
|
+
const seen = new Map(order.map((spec, i) => [spec, i]));
|
|
362
|
+
|
|
363
|
+
const missing = [];
|
|
364
|
+
/** Set when theme.css is absent but the app declares every role itself. */
|
|
365
|
+
let selfPainted = false;
|
|
366
|
+
let last = seen.get("tailwindcss") ?? -1;
|
|
367
|
+
for (const entry of cssEntries) {
|
|
368
|
+
const at = seen.get(entry);
|
|
369
|
+
if (at === undefined) {
|
|
370
|
+
missing.push(entry);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (at < last) {
|
|
374
|
+
out.push(
|
|
375
|
+
finding(
|
|
376
|
+
"error",
|
|
377
|
+
`${entry} is imported out of order`,
|
|
378
|
+
rel,
|
|
379
|
+
`The order is ${["tailwindcss", ...cssEntries].join(" → ")}. Later files re-point variables the earlier ones define.`,
|
|
380
|
+
),
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
last = at;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
for (const entry of missing) {
|
|
387
|
+
if (OPTIONAL_CSS.has(entry)) {
|
|
388
|
+
out.push(finding("info", `${entry} is not imported`, "only needed if you render code fences", "Add it when you add Shiki."));
|
|
389
|
+
} else if (entry === PALETTE_CSS) {
|
|
390
|
+
// The app's own files, package imports left out — the question is whether
|
|
391
|
+
// it paints the roles itself, and its palette may live a file away.
|
|
392
|
+
const own = stripComments(expandImports(cssFile, { includePackage: false }));
|
|
393
|
+
const unpainted = roles.filter((role) => !new RegExp(`\\${role}\\s*:`).test(own));
|
|
394
|
+
selfPainted = !unpainted.length;
|
|
395
|
+
out.push(
|
|
396
|
+
unpainted.length
|
|
397
|
+
? finding(
|
|
398
|
+
"error",
|
|
399
|
+
`${entry} is not imported, and ${unpainted.length} role${unpainted.length === 1 ? " has" : "s have"} no value`,
|
|
400
|
+
rel,
|
|
401
|
+
`Add @import "${entry}"; to ${rel}, or declare the roles yourself. Unpainted: ${unpainted.join(", ")}.`,
|
|
402
|
+
)
|
|
403
|
+
: finding("ok", `${entry} is not imported`, `${rel} paints all ${roles.length} roles itself`),
|
|
404
|
+
);
|
|
405
|
+
} else {
|
|
406
|
+
out.push(finding("error", `${entry} is not imported`, rel, `Add @import "${entry}"; to ${rel}, in order.`));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const ours = sourceDirectives(css).find((s) => s.includes(PKG_NAME));
|
|
411
|
+
if (!ours) {
|
|
412
|
+
out.push(
|
|
413
|
+
finding(
|
|
414
|
+
"error",
|
|
415
|
+
"no @source line for the package",
|
|
416
|
+
rel,
|
|
417
|
+
"Tailwind does not scan node_modules by default, so without this line every class the package ships is purged and the components render with no styles.",
|
|
418
|
+
),
|
|
419
|
+
);
|
|
420
|
+
} else if (!existsSync(resolve(dirname(cssFile), ours.split("*")[0]))) {
|
|
421
|
+
out.push(finding("error", "the @source path does not resolve", `${ours} — from ${rel}`, `expected ${sourceGlob(appRoot, cssFile)}`));
|
|
422
|
+
} else {
|
|
423
|
+
out.push(finding("ok", "@source scans the package", ours));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (/@custom-variant\s+dark/.test(css)) {
|
|
427
|
+
out.push(
|
|
428
|
+
finding(
|
|
429
|
+
"warn",
|
|
430
|
+
"this app declares its own dark variant",
|
|
431
|
+
rel,
|
|
432
|
+
"tokens.css already binds dark: to the .dark class. With two declarations the later one wins, and nothing tells you which.",
|
|
433
|
+
),
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const required = missing.filter(
|
|
438
|
+
(entry) => !OPTIONAL_CSS.has(entry) && !(entry === PALETTE_CSS && selfPainted),
|
|
439
|
+
);
|
|
440
|
+
if (!required.length && ours) out.push(finding("ok", "the style layer is complete", rel));
|
|
441
|
+
return out;
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const checkFonts = (appRoot) => {
|
|
445
|
+
const out = [];
|
|
446
|
+
const layout = findLayout(appRoot);
|
|
447
|
+
if (!layout) {
|
|
448
|
+
out.push(
|
|
449
|
+
finding("warn", "no app/layout.tsx found", `looked for ${LAYOUTS.join(", ")}`, "The roles are bound wherever your root <html> lives. Check that file by hand."),
|
|
450
|
+
);
|
|
451
|
+
return out;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const src = read(layout) ?? "";
|
|
455
|
+
const rel = relative(appRoot, layout);
|
|
456
|
+
const editorial = ["app", "src"]
|
|
457
|
+
.flatMap((dir) => walk(join(appRoot, dir), [".tsx", ".jsx", ".mdx"]))
|
|
458
|
+
.some((file) => /\beditorial\b/.test(read(file) ?? ""));
|
|
459
|
+
|
|
460
|
+
for (const { name, editorialOnly } of fontVars) {
|
|
461
|
+
if (src.includes(name)) continue;
|
|
462
|
+
out.push(
|
|
463
|
+
finding(
|
|
464
|
+
editorialOnly && !editorial ? "info" : "error",
|
|
465
|
+
`${name} is not bound`,
|
|
466
|
+
rel,
|
|
467
|
+
editorialOnly
|
|
468
|
+
? "The .editorial heading face. Bind it when a surface goes editorial."
|
|
469
|
+
: `Load the typeface with next/font and pass variable: "${name}".`,
|
|
470
|
+
),
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const consts = fontConsts(src);
|
|
475
|
+
const misbound = consts.filter((name) => new RegExp(`\\b${name}\\.className\\b`).test(src));
|
|
476
|
+
if (misbound.length) {
|
|
477
|
+
out.push(
|
|
478
|
+
finding(
|
|
479
|
+
"error",
|
|
480
|
+
`bound with .className: ${misbound.join(", ")}`,
|
|
481
|
+
rel,
|
|
482
|
+
"Use .variable. A className sets font-family on the element itself and leaves the roles unresolved, so the page renders one typeface while every font-sans and font-heading utility renders another.",
|
|
483
|
+
),
|
|
484
|
+
);
|
|
485
|
+
} else if (consts.length) {
|
|
486
|
+
out.push(finding("ok", `${consts.length} font${consts.length > 1 ? "s" : ""} bound with .variable`, rel));
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return out;
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* One CSS file with its `@import`s expanded in place, so a check reads the
|
|
494
|
+
* cascade the browser sees rather than one file of it. A palette split into a
|
|
495
|
+
* neighbouring file is a normal layout, and dropping it would make an app look
|
|
496
|
+
* like it declared nothing. Bare specifiers other than this package's are left
|
|
497
|
+
* out: Tailwind and a component library declare no root palette worth measuring.
|
|
498
|
+
*/
|
|
499
|
+
const expandImports = (file, { includePackage }, seen = new Set()) => {
|
|
500
|
+
if (seen.has(file)) return "";
|
|
501
|
+
seen.add(file);
|
|
502
|
+
return (read(file) ?? "").replace(/@import\s+["']([^"']+)["'];?/g, (_line, spec) => {
|
|
503
|
+
if (spec.startsWith(`${PKG_NAME}/`))
|
|
504
|
+
return includePackage ? (read(join(pkgRoot, "src", spec.slice(PKG_NAME.length + 1))) ?? "") : "";
|
|
505
|
+
if (spec.startsWith(".")) return expandImports(resolve(dirname(file), spec), { includePackage }, seen);
|
|
506
|
+
return "";
|
|
507
|
+
});
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* The one check that reads colour rather than wiring. Structural inks are an
|
|
512
|
+
* error: a page whose body text does not clear its own background is broken,
|
|
513
|
+
* not badly styled. Fills and tinted inks are a warning — they are a palette
|
|
514
|
+
* decision, and the app owns its palette.
|
|
515
|
+
*/
|
|
516
|
+
const checkContrast = async (appRoot) => {
|
|
517
|
+
const out = [];
|
|
518
|
+
const cssFile = findCssEntry(appRoot);
|
|
519
|
+
if (!cssFile) return out;
|
|
520
|
+
|
|
521
|
+
let checkLegibility, checkSignals, formatFailures;
|
|
522
|
+
try {
|
|
523
|
+
({ checkLegibility, checkSignals, formatFailures } = await import(
|
|
524
|
+
join(pkgRoot, "dist/contrast.js")
|
|
525
|
+
));
|
|
526
|
+
} catch {
|
|
527
|
+
// An install without dist/ has louder problems, already reported above.
|
|
528
|
+
return out;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const css = expandImports(cssFile, { includePackage: true });
|
|
532
|
+
const rel = relative(appRoot, cssFile);
|
|
533
|
+
const groups = [
|
|
534
|
+
["error", "structural ink below 4.5:1", checkLegibility(css)],
|
|
535
|
+
["warn", "mark or tinted ink below its bar", checkSignals(css)],
|
|
536
|
+
];
|
|
537
|
+
|
|
538
|
+
for (const [level, title, failures] of groups) {
|
|
539
|
+
if (!failures.length) continue;
|
|
540
|
+
out.push(
|
|
541
|
+
finding(level, `${failures.length} ${title}`, rel, formatFailures(failures).split("\n").join("; ")),
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (!out.length) out.push(finding("ok", "every ink clears its surface in both themes", rel));
|
|
546
|
+
return out;
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
/* ---------------------------------------------------------------- commands */
|
|
550
|
+
|
|
551
|
+
const fontSnippet = () => `import { Ubuntu_Sans, Ubuntu_Sans_Mono, Average } from "next/font/google";
|
|
552
|
+
|
|
553
|
+
const sans = Ubuntu_Sans({ variable: "--font-ubuntu-sans", subsets: ["latin"] });
|
|
554
|
+
const mono = Ubuntu_Sans_Mono({ variable: "--font-ubuntu-sans-mono", subsets: ["latin"] });
|
|
555
|
+
const serif = Average({ variable: "--font-average", weight: "400", subsets: ["latin"] });
|
|
556
|
+
|
|
557
|
+
// .variable, never .className
|
|
558
|
+
<html className={\`\${sans.variable} \${mono.variable} \${serif.variable} font-sans\`}>`;
|
|
559
|
+
|
|
560
|
+
const doctor = async (appRoot) => {
|
|
561
|
+
const install = checkInstall(appRoot);
|
|
562
|
+
const styles = checkStyles(appRoot);
|
|
563
|
+
const fonts = checkFonts(appRoot);
|
|
564
|
+
const contrast = await checkContrast(appRoot);
|
|
565
|
+
const all = [...install, ...styles, ...fonts, ...contrast];
|
|
566
|
+
|
|
567
|
+
console.log(`\n${bold(PKG_NAME)} ${dim(`doctor · ${appRoot}`)}`);
|
|
568
|
+
report([
|
|
569
|
+
["Install", install],
|
|
570
|
+
["Styles", styles],
|
|
571
|
+
["Fonts", fonts],
|
|
572
|
+
["Contrast", contrast],
|
|
573
|
+
]);
|
|
574
|
+
|
|
575
|
+
const errors = all.filter((f) => f.level === "error").length;
|
|
576
|
+
const warnings = all.filter((f) => f.level === "warn").length;
|
|
577
|
+
const tally = [
|
|
578
|
+
errors ? `${errors} problem${errors > 1 ? "s" : ""}` : null,
|
|
579
|
+
warnings ? `${warnings} warning${warnings > 1 ? "s" : ""}` : null,
|
|
580
|
+
].filter(Boolean);
|
|
581
|
+
|
|
582
|
+
console.log(`\n${tally.length ? tally.join(", ") : "no problems"}${errors ? dim(". Run `foundations init` to write the CSS block.") : ""}\n`);
|
|
583
|
+
return errors ? 1 : 0;
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const init = (appRoot, { dryRun }) => {
|
|
587
|
+
const cssFile = findCssEntry(appRoot);
|
|
588
|
+
if (!cssFile) {
|
|
589
|
+
console.log(`\nNo CSS entry importing tailwindcss under ${appRoot}.`);
|
|
590
|
+
console.log("Create app/global.css with:\n");
|
|
591
|
+
console.log(
|
|
592
|
+
['@import "tailwindcss";', ...cssEntries.map((e) => `@import "${e}";`), "", `@source '../node_modules/${PKG_NAME}/dist/**/*.js';`].join("\n"),
|
|
593
|
+
);
|
|
594
|
+
console.log();
|
|
595
|
+
return 1;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const before = read(cssFile) ?? "";
|
|
599
|
+
const live = stripComments(before);
|
|
600
|
+
const rel = relative(appRoot, cssFile);
|
|
601
|
+
const lines = before.split("\n");
|
|
602
|
+
|
|
603
|
+
// Lift the package's own @import lines out, then lay them back down in the
|
|
604
|
+
// one order the cascade accepts. Lifting the whole LINE keeps a trailing
|
|
605
|
+
// comment attached to the import a consumer wrote it against, and makes a
|
|
606
|
+
// file that is merely out of order repairable rather than only diagnosable.
|
|
607
|
+
const existing = new Map();
|
|
608
|
+
const present = new Set(importsOf(live));
|
|
609
|
+
const kept = lines.filter((line) => {
|
|
610
|
+
const [spec] = importsOf(stripComments(line));
|
|
611
|
+
// `present` keeps a commented-out import where it is instead of reviving it.
|
|
612
|
+
if (spec && cssEntries.includes(spec) && present.has(spec)) {
|
|
613
|
+
existing.set(spec, line);
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
return true;
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
const block = cssEntries
|
|
620
|
+
.filter((entry) => existing.has(entry) || !OPTIONAL_CSS.has(entry))
|
|
621
|
+
.map((entry) => existing.get(entry) ?? `@import "${entry}";`);
|
|
622
|
+
|
|
623
|
+
// By spec, not by string: a commented-out import contains the same text.
|
|
624
|
+
const added = block.filter((line) => {
|
|
625
|
+
const [spec] = importsOf(line);
|
|
626
|
+
return spec && !present.has(spec);
|
|
627
|
+
});
|
|
628
|
+
const reordered = !added.length && block.join("\n") !== [...existing.values()].join("\n");
|
|
629
|
+
const needsSource = !sourceDirectives(live).some((s) => s.includes(PKG_NAME));
|
|
630
|
+
if (needsSource) block.push("", `@source '${sourceGlob(appRoot, cssFile)}';`);
|
|
631
|
+
|
|
632
|
+
if (!added.length && !reordered && !needsSource) {
|
|
633
|
+
console.log(`\n${rel} already imports the style layer, in order, and scans the package.`);
|
|
634
|
+
} else {
|
|
635
|
+
// After Tailwind itself: the tokens re-point variables it defines.
|
|
636
|
+
const anchor = kept.reduce((at, line, i) => (/@import\s+["']tailwindcss["']/.test(line) ? i : at), -1);
|
|
637
|
+
kept.splice(anchor + 1, 0, ...block);
|
|
638
|
+
|
|
639
|
+
if (dryRun) {
|
|
640
|
+
console.log(`\n${bold(rel)} ${dim("(dry run — nothing written)")}`);
|
|
641
|
+
} else {
|
|
642
|
+
writeFileSync(cssFile, kept.join("\n"));
|
|
643
|
+
console.log(`\n${paint(32, "✔")} patched ${bold(rel)}`);
|
|
644
|
+
}
|
|
645
|
+
for (const line of block.filter(Boolean)) {
|
|
646
|
+
const mark = added.includes(line) || (needsSource && line.startsWith("@source")) ? paint(32, "+") : dim(" ");
|
|
647
|
+
console.log(` ${mark} ${line}`);
|
|
648
|
+
}
|
|
649
|
+
if (reordered) console.log(` ${dim("reordered: later files re-point variables the earlier ones define")}`);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
console.log(`\n${bold("Bind the fonts")} in your root layout. The package cannot load them for you:\n`);
|
|
653
|
+
console.log(fontSnippet());
|
|
654
|
+
|
|
655
|
+
// The package ships an llms.txt for whatever coding agent the app runs. It is
|
|
656
|
+
// only useful if the agent is pointed at it, and that is one line in a file
|
|
657
|
+
// this command should not edit on its own.
|
|
658
|
+
console.log(`\n${bold("If you use a coding agent")}, point it at the API summary:\n`);
|
|
659
|
+
console.log(` ${dim("# CLAUDE.md, AGENTS.md, or your agent's equivalent")}`);
|
|
660
|
+
console.log(` @node_modules/${PKG_NAME}/llms.txt`);
|
|
661
|
+
|
|
662
|
+
console.log(`\nThen: ${bold("foundations doctor")}\n`);
|
|
663
|
+
return 0;
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
const usage = () => {
|
|
667
|
+
console.log(`
|
|
668
|
+
${bold(PKG_NAME)} ${dim(`v${pkgJson.version}`)}
|
|
669
|
+
|
|
670
|
+
${bold("foundations doctor")} check this app against the package's contract
|
|
671
|
+
${bold("foundations init")} add and reorder the CSS imports, print the rest
|
|
672
|
+
|
|
673
|
+
Options
|
|
674
|
+
--cwd <dir> run against another app instead of the current directory
|
|
675
|
+
--dry-run init only: show the patch without writing it
|
|
676
|
+
`);
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
/* ------------------------------------------------------------------- entry */
|
|
680
|
+
|
|
681
|
+
const args = process.argv.slice(2);
|
|
682
|
+
const command = args.find((a) => !a.startsWith("--")) ?? "help";
|
|
683
|
+
const flag = (name) => args.includes(`--${name}`);
|
|
684
|
+
const value = (name) => {
|
|
685
|
+
const at = args.indexOf(`--${name}`);
|
|
686
|
+
return at === -1 ? null : args[at + 1];
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
if (command === "help" || flag("help")) {
|
|
690
|
+
usage();
|
|
691
|
+
process.exit(0);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const appRoot = findAppRoot(value("cwd") ?? process.cwd());
|
|
695
|
+
if (!appRoot) {
|
|
696
|
+
console.error("No package.json found. Run this from inside your app.");
|
|
697
|
+
process.exit(1);
|
|
698
|
+
}
|
|
699
|
+
if (resolve(appRoot) === resolve(pkgRoot)) {
|
|
700
|
+
console.error(`This is ${PKG_NAME} itself. Run the CLI from an app that uses it, or pass --cwd.`);
|
|
701
|
+
process.exit(1);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
switch (command) {
|
|
705
|
+
case "doctor":
|
|
706
|
+
process.exit(await doctor(appRoot));
|
|
707
|
+
case "init":
|
|
708
|
+
process.exit(init(appRoot, { dryRun: flag("dry-run") }));
|
|
709
|
+
default:
|
|
710
|
+
console.error(`unknown command: ${command}`);
|
|
711
|
+
usage();
|
|
712
|
+
process.exit(1);
|
|
713
|
+
}
|