@spectrabrain/eds 0.0.3 → 0.1.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.
@@ -0,0 +1,239 @@
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
+ const cssImportPattern = /^[ \t]*@import\s+(?:url\()?["']([^"']+)["']/gm;
32
+ const scriptImportPattern = /^[ \t]*import\s+["']([^"']+)["']/gm;
33
+ const legacyGeneratedImportPattern = /^[ \t]*import\s+["'][^"']*eds-tokens\.css["'];?[ \t]*(?:\r?\n|$)/gm;
34
+ const cssCommentPattern = /\/\*[\s\S]*?\*\//g;
35
+ const pretendardPattern = /pretendard/i;
36
+ const packageReferencePattern = /@spectrabrain\/eds\//;
37
+
38
+ // Blanks `/* ... */` comments while keeping every index and line break, so a commented-out import is
39
+ // neither a Tailwind root nor a satisfied EDS import, and match positions still apply to the original.
40
+ export function stripCssComments(contents) {
41
+ return contents.replace(cssCommentPattern, (comment) => comment.replace(/[^\r\n]/g, " "));
42
+ }
43
+
44
+ function collectImports(contents, pattern) {
45
+ return [...contents.matchAll(pattern)].map((match) => match[1]);
46
+ }
47
+
48
+ function hasTailwindImport(contents) {
49
+ return tailwindImportPattern.test(stripCssComments(contents));
50
+ }
51
+
52
+ function readSources(consumerRoot) {
53
+ const generatedPath = path.join(consumerRoot, generatedCssRelativePath);
54
+ const css = listCssFiles(consumerRoot)
55
+ .filter((filePath) => filePath !== generatedPath)
56
+ .map((filePath) => ({ path: filePath, contents: fs.readFileSync(filePath, "utf8") }));
57
+ const scripts = listScriptFiles(consumerRoot)
58
+ .map((filePath) => ({ path: filePath, contents: fs.readFileSync(filePath, "utf8") }));
59
+ const html = listHtmlFiles(consumerRoot)
60
+ .map((filePath) => ({ path: filePath, contents: fs.readFileSync(filePath, "utf8") }));
61
+ return { generatedPath, css, scripts, html };
62
+ }
63
+
64
+ export function readConfiguredTailwindRoot(consumerRoot) {
65
+ const configured = readPackageJson(consumerRoot)?.[consumerConfigField]?.[tailwindRootConfigKey];
66
+ if (configured === undefined || configured === null) {
67
+ return null;
68
+ }
69
+ if (typeof configured !== "string" || configured.trim() === "") {
70
+ throw createExitError("package.json " + consumerConfigField + "." + tailwindRootConfigKey + " must be a relative path string, for example \"src/index.css\".", 2);
71
+ }
72
+ return configured;
73
+ }
74
+
75
+ function resolveExplicitRoot(consumerRoot, relative, { source, label, remedy }) {
76
+ const rootPath = path.resolve(consumerRoot, relative);
77
+ if (!fs.existsSync(rootPath) || !fs.statSync(rootPath).isFile()) {
78
+ throw createExitError(label + " does not point to an existing file: " + relative + ". " + remedy, 2);
79
+ }
80
+ const contents = fs.readFileSync(rootPath, "utf8");
81
+ const relativePath = relativeTo(consumerRoot, rootPath);
82
+ return {
83
+ path: rootPath,
84
+ relativePath,
85
+ contents,
86
+ source,
87
+ warning: hasTailwindImport(contents)
88
+ ? null
89
+ : relativePath + ' does not import "tailwindcss"; EDS imports will be inserted at the top of the file.',
90
+ };
91
+ }
92
+
93
+ // Resolution order: EDS_TAILWIND_ROOT, then package.json eds.tailwindRoot, then auto-detection.
94
+ // Returns null when the consumer has no Tailwind root at all: sync then skips the Tailwind wiring.
95
+ // Two or more candidates and an explicit path that does not exist still exit 2.
96
+ export function findTailwindRoot(
97
+ consumerRoot,
98
+ env = process.env,
99
+ sources = readSources(consumerRoot),
100
+ packageManager = detectPackageManager(consumerRoot, env),
101
+ ) {
102
+ const override = env[tailwindRootEnvironmentVariable];
103
+ if (override) {
104
+ return resolveExplicitRoot(consumerRoot, override, {
105
+ source: "env",
106
+ label: tailwindRootEnvironmentVariable,
107
+ remedy: "Point it at the CSS file that imports tailwindcss.",
108
+ });
109
+ }
110
+
111
+ const configured = readConfiguredTailwindRoot(consumerRoot);
112
+ if (configured !== null) {
113
+ return resolveExplicitRoot(consumerRoot, toPosix(configured), {
114
+ source: "config",
115
+ label: "package.json " + consumerConfigField + "." + tailwindRootConfigKey,
116
+ remedy: "Update or remove that key, or run " + tailwindRootEnvironmentVariable + "=<file> " +
117
+ runnerCommand(packageManager, "sync:eds") + " once to record the new path.",
118
+ });
119
+ }
120
+
121
+ const candidates = sources.css.filter((file) => hasTailwindImport(file.contents));
122
+ if (candidates.length === 0) {
123
+ return null;
124
+ }
125
+ if (candidates.length > 1) {
126
+ const found = candidates.map((file) => relativeTo(consumerRoot, file.path));
127
+ throw createExitError(
128
+ "Could not identify one Tailwind root CSS file (found " + found.length + ": " + found.join(", ") + "). " +
129
+ "Set " + tailwindRootEnvironmentVariable + ", for example " +
130
+ tailwindRootEnvironmentVariable + "=src/index.css " + runnerCommand(packageManager, "sync:eds") + ". " +
131
+ "sync records the choice in package.json as \"" + consumerConfigField + "\": { \"" + tailwindRootConfigKey +
132
+ "\": \"src/index.css\" } so doctor and the hooks reuse it.",
133
+ 2,
134
+ );
135
+ }
136
+
137
+ const [root] = candidates;
138
+ return { path: root.path, relativePath: relativeTo(consumerRoot, root.path), contents: root.contents, source: "detected", warning: null };
139
+ }
140
+
141
+ export function importsPackageFile(sources, fileName) {
142
+ const pattern = new RegExp("^@spectrabrain/eds/" + fileName.replace(".", "\\.") + "$");
143
+ const cssHit = sources.css.some((file) => collectImports(stripCssComments(file.contents), cssImportPattern).some((entry) => pattern.test(entry)));
144
+ const scriptHit = sources.scripts.some((file) => collectImports(file.contents, scriptImportPattern).some((entry) => pattern.test(entry)));
145
+ return cssHit || scriptHit;
146
+ }
147
+
148
+ // EDS text styles reference the font family "Pretendard" from fonts.css, so a consumer that opts out of
149
+ // styles.css must import tokens.css, components.css and fonts.css. Returns the import lines still missing.
150
+ export function planPackageImports(sources) {
151
+ const hasStyles = importsPackageFile(sources, "styles.css");
152
+ const hasTokensAndComponents = importsPackageFile(sources, "tokens.css") && importsPackageFile(sources, "components.css");
153
+ const hasFonts = importsPackageFile(sources, "fonts.css");
154
+ if (!hasStyles && !hasTokensAndComponents) {
155
+ return [stylesImportLine];
156
+ }
157
+ if (!hasStyles && !hasFonts) {
158
+ return [fontsImportLine];
159
+ }
160
+ return [];
161
+ }
162
+
163
+ // Files (other than the EDS imports themselves) that mention Pretendard: a CDN link, a second @font-face
164
+ // or a font-family declaration. Reported by doctor as a note only.
165
+ export function findPretendardReferences(consumerRoot, sources) {
166
+ return [...sources.html, ...sources.css]
167
+ .filter((file) => file.contents.split(/\r?\n/).some((line) => pretendardPattern.test(line) && !packageReferencePattern.test(line)))
168
+ .map((file) => relativeTo(consumerRoot, file.path));
169
+ }
170
+
171
+ function importsGeneratedFile(root, generatedPath) {
172
+ return collectImports(stripCssComments(root.contents), cssImportPattern).some((specifier) => {
173
+ if (!specifier.startsWith(".")) {
174
+ return false;
175
+ }
176
+ return path.resolve(path.dirname(root.path), specifier) === generatedPath;
177
+ });
178
+ }
179
+
180
+ // End index of the last Tailwind import line (comments ignored), or null. With granular imports the EDS
181
+ // lines go after the last one so they follow both theme.css and utilities.css.
182
+ function lastTailwindImportEnd(contents) {
183
+ let end = null;
184
+ for (const match of stripCssComments(contents).matchAll(tailwindImportPatternGlobal)) {
185
+ end = match.index + match[0].length;
186
+ }
187
+ return end;
188
+ }
189
+
190
+ export function planCssRootImports({ consumerRoot, root, sources }) {
191
+ const generatedPath = sources.generatedPath;
192
+ const generatedImportLine = '@import "' + relativeImportPath(root.path, generatedPath) + '";';
193
+ const missing = planPackageImports(sources);
194
+ if (!importsGeneratedFile(root, generatedPath)) {
195
+ missing.push(generatedImportLine);
196
+ }
197
+
198
+ const eol = detectEol(root.contents);
199
+ let after = root.contents;
200
+ if (missing.length > 0) {
201
+ const lineEnd = lastTailwindImportEnd(root.contents);
202
+ if (lineEnd !== null) {
203
+ const lineBreak = root.contents.slice(lineEnd, lineEnd + eol.length) === eol ? eol : "";
204
+ const inserted = missing.join(eol);
205
+ after = root.contents.slice(0, lineEnd) + eol + inserted + (lineBreak ? "" : eol) + root.contents.slice(lineEnd);
206
+ } else {
207
+ after = missing.join(eol) + eol + root.contents;
208
+ }
209
+ }
210
+
211
+ return {
212
+ label: "css-root",
213
+ path: root.path,
214
+ relativePath: relativeTo(consumerRoot, root.path),
215
+ before: root.contents,
216
+ after,
217
+ missing,
218
+ };
219
+ }
220
+
221
+ export function planLegacyJsImportCleanup({ consumerRoot }) {
222
+ const sourceDirectory = path.join(consumerRoot, "src");
223
+ return listScriptFiles(sourceDirectory).flatMap((filePath) => {
224
+ const before = fs.readFileSync(filePath, "utf8");
225
+ const after = before.replace(legacyGeneratedImportPattern, "");
226
+ if (after === before) {
227
+ return [];
228
+ }
229
+ return [{
230
+ label: "legacy-js-import",
231
+ path: filePath,
232
+ relativePath: relativeTo(consumerRoot, filePath),
233
+ before,
234
+ after,
235
+ }];
236
+ });
237
+ }
238
+
239
+ export { readSources };
@@ -0,0 +1,178 @@
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 { 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, noTailwindNote } from "./sync.mjs";
8
+
9
+ export const doctorEnvironmentVariable = "EDS_DOCTOR";
10
+
11
+ const fontGuidance = {
12
+ [stylesImportLine]: "missing: @spectrabrain/eds/styles.css import in CSS or JS (or tokens.css + components.css + fonts.css); EDS text styles need the bundled Pretendard font",
13
+ [fontsImportLine]: "missing: @spectrabrain/eds/fonts.css import next to tokens.css and components.css; EDS text styles reference the font family \"Pretendard\" from fonts.css",
14
+ };
15
+
16
+ export function collectFindings({ consumerRoot, env = process.env }) {
17
+ const plans = collectPlans({ consumerRoot, env });
18
+ const installed = plans.pkg.version;
19
+ const generatedRelativePath = toPosix(generatedCssRelativePath);
20
+ const findings = [];
21
+ const notes = [];
22
+ let rawColors = null;
23
+
24
+ const scripts = readPackageJson(consumerRoot)?.scripts ?? {};
25
+ for (const [name, command] of [[syncScriptName, syncCommand], [doctorScriptName, doctorCommand]]) {
26
+ if (isCompatibleCommand(scripts[name], command)) {
27
+ continue;
28
+ }
29
+ findings.push(
30
+ scripts[name] === undefined
31
+ ? "scripts: " + name + " is not registered; run npx @spectrabrain/eds init"
32
+ : "scripts: " + name + " runs " + JSON.stringify(scripts[name]) + " instead of \"" + command +
33
+ "\"; rename that script and run npx @spectrabrain/eds init, or make it run \"" + command + "\"",
34
+ );
35
+ }
36
+ // The fix line may only point at sync:eds when that script really runs eds sync.
37
+ const syncRegistered = isCompatibleCommand(scripts[syncScriptName], syncCommand);
38
+
39
+ if (plans.root === null) {
40
+ notes.push(noTailwindNote);
41
+ // Without a Tailwind root, sync cannot insert the imports, so doctor states the requirement directly.
42
+ for (const line of planPackageImports(plans.sources)) {
43
+ findings.push(fontGuidance[line]);
44
+ }
45
+ } else {
46
+ if (plans.tailwind.before === null) {
47
+ findings.push("missing: " + generatedRelativePath);
48
+ } else if (normalizeEol(plans.tailwind.before) !== normalizeEol(plans.tailwind.after)) {
49
+ // Line endings are ignored: an autocrlf checkout of the LF generated file is not stale.
50
+ const generatedBy = readGeneratedVersion(plans.tailwind.before);
51
+ findings.push(
52
+ generatedBy === null
53
+ ? "stale: " + generatedRelativePath + " has no @spectrabrain/eds version header (installed " + installed + ")"
54
+ : generatedBy === installed
55
+ ? "stale: " + generatedRelativePath + " differs from the output of @spectrabrain/eds@" + installed
56
+ : "stale: " + generatedRelativePath + " was generated by @spectrabrain/eds@" + generatedBy + " (installed " + installed + ")",
57
+ );
58
+ }
59
+
60
+ for (const line of plans.cssRoot.missing) {
61
+ findings.push("missing: " + line + " in " + plans.cssRoot.relativePath);
62
+ }
63
+
64
+ for (const write of plans.legacyImports) {
65
+ findings.push("legacy: " + write.relativePath + " imports eds-tokens.css from JavaScript (Tailwind cannot compile it)");
66
+ }
67
+
68
+ // Tailwind drops a class no token backs without a word, so an unknown eds-* name is always a finding.
69
+ const classScan = scanClassNames({
70
+ consumerRoot,
71
+ manifest: readJson(plans.pkg.resolve("tokens.json")),
72
+ files: [...plans.sources.scripts, ...plans.sources.css, ...plans.sources.html],
73
+ });
74
+ for (const hit of classScan.unknown) {
75
+ findings.push("unknown: " + hit.location + " " + hit.className + " is not a public EDS class; check tokens.json or " + generatedRelativePath);
76
+ }
77
+ rawColors = classScan.raw;
78
+ }
79
+
80
+ for (const filePath of findPretendardReferences(consumerRoot, plans.sources)) {
81
+ notes.push(filePath + " references Pretendard outside @spectrabrain/eds; EDS text styles use the bundled font, so the extra font source can be removed.");
82
+ }
83
+
84
+ for (const error of plans.errors) {
85
+ findings.push("invalid: " + error.message);
86
+ }
87
+ if (plans.agents) {
88
+ if (plans.agents.existingBlock === null) {
89
+ findings.push("missing: AGENTS.md managed block");
90
+ } else if (normalizeEol(plans.agents.existingBlock) !== normalizeEol(plans.agents.block)) {
91
+ const blockVersion = plans.agents.existingVersion ?? legacyVersionLabel;
92
+ findings.push(
93
+ blockVersion === installed
94
+ ? "stale: AGENTS.md managed block differs from @spectrabrain/eds@" + installed
95
+ : "stale: AGENTS.md managed block is for " + blockVersion + " (installed " + installed + ")",
96
+ );
97
+ }
98
+ }
99
+
100
+ if (plans.claude && normalizeEol(plans.claude.before) !== normalizeEol(plans.claude.after)) {
101
+ findings.push("missing: @AGENTS.md import in CLAUDE.md");
102
+ }
103
+ if (plans.gemini && normalizeEol(plans.gemini.before) !== normalizeEol(plans.gemini.after)) {
104
+ findings.push("missing: @./AGENTS.md import in GEMINI.md");
105
+ }
106
+
107
+ return { plans, findings, notes, installed, generatedRelativePath, syncRegistered, rawColors };
108
+ }
109
+
110
+ const rawColorLineLimit = 40;
111
+
112
+ // --scan lists the classes that bypass the EDS tokens. It never changes the exit code: legacy code may keep them.
113
+ function logRawColors(rawColors, log) {
114
+ if (rawColors === null) {
115
+ log("[EDS] scan: skipped (no Tailwind root)");
116
+ return;
117
+ }
118
+ if (rawColors.length === 0) {
119
+ log("[EDS] scan: no raw color classes found");
120
+ return;
121
+ }
122
+ const files = new Set(rawColors.map((hit) => hit.location.split(":")[0]));
123
+ log(
124
+ "[EDS] scan: " + rawColors.length + " raw color class" + (rawColors.length === 1 ? "" : "es") + " in " + files.size +
125
+ " file" + (files.size === 1 ? "" : "s") + " bypass the EDS tokens (informational; prefer text-eds-fg-*, bg-eds-bg-*, border-eds-stroke-*)",
126
+ );
127
+ for (const hit of rawColors.slice(0, rawColorLineLimit)) {
128
+ log("[EDS] raw: " + hit.location + " " + hit.className);
129
+ }
130
+ if (rawColors.length > rawColorLineLimit) {
131
+ log("[EDS] raw: … " + (rawColors.length - rawColorLineLimit) + " more");
132
+ }
133
+ }
134
+
135
+ export function runDoctor({ consumerRoot, env = process.env, warn = false, scan = false, log = console.log }) {
136
+ const mode = env[doctorEnvironmentVariable];
137
+ if (mode === "off") {
138
+ return 0;
139
+ }
140
+ const downgrade = warn || mode === "warn";
141
+
142
+ let result;
143
+ try {
144
+ result = collectFindings({ consumerRoot, env });
145
+ } catch (error) {
146
+ log("[EDS] " + error.message);
147
+ if (error.exitCode === 2) {
148
+ return downgrade ? 0 : 2;
149
+ }
150
+ throw error;
151
+ }
152
+
153
+ for (const note of result.notes) {
154
+ log("[EDS] note: " + note);
155
+ }
156
+ if (scan) {
157
+ logRawColors(result.rawColors, log);
158
+ }
159
+
160
+ if (result.findings.length === 0) {
161
+ const syncedPaths = result.plans.root === null
162
+ ? ["AGENTS.md"]
163
+ : [result.plans.root.relativePath, result.generatedRelativePath, "AGENTS.md"];
164
+ log("[EDS] synced with @spectrabrain/eds@" + result.installed + ": " + syncedPaths.join(", "));
165
+ return 0;
166
+ }
167
+
168
+ for (const finding of result.findings) {
169
+ log("[EDS] " + finding);
170
+ }
171
+ const fixCommand = result.syncRegistered ? runnerCommand(result.plans.packageManager, syncScriptName) : "npx eds sync";
172
+ log(
173
+ "[EDS] Run " + fixCommand + " to fix. (" +
174
+ doctorEnvironmentVariable + "=warn or eds doctor --warn downgrades this to a warning.)",
175
+ );
176
+ log("[EDS] Then read the AGENTS.md managed block and QUICKSTART.md in the package root before UI work.");
177
+ return downgrade ? 0 : 1;
178
+ }
@@ -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
+ }
@@ -0,0 +1,172 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createExitError, detectPackageManager, packageName, resolvePackage, runnerCommand } from "./paths.mjs";
4
+ import { registerConsumerScripts, syncScriptName, writeConsumerPackageJson } from "./register-scripts.mjs";
5
+ import { runSync } from "./sync.mjs";
6
+
7
+ export const ciTargets = new Set(["github", "gitlab"]);
8
+ const allowScriptsField = "allowScripts";
9
+ const minimumAllowScriptsNpmMajor = 12;
10
+
11
+ const installCommands = {
12
+ npm: "npm ci",
13
+ pnpm: "pnpm install --frozen-lockfile",
14
+ yarn: "yarn install --frozen-lockfile",
15
+ bun: "bun install --frozen-lockfile",
16
+ };
17
+
18
+ function installCommand(packageManager) {
19
+ if (packageManager.name === "yarn" && packageManager.berry) {
20
+ return "yarn install --immutable";
21
+ }
22
+ return installCommands[packageManager.name];
23
+ }
24
+
25
+ // A CI job that fails when sync is out of date. It runs `npx eds doctor` so the consumer's own
26
+ // package.json scripts are not required.
27
+ export function renderCiSnippet(target, packageManager) {
28
+ const install = installCommand(packageManager);
29
+ if (target === "github") {
30
+ const setup = packageManager.name === "pnpm"
31
+ ? [" - uses: pnpm/action-setup@v4"]
32
+ : packageManager.name === "bun"
33
+ ? [" - uses: oven-sh/setup-bun@v2"]
34
+ : [];
35
+ const cache = packageManager.name === "bun" ? [] : [" cache: " + packageManager.name];
36
+ return [
37
+ "# .github/workflows/eds-doctor.yml",
38
+ "name: eds doctor",
39
+ "on: [push, pull_request]",
40
+ "jobs:",
41
+ " eds-doctor:",
42
+ " runs-on: ubuntu-latest",
43
+ " steps:",
44
+ " - uses: actions/checkout@v4",
45
+ ...setup,
46
+ " - uses: actions/setup-node@v4",
47
+ " with:",
48
+ " node-version: 22",
49
+ ...cache,
50
+ " - run: " + install,
51
+ " - run: npx eds doctor",
52
+ "",
53
+ ].join("\n");
54
+ }
55
+ if (target === "gitlab") {
56
+ const before = packageManager.name === "pnpm"
57
+ ? [" - corepack enable"]
58
+ : packageManager.name === "bun"
59
+ ? [" - npm install -g bun"]
60
+ : [];
61
+ return [
62
+ "# .gitlab-ci.yml",
63
+ "eds-doctor:",
64
+ " image: node:22",
65
+ " stage: test",
66
+ " script:",
67
+ ...before,
68
+ " - " + install,
69
+ " - npx eds doctor",
70
+ "",
71
+ ].join("\n");
72
+ }
73
+ throw createExitError("Unknown --print-ci target: " + target + " (use github or gitlab).", 2);
74
+ }
75
+
76
+ // npm 12+ blocks dependency install scripts unless package.json allows them. A name-only entry keeps
77
+ // the EDS postinstall running on later installs and upgrades without re-approval.
78
+ function writeAllowScripts(consumerRoot, { log, dryRun }) {
79
+ const packagePath = path.join(consumerRoot, "package.json");
80
+ const source = fs.readFileSync(packagePath, "utf8");
81
+ const consumerPackage = JSON.parse(source);
82
+ const existing = consumerPackage[allowScriptsField];
83
+ if (existing !== undefined && typeof existing !== "object") {
84
+ log("[EDS] package.json " + allowScriptsField + " is not an object; approve the postinstall manually with npm install-scripts approve --no-allow-scripts-pin " + packageName + ".");
85
+ return false;
86
+ }
87
+ const current = existing?.[packageName];
88
+ if (current !== undefined) {
89
+ log("[EDS] package.json " + allowScriptsField + "[\"" + packageName + "\"] is already " + JSON.stringify(current) + "; left unchanged.");
90
+ return false;
91
+ }
92
+ if (dryRun) {
93
+ log("[EDS] would write " + allowScriptsField + "[\"" + packageName + "\"] = true to package.json");
94
+ return false;
95
+ }
96
+ consumerPackage[allowScriptsField] = { ...(existing ?? {}), [packageName]: true };
97
+ writeConsumerPackageJson(packagePath, source, consumerPackage);
98
+ log("[EDS] wrote " + allowScriptsField + "[\"" + packageName + "\"] = true to package.json so npm " + minimumAllowScriptsNpmMajor + "+ runs the EDS postinstall on later installs.");
99
+ return true;
100
+ }
101
+
102
+ function printInstallScriptGuidance(consumerRoot, packageManager, { log, dryRun, allowScripts }) {
103
+ const { name, major, berry } = packageManager;
104
+ if (name === "npm") {
105
+ if (major !== null && major >= minimumAllowScriptsNpmMajor) {
106
+ if (allowScripts) {
107
+ writeAllowScripts(consumerRoot, { log, dryRun });
108
+ } else {
109
+ log("[EDS] --no-allow-scripts: not writing " + allowScriptsField + "; later installs skip the EDS postinstall until you run npm install-scripts approve " + packageName + ".");
110
+ }
111
+ } else if (major === null) {
112
+ log("[EDS] npm " + minimumAllowScriptsNpmMajor + "+ blocks dependency install scripts by default; run npm install-scripts approve --no-allow-scripts-pin " + packageName + " there, or rerun init with npm so it can write " + allowScriptsField + ".");
113
+ }
114
+ return;
115
+ }
116
+ if (name === "pnpm") {
117
+ log("[EDS] pnpm blocks dependency build scripts by default. Run pnpm approve-builds and select " + packageName + ", or add to package.json:");
118
+ log(" \"pnpm\": { \"onlyBuiltDependencies\": [\"" + packageName + "\"] }");
119
+ log("[EDS] Without it, rerun " + runnerCommand(packageManager, syncScriptName) + " (or npx eds init) after each install.");
120
+ return;
121
+ }
122
+ if (name === "bun") {
123
+ log("[EDS] bun runs dependency lifecycle scripts only for trustedDependencies. Add to package.json:");
124
+ log(" \"trustedDependencies\": [\"" + packageName + "\"]");
125
+ log("[EDS] Note: setting trustedDependencies replaces bun's default trusted list; include any other package whose install scripts you rely on.");
126
+ return;
127
+ }
128
+ if (name === "yarn" && berry) {
129
+ log("[EDS] Yarn 2+ skips dependency install scripts when enableScripts is false. To run the EDS postinstall, keep enableScripts: true in .yarnrc.yml, or add to package.json:");
130
+ log(" \"dependenciesMeta\": { \"" + packageName + "\": { \"built\": true } }");
131
+ log("[EDS] Otherwise rerun " + runnerCommand(packageManager, syncScriptName) + " (or npx eds init) after each install.");
132
+ }
133
+ }
134
+
135
+ export function runInit({
136
+ consumerRoot,
137
+ env = process.env,
138
+ noSync = false,
139
+ dryRun = false,
140
+ allowScripts = true,
141
+ printCi = null,
142
+ log = console.log,
143
+ }) {
144
+ const packageManager = detectPackageManager(consumerRoot, env);
145
+ if (printCi !== null) {
146
+ if (!ciTargets.has(printCi)) {
147
+ throw createExitError("Unknown --print-ci target: " + printCi + " (use github or gitlab).", 2);
148
+ }
149
+ log(renderCiSnippet(printCi, packageManager));
150
+ return 0;
151
+ }
152
+
153
+ if (!fs.existsSync(path.join(consumerRoot, "package.json"))) {
154
+ throw createExitError(
155
+ "No package.json in " + consumerRoot + "; run npx " + packageName + " init from the consumer root.",
156
+ 1,
157
+ );
158
+ }
159
+ try {
160
+ resolvePackage(consumerRoot);
161
+ } catch {
162
+ throw createExitError(packageName + " is not installed here; run npm install " + packageName + " first.", 1);
163
+ }
164
+
165
+ registerConsumerScripts(consumerRoot, { log, dryRun, packageManager });
166
+ printInstallScriptGuidance(consumerRoot, packageManager, { log, dryRun, allowScripts });
167
+ if (noSync) {
168
+ log("[EDS] Scripts registered. Run " + runnerCommand(packageManager, syncScriptName) + " to finish setup.");
169
+ return 0;
170
+ }
171
+ return runSync({ consumerRoot, env, dryRun, log });
172
+ }