@gemboss/website-ui 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +191 -0
- package/CHANGELOG.md +47 -0
- package/README.md +118 -2
- package/bin/gemboss-website.mjs +78 -0
- package/bin/lib/check.mjs +695 -0
- package/bin/lib/gap.mjs +189 -0
- package/bin/lib/init.mjs +99 -0
- package/bin/lib/upgrade.mjs +155 -0
- package/bin/lib/util.mjs +147 -0
- package/css/base.css +107 -0
- package/css/components.css +324 -0
- package/css/footer.css +53 -0
- package/css/header.css +231 -0
- package/css/layout.css +59 -0
- package/css/prose.css +119 -0
- package/css/sections.css +173 -0
- package/css/tokens.css +200 -0
- package/demo/index.html +703 -0
- package/demo/theme-example.html +133 -0
- package/docs/components.md +407 -0
- package/docs/markup-contract.md +148 -0
- package/docs/philosophy.md +69 -0
- package/docs/theming.md +96 -0
- package/js/website.js +186 -0
- package/package.json +50 -4
- package/skill/SKILL.md +49 -0
- package/system.json +122 -0
- package/templates/footer.html +40 -0
- package/templates/header.html +60 -0
- package/templates/page.html +134 -0
- package/templates/sections/cta.html +15 -0
- package/templates/sections/faq.html +24 -0
- package/templates/sections/features.html +32 -0
- package/templates/sections/hero.html +27 -0
- package/templates/sections/logos.html +15 -0
- package/templates/sections/pricing.html +51 -0
- package/templates/sections/prose.html +36 -0
- package/templates/sections/quote.html +13 -0
- package/templates/sections/showcase.html +25 -0
- package/templates/sections/split.html +27 -0
- package/templates/sections/stats.html +26 -0
- package/templates/sections/steps.html +17 -0
- package/website.css +1274 -0
- package/website.min.css +1 -0
package/bin/lib/gap.mjs
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// `gemboss-website gap`: report something the kit is missing, as a GitHub issue in the kit's repo (gh), or as
|
|
2
|
+
// kit-gaps/<slug>.md for the person to send when gh is not there.
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { basename, extname, join, resolve } from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { PKG, PKG_NAME, UsageError, installedKit, parseArgs, projectPackage, rel } from "./util.mjs";
|
|
8
|
+
|
|
9
|
+
export const KINDS = ["token", "component", "section", "behavior", "doc", "other"];
|
|
10
|
+
const TITLE_PREFIX = "[website-ui gap]";
|
|
11
|
+
const LABELS = ["gap", "website-ui"];
|
|
12
|
+
const MAX_LINES = 120, MAX_CHARS = 6000;
|
|
13
|
+
|
|
14
|
+
export function slugify(title) {
|
|
15
|
+
const s = title.normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[đĐ]/g, "d").toLowerCase()
|
|
16
|
+
.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/-+$/, "");
|
|
17
|
+
return s || `gap-${Date.now()}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function trimCode(text) {
|
|
21
|
+
let lines = text.replace(/\s+$/, "").split("\n");
|
|
22
|
+
let note = "";
|
|
23
|
+
if (lines.length > MAX_LINES) { note = `(trimmed: first ${MAX_LINES} of ${lines.length} lines)`; lines = lines.slice(0, MAX_LINES); }
|
|
24
|
+
let out = lines.join("\n");
|
|
25
|
+
if (out.length > MAX_CHARS) { out = out.slice(0, MAX_CHARS); note = `(trimmed to ${MAX_CHARS} characters)`; }
|
|
26
|
+
return { code: out, note };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function reporter(cwd) {
|
|
30
|
+
try {
|
|
31
|
+
const r = spawnSync("git", ["config", "user.name"], { cwd, encoding: "utf8", timeout: 5000 });
|
|
32
|
+
const name = (r.stdout || "").trim();
|
|
33
|
+
if (r.status === 0 && name) return name;
|
|
34
|
+
} catch { /* no git */ }
|
|
35
|
+
return "(unknown: git config user.name is not set)";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The issue body, from answered fields. */
|
|
39
|
+
export function buildBody(f) {
|
|
40
|
+
const fence = (lang, code) => {
|
|
41
|
+
const ticks = code.includes("```") ? "````" : "```";
|
|
42
|
+
return `${ticks}${lang}\n${code}\n${ticks}`;
|
|
43
|
+
};
|
|
44
|
+
const out = [
|
|
45
|
+
"## What is missing", "", f.need, "", `Kind: **${f.kind}**`, "",
|
|
46
|
+
"## Where it is needed", "", `- Site: ${f.site}`, `- Page: ${f.page}`, "",
|
|
47
|
+
"## Why existing kit parts do not cover it", "", f.why, "",
|
|
48
|
+
"## Current stopgap", "",
|
|
49
|
+
];
|
|
50
|
+
if (f.stopgap) {
|
|
51
|
+
out.push(`\`${f.stopgap.path}\`${f.stopgap.note ? ` ${f.stopgap.note}` : ""}:`, "", fence(f.stopgap.lang, f.stopgap.code), "");
|
|
52
|
+
} else out.push("None yet.", "");
|
|
53
|
+
out.push("## Proposed shape (optional)", "", f.shape || "Not given.", "");
|
|
54
|
+
if (f.screenshot) {
|
|
55
|
+
out.push("## Screenshot", "", /^https?:\/\//i.test(f.screenshot)
|
|
56
|
+
? ``
|
|
57
|
+
: `Local file \`${f.screenshot}\`: attach it to this issue by hand (drop it into a comment).`, "");
|
|
58
|
+
}
|
|
59
|
+
out.push("## Kit version", "", f.kitVersion, "", "## Reporter", "", `${f.reporter}, with \`npx gemboss-website gap\` (${PKG_NAME} CLI ${PKG.version})`, "");
|
|
60
|
+
return out.join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function ask(fields, missing) {
|
|
64
|
+
const { createInterface } = await import("node:readline/promises");
|
|
65
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
66
|
+
const q = {
|
|
67
|
+
title: ["Short title of what is missing (e.g. \"Tabs component\")", true],
|
|
68
|
+
kind: [`Kind (${KINDS.join("/")})`, true],
|
|
69
|
+
need: ["What is missing: what should the kit give you?", true],
|
|
70
|
+
why: ["Why existing kit parts (demo/, docs/components.md) do not cover it", false],
|
|
71
|
+
site: ["Site (domain or name)", false],
|
|
72
|
+
page: ["Page URL where it is needed", false],
|
|
73
|
+
shape: ["Proposed shape: class names, markup or behaviour (optional, Enter to skip)", false],
|
|
74
|
+
stopgap: ["Stopgap file, e.g. kit-gaps.css (optional, Enter to skip)", false],
|
|
75
|
+
screenshot: ["Screenshot path or URL (optional, Enter to skip)", false],
|
|
76
|
+
};
|
|
77
|
+
try {
|
|
78
|
+
for (const k of missing) {
|
|
79
|
+
const [prompt, required] = q[k];
|
|
80
|
+
let v = "";
|
|
81
|
+
do v = (await rl.question(`${prompt}: `)).trim(); while (required && !v);
|
|
82
|
+
if (v) fields[k] = v;
|
|
83
|
+
}
|
|
84
|
+
} finally { rl.close(); }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function ghCall(gh, args, cwd) {
|
|
88
|
+
try {
|
|
89
|
+
const r = spawnSync(gh, args, { cwd, encoding: "utf8", timeout: 60000 });
|
|
90
|
+
if (r.error) return { ok: false, missing: r.error.code === "ENOENT", err: String(r.error.message || r.error) };
|
|
91
|
+
return { ok: r.status === 0, out: (r.stdout || "").trim(), err: (r.stderr || "").trim() };
|
|
92
|
+
} catch (e) { return { ok: false, err: String(e.message || e) }; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function cmdGap(argv) {
|
|
96
|
+
const opts = parseArgs(argv, {
|
|
97
|
+
title: "string", need: "string", page: "string", kind: "string", stopgap: "string", site: "string",
|
|
98
|
+
screenshot: "string", why: "string", shape: "string", "dry-run": "bool",
|
|
99
|
+
});
|
|
100
|
+
if (opts._.length) throw new UsageError(`gap takes options, not words (got "${opts._.join(" ")}"); try --title "${opts._.join(" ")}"`);
|
|
101
|
+
const cwd = process.cwd();
|
|
102
|
+
const f = { title: opts.title, need: opts.need, page: opts.page, kind: opts.kind, stopgap: opts.stopgap, site: opts.site, screenshot: opts.screenshot, why: opts.why, shape: opts.shape };
|
|
103
|
+
const order = ["title", "kind", "need", "why", "site", "page", "shape", "stopgap", "screenshot"];
|
|
104
|
+
const missing = order.filter((k) => !f[k]);
|
|
105
|
+
if (missing.length && process.stdin.isTTY && process.stdout.isTTY) await ask(f, missing);
|
|
106
|
+
const lacking = ["title", "need"].filter((k) => !f[k]);
|
|
107
|
+
if (lacking.length) throw new UsageError(`gap needs ${lacking.map((k) => "--" + k).join(" and ")} (no terminal to ask in). Example:\n npx gemboss-website gap --title "Tabs component" --kind component --need "Tabs to switch between three code samples" --page https://example.com/docs --stopgap kit-gaps.css`);
|
|
108
|
+
|
|
109
|
+
f.title = f.title.replace(/^\s*\[website-ui gap\]\s*/i, "").trim();
|
|
110
|
+
f.kind = (f.kind || "other").toLowerCase().replace(/^behaviour$/, "behavior");
|
|
111
|
+
if (!KINDS.includes(f.kind)) throw new UsageError(`--kind must be one of ${KINDS.join(", ")} (got "${f.kind}")`);
|
|
112
|
+
const pj = projectPackage(cwd);
|
|
113
|
+
f.site = f.site || (pj && (pj.homepage || pj.name) ? `${pj.homepage || pj.name} (from package.json)` : `${basename(cwd)} (folder name)`);
|
|
114
|
+
f.page = f.page || "Not given.";
|
|
115
|
+
f.why = f.why || "Not given.";
|
|
116
|
+
if (f.stopgap) {
|
|
117
|
+
const p = resolve(cwd, f.stopgap);
|
|
118
|
+
if (!existsSync(p)) throw new UsageError(`--stopgap: no such file ${f.stopgap}`);
|
|
119
|
+
const { code, note } = trimCode(readFileSync(p, "utf8"));
|
|
120
|
+
f.stopgap = { path: rel(p, cwd), code, note, lang: (extname(p).slice(1) || "text").replace(/^htm$/, "html").replace(/^mjs$/, "js") };
|
|
121
|
+
}
|
|
122
|
+
const kit = installedKit(cwd);
|
|
123
|
+
f.kitVersion = kit ? `${PKG_NAME} ${kit.version}` : `${PKG_NAME} is not installed in this project (CLI ${PKG.version})`;
|
|
124
|
+
f.reporter = reporter(cwd);
|
|
125
|
+
|
|
126
|
+
const repo = (PKG.gemboss && PKG.gemboss.gapRepo) || "chriskoh26/gemboss-www";
|
|
127
|
+
const fullTitle = `${TITLE_PREFIX} ${f.title}`;
|
|
128
|
+
const body = buildBody(f);
|
|
129
|
+
const gh = process.env.GEMBOSS_WEBSITE_GH || "gh";
|
|
130
|
+
|
|
131
|
+
if (!opts["dry-run"]) {
|
|
132
|
+
const auth = ghCall(gh, ["auth", "status"], cwd);
|
|
133
|
+
if (auth.ok) {
|
|
134
|
+
const tmp = join(tmpdir(), `gemboss-gap-${process.pid}-${Date.now()}.md`);
|
|
135
|
+
writeFileSync(tmp, body);
|
|
136
|
+
try {
|
|
137
|
+
const base = ["issue", "create", "-R", repo, "--title", fullTitle, "--body-file", tmp];
|
|
138
|
+
let r = ghCall(gh, [...base.slice(0, 6), ...LABELS.flatMap((l) => ["--label", l]), ...base.slice(6)], cwd);
|
|
139
|
+
if (!r.ok) {
|
|
140
|
+
console.error(`gh could not file it with labels (${(r.err || "").split("\n")[0]}); trying once without labels.`);
|
|
141
|
+
r = ghCall(gh, base, cwd);
|
|
142
|
+
}
|
|
143
|
+
if (r.ok) {
|
|
144
|
+
const url = (r.out.match(/https?:\/\/\S+/g) || []).pop() || r.out;
|
|
145
|
+
const num = (/\/issues\/(\d+)/.exec(url) || [])[1];
|
|
146
|
+
console.log(`Gap reported: ${url}`);
|
|
147
|
+
if (num) {
|
|
148
|
+
console.log(`Name it above your stopgap so \`upgrade\` can tell you when the kit has it:`);
|
|
149
|
+
console.log(` /* gap #${num}: ${f.title} */`);
|
|
150
|
+
}
|
|
151
|
+
if (f.screenshot && !/^https?:\/\//i.test(f.screenshot)) console.log(`Attach the screenshot by hand: open the issue and drop ${f.screenshot} into a comment.`);
|
|
152
|
+
console.log("Tell the person: the gap is reported to the kit team, the page uses a kitgap- stopgap until the kit ships it.");
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
console.error(`gh issue create failed: ${(r.err || "").split("\n")[0]}. Writing the report to a file instead.`);
|
|
156
|
+
} finally { rmSync(tmp, { force: true }); }
|
|
157
|
+
} else {
|
|
158
|
+
console.error(auth.missing ? "gh (GitHub CLI) is not installed; writing the report to a file instead."
|
|
159
|
+
: "gh is not signed in (gh auth login); writing the report to a file instead.");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const dir = resolve(cwd, "kit-gaps");
|
|
164
|
+
mkdirSync(dir, { recursive: true });
|
|
165
|
+
const slug = slugify(f.title);
|
|
166
|
+
let file = join(dir, `${slug}.md`);
|
|
167
|
+
for (let k = 2; existsSync(file); k++) file = join(dir, `${slug}-${k}.md`);
|
|
168
|
+
const fileRel = rel(file, cwd);
|
|
169
|
+
const send = `gh issue create -R ${repo} --title ${JSON.stringify(fullTitle)} --label gap --label website-ui --body-file ${fileRel}`;
|
|
170
|
+
let url = `https://github.com/${repo}/issues/new?title=${encodeURIComponent(fullTitle)}&labels=${LABELS.join(",")}`;
|
|
171
|
+
if ((url + "&body=" + encodeURIComponent(body)).length < 6000) url += "&body=" + encodeURIComponent(body);
|
|
172
|
+
const header = [
|
|
173
|
+
`<!-- ${TITLE_PREFIX} ${f.title.replace(/--+>?/g, "-")}`,
|
|
174
|
+
"Not filed yet. Send it with:",
|
|
175
|
+
` ${send}`,
|
|
176
|
+
`or open https://github.com/${repo}/issues/new, use the title above and paste this file. -->`,
|
|
177
|
+
"",
|
|
178
|
+
].join("\n");
|
|
179
|
+
writeFileSync(file, header + body);
|
|
180
|
+
console.log(`${opts["dry-run"] ? "Dry run: nothing was sent." : "Not sent."} The report is in ${fileRel}`);
|
|
181
|
+
console.log("");
|
|
182
|
+
console.log("To send it, one of:");
|
|
183
|
+
console.log(` 1. ${send}`);
|
|
184
|
+
console.log(` 2. Open this link, check the text and press Submit:\n ${url}`);
|
|
185
|
+
console.log(` 3. Give ${fileRel} to the kit team (repo ${repo}).`);
|
|
186
|
+
console.log("");
|
|
187
|
+
console.log("Tell the person a gap report is waiting to be sent, and which page uses the kitgap- stopgap.");
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
package/bin/lib/init.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// `gemboss-website init`: connect the kit's rules to the project's Claude Code (CLAUDE.md block + skill).
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { PKG_DIR, PKG_NAME, UsageError, parseArgs, rel } from "./util.mjs";
|
|
5
|
+
|
|
6
|
+
export const START = `<!-- ${PKG_NAME}:start -->`;
|
|
7
|
+
export const END = `<!-- ${PKG_NAME}:end -->`;
|
|
8
|
+
export const SKILL_MARKER = `<!-- managed by ${PKG_NAME} init -->`;
|
|
9
|
+
export const SKILL_PATH = join(".claude", "skills", "gemboss-website-ui", "SKILL.md");
|
|
10
|
+
|
|
11
|
+
const KIT = `node_modules/${PKG_NAME}`;
|
|
12
|
+
|
|
13
|
+
/** The block between the markers (markers included), without line-ending decisions. */
|
|
14
|
+
export function claudeBlock() {
|
|
15
|
+
return [
|
|
16
|
+
START,
|
|
17
|
+
`## Website UI kit: ${PKG_NAME}`,
|
|
18
|
+
"",
|
|
19
|
+
`This site's pages are built with the GemCommerce website kit \`${PKG_NAME}\`. Its rules win over habit and`,
|
|
20
|
+
"over generic design advice. Before you build or change any page, section, style or copy of this website:",
|
|
21
|
+
"",
|
|
22
|
+
`1. Read \`${KIT}/AGENTS.md\` completely, once per session.`,
|
|
23
|
+
`2. Start every new page from \`${KIT}/templates/page.html\` and compose it from \`${KIT}/templates/sections/\`.`,
|
|
24
|
+
` Look in \`${KIT}/demo/index.html\` and \`${KIT}/docs/components.md\` before you write a new class.`,
|
|
25
|
+
"3. Use only the kit's tokens and scales. Never override a `.gw-*` class and never edit `node_modules`.",
|
|
26
|
+
"4. When the kit is missing something, follow the gap protocol (AGENTS.md section 9): a stopgap in",
|
|
27
|
+
" `kit-gaps.css` with `kitgap-` classes, then `npx gemboss-website gap`, and tell the person you reported it.",
|
|
28
|
+
"5. Before you say a page is done, run `npx gemboss-website check`, fix every error, and look at the page at",
|
|
29
|
+
" 320px and 1366px.",
|
|
30
|
+
"6. To upgrade the kit, run `npx gemboss-website upgrade` first: what changed, which gaps the kit now covers",
|
|
31
|
+
" (replace those stopgaps with the kit's classes) and the install command.",
|
|
32
|
+
"",
|
|
33
|
+
"The skill `gemboss-website-ui` (`.claude/skills/gemboss-website-ui/SKILL.md`) has the same procedure.",
|
|
34
|
+
"This block is written by `npx gemboss-website init`; edit outside the markers, not inside.",
|
|
35
|
+
END,
|
|
36
|
+
].join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Put the block into `text`: replace what is between the markers, or append it. Returns { text, action } where
|
|
41
|
+
* action is "added" | "updated" | "unchanged". Throws when the markers are broken (never guesses).
|
|
42
|
+
*/
|
|
43
|
+
export function upsertBlock(text, block) {
|
|
44
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
45
|
+
const b = block.split("\n").join(eol);
|
|
46
|
+
const starts = text.split(START).length - 1, ends = text.split(END).length - 1;
|
|
47
|
+
if (starts !== ends || starts > 1) {
|
|
48
|
+
throw new UsageError(`CLAUDE.md has ${starts} "${START}" and ${ends} "${END}" markers. Fix them by hand (one of each, start before end) and run init again; nothing was changed.`);
|
|
49
|
+
}
|
|
50
|
+
if (starts === 1) {
|
|
51
|
+
const s = text.indexOf(START), e = text.indexOf(END);
|
|
52
|
+
if (e < s) throw new UsageError(`CLAUDE.md: "${END}" comes before "${START}". Fix the markers by hand; nothing was changed.`);
|
|
53
|
+
const current = text.slice(s, e + END.length);
|
|
54
|
+
if (current === b) return { text, action: "unchanged" };
|
|
55
|
+
return { text: text.slice(0, s) + b + text.slice(e + END.length), action: "updated" };
|
|
56
|
+
}
|
|
57
|
+
if (!text.trim()) return { text: b + eol, action: "added" };
|
|
58
|
+
const sep = text.endsWith(eol + eol) ? "" : text.endsWith(eol) ? eol : eol + eol;
|
|
59
|
+
return { text: text + sep + b + eol, action: "added" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function cmdInit(argv) {
|
|
63
|
+
const opts = parseArgs(argv, { force: "bool" });
|
|
64
|
+
if (opts._.length) throw new UsageError(`init takes no arguments (got ${opts._.join(" ")}); run it in the project's root folder`);
|
|
65
|
+
const cwd = process.cwd();
|
|
66
|
+
const done = [];
|
|
67
|
+
|
|
68
|
+
const claudePath = resolve(cwd, "CLAUDE.md");
|
|
69
|
+
const had = existsSync(claudePath);
|
|
70
|
+
const before = had ? readFileSync(claudePath, "utf8") : "";
|
|
71
|
+
const { text, action } = upsertBlock(before, claudeBlock());
|
|
72
|
+
if (action !== "unchanged") writeFileSync(claudePath, text);
|
|
73
|
+
done.push([rel(claudePath), !had ? "created, kit block added" : action === "added" ? "kit block added at the end" : action === "updated" ? "kit block updated (text outside the markers untouched)" : "kit block already up to date"]);
|
|
74
|
+
|
|
75
|
+
const skillSrc = readFileSync(join(PKG_DIR, "skill", "SKILL.md"), "utf8");
|
|
76
|
+
const skillPath = resolve(cwd, SKILL_PATH);
|
|
77
|
+
if (!existsSync(skillPath)) {
|
|
78
|
+
mkdirSync(dirname(skillPath), { recursive: true });
|
|
79
|
+
writeFileSync(skillPath, skillSrc);
|
|
80
|
+
done.push([rel(skillPath), "created"]);
|
|
81
|
+
} else {
|
|
82
|
+
const cur = readFileSync(skillPath, "utf8");
|
|
83
|
+
if (cur === skillSrc) done.push([rel(skillPath), "already up to date"]);
|
|
84
|
+
else if (cur.includes(SKILL_MARKER) || opts.force) {
|
|
85
|
+
writeFileSync(skillPath, skillSrc);
|
|
86
|
+
done.push([rel(skillPath), cur.includes(SKILL_MARKER) ? "updated" : "overwritten (--force)"]);
|
|
87
|
+
} else {
|
|
88
|
+
done.push([rel(skillPath), "left alone: it was not written by init (no marker line). --force replaces it"]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const w = Math.max(...done.map(([p]) => p.length));
|
|
93
|
+
console.log(`gemboss-website init (${PKG_NAME})`);
|
|
94
|
+
for (const [p, what] of done) console.log(` ${p.padEnd(w)} ${what}`);
|
|
95
|
+
console.log("");
|
|
96
|
+
console.log(`Claude Code now reads ${KIT}/AGENTS.md before website work in this project.`);
|
|
97
|
+
console.log("Next: start a page from the kit's templates/page.html, then run: npx gemboss-website check");
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// `gemboss-website upgrade`: what changed since the installed kit, which reported gaps the kit now covers, which of
|
|
2
|
+
// the project's stopgaps that frees, and the install command.
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { PKG, PKG_DIR, PKG_NAME, UsageError, c, installedKit, isSemver, parseArgs, projectPackage, rel, semverCompare, walkFiles } from "./util.mjs";
|
|
7
|
+
|
|
8
|
+
/** Changelog sections: [{ version, heading, lines }] in file order. "Unreleased" and other headings are skipped. */
|
|
9
|
+
export function parseChangelog(text) {
|
|
10
|
+
const out = [];
|
|
11
|
+
let cur = null;
|
|
12
|
+
for (const line of text.split(/\r?\n/)) {
|
|
13
|
+
const h = /^##\s+\[?v?(\d+\.\d+\.\d+(?:-[\w.-]+)?)\]?/.exec(line);
|
|
14
|
+
if (h) { cur = { version: h[1], heading: line.replace(/^##\s+/, ""), lines: [] }; out.push(cur); continue; }
|
|
15
|
+
if (/^##\s/.test(line)) { cur = null; continue; }
|
|
16
|
+
if (cur) cur.lines.push(line);
|
|
17
|
+
}
|
|
18
|
+
for (const s of out) {
|
|
19
|
+
while (s.lines.length && !s.lines[s.lines.length - 1].trim()) s.lines.pop();
|
|
20
|
+
while (s.lines.length && !s.lines[0].trim()) s.lines.shift();
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Gap numbers in a line: "Closes gap #12", "gap #12 and #14", "gaps #3, #4". */
|
|
26
|
+
export function gapNumbers(line) {
|
|
27
|
+
const nums = [];
|
|
28
|
+
for (const m of line.matchAll(/\bgaps?\s*((?:#\d+(?:\s*(?:,|and|&)\s*)?)+)/gi)) {
|
|
29
|
+
for (const k of m[1].matchAll(/#(\d+)/g)) nums.push(Number(k[1]));
|
|
30
|
+
}
|
|
31
|
+
return nums;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Lines in the project that name a gap number, with the kitgap- classes near them. */
|
|
35
|
+
function stopgapMentions(cwd) {
|
|
36
|
+
const files = walkFiles([cwd], /\.(?:css|html?|m?js|ts|jsx|tsx|vue|svelte|astro|scss)$/i);
|
|
37
|
+
const hits = [];
|
|
38
|
+
const stopgaps = new Map();
|
|
39
|
+
for (const f of files) {
|
|
40
|
+
let text;
|
|
41
|
+
try { text = readFileSync(f, "utf8"); } catch { continue; }
|
|
42
|
+
if (!/kitgap-|gap\s*#\d/i.test(text)) continue;
|
|
43
|
+
const lines = text.split("\n");
|
|
44
|
+
lines.forEach((l, i) => {
|
|
45
|
+
for (const m of l.matchAll(/\.?(kitgap-[\w-]+)/g)) {
|
|
46
|
+
const k = m[1].replace(/(__|--).*$/, "");
|
|
47
|
+
if (!stopgaps.has(k)) stopgaps.set(k, `${rel(f, cwd)}:${i + 1}`);
|
|
48
|
+
}
|
|
49
|
+
const nums = gapNumbers(l);
|
|
50
|
+
if (!nums.length) return;
|
|
51
|
+
// The stopgap a mention names: kitgap- classes from that line down to a blank line, the next comment or the
|
|
52
|
+
// next gap number.
|
|
53
|
+
const near = new Set();
|
|
54
|
+
for (let j = i; j < Math.min(lines.length, i + 40); j++) {
|
|
55
|
+
const nl = lines[j];
|
|
56
|
+
if (j > i && (!nl.trim() || /\/\*|<!--|^\s*\/\/|gap\s*#\d/i.test(nl))) break;
|
|
57
|
+
for (const m of nl.matchAll(/(kitgap-[\w-]+)/g)) near.add(m[1].replace(/(__|--).*$/, ""));
|
|
58
|
+
}
|
|
59
|
+
hits.push({ file: rel(f, cwd), line: i + 1, nums, text: l.trim().slice(0, 120), classes: [...near] });
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return { hits, stopgaps };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function latestFromNpm() {
|
|
66
|
+
if (process.env.GEMBOSS_WEBSITE_LATEST) {
|
|
67
|
+
const v = process.env.GEMBOSS_WEBSITE_LATEST.trim();
|
|
68
|
+
return isSemver(v) ? { version: v } : { error: `GEMBOSS_WEBSITE_LATEST=${v}` };
|
|
69
|
+
}
|
|
70
|
+
if (process.env.GEMBOSS_WEBSITE_OFFLINE) return { error: "offline (GEMBOSS_WEBSITE_OFFLINE is set)" };
|
|
71
|
+
try {
|
|
72
|
+
const r = spawnSync("npm", ["view", PKG_NAME, "version"], { encoding: "utf8", timeout: 10000, shell: process.platform === "win32" });
|
|
73
|
+
if (r.error) return { error: r.error.code === "ETIMEDOUT" ? "npm did not answer within 10s" : String(r.error.message || r.error) };
|
|
74
|
+
const v = (r.stdout || "").trim().split(/\s+/).pop();
|
|
75
|
+
if (r.status !== 0 || !isSemver(v)) return { error: ((r.stderr || "").trim().split("\n")[0]) || `npm exited ${r.status}` };
|
|
76
|
+
return { version: v };
|
|
77
|
+
} catch (e) { return { error: String(e.message || e) }; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function installCommand(cwd, version, dev) {
|
|
81
|
+
const spec = `${PKG_NAME}@${version}`;
|
|
82
|
+
if (existsSync(join(cwd, "pnpm-lock.yaml"))) return `pnpm add ${dev ? "-D " : ""}${spec}`;
|
|
83
|
+
if (existsSync(join(cwd, "yarn.lock"))) return `yarn add ${dev ? "-D " : ""}${spec}`;
|
|
84
|
+
if (existsSync(join(cwd, "bun.lockb")) || existsSync(join(cwd, "bun.lock"))) return `bun add ${dev ? "-d " : ""}${spec}`;
|
|
85
|
+
return `npm install ${dev ? "-D " : ""}${spec}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function cmdUpgrade(argv) {
|
|
89
|
+
const opts = parseArgs(argv, {});
|
|
90
|
+
if (opts._.length) throw new UsageError("upgrade takes no arguments");
|
|
91
|
+
const cwd = process.cwd();
|
|
92
|
+
const kit = installedKit(cwd);
|
|
93
|
+
const installed = kit ? kit.version : null;
|
|
94
|
+
const latest = latestFromNpm();
|
|
95
|
+
const changelogPath = process.env.GEMBOSS_WEBSITE_CHANGELOG ? resolve(process.env.GEMBOSS_WEBSITE_CHANGELOG) : join(PKG_DIR, "CHANGELOG.md");
|
|
96
|
+
const sections = existsSync(changelogPath) ? parseChangelog(readFileSync(changelogPath, "utf8")) : [];
|
|
97
|
+
const newestKnown = sections.reduce((v, s) => (!v || semverCompare(s.version, v) > 0 ? s.version : v), null);
|
|
98
|
+
|
|
99
|
+
const out = [c.bold("gemboss-website upgrade")];
|
|
100
|
+
out.push(` installed: ${installed ? `${PKG_NAME} ${installed}` : `none (no node_modules/${PKG_NAME} from ${rel(cwd) || "."})`}`);
|
|
101
|
+
out.push(` latest: ${latest.version ? `${latest.version} (npm)` : `unknown: ${latest.error}. Using the changelog that ships with this CLI (${PKG.version}).`}`);
|
|
102
|
+
|
|
103
|
+
const from = installed || "0.0.0";
|
|
104
|
+
const newer = sections.filter((s) => semverCompare(s.version, from) > 0 && (!latest.version || semverCompare(s.version, latest.version) <= 0));
|
|
105
|
+
const closed = new Map();
|
|
106
|
+
if (installed && latest.version && semverCompare(installed, latest.version) >= 0) {
|
|
107
|
+
out.push("", c.green(`Up to date: ${installed} is the latest version.`));
|
|
108
|
+
} else if (!newer.length) {
|
|
109
|
+
out.push("", "No changelog entries newer than the installed version here.");
|
|
110
|
+
} else {
|
|
111
|
+
out.push("", `Changes since ${installed || "the start"} (${changelogPath === join(PKG_DIR, "CHANGELOG.md") ? `${PKG_NAME} CHANGELOG.md` : rel(changelogPath)}):`);
|
|
112
|
+
for (const s of newer) {
|
|
113
|
+
out.push("", c.bold(`## ${s.heading}`));
|
|
114
|
+
for (const l of s.lines) {
|
|
115
|
+
const nums = gapNumbers(l);
|
|
116
|
+
nums.forEach((n) => closed.set(n, { version: s.version, line: l.trim() }));
|
|
117
|
+
out.push(nums.length ? `${c.yellow(">> " + l)}` : ` ${l}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (latest.version && (!newestKnown || semverCompare(latest.version, newestKnown) > 0) && !(installed && semverCompare(installed, latest.version) >= 0)) {
|
|
123
|
+
out.push("", `This CLI's changelog stops at ${newestKnown || "nothing"}. For everything up to ${latest.version}, run the latest CLI:`,
|
|
124
|
+
` npx -y ${PKG_NAME}@${latest.version} upgrade`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const { hits, stopgaps } = stopgapMentions(cwd);
|
|
128
|
+
if (closed.size) {
|
|
129
|
+
out.push("", c.bold(`Gaps the kit now covers: ${[...closed.keys()].map((n) => "#" + n).join(", ")}`));
|
|
130
|
+
for (const [n, info] of closed) out.push(` #${n} in ${info.version}: ${info.line.replace(/^[-*]\s*/, "")}`);
|
|
131
|
+
const mine = hits.filter((h) => h.nums.some((n) => closed.has(n)));
|
|
132
|
+
if (mine.length) {
|
|
133
|
+
out.push("", "Stopgaps in this project for those gaps: after upgrading, use the kit's class and delete the kitgap- code.");
|
|
134
|
+
for (const h of mine) out.push(` ${h.file}:${h.line} ${h.text}${h.classes.length ? ` -> ${h.classes.map((k) => "." + k).join(", ")}` : ""}`);
|
|
135
|
+
} else out.push(" No stopgap in this project names those gap numbers.");
|
|
136
|
+
}
|
|
137
|
+
const named = new Set(hits.flatMap((h) => h.classes));
|
|
138
|
+
const unnamed = [...stopgaps].filter(([k]) => !named.has(k));
|
|
139
|
+
if (unnamed.length) {
|
|
140
|
+
out.push("", "Stopgaps without a gap number nearby (check by name whether the new kit has them):");
|
|
141
|
+
for (const [k, at] of unnamed) out.push(` .${k} ${at}`);
|
|
142
|
+
}
|
|
143
|
+
const reports = existsSync(join(cwd, "kit-gaps")) ? readdirSync(join(cwd, "kit-gaps")).filter((n) => n.endsWith(".md")) : [];
|
|
144
|
+
if (reports.length) out.push("", `Gap reports not sent yet (kit-gaps/): ${reports.join(", ")}. Send them: see the comment at the top of each.`);
|
|
145
|
+
|
|
146
|
+
const pj = projectPackage(cwd) || {};
|
|
147
|
+
const dev = !!(pj.devDependencies && pj.devDependencies[PKG_NAME]) && !(pj.dependencies && pj.dependencies[PKG_NAME]);
|
|
148
|
+
if (!(installed && latest.version && semverCompare(installed, latest.version) >= 0)) {
|
|
149
|
+
out.push("", c.bold("Upgrade:"), ` ${installCommand(cwd, latest.version || "latest", dev)}`,
|
|
150
|
+
" npx gemboss-website init # refresh the CLAUDE.md block and the skill",
|
|
151
|
+
" npx gemboss-website check # then look at the pages at 320px and 1366px");
|
|
152
|
+
}
|
|
153
|
+
console.log(out.join("\n"));
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
package/bin/lib/util.mjs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Shared helpers for the gemboss-website CLI. Node >= 18, no dependencies.
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
3
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
/** The kit package this CLI belongs to (…/@gemboss/website-ui). */
|
|
7
|
+
export const PKG_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
8
|
+
export const PKG = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf8"));
|
|
9
|
+
export const PKG_NAME = PKG.name;
|
|
10
|
+
export const SYSTEM = JSON.parse(readFileSync(join(PKG_DIR, "system.json"), "utf8"));
|
|
11
|
+
|
|
12
|
+
export class UsageError extends Error {}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse argv for one command. `spec` maps flag name -> "bool" | "string" | "list".
|
|
16
|
+
* Accepts --flag value, --flag=value and positionals; unknown flags are a usage error.
|
|
17
|
+
*/
|
|
18
|
+
export function parseArgs(argv, spec) {
|
|
19
|
+
const out = { _: [] };
|
|
20
|
+
for (const [k, t] of Object.entries(spec)) out[k] = t === "bool" ? false : t === "list" ? [] : undefined;
|
|
21
|
+
for (let i = 0; i < argv.length; i++) {
|
|
22
|
+
const a = argv[i];
|
|
23
|
+
if (a === "--") { out._.push(...argv.slice(i + 1)); break; }
|
|
24
|
+
if (!a.startsWith("--")) { out._.push(a); continue; }
|
|
25
|
+
const eq = a.indexOf("=");
|
|
26
|
+
const name = a.slice(2, eq === -1 ? undefined : eq);
|
|
27
|
+
const type = spec[name];
|
|
28
|
+
if (!type) throw new UsageError(`unknown option --${name}`);
|
|
29
|
+
if (type === "bool") {
|
|
30
|
+
if (eq !== -1) throw new UsageError(`--${name} takes no value`);
|
|
31
|
+
out[name] = true;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
let val = eq === -1 ? argv[++i] : a.slice(eq + 1);
|
|
35
|
+
if (val === undefined || (eq === -1 && val.startsWith("--"))) throw new UsageError(`--${name} needs a value`);
|
|
36
|
+
if (type === "list") out[name].push(val); else out[name] = val;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Forward-slash path of `p` relative to `from` (or `p` itself when outside). */
|
|
42
|
+
export function rel(p, from = process.cwd()) {
|
|
43
|
+
const r = relative(from, p);
|
|
44
|
+
return (r && !r.startsWith("..") ? r : p).split(sep).join("/");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** True when `p` is inside the kit package itself (the kit's own css, templates, demo…). */
|
|
48
|
+
export function isKitPath(p) {
|
|
49
|
+
let real;
|
|
50
|
+
try { real = realpathSync(p); } catch { real = resolve(p); }
|
|
51
|
+
let kit;
|
|
52
|
+
try { kit = realpathSync(PKG_DIR); } catch { kit = PKG_DIR; }
|
|
53
|
+
return real === kit || real.startsWith(kit + sep);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The @gemboss/website-ui installed in the project at `cwd`: walks up looking for
|
|
58
|
+
* node_modules/@gemboss/website-ui/package.json the way Node resolves packages.
|
|
59
|
+
*/
|
|
60
|
+
export function installedKit(cwd = process.cwd()) {
|
|
61
|
+
let dir = resolve(cwd);
|
|
62
|
+
for (;;) {
|
|
63
|
+
const pj = join(dir, "node_modules", ...PKG_NAME.split("/"), "package.json");
|
|
64
|
+
if (existsSync(pj)) {
|
|
65
|
+
try { return { version: JSON.parse(readFileSync(pj, "utf8")).version, dir: dirname(pj) }; } catch { /* broken */ }
|
|
66
|
+
}
|
|
67
|
+
const up = dirname(dir);
|
|
68
|
+
if (up === dir) return null;
|
|
69
|
+
dir = up;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The project's package.json (in cwd), or null. */
|
|
74
|
+
export function projectPackage(cwd = process.cwd()) {
|
|
75
|
+
try { return JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")); } catch { return null; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Compare two semver strings (x.y.z with optional -pre). */
|
|
79
|
+
export function semverCompare(a, b) {
|
|
80
|
+
const pa = parseSemver(a), pb = parseSemver(b);
|
|
81
|
+
for (let i = 0; i < 3; i++) if (pa.n[i] !== pb.n[i]) return pa.n[i] < pb.n[i] ? -1 : 1;
|
|
82
|
+
if (pa.pre === pb.pre) return 0;
|
|
83
|
+
if (!pa.pre) return 1;
|
|
84
|
+
if (!pb.pre) return -1;
|
|
85
|
+
return pa.pre < pb.pre ? -1 : 1;
|
|
86
|
+
}
|
|
87
|
+
function parseSemver(v) {
|
|
88
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?/.exec(String(v || "").trim());
|
|
89
|
+
return m ? { n: [+m[1], +m[2], +m[3]], pre: m[4] || "" } : { n: [0, 0, 0], pre: "" };
|
|
90
|
+
}
|
|
91
|
+
export const isSemver = (v) => /^v?\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(String(v || "").trim());
|
|
92
|
+
|
|
93
|
+
/** Directory names never descended into while walking a project. */
|
|
94
|
+
export const SKIP_DIRS = new Set(["node_modules", "dist", "build", "out", "coverage"]);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Every file under `paths` whose name matches `re`, skipping SKIP_DIRS, dot-directories and the
|
|
98
|
+
* kit's own files. A path given explicitly is walked even when its own name is in SKIP_DIRS.
|
|
99
|
+
*/
|
|
100
|
+
export function walkFiles(paths, re, { ignore = [], includeKit = false } = {}) {
|
|
101
|
+
const ign = ignore.map((p) => resolve(p));
|
|
102
|
+
const ignored = (p) => ign.some((g) => p === g || p.startsWith(g + sep));
|
|
103
|
+
const out = [];
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
const visit = (p, top) => {
|
|
106
|
+
if (ignored(p) || seen.has(p)) return;
|
|
107
|
+
seen.add(p);
|
|
108
|
+
if (!includeKit && isKitPath(p)) return;
|
|
109
|
+
let entries;
|
|
110
|
+
try { entries = readdirSync(p, { withFileTypes: true }); } catch {
|
|
111
|
+
if (re.test(p)) out.push(p);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (!top) {
|
|
115
|
+
const name = p.split(sep).pop();
|
|
116
|
+
if (SKIP_DIRS.has(name) || name.startsWith(".")) return;
|
|
117
|
+
}
|
|
118
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
119
|
+
const full = join(p, e.name);
|
|
120
|
+
if (e.isDirectory()) visit(full, false);
|
|
121
|
+
else if (e.isFile() && re.test(e.name) && !ignored(full) && (includeKit || !isKitPath(full))) out.push(full);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
for (const p of paths) visit(resolve(p), true);
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Offsets of line starts, for offset -> line lookups. */
|
|
129
|
+
export function lineIndex(text) {
|
|
130
|
+
const starts = [0];
|
|
131
|
+
for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) starts.push(i + 1);
|
|
132
|
+
return (offset) => {
|
|
133
|
+
let lo = 0, hi = starts.length - 1;
|
|
134
|
+
while (lo < hi) {
|
|
135
|
+
const mid = (lo + hi + 1) >> 1;
|
|
136
|
+
if (starts[mid] <= offset) lo = mid; else hi = mid - 1;
|
|
137
|
+
}
|
|
138
|
+
return lo + 1;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Replace every character except newlines with a space, so offsets and lines stay put. */
|
|
143
|
+
export const blank = (s) => s.replace(/[^\n]/g, " ");
|
|
144
|
+
|
|
145
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
146
|
+
const paint = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
147
|
+
export const c = { bold: paint(1), dim: paint(2), red: paint(31), yellow: paint(33), green: paint(32), cyan: paint(36) };
|