@spectrabrain/eds 0.0.4 → 0.1.1

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.
Files changed (62) hide show
  1. package/AI.md +13 -40
  2. package/AI_CONTEXT.json +90 -0
  3. package/CHANGELOG.md +120 -0
  4. package/CONSUMER_AGENT.md +152 -0
  5. package/FRONTEND_CONVENTIONS.md +252 -0
  6. package/QUICKSTART.md +42 -0
  7. package/README.md +38 -69
  8. package/TAILWIND_V4.md +57 -243
  9. package/TOKEN_CONTEXT.md +98 -0
  10. package/consumer/align/templates/components.json +21 -0
  11. package/consumer/align/templates/env.mock +1 -0
  12. package/consumer/align/templates/eslint.config.js +35 -0
  13. package/consumer/align/templates/package.scripts.json +10 -0
  14. package/consumer/align/templates/prettierignore +2 -0
  15. package/consumer/align/templates/prettierrc.json +7 -0
  16. package/consumer/align/templates/src/app/App.tsx +16 -0
  17. package/consumer/align/templates/src/app/main.tsx +27 -0
  18. package/consumer/align/templates/src/app/providers/ErrorBoundary.tsx +58 -0
  19. package/consumer/align/templates/src/app/providers/ErrorHandler.tsx +29 -0
  20. package/consumer/align/templates/src/app/router/index.tsx +23 -0
  21. package/consumer/align/templates/src/app/styles/index.css +2 -0
  22. package/consumer/align/templates/src/mocks/browser.ts +5 -0
  23. package/consumer/align/templates/src/mocks/handlers.ts +4 -0
  24. package/consumer/align/templates/src/shared/api/index.ts +23 -0
  25. package/consumer/align/templates/src/shared/api/queryClient.ts +24 -0
  26. package/consumer/align/templates/src/shared/lib/eventBus.ts +44 -0
  27. package/consumer/align/templates/src/shared/lib/utils/index.ts +6 -0
  28. package/consumer/align/templates/src/vite-env.d.ts +2 -0
  29. package/consumer/align/templates/tsconfig.json +22 -0
  30. package/consumer/align/templates/vite.config.babel.ts +36 -0
  31. package/consumer/align/templates/vite.config.rolldown.ts +35 -0
  32. package/consumer/align-analysis.mjs +1159 -0
  33. package/consumer/align-plan.mjs +377 -0
  34. package/consumer/align-rules.mjs +473 -0
  35. package/consumer/align.mjs +401 -0
  36. package/consumer/cli.mjs +157 -0
  37. package/consumer/css-root.mjs +259 -0
  38. package/consumer/doctor.mjs +204 -0
  39. package/consumer/generate-tailwind.mjs +83 -0
  40. package/consumer/init.mjs +186 -0
  41. package/consumer/managed-block.mjs +126 -0
  42. package/consumer/paths.mjs +266 -0
  43. package/consumer/register-scripts.mjs +129 -0
  44. package/consumer/scan-classes.mjs +71 -0
  45. package/consumer/sync.mjs +155 -0
  46. package/consumer/update-agent-context.mjs +143 -0
  47. package/consumer-postinstall.mjs +60 -0
  48. package/dist/base.css +4 -0
  49. package/dist/components/Button/README.md +70 -80
  50. package/dist/components/Button/spec.json +71 -133
  51. package/dist/components.css +102 -368
  52. package/dist/index.d.mts +40 -1
  53. package/dist/index.d.ts +40 -1
  54. package/dist/index.js +34 -6250
  55. package/dist/index.mjs +36 -6252
  56. package/dist/styles.css +1 -0
  57. package/dist/tokens.d.mts +5 -2
  58. package/dist/tokens.d.ts +5 -2
  59. package/dist/tokens.js +6 -7
  60. package/dist/tokens.mjs +6 -7
  61. package/llms.txt +22 -9
  62. package/package.json +33 -2
@@ -0,0 +1,259 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ createExitError,
5
+ detectEol,
6
+ detectPackageManager,
7
+ generatedCssRelativePath,
8
+ listCssFiles,
9
+ listHtmlFiles,
10
+ listScriptFiles,
11
+ readPackageJson,
12
+ relativeImportPath,
13
+ relativeTo,
14
+ runnerCommand,
15
+ toPosix,
16
+ } from "./paths.mjs";
17
+
18
+ export const tailwindRootEnvironmentVariable = "EDS_TAILWIND_ROOT";
19
+ // Consumer package.json: "eds": { "tailwindRoot": "src/index.css" }. Written by sync when the override is used.
20
+ export const consumerConfigField = "eds";
21
+ export const tailwindRootConfigKey = "tailwindRoot";
22
+ export const stylesImportLine = '@import "@spectrabrain/eds/styles.css";';
23
+ export const fontsImportLine = '@import "@spectrabrain/eds/fonts.css";';
24
+
25
+ // Accepts the canonical `@import "tailwindcss";`, the granular `tailwindcss/theme.css` and
26
+ // `tailwindcss/utilities.css` entries (Preflight opt-out) and the `@import url("tailwindcss")` form.
27
+ const tailwindSpecifier = "tailwindcss(?:\\/(?:theme|utilities)(?:\\.css)?)?";
28
+ const tailwindImportSource = "^[ \\t]*@import\\s+(?:url\\(\\s*)?[\"']" + tailwindSpecifier + "[\"']\\s*\\)?[^;]*;[^\\r\\n]*";
29
+ const tailwindImportPattern = new RegExp(tailwindImportSource, "m");
30
+ const tailwindImportPatternGlobal = new RegExp(tailwindImportSource, "gm");
31
+ // Tailwind CSS v3 roots use @tailwind directives instead of @import "tailwindcss"; EDS wiring needs v4.
32
+ const tailwindV3DirectivePattern = /^[ \t]*@tailwind\s+(?:base|components|utilities|variants|screens)\b/m;
33
+ const cssImportPattern = /^[ \t]*@import\s+(?:url\()?["']([^"']+)["']/gm;
34
+ const scriptImportPattern = /^[ \t]*import\s+["']([^"']+)["']/gm;
35
+ const legacyGeneratedImportPattern = /^[ \t]*import\s+["'][^"']*eds-tokens\.css["'];?[ \t]*(?:\r?\n|$)/gm;
36
+ const cssCommentPattern = /\/\*[\s\S]*?\*\//g;
37
+ const pretendardPattern = /pretendard/i;
38
+ const packageReferencePattern = /@spectrabrain\/eds\//;
39
+
40
+ // Blanks `/* ... */` comments while keeping every index and line break, so a commented-out import is
41
+ // neither a Tailwind root nor a satisfied EDS import, and match positions still apply to the original.
42
+ export function stripCssComments(contents) {
43
+ return contents.replace(cssCommentPattern, (comment) => comment.replace(/[^\r\n]/g, " "));
44
+ }
45
+
46
+ function collectImports(contents, pattern) {
47
+ return [...contents.matchAll(pattern)].map((match) => match[1]);
48
+ }
49
+
50
+ function hasTailwindImport(contents) {
51
+ return tailwindImportPattern.test(stripCssComments(contents));
52
+ }
53
+
54
+ function hasTailwindV3Directive(contents) {
55
+ return tailwindV3DirectivePattern.test(stripCssComments(contents));
56
+ }
57
+
58
+ export const tailwindV3Advice = "EDS Tailwind wiring needs Tailwind CSS v4. Import @spectrabrain/eds/styles.css in the app entry and use " +
59
+ "var(--eds-*) or cssVar() for tokens; ask the user before upgrading to Tailwind CSS v4.";
60
+
61
+ // The first CSS file that still uses Tailwind CSS v3 @tailwind directives, or null.
62
+ export function findTailwindV3File(consumerRoot, sources) {
63
+ const file = sources.css.find((candidate) => hasTailwindV3Directive(candidate.contents) && !hasTailwindImport(candidate.contents));
64
+ return file ? relativeTo(consumerRoot, file.path) : null;
65
+ }
66
+
67
+ function readSources(consumerRoot) {
68
+ const generatedPath = path.join(consumerRoot, generatedCssRelativePath);
69
+ const css = listCssFiles(consumerRoot)
70
+ .filter((filePath) => filePath !== generatedPath)
71
+ .map((filePath) => ({ path: filePath, contents: fs.readFileSync(filePath, "utf8") }));
72
+ const scripts = listScriptFiles(consumerRoot)
73
+ .map((filePath) => ({ path: filePath, contents: fs.readFileSync(filePath, "utf8") }));
74
+ const html = listHtmlFiles(consumerRoot)
75
+ .map((filePath) => ({ path: filePath, contents: fs.readFileSync(filePath, "utf8") }));
76
+ return { generatedPath, css, scripts, html };
77
+ }
78
+
79
+ export function readConfiguredTailwindRoot(consumerRoot) {
80
+ const configured = readPackageJson(consumerRoot)?.[consumerConfigField]?.[tailwindRootConfigKey];
81
+ if (configured === undefined || configured === null) {
82
+ return null;
83
+ }
84
+ if (typeof configured !== "string" || configured.trim() === "") {
85
+ throw createExitError("package.json " + consumerConfigField + "." + tailwindRootConfigKey + " must be a relative path string, for example \"src/index.css\".", 2);
86
+ }
87
+ return configured;
88
+ }
89
+
90
+ function resolveExplicitRoot(consumerRoot, relative, { source, label, remedy }) {
91
+ const rootPath = path.resolve(consumerRoot, relative);
92
+ if (!fs.existsSync(rootPath) || !fs.statSync(rootPath).isFile()) {
93
+ throw createExitError(label + " does not point to an existing file: " + relative + ". " + remedy, 2);
94
+ }
95
+ const contents = fs.readFileSync(rootPath, "utf8");
96
+ const relativePath = relativeTo(consumerRoot, rootPath);
97
+ if (hasTailwindV3Directive(contents) && !hasTailwindImport(contents)) {
98
+ throw createExitError(
99
+ label + " points to " + relativePath + ", which uses Tailwind CSS v3 @tailwind directives. Remove that setting. " + tailwindV3Advice,
100
+ 2,
101
+ );
102
+ }
103
+ return {
104
+ path: rootPath,
105
+ relativePath,
106
+ contents,
107
+ source,
108
+ warning: hasTailwindImport(contents)
109
+ ? null
110
+ : relativePath + ' does not import "tailwindcss"; EDS imports will be inserted at the top of the file.',
111
+ };
112
+ }
113
+
114
+ // Resolution order: EDS_TAILWIND_ROOT, then package.json eds.tailwindRoot, then auto-detection.
115
+ // Returns null when the consumer has no Tailwind root at all: sync then skips the Tailwind wiring.
116
+ // Two or more candidates and an explicit path that does not exist still exit 2.
117
+ export function findTailwindRoot(
118
+ consumerRoot,
119
+ env = process.env,
120
+ sources = readSources(consumerRoot),
121
+ packageManager = detectPackageManager(consumerRoot, env),
122
+ ) {
123
+ const override = env[tailwindRootEnvironmentVariable];
124
+ if (override) {
125
+ return resolveExplicitRoot(consumerRoot, override, {
126
+ source: "env",
127
+ label: tailwindRootEnvironmentVariable,
128
+ remedy: "Point it at the CSS file that imports tailwindcss.",
129
+ });
130
+ }
131
+
132
+ const configured = readConfiguredTailwindRoot(consumerRoot);
133
+ if (configured !== null) {
134
+ return resolveExplicitRoot(consumerRoot, toPosix(configured), {
135
+ source: "config",
136
+ label: "package.json " + consumerConfigField + "." + tailwindRootConfigKey,
137
+ remedy: "Update or remove that key, or run " + tailwindRootEnvironmentVariable + "=<file> " +
138
+ runnerCommand(packageManager, "sync:eds") + " once to record the new path.",
139
+ });
140
+ }
141
+
142
+ const candidates = sources.css.filter((file) => hasTailwindImport(file.contents));
143
+ if (candidates.length === 0) {
144
+ return null;
145
+ }
146
+ if (candidates.length > 1) {
147
+ const found = candidates.map((file) => relativeTo(consumerRoot, file.path));
148
+ throw createExitError(
149
+ "Could not identify one Tailwind root CSS file (found " + found.length + ": " + found.join(", ") + "). " +
150
+ "Ask the user which of these is the app's Tailwind root, then run " + tailwindRootEnvironmentVariable + "=<that file> " +
151
+ runnerCommand(packageManager, "sync:eds") + " once. sync records the choice in package.json as \"" + consumerConfigField +
152
+ "\": { \"" + tailwindRootConfigKey + "\": \"<that file>\" } so doctor and the hooks reuse it.",
153
+ 2,
154
+ );
155
+ }
156
+
157
+ const [root] = candidates;
158
+ return { path: root.path, relativePath: relativeTo(consumerRoot, root.path), contents: root.contents, source: "detected", warning: null };
159
+ }
160
+
161
+ export function importsPackageFile(sources, fileName) {
162
+ const pattern = new RegExp("^@spectrabrain/eds/" + fileName.replace(".", "\\.") + "$");
163
+ const cssHit = sources.css.some((file) => collectImports(stripCssComments(file.contents), cssImportPattern).some((entry) => pattern.test(entry)));
164
+ const scriptHit = sources.scripts.some((file) => collectImports(file.contents, scriptImportPattern).some((entry) => pattern.test(entry)));
165
+ return cssHit || scriptHit;
166
+ }
167
+
168
+ // EDS text styles reference the font family "Pretendard" from fonts.css, so a consumer that opts out of
169
+ // styles.css must import tokens.css, components.css and fonts.css. Returns the import lines still missing.
170
+ export function planPackageImports(sources) {
171
+ const hasStyles = importsPackageFile(sources, "styles.css");
172
+ const hasTokensAndComponents = importsPackageFile(sources, "tokens.css") && importsPackageFile(sources, "components.css");
173
+ const hasFonts = importsPackageFile(sources, "fonts.css");
174
+ if (!hasStyles && !hasTokensAndComponents) {
175
+ return [stylesImportLine];
176
+ }
177
+ if (!hasStyles && !hasFonts) {
178
+ return [fontsImportLine];
179
+ }
180
+ return [];
181
+ }
182
+
183
+ // Files (other than the EDS imports themselves) that mention Pretendard: a CDN link, a second @font-face
184
+ // or a font-family declaration. Reported by doctor as a note only.
185
+ export function findPretendardReferences(consumerRoot, sources) {
186
+ return [...sources.html, ...sources.css]
187
+ .filter((file) => file.contents.split(/\r?\n/).some((line) => pretendardPattern.test(line) && !packageReferencePattern.test(line)))
188
+ .map((file) => relativeTo(consumerRoot, file.path));
189
+ }
190
+
191
+ function importsGeneratedFile(root, generatedPath) {
192
+ return collectImports(stripCssComments(root.contents), cssImportPattern).some((specifier) => {
193
+ if (!specifier.startsWith(".")) {
194
+ return false;
195
+ }
196
+ return path.resolve(path.dirname(root.path), specifier) === generatedPath;
197
+ });
198
+ }
199
+
200
+ // End index of the last Tailwind import line (comments ignored), or null. With granular imports the EDS
201
+ // lines go after the last one so they follow both theme.css and utilities.css.
202
+ function lastTailwindImportEnd(contents) {
203
+ let end = null;
204
+ for (const match of stripCssComments(contents).matchAll(tailwindImportPatternGlobal)) {
205
+ end = match.index + match[0].length;
206
+ }
207
+ return end;
208
+ }
209
+
210
+ export function planCssRootImports({ consumerRoot, root, sources }) {
211
+ const generatedPath = sources.generatedPath;
212
+ const generatedImportLine = '@import "' + relativeImportPath(root.path, generatedPath) + '";';
213
+ const missing = planPackageImports(sources);
214
+ if (!importsGeneratedFile(root, generatedPath)) {
215
+ missing.push(generatedImportLine);
216
+ }
217
+
218
+ const eol = detectEol(root.contents);
219
+ let after = root.contents;
220
+ if (missing.length > 0) {
221
+ const lineEnd = lastTailwindImportEnd(root.contents);
222
+ if (lineEnd !== null) {
223
+ const lineBreak = root.contents.slice(lineEnd, lineEnd + eol.length) === eol ? eol : "";
224
+ const inserted = missing.join(eol);
225
+ after = root.contents.slice(0, lineEnd) + eol + inserted + (lineBreak ? "" : eol) + root.contents.slice(lineEnd);
226
+ } else {
227
+ after = missing.join(eol) + eol + root.contents;
228
+ }
229
+ }
230
+
231
+ return {
232
+ label: "css-root",
233
+ path: root.path,
234
+ relativePath: relativeTo(consumerRoot, root.path),
235
+ before: root.contents,
236
+ after,
237
+ missing,
238
+ };
239
+ }
240
+
241
+ export function planLegacyJsImportCleanup({ consumerRoot }) {
242
+ const sourceDirectory = path.join(consumerRoot, "src");
243
+ return listScriptFiles(sourceDirectory).flatMap((filePath) => {
244
+ const before = fs.readFileSync(filePath, "utf8");
245
+ const after = before.replace(legacyGeneratedImportPattern, "");
246
+ if (after === before) {
247
+ return [];
248
+ }
249
+ return [{
250
+ label: "legacy-js-import",
251
+ path: filePath,
252
+ relativePath: relativeTo(consumerRoot, filePath),
253
+ before,
254
+ after,
255
+ }];
256
+ });
257
+ }
258
+
259
+ export { readSources };
@@ -0,0 +1,204 @@
1
+ import { findPretendardReferences, planPackageImports, stylesImportLine, fontsImportLine } from "./css-root.mjs";
2
+ import { readGeneratedVersion } from "./generate-tailwind.mjs";
3
+ import { legacyVersionLabel } from "./managed-block.mjs";
4
+ import { cliCommand, generatedCssRelativePath, normalizeEol, readJson, readPackageJson, runnerCommand, toPosix } from "./paths.mjs";
5
+ import { doctorCommand, doctorScriptName, isCompatibleCommand, syncCommand, syncScriptName } from "./register-scripts.mjs";
6
+ import { scanClassNames } from "./scan-classes.mjs";
7
+ import { collectPlans, describeMissingTailwindRoot } from "./sync.mjs";
8
+
9
+ export const doctorEnvironmentVariable = "EDS_DOCTOR";
10
+
11
+ // Without a Tailwind root, sync cannot add these imports, so each line says where they go.
12
+ const fontGuidance = {
13
+ [stylesImportLine]: "missing: @spectrabrain/eds/styles.css import (or tokens.css + components.css + fonts.css); add it to the app entry, " +
14
+ "for example src/main.tsx, because sync cannot add it without a Tailwind root",
15
+ [fontsImportLine]: "missing: @spectrabrain/eds/fonts.css import next to tokens.css and components.css; add it where they are imported, " +
16
+ "because EDS text styles use the font family \"Pretendard\" it declares",
17
+ };
18
+ const syncFixablePattern = /^(?:missing|stale|legacy): /;
19
+
20
+ export function collectFindings({ consumerRoot, env = process.env }) {
21
+ const plans = collectPlans({ consumerRoot, env });
22
+ const installed = plans.pkg.version;
23
+ const generatedRelativePath = toPosix(generatedCssRelativePath);
24
+ const findings = [];
25
+ const notes = [];
26
+ let rawColors = null;
27
+
28
+ const scripts = readPackageJson(consumerRoot)?.scripts ?? {};
29
+ const unregistered = [];
30
+ for (const [name, command] of [[syncScriptName, syncCommand], [doctorScriptName, doctorCommand]]) {
31
+ if (isCompatibleCommand(scripts[name], command)) {
32
+ continue;
33
+ }
34
+ if (scripts[name] === undefined) {
35
+ unregistered.push(name);
36
+ continue;
37
+ }
38
+ findings.push(
39
+ "scripts: " + name + " runs " + JSON.stringify(scripts[name]) + " instead of \"" + command +
40
+ "\"; rename that script and run " + cliCommand(plans.packageManager, "init") + ", or make it run \"" + command + "\"",
41
+ );
42
+ }
43
+ if (unregistered.length > 0) {
44
+ findings.unshift("scripts: " + unregistered.join(", ") + (unregistered.length === 1 ? " is" : " are") + " not registered; run " +
45
+ cliCommand(plans.packageManager, "init"));
46
+ }
47
+ // The fix lines may only point at sync:eds and eds:doctor when those scripts really run the EDS commands.
48
+ const syncRegistered = isCompatibleCommand(scripts[syncScriptName], syncCommand);
49
+ const doctorRegistered = isCompatibleCommand(scripts[doctorScriptName], doctorCommand);
50
+ const nonSyncFindings = new Set();
51
+
52
+ if (plans.root === null) {
53
+ notes.push(describeMissingTailwindRoot(plans));
54
+ // Without a Tailwind root, sync cannot insert the imports, so doctor states the requirement directly.
55
+ for (const line of planPackageImports(plans.sources)) {
56
+ findings.push(fontGuidance[line]);
57
+ nonSyncFindings.add(fontGuidance[line]);
58
+ }
59
+ } else {
60
+ if (plans.tailwind.before === null) {
61
+ findings.push("missing: " + generatedRelativePath);
62
+ } else if (normalizeEol(plans.tailwind.before) !== normalizeEol(plans.tailwind.after)) {
63
+ // Line endings are ignored: an autocrlf checkout of the LF generated file is not stale.
64
+ const generatedBy = readGeneratedVersion(plans.tailwind.before);
65
+ findings.push(
66
+ generatedBy === null
67
+ ? "stale: " + generatedRelativePath + " has no @spectrabrain/eds version header (installed " + installed + ")"
68
+ : generatedBy === installed
69
+ ? "stale: " + generatedRelativePath + " differs from the output of @spectrabrain/eds@" + installed
70
+ : "stale: " + generatedRelativePath + " was generated by @spectrabrain/eds@" + generatedBy + " (installed " + installed + ")",
71
+ );
72
+ }
73
+
74
+ for (const line of plans.cssRoot.missing) {
75
+ findings.push("missing: " + line + " in " + plans.cssRoot.relativePath);
76
+ }
77
+
78
+ for (const write of plans.legacyImports) {
79
+ findings.push("legacy: " + write.relativePath + " imports eds-tokens.css from JavaScript (Tailwind cannot compile it)");
80
+ }
81
+
82
+ // Tailwind drops a class no token backs without a word, so an unknown eds-* name is always a finding.
83
+ const classScan = scanClassNames({
84
+ consumerRoot,
85
+ manifest: readJson(plans.pkg.resolve("tokens.json")),
86
+ files: [...plans.sources.scripts, ...plans.sources.css, ...plans.sources.html],
87
+ });
88
+ for (const hit of classScan.unknown) {
89
+ findings.push("unknown: " + hit.location + " " + hit.className + " is not a public EDS class; use a class from " + generatedRelativePath);
90
+ }
91
+ rawColors = classScan.raw;
92
+ }
93
+
94
+ notes.push(...plans.deliveryNotes);
95
+ for (const filePath of findPretendardReferences(consumerRoot, plans.sources)) {
96
+ notes.push(filePath + " references Pretendard outside @spectrabrain/eds. EDS text styles use the font from @spectrabrain/eds/fonts.css, " +
97
+ "so this does not affect EDS; keep it unless the user asks to remove it, because other styles may use it.");
98
+ }
99
+
100
+ for (const error of plans.errors) {
101
+ findings.push("invalid: " + error.message);
102
+ }
103
+ if (plans.agents) {
104
+ if (plans.agents.existingBlock === null) {
105
+ findings.push("missing: AGENTS.md managed block");
106
+ } else if (normalizeEol(plans.agents.existingBlock) !== normalizeEol(plans.agents.block)) {
107
+ const blockVersion = plans.agents.existingVersion ?? legacyVersionLabel;
108
+ findings.push(
109
+ blockVersion === installed
110
+ ? "stale: AGENTS.md managed block differs from @spectrabrain/eds@" + installed
111
+ : "stale: AGENTS.md managed block is for " + blockVersion + " (installed " + installed + ")",
112
+ );
113
+ }
114
+ }
115
+
116
+ if (plans.claude && normalizeEol(plans.claude.before) !== normalizeEol(plans.claude.after)) {
117
+ findings.push("missing: " + plans.claude.importLine + " import in " + plans.claude.relativePath);
118
+ }
119
+ if (plans.gemini && normalizeEol(plans.gemini.before) !== normalizeEol(plans.gemini.after)) {
120
+ findings.push("missing: " + plans.gemini.importLine + " import in " + plans.gemini.relativePath);
121
+ }
122
+
123
+ return { plans, findings, notes, installed, generatedRelativePath, syncRegistered, doctorRegistered, nonSyncFindings, unregistered, rawColors };
124
+ }
125
+
126
+ const rawColorLineLimit = 40;
127
+
128
+ // --scan lists the classes that bypass the EDS tokens. It never changes the exit code: legacy code may keep them.
129
+ function logRawColors(rawColors, log) {
130
+ if (rawColors === null) {
131
+ log("[EDS] scan: skipped (no Tailwind root)");
132
+ return;
133
+ }
134
+ if (rawColors.length === 0) {
135
+ log("[EDS] scan: no raw color classes found");
136
+ return;
137
+ }
138
+ const files = new Set(rawColors.map((hit) => hit.location.split(":")[0]));
139
+ log(
140
+ "[EDS] scan: " + rawColors.length + " raw color class" + (rawColors.length === 1 ? "" : "es") + " in " + files.size +
141
+ " file" + (files.size === 1 ? "" : "s") + " bypass the EDS tokens (informational; prefer text-eds-fg-*, bg-eds-bg-*, border-eds-stroke-*)",
142
+ );
143
+ for (const hit of rawColors.slice(0, rawColorLineLimit)) {
144
+ log("[EDS] raw: " + hit.location + " " + hit.className);
145
+ }
146
+ if (rawColors.length > rawColorLineLimit) {
147
+ log("[EDS] raw: … " + (rawColors.length - rawColorLineLimit) + " more");
148
+ }
149
+ }
150
+
151
+ export function runDoctor({ consumerRoot, env = process.env, warn = false, scan = false, log = console.log }) {
152
+ const mode = env[doctorEnvironmentVariable];
153
+ if (mode === "off") {
154
+ return 0;
155
+ }
156
+ const downgrade = warn || mode === "warn";
157
+
158
+ let result;
159
+ try {
160
+ result = collectFindings({ consumerRoot, env });
161
+ } catch (error) {
162
+ log("[EDS] " + error.message);
163
+ if (error.exitCode === 2) {
164
+ return downgrade ? 0 : 2;
165
+ }
166
+ throw error;
167
+ }
168
+
169
+ for (const note of result.notes) {
170
+ log("[EDS] note: " + note);
171
+ }
172
+ if (scan) {
173
+ logRawColors(result.rawColors, log);
174
+ }
175
+
176
+ if (result.findings.length === 0) {
177
+ const syncedPaths = result.plans.root === null
178
+ ? ["AGENTS.md"]
179
+ : [result.plans.root.relativePath, result.generatedRelativePath, "AGENTS.md"];
180
+ log("[EDS] synced with @spectrabrain/eds@" + result.installed + ": " + syncedPaths.join(", "));
181
+ return 0;
182
+ }
183
+
184
+ for (const finding of result.findings) {
185
+ log("[EDS] " + finding);
186
+ }
187
+ // One next command: init when scripts are missing (it also syncs), otherwise sync when it can fix a line.
188
+ // unknown:, invalid:, scripts: conflicts and the imports sync cannot add carry their own fix on the line.
189
+ const packageManager = result.plans.packageManager;
190
+ const syncFixable = result.findings.some((finding) => syncFixablePattern.test(finding) && !result.nonSyncFindings.has(finding));
191
+ if (result.unregistered.length > 0) {
192
+ log("[EDS] Run " + cliCommand(packageManager, "init") + " to register the scripts; it also runs sync.");
193
+ } else if (syncFixable) {
194
+ const syncRun = result.syncRegistered ? runnerCommand(packageManager, syncScriptName) : cliCommand(packageManager, "sync");
195
+ log("[EDS] Run " + syncRun + " to fix the missing:, stale: and legacy: lines.");
196
+ }
197
+ if (downgrade) {
198
+ log("[EDS] Warn mode: the lines above are not fixed, so this exit 0 does not mean the check passed.");
199
+ }
200
+ const doctorRun = result.doctorRegistered ? runnerCommand(packageManager, doctorScriptName) : cliCommand(packageManager, "doctor");
201
+ // The completion line itself starts with "[EDS] synced with"; this line must not contain that text.
202
+ log("[EDS] Then rerun " + doctorRun + " until it reports synced; the QUICKSTART.md completion criteria in the package root list the order.");
203
+ return downgrade ? 0 : 1;
204
+ }
@@ -0,0 +1,83 @@
1
+ import path from "node:path";
2
+ import { generatedCssRelativePath, readJson, readTextIfExists, relativeTo } from "./paths.mjs";
3
+
4
+ const propertyNames = [
5
+ "fontFamily",
6
+ "fontWeight",
7
+ "fontSize",
8
+ "lineHeight",
9
+ "letterSpacing",
10
+ ];
11
+
12
+ export const fontSansStyleName = "body1";
13
+ export function renderTailwindCss(manifest, version) {
14
+ const tokens = Object.keys(manifest.tokens ?? {});
15
+ const colorNames = tokens.filter((name) => name.startsWith("color-"));
16
+ const typography = new Map();
17
+
18
+ for (const name of tokens.filter((entry) => entry.startsWith("typography-"))) {
19
+ const match = name.match(/^typography-(.+)-(fontFamily|fontWeight|fontSize|lineHeight|letterSpacing)$/);
20
+ if (!match) {
21
+ throw new Error(`Unsupported typography token name: ${name}`);
22
+ }
23
+
24
+ const [, styleName, propertyName] = match;
25
+ const properties = typography.get(styleName) ?? new Set();
26
+ properties.add(propertyName);
27
+ typography.set(styleName, properties);
28
+ }
29
+
30
+ for (const [styleName, properties] of typography) {
31
+ for (const propertyName of propertyNames) {
32
+ if (!properties.has(propertyName)) {
33
+ throw new Error(`Typography preset ${styleName} is missing ${propertyName}.`);
34
+ }
35
+ }
36
+ }
37
+
38
+ if (!typography.has(fontSansStyleName)) {
39
+ throw new Error(`Typography preset ${fontSansStyleName} is required for --font-sans.`);
40
+ }
41
+
42
+ const colorAliases = colorNames
43
+ .map((name) => ` --color-eds-${name.slice("color-".length)}: var(--eds-${name});`)
44
+ .join("\n");
45
+ // Last line of the @theme block: Tailwind's default font family becomes the EDS body font.
46
+ const fontSans = ` --font-sans: var(--eds-typography-${fontSansStyleName}-fontFamily);`;
47
+ const typographyUtilities = [...typography.keys()].sort().map((styleName) => [
48
+ `@utility eds-typography-${styleName} {`,
49
+ ` font-family: var(--eds-typography-${styleName}-fontFamily);`,
50
+ ` font-weight: var(--eds-typography-${styleName}-fontWeight);`,
51
+ ` font-size: var(--eds-typography-${styleName}-fontSize);`,
52
+ ` line-height: var(--eds-typography-${styleName}-lineHeight);`,
53
+ ` letter-spacing: var(--eds-typography-${styleName}-letterSpacing);`,
54
+ `}`,
55
+ ].join("\n")).join("\n\n");
56
+
57
+ return [
58
+ `/* Generated from @spectrabrain/eds@${version}. Do not edit manually. */`,
59
+ "@theme inline {",
60
+ colorAliases,
61
+ fontSans,
62
+ "}",
63
+ "",
64
+ typographyUtilities,
65
+ "",
66
+ ].join("\n");
67
+ }
68
+
69
+ export function readGeneratedVersion(contents) {
70
+ return contents.match(/^\/\* Generated from @spectrabrain\/eds@(\S+)\. Do not edit manually\. \*\//m)?.[1] ?? null;
71
+ }
72
+
73
+ export function planTailwind({ consumerRoot, pkg }) {
74
+ const manifest = readJson(pkg.resolve("tokens.json"));
75
+ const filePath = path.join(consumerRoot, generatedCssRelativePath);
76
+ return {
77
+ label: "tailwind",
78
+ path: filePath,
79
+ relativePath: relativeTo(consumerRoot, filePath),
80
+ before: readTextIfExists(filePath),
81
+ after: renderTailwindCss(manifest, pkg.version),
82
+ };
83
+ }