diff --git a/.github/scripts/pr-preview/analyze-pr.mjs b/.github/scripts/pr-preview/analyze-pr.mjs new file mode 100644 index 0000000..5cc9629 --- /dev/null +++ b/.github/scripts/pr-preview/analyze-pr.mjs @@ -0,0 +1,183 @@ +#!/usr/bin/env node +// Work out which components a PR changed, and map each one onto the story +// entries that Storybook actually built for it. The mapping is read out of the +// built `index.json` rather than guessed, so a component whose stories live +// under a different title still resolves. +// +// A component here is a directory: `src/components//` for ui-common's +// own components, `src/forks//` for its copies of Astryx components. Any +// file inside it (source, stylesheet, messages, stories) counts as a change to +// that component; tests alone do not, since they change nothing to look at. +// +// Usage: +// node analyze-pr.mjs --base origin/main --head HEAD \ +// --storybook-index --output analysis.json + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { parseArgs } from "node:util"; + +const COMPONENT_DIR_RE = /^src\/(components|forks)\/([^/]+)\//; + +const { + values: { base, head, "storybook-index": indexPath, output }, +} = parseArgs({ + options: { + base: { type: "string", default: "origin/main" }, + head: { type: "string", default: "HEAD" }, + "storybook-index": { type: "string" }, + output: { type: "string", default: "analysis.json" }, + }, +}); + +const git = (args) => + execFileSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); + +// A three-dot diff reports only what the PR branch changed, ignoring commits +// that landed on base meanwhile — that is the list we want. It needs a merge +// base, which a shallow clone may not have once base has advanced past the +// fetch window; deepen and retry before degrading. The two-dot fallback needs +// no merge base but compares two trees, so it can both over-report base churn +// and under-report a change that also exists on base. Callers get `diffMode` so +// the report can say when the list is approximate. +function changedFiles() { + // `--name-status` so a newly ADDED component can be told apart from a + // modified one; the report only flags missing stories for the former. + const parse = (raw) => + raw + .split("\n") + .filter(Boolean) + .flatMap((line) => { + const [status, ...paths] = line.split("\t"); + // A rename (`R100\told\tnew`) changes both directories: report it as + // the old path deleted and the new one added. + if (status[0] === "R") { + return [ + { status: "D", file: paths[0] }, + { status: "A", file: paths[1] }, + ]; + } + return [{ status: status[0], file: paths[paths.length - 1] }]; + }); + + const threeDot = () => parse(git(["diff", "--name-status", `${base}...${head}`])); + + try { + return { files: threeDot(), diffMode: "three-dot" }; + } catch { + /* fall through to the deepen retry */ + } + + try { + const bareBase = base.replace(/^origin\//, ""); + git([ + "fetch", + "--deepen=200", + "origin", + `+refs/heads/${bareBase}:refs/remotes/origin/${bareBase}`, + ]); + return { files: threeDot(), diffMode: "three-dot" }; + } catch { + /* fall through to the two-dot fallback */ + } + + try { + return { + files: parse(git(["diff", "--name-status", `${base}..${head}`])), + diffMode: "two-dot", + }; + } catch (error) { + // A failed diff is not the same as "nothing changed" — publishing an empty + // analysis here would look legitimate and silently drop the whole report. + console.error(`::error::Could not diff ${base}..${head}: ${error.message}`); + process.exit(1); + } +} + +const isStory = (file) => /\.stories\.(ts|tsx)$/.test(file); +const isTest = (file) => /\.test\.(ts|tsx)$/.test(file); + +function loadStoryEntries() { + if (!indexPath || !existsSync(indexPath)) return []; + const parsed = JSON.parse(readFileSync(indexPath, "utf8")); + return Object.values(parsed.entries ?? {}); +} + +/** + * Where a component directory's stories open (one stories file per directory, + * docs/storybook.md). The autodocs page comes first — it shows every story of + * the component on one page, which is what a reviewer wants first. + */ +function storiesFor(dir, storyEntries) { + const entries = storyEntries.filter((e) => e.importPath?.startsWith(`./${dir}`)); + const preferred = entries.find((e) => e.type === "docs") ?? entries[0]; + return { + storyCount: entries.filter((e) => e.type === "story").length, + deepLink: preferred ? `?path=/${preferred.type}/${preferred.id}` : null, + }; +} + +const { files, diffMode } = changedFiles(); +const storyEntries = loadStoryEntries(); + +// One row per changed component directory. +const rows = new Map(); +const accounted = new Set(); + +for (const { status, file } of files) { + const match = COMPONENT_DIR_RE.exec(file); + if (!match || isTest(file)) continue; + const [dir, kind, name] = match; + accounted.add(file); + + let row = rows.get(dir); + if (!row) { + const { storyCount, deepLink } = storiesFor(dir, storyEntries); + row = { + name, + kind, + dir, + deepLink, + hasStories: deepLink !== null, + storyCount, + sourceChanged: false, + storiesChanged: false, + isNew: false, + }; + rows.set(dir, row); + } + + if (isStory(file)) { + row.storiesChanged = true; + } else { + row.sourceChanged = true; + // A directory whose barrel was added in this PR is a new component. + if (status === "A" && file === `${dir}index.ts`) row.isNew = true; + } +} + +const components = [...rows.values()].sort((a, b) => a.name.localeCompare(b.name)); + +const analysis = { + diffMode, + base, + // A hook, theme or i18n change still deploys a preview; the report says so + // rather than pretending nothing happened. + otherSourceFiles: files + .map(({ file }) => file) + .filter((f) => f.startsWith("src/") && !accounted.has(f) && !isTest(f)), + components, + summary: { + // Only NEW components are counted as a gap. Most existing components + // have no story yet, and nagging about them on every unrelated edit trains + // reviewers to ignore the report. + newWithoutStories: components.filter((c) => c.isNew && !c.hasStories).length, + }, +}; + +writeFileSync(output, `${JSON.stringify(analysis, null, 2)}\n`); +console.log( + `Analyzed ${files.length} changed files (${diffMode}); ` + + `${components.length} component(s) changed, ` + + `${analysis.summary.newWithoutStories} new one(s) without stories.`, +); diff --git a/.github/scripts/pr-preview/compact-gh-pages-history.sh b/.github/scripts/pr-preview/compact-gh-pages-history.sh new file mode 100755 index 0000000..63712eb --- /dev/null +++ b/.github/scripts/pr-preview/compact-gh-pages-history.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Squash gh-pages down to a single orphan commit. +# +# Every preview deploy and every cleanup adds a commit whose tree carries a full +# Storybook build per live PR. The history of those trees is worthless — only +# the current tree is ever served — but it is what makes the branch grow without +# bound. Rewriting to one orphan commit lets git drop every unreferenced blob on +# the next gc. +# +# Force-pushing a branch is normally off-limits here. gh-pages is the exception: +# it holds no reviewed history, nobody branches from it, and the tree is +# reproducible from CI. The rewrite keeps the tree byte-identical — it only +# discards ancestry. +set -euo pipefail + +BRANCH="${BRANCH:-gh-pages}" +WORK="${WORK:-${RUNNER_TEMP:-/tmp}/gh-pages-compact}" +# Below this, the rewrite costs more (a full force-push of the tree) than the +# history it reclaims. +MIN_COMMITS="${MIN_COMMITS:-5}" +# REPO_URL is overridable so the logic can be exercised against a local bare +# repository; CI leaves it unset and gets the token URL. +if [ -z "${REPO_URL:-}" ]; then + REPO="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" + TOKEN="${GITHUB_TOKEN:?GITHUB_TOKEN is required}" + REPO_URL="https://x-access-token:${TOKEN}@github.com/${REPO}.git" +fi + +rm -rf "$WORK" +git clone --branch "$BRANCH" "$REPO_URL" "$WORK" \ + || { echo "No ${BRANCH} branch yet — nothing to compact."; exit 0; } + +cd "$WORK" +git config user.name "github-actions[bot]" +git config user.email "github-actions[bot]@users.noreply.github.com" + +# The tip we cloned. Everything below is asserted against exactly this commit, +# and the push is leased to it, so a deploy or cleanup that lands while we work +# makes the push fail instead of being erased by it. +CLONED_TIP="$(git rev-parse HEAD)" + +COMMITS="$(git rev-list --count HEAD)" +echo "${BRANCH} has ${COMMITS} commit(s) at ${CLONED_TIP}." +if [ "$COMMITS" -lt "$MIN_COMMITS" ]; then + echo "Fewer than ${MIN_COMMITS} — leaving history alone." + exit 0 +fi + +BEFORE="$(git count-objects -vH | awk '/size-pack/ {print $2 $3}')" + +git checkout --orphan compacted +git add -A +git commit -m "Compact ${BRANCH} history ($(date -u +%Y-%m-%d), was ${COMMITS} commits)" + +# The tree must be identical to the commit we cloned; a mismatch means the +# orphan commit lost content and must not be pushed. +if [ "$(git rev-parse compacted^{tree})" != "$(git rev-parse "${CLONED_TIP}^{tree}")" ]; then + echo "::error::Compacted tree differs from ${CLONED_TIP} — refusing to force-push." + exit 1 +fi + +# --force-with-lease pinned to the cloned tip, NOT a bare --force. The deploy and +# cleanup jobs run in different concurrency groups, so one of them can land a new +# preview between our clone and this push; a bare force would delete it, and the +# tree check above would not notice because it only ever saw the stale tip. +# Failing here is correct — the next weekly run compacts the newer history. +if ! git push --force-with-lease="${BRANCH}:${CLONED_TIP}" origin "compacted:${BRANCH}"; then + echo "::warning::${BRANCH} moved since the clone (a deploy or cleanup landed) — skipping compaction this run." + exit 0 +fi +echo "Compacted ${COMMITS} commits into 1 (pack size before rewrite: ${BEFORE})." diff --git a/.github/scripts/pr-preview/generate-pr-comment.mjs b/.github/scripts/pr-preview/generate-pr-comment.mjs new file mode 100644 index 0000000..47f34c5 --- /dev/null +++ b/.github/scripts/pr-preview/generate-pr-comment.mjs @@ -0,0 +1,212 @@ +#!/usr/bin/env node +// Render the "PR Analysis Report" comment body from the artifacts CI produced. +// Writes markdown to stdout; the calling workflow posts or updates it. +// +// Usage: +// node generate-pr-comment.mjs --meta pr-meta.json --analysis analysis.json \ +// [--bundle-head bundle-head.json] [--bundle-base bundle-base.json] + +import { existsSync, readFileSync } from "node:fs"; +import { parseArgs } from "node:util"; + +const { values: args } = parseArgs({ + options: { + meta: { type: "string" }, + analysis: { type: "string" }, + "bundle-head": { type: "string" }, + "bundle-base": { type: "string" }, + }, +}); + +const readJson = (path) => + path && existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null; + +const analysis = readJson(args.analysis); +const meta = readJson(args.meta); +const bundleHead = readJson(args["bundle-head"]); +const bundleBase = readJson(args["bundle-base"]); + +if (!meta?.storybookUrl) { + console.error("Missing pr-meta.json or its storybookUrl"); + process.exit(1); +} +if (!analysis) { + console.error("Missing analysis.json — nothing to report"); + process.exit(1); +} + +const kb = (bytes) => `${(bytes / 1024).toFixed(1)} kB`; +const signed = (bytes) => + `${bytes > 0 ? "+" : bytes < 0 ? "−" : "±"}${kb(Math.abs(bytes))}`; + +const link = (text, href) => + `${text}`; + +const out = []; +out.push("## PR Analysis Report"); +out.push(""); + +// ── Storybook preview ──────────────────────────────────────────────────────── +out.push("### 📚 Storybook Preview"); +out.push(""); +out.push(`**${link("View Storybook for this PR", meta.storybookUrl)}**`); +out.push(""); +out.push("_GitHub Pages may take up to a minute to hydrate after the deploy._"); +out.push(""); + +// ── Changed components ─────────────────────────────────────────────────────── +out.push("### 🧩 Changed Components"); +out.push(""); +// The table is the actionable part, so it holds only the components a reviewer +// can actually open. Everything else goes below it, out of the way — a wall of +// "no story, nothing to open" rows is what makes a report like this get ignored. +const previewable = analysis.components.filter((c) => c.hasStories); +const unpreviewable = analysis.components.filter((c) => !c.hasStories); +const MAX_ROWS = 30; + +// `components/Drawer` would read the same as `forks/Drawer`; name the kind only +// when a PR touches both. +const nameCounts = new Map(); +for (const c of analysis.components) { + nameCounts.set(c.name, (nameCounts.get(c.name) ?? 0) + 1); +} +const label = (c) => (nameCounts.get(c.name) === 1 ? c.name : `${c.kind}/${c.name}`); + +if (analysis.components.length === 0) { + out.push("_No files changed under `src/components/` or `src/forks/`._"); +} else if (previewable.length === 0) { + out.push( + `_${analysis.components.length} component(s) changed, none of which has a ` + + "`*.stories.tsx` — nothing to open in the preview._", + ); +} else { + out.push("| Component | Changed | Stories | Open |"); + out.push("|---|---|---|---|"); + for (const c of previewable.slice(0, MAX_ROWS)) { + const changed = [ + c.isNew ? "new" : c.sourceChanged && "source", + c.storiesChanged && "stories", + ] + .filter(Boolean) + .join(" + "); + out.push( + `| \`${label(c)}\` | ${changed} | ${c.storyCount} | ` + + `${link("Storybook ↗", `${meta.storybookUrl}${c.deepLink}`)} |`, + ); + } + if (previewable.length > MAX_ROWS) { + out.push(`| _…and ${previewable.length - MAX_ROWS} more_ | | | |`); + } +} +out.push(""); + +if (analysis.summary.newWithoutStories > 0) { + const names = analysis.components + .filter((c) => c.isNew && !c.hasStories) + .map((c) => `\`${label(c)}\``) + .join(", "); + out.push( + `> ⚠️ New component(s) without a \`*.stories.tsx\`: ${names}. They cannot appear ` + + "in the preview, and a story is the cheapest way to make a new component " + + "reviewable.", + ); + out.push(""); +} + +if (unpreviewable.length > 0) { + out.push( + `
${unpreviewable.length} changed component(s) with no story ` + + "to open", + ); + out.push(""); + for (const c of unpreviewable) out.push(`- \`${label(c)}\` — \`${c.dir}\``); + out.push(""); + out.push("
"); + out.push(""); +} + +if (analysis.otherSourceFiles.length > 0) { + const shown = analysis.otherSourceFiles.slice(0, 10); + out.push( + `
${analysis.otherSourceFiles.length} other changed file(s) in ` + + "src/", + ); + out.push(""); + for (const f of shown) out.push(`- \`${f}\``); + if (analysis.otherSourceFiles.length > shown.length) { + out.push(`- …and ${analysis.otherSourceFiles.length - shown.length} more`); + } + out.push(""); + out.push("
"); + out.push(""); +} + +// ── Bundle size ────────────────────────────────────────────────────────────── +out.push("### 📦 Bundle Size"); +out.push(""); +if (!bundleHead) { + out.push("_Bundle was not measured for this run._"); +} else { + const totalHead = bundleHead.entries.reduce((n, e) => n + e.gzipBytes, 0); + if (bundleBase) { + const baseByFile = new Map(bundleBase.entries.map((e) => [e.file, e])); + const headFiles = new Set(bundleHead.entries.map((e) => e.file)); + // Only what moved. With every module its own file, an unchanged group is + // byte-identical and its row says nothing. + const changed = [ + ...bundleHead.entries + .filter((e) => baseByFile.get(e.file)?.hash !== e.hash) + .map((e) => ({ file: e.file, head: e, prev: baseByFile.get(e.file) })), + ...bundleBase.entries + .filter((e) => !headFiles.has(e.file)) + .map((e) => ({ file: e.file, head: null, prev: e })), + ]; + const totalBase = bundleBase.entries.reduce((n, e) => n + e.gzipBytes, 0); + if (changed.length === 0) { + out.push("_No change to the built JS or CSS._"); + } else { + out.push("| Group | Gzip | Δ Gzip vs base |"); + out.push("|---|---|---|"); + for (const { file, head, prev } of changed.slice(0, MAX_ROWS)) { + const delta = !prev + ? "🆕 new" + : !head + ? "🗑️ removed" + : signed(head.gzipBytes - prev.gzipBytes); + out.push(`| \`${file}\` | ${head ? kb(head.gzipBytes) : "—"} | ${delta} |`); + } + if (changed.length > MAX_ROWS) { + out.push(`| _…and ${changed.length - MAX_ROWS} more_ | | |`); + } + } + out.push(""); + out.push( + `**Total gzip (JS + CSS):** ${kb(totalHead)} (${signed(totalHead - totalBase)} vs \`${analysis.base}\`)`, + ); + } else { + out.push(`**Total gzip (JS + CSS):** ${kb(totalHead)}`); + out.push(""); + out.push( + "_No base measurement — the merge-base build was skipped " + + "(a build input outside `src/` changed, or the base build failed)._", + ); + } +} +out.push(""); + +if (analysis.diffMode === "two-dot") { + out.push( + "> ℹ️ The changed-file list came from a two-dot diff (no merge base was " + + "reachable in the shallow clone), so it may over- or under-report.", + ); + out.push(""); +} + +out.push("---"); +out.push(""); +out.push( + `Generated by the PR Preview workflow for \`${meta.shortHash}\` · ` + + `${link("Storybook", meta.storybookUrl)} · ${link("CI run", meta.runUrl)}`, +); + +console.log(out.join("\n")); diff --git a/.github/scripts/pr-preview/measure-dist.mjs b/.github/scripts/pr-preview/measure-dist.mjs new file mode 100644 index 0000000..a742518 --- /dev/null +++ b/.github/scripts/pr-preview/measure-dist.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +// Measure the built package: raw and gzip bytes of the JS and CSS in `dist`, +// grouped by what a consumer imports. The build preserves modules, so one file +// is not one entry; a component's group is its whole directory +// (`components/StatCard`, `forks/Drawer`), and everything else is grouped by +// its top-level directory. Run once for the PR head and once for the merge +// base; the PR comment renders the delta between the two reports. +// +// Usage: node measure-dist.mjs --dist --output + +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, relative, sep } from "node:path"; +import { parseArgs } from "node:util"; +import { gzipSync } from "node:zlib"; + +const { + values: { dist: distDir, output }, +} = parseArgs({ options: { dist: { type: "string" }, output: { type: "string" } } }); +if (!distDir || !output) { + console.error("usage: measure-dist.mjs --dist --output "); + process.exit(1); +} + +// Sorted, so a group's hash depends on its files and not on directory order. +function* walk(dir) { + const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0, + ); + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) yield* walk(path); + else yield path; + } +} + +/** `components/StatCard/StatCard.js` -> `components/StatCard`; `index.js` -> `index.js`. */ +const groupOf = (file) => { + const parts = file.split("/"); + if ((parts[0] === "components" || parts[0] === "forks") && parts.length > 2) { + return `${parts[0]}/${parts[1]}`; + } + return parts.length > 1 ? parts[0] : file; +}; + +// Only what ships to a browser. Source maps and declarations do not, and the +// locale catalogs are data the consumer loads one at a time. +const groups = new Map(); +for (const path of walk(distDir)) { + const file = relative(distDir, path).split(sep).join("/"); + if (!/\.(js|css)$/.test(file) || /(^|\/)(locales|ui-common-locales)\//.test(file)) { + continue; + } + const buf = readFileSync(path); + const group = groupOf(file); + const current = groups.get(group) ?? { + file: group, + bytes: 0, + gzipBytes: 0, + hash: createHash("sha256"), + }; + current.bytes += buf.length; + current.gzipBytes += gzipSync(buf, { level: 9 }).length; + current.hash.update(file).update(buf); + groups.set(group, current); +} + +const entries = [...groups.values()] + .map((g) => ({ ...g, hash: g.hash.digest("hex").slice(0, 12) })) + .sort((a, b) => b.bytes - a.bytes); + +writeFileSync(output, `${JSON.stringify({ entries }, null, 2)}\n`); +console.log(`Measured ${entries.length} dist group(s) -> ${output}`); diff --git a/.github/scripts/pr-preview/pr-preview.test.ts b/.github/scripts/pr-preview/pr-preview.test.ts new file mode 100644 index 0000000..9f6b771 --- /dev/null +++ b/.github/scripts/pr-preview/pr-preview.test.ts @@ -0,0 +1,226 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const ANALYZE = join(here, "analyze-pr.mjs"); +const COMMENT = join(here, "generate-pr-comment.mjs"); + +const META = { + prNumber: "61", + shortHash: "a1b2c3d", + storybookUrl: "https://lablup.github.io/ui-common/pr/61/storybook/", + runUrl: "https://github.com/lablup/ui-common/actions/runs/1", +}; + +function tempDir() { + return mkdtempSync(join(tmpdir(), "pr-preview-")); +} + +function writeJson(dir: string, name: string, value: unknown) { + const path = join(dir, name); + writeFileSync(path, JSON.stringify(value)); + return path; +} + +function renderComment(args: { + analysis: unknown; + bundleHead?: unknown; + bundleBase?: unknown; +}) { + const dir = tempDir(); + const argv = [ + COMMENT, + "--meta", + writeJson(dir, "meta.json", META), + "--analysis", + writeJson(dir, "analysis.json", args.analysis), + ]; + if (args.bundleHead) + argv.push("--bundle-head", writeJson(dir, "head.json", args.bundleHead)); + if (args.bundleBase) + argv.push("--bundle-base", writeJson(dir, "base.json", args.bundleBase)); + return execFileSync("node", argv, { encoding: "utf8" }); +} + +describe("analyze-pr.mjs", () => { + function repoWithPr() { + const repo = tempDir(); + const git = (...args: string[]) => + execFileSync("git", args, { cwd: repo, encoding: "utf8" }); + const write = (file: string, contents = "x") => { + mkdirSync(dirname(join(repo, file)), { recursive: true }); + writeFileSync(join(repo, file), contents); + }; + + git("init", "-q", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "test"); + write("src/components/StatCard/index.ts"); + write("src/components/StatCard/StatCard.tsx"); + write("src/components/Modal/Modal.tsx"); + write("src/components/Old/index.ts", "export {};"); + write("src/components/Old/Old.tsx", "export const Old = 1;"); + write("src/hooks/index.ts"); + git("add", "-A"); + git("commit", "-q", "-m", "base"); + git("checkout", "-q", "-b", "pr"); + + write("src/components/StatCard/StatCard.css", "changed"); + write("src/components/StatCard/StatCard.stories.tsx", "new"); + write("src/components/Modal/Modal.test.tsx", "test only"); + write("src/components/Fresh/index.ts"); + write("src/components/Fresh/Fresh.tsx"); + write("src/hooks/index.ts", "changed"); + git("mv", "src/components/Old", "src/components/Renamed"); + git("add", "-A"); + git("commit", "-q", "-m", "pr"); + return repo; + } + + it("groups changes by component directory and links the built stories", () => { + const repo = repoWithPr(); + const index = writeJson(repo, "index.json", { + entries: { + "components-statcard--documentation": { + id: "components-statcard--documentation", + title: "Components/StatCard", + type: "docs", + importPath: "./src/components/StatCard/StatCard.stories.tsx", + }, + "components-statcard--default": { + id: "components-statcard--default", + title: "Components/StatCard", + type: "story", + importPath: "./src/components/StatCard/StatCard.stories.tsx", + }, + }, + }); + const output = join(repo, "analysis.json"); + execFileSync( + "node", + [ + ANALYZE, + "--base", + "main", + "--head", + "pr", + "--storybook-index", + index, + "--output", + output, + ], + { cwd: repo }, + ); + const analysis = JSON.parse(readFileSync(output, "utf8")); + + expect(analysis.diffMode).toBe("three-dot"); + // A test-only change is not a component change. + // A rename shows up under both the old and the new directory. + expect(analysis.components.map((c: { name: string }) => c.name)).toEqual([ + "Fresh", + "Old", + "Renamed", + "StatCard", + ]); + + const statCard = analysis.components.find( + (c: { name: string }) => c.name === "StatCard", + ); + expect(statCard).toMatchObject({ + kind: "components", + sourceChanged: true, + storiesChanged: true, + isNew: false, + storyCount: 1, + deepLink: "?path=/docs/components-statcard--documentation", + }); + + const fresh = analysis.components.find((c: { name: string }) => c.name === "Fresh"); + expect(fresh).toMatchObject({ isNew: true, hasStories: false }); + const renamed = analysis.components.find( + (c: { name: string }) => c.name === "Renamed", + ); + expect(renamed).toMatchObject({ isNew: true }); + expect(analysis.summary.newWithoutStories).toBe(2); + expect(analysis.otherSourceFiles).toEqual(["src/hooks/index.ts"]); + }); +}); + +describe("generate-pr-comment.mjs", () => { + const ANALYSIS = { + base: "origin/main", + diffMode: "three-dot", + components: [ + { + name: "Skeleton", + kind: "components", + dir: "src/components/Skeleton/", + deepLink: "?path=/docs/components-skeleton--documentation", + hasStories: true, + storyCount: 3, + sourceChanged: true, + storiesChanged: false, + isNew: false, + }, + { + name: "Fresh", + kind: "components", + dir: "src/components/Fresh/", + deepLink: null, + hasStories: false, + storyCount: 0, + sourceChanged: true, + storiesChanged: false, + isNew: true, + }, + ], + otherSourceFiles: ["src/index.ts"], + summary: { newWithoutStories: 1 }, + }; + + it("links the preview and every changed component's stories", () => { + const body = renderComment({ analysis: ANALYSIS }); + + expect(body).toContain("## PR Analysis Report"); + expect(body).toContain(`href="${META.storybookUrl}"`); + expect(body).toContain( + `href="${META.storybookUrl}?path=/docs/components-skeleton--documentation"`, + ); + expect(body).toContain("| `Skeleton` | source | 3 |"); + expect(body).toContain("New component(s) without a `*.stories.tsx`: `Fresh`"); + expect(body).toContain("1 changed component(s) with no story"); + expect(body).toContain("`src/index.ts`"); + expect(body).toContain("_Bundle was not measured for this run._"); + }); + + it("reports only the bundle groups that changed", () => { + const body = renderComment({ + analysis: ANALYSIS, + bundleHead: { + entries: [ + { file: "components/StatCard", bytes: 4096, gzipBytes: 2048, hash: "b" }, + { file: "components/Modal", bytes: 4096, gzipBytes: 1024, hash: "same" }, + { file: "components/Fresh", bytes: 1024, gzipBytes: 512, hash: "n" }, + ], + }, + bundleBase: { + entries: [ + { file: "components/StatCard", bytes: 2048, gzipBytes: 1024, hash: "a" }, + { file: "components/Modal", bytes: 4096, gzipBytes: 1024, hash: "same" }, + { file: "components/Gone", bytes: 1024, gzipBytes: 512, hash: "g" }, + ], + }, + }); + + expect(body).toContain("| `components/StatCard` | 2.0 kB | +1.0 kB |"); + expect(body).toContain("| `components/Fresh` | 0.5 kB | 🆕 new |"); + expect(body).toContain("| `components/Gone` | — | 🗑️ removed |"); + expect(body).not.toContain("`components/Modal`"); + expect(body).toContain( + "**Total gzip (JS + CSS):** 3.5 kB (+1.0 kB vs `origin/main`)", + ); + }); +}); diff --git a/.github/scripts/pr-preview/write-index.mjs b/.github/scripts/pr-preview/write-index.mjs new file mode 100644 index 0000000..00d6dbb --- /dev/null +++ b/.github/scripts/pr-preview/write-index.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +// Regenerate the gh-pages landing page from whatever `pr//` directories are +// currently on disk. Both the deploy and the cleanup job call this after they +// change the tree, so the index never claims a preview that is gone. +// +// Usage: node write-index.mjs --root + +import { existsSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { parseArgs } from "node:util"; + +const { + values: { root }, +} = parseArgs({ options: { root: { type: "string" } } }); +if (!root) { + console.error("usage: write-index.mjs --root "); + process.exit(1); +} + +const prRoot = join(root, "pr"); +const isDir = (path) => existsSync(path) && statSync(path).isDirectory(); + +const previews = isDir(prRoot) + ? readdirSync(prRoot) + .filter((name) => /^\d+$/.test(name)) + .filter((name) => isDir(join(prRoot, name, "storybook"))) + .map(Number) + .sort((a, b) => b - a) + : []; + +const items = previews + .map( + (n) => + `
  • PR #${n} — Storybook ` + + `source ↗
  • `, + ) + .join("\n"); + +const html = ` + + + + + + ui-common — PR previews + + + +

    ui-common — PR previews

    +

    + One Storybook build per open pull request that changed it. Previews are + removed when their PR is merged or closed. +

    +${previews.length > 0 ? `
      \n${items}\n
    ` : "

    No live previews right now.

    "} + + +`; + +writeFileSync(join(root, "index.html"), html); +console.log(`Wrote index.html listing ${previews.length} preview(s).`); diff --git a/.github/workflows/pr-preview-cleanup.yml b/.github/workflows/pr-preview-cleanup.yml new file mode 100644 index 0000000..5579e08 --- /dev/null +++ b/.github/workflows/pr-preview-cleanup.yml @@ -0,0 +1,163 @@ +# Keep gh-pages bounded. +# +# Two independent chores, both cheap and both necessary because GitHub Pages has +# a hard 1 GB published-site ceiling: +# +# cleanup — delete `pr//` for every PR that is no longer open. This is the +# only thing keeping merged PRs' previews from staying forever. +# compact — squash gh-pages history to a single orphan commit once a week, so +# the branch's *history* of those trees stops growing the repository. +# +# The cleanup reconciles against the live open-PR list on every run, so it needs +# no `closed` event to be correct — firing on any preview build (plus the daily +# cron) is enough to garbage-collect. +# +# SAFETY: never checks out or runs PR-controlled code. It only edits gh-pages. + +name: PR Preview Cleanup + +on: + schedule: + - cron: "0 6 * * *" # daily — reclaim closed PRs' previews + - cron: "17 9 * * 1" # Mondays — compact gh-pages history + workflow_run: + workflows: ["PR Preview"] + types: [completed] + workflow_dispatch: + inputs: + dry_run: + description: "List what would be deleted without deleting it" + type: boolean + default: false + compact: + description: "Also squash gh-pages history" + type: boolean + default: false + +permissions: {} + +concurrency: + # One cleanup at a time. A burst of preview completions collapses into a + # single pending run, which is fine: every run reconciles against the live + # open-PR list. The deploy has no group of its own; races against it are + # handled by the push retry instead. + group: pr-preview-cleanup + cancel-in-progress: false + +jobs: + cleanup: + # Skip on the weekly compaction cron — that fires the `compact` job instead — + # and after a preview run that was cancelled or failed, which changed + # nothing on gh-pages. + if: >- + (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') + && (github.event_name != 'schedule' || github.event.schedule == '0 6 * * *') + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: read + steps: + - name: Check out the maintenance scripts + uses: actions/checkout@v7 + with: + sparse-checkout: .github/scripts/pr-preview + + - name: Remove previews for closed PRs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DRY_RUN: ${{ github.event.inputs.dry_run }} + run: | + set -euo pipefail + REPO_URL="https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.git" + WORK_DIR="$PWD" + PAGES_DIR="${RUNNER_TEMP}/gh-pages" + MAX_ATTEMPTS=5 + + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + # One list for the whole run: an open PR that closes mid-run keeps its + # preview until tomorrow, which is the harmless direction to err in. + OPEN_PRS="$(gh pr list --repo "${{ github.repository }}" --state open --limit 500 --json number --jq '.[].number' | sort -u)" + echo "Open PRs: $(echo "$OPEN_PRS" | wc -w)" + + for attempt in $(seq 1 $MAX_ATTEMPTS); do + echo "::group::Attempt ${attempt} of ${MAX_ATTEMPTS}" + cd "$WORK_DIR" + rm -rf "$PAGES_DIR" + + if ! git clone --depth=1 --single-branch --branch gh-pages "$REPO_URL" "$PAGES_DIR" 2>/dev/null; then + echo "No gh-pages branch — nothing to clean." + echo "::endgroup::" + exit 0 + fi + + REMOVED=0 + if [ -d "${PAGES_DIR}/pr" ]; then + for dir in "${PAGES_DIR}"/pr/*/; do + [ -d "$dir" ] || continue + n="$(basename "$dir")" + case "$n" in ''|*[!0-9]*) echo "Skipping non-numeric entry: $n"; continue ;; esac + if echo "$OPEN_PRS" | grep -qx "$n"; then continue; fi + SIZE="$(du -sh "$dir" | cut -f1)" + if [ "${DRY_RUN:-false}" = "true" ]; then + echo "DRY RUN — would delete pr/${n} (${SIZE})" + else + echo "Deleting pr/${n} (${SIZE}) — PR is not open." + rm -rf "$dir" + REMOVED=$((REMOVED + 1)) + fi + done + fi + + if [ "${DRY_RUN:-false}" = "true" ]; then + echo "Dry run complete; nothing was pushed." + echo "::endgroup::" + exit 0 + fi + + node .github/scripts/pr-preview/write-index.mjs --root "$PAGES_DIR" + + cd "$PAGES_DIR" + git add -A + git commit -m "Clean up ${REMOVED} stale PR preview(s)" \ + || { echo "Nothing to clean up."; echo "::endgroup::"; exit 0; } + + if git push origin gh-pages; then + echo "Removed ${REMOVED} preview(s) on attempt ${attempt}." + echo "::endgroup::" + exit 0 + fi + + echo "Push failed (likely a concurrent deploy); retrying in $((attempt * 2))s." + echo "::endgroup::" + sleep $((attempt * 2)) + done + + echo "::error::Failed to push the cleanup after ${MAX_ATTEMPTS} attempts." + exit 1 + + compact: + # Sequenced after `cleanup`, never beside it. On a manual dispatch with + # compact=true the cleanup condition is also true, and two jobs cloning the + # same tip and pushing would race — the compaction would rewrite history + # that the cleanup had just changed. `always()` keeps this running on the + # weekly cron, where `cleanup` is skipped rather than run. + needs: [cleanup] + if: >- + always() && + (needs.cleanup.result == 'success' || needs.cleanup.result == 'skipped') && + (github.event.schedule == '17 9 * * 1' || github.event.inputs.compact == 'true') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + with: + sparse-checkout: .github/scripts/pr-preview + + - name: Compact gh-pages history + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash .github/scripts/pr-preview/compact-gh-pages-history.sh diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml new file mode 100644 index 0000000..0438c03 --- /dev/null +++ b/.github/workflows/pr-preview.yml @@ -0,0 +1,301 @@ +# Build the per-PR Storybook preview, publish it to gh-pages, and post the PR +# Analysis Report: the preview link, a deep link for each changed component's +# stories, and the bundle-size delta. Modeled on backend.ai-webui's PR Preview. +# +# The `paths` filter decides whether a PR gets a preview. GitHub matches it +# against the PR's own diff from the merge base, so no job re-asks. +# +# WHY THE DEPLOY LIVES HERE. A separate `workflow_run` deploy would run main's +# code with main's token, but a push by Copilot coding agent holds every run it +# causes for approval, and the `workflow_run` run that follows is held again — +# invisibly, because such runs are attached to main and never listed on the PR. +# Approval is per run, so the only way one approval can cover the deploy is for +# the deploy to be part of the approved run. +# +# SECURITY, the trade-off that buys it. `publish` and `report` are the only jobs +# with a write token, and they run from the PR's own copy of this file and of +# `.github/scripts/pr-preview`. Approving a Copilot run is therefore a review of +# the whole diff, not just a click to start the build. Keep both jobs free of +# anything that executes PR-authored source: they download the artifacts the +# build job produced and copy them as static files. Do not add a build step, a +# `pnpm install`, or any `run:` that invokes package scripts to either. +# +# Fork PRs get a read-only token no matter what this file declares, so +# `publish` and `report` skip them and a fork PR gets no preview. +# +# Needs GitHub Pages enabled for the `gh-pages` branch (Settings → Pages). +# Until then the deploy still pushes the branch and the report still posts; the +# links resolve once Pages is switched on. + +name: PR Preview + +on: + pull_request: + paths: + - src/** + - .storybook/** + - package.json + - pnpm-lock.yaml + - pnpm-workspace.yaml + - scripts/build-defines.mjs + - .github/workflows/pr-preview.yml + - .github/scripts/pr-preview/** + +permissions: + contents: read + +concurrency: + # Keyed by PR number, not branch name: branch names are unique only within a + # repository, so two fork PRs both using e.g. `patch-1` would share a group + # and cancel each other's runs. + group: pr-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + # Enough history for a three-dot diff against the base in the common + # case; analyze-pr.mjs deepens on its own when the base has moved past + # this window. + fetch-depth: 50 + + - name: Fetch base branch + run: git fetch --depth=50 origin "+refs/heads/${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }}" + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v7 + with: + node-version: 26 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Build Storybook + run: pnpm run build-storybook + + - name: Analyze changed components + run: | + node .github/scripts/pr-preview/analyze-pr.mjs \ + --base "origin/${{ github.base_ref }}" \ + --head HEAD \ + --storybook-index storybook-static/index.json \ + --output analysis.json + + - name: Build and measure the package bundle (head) + run: | + pnpm run build + node .github/scripts/pr-preview/measure-dist.mjs \ + --dist dist \ + --output bundle-head.json + + # Rebuild the package from the base's sources to get a comparable number. + # Only `src/` is swapped, so node_modules stays put — which is also why + # the delta is abandoned outright when the PR touches anything else that + # feeds the build. Swapping only `src/` under a changed vite config or a + # changed dependency set would build the "base" with the HEAD's bundling, + # and report a delta (often a reassuring zero) that describes no real + # comparison. Runs LAST, and best-effort: a failure costs the delta + # column and nothing else. + - name: Build and measure the package bundle (base) + id: bundle-base + continue-on-error: true + run: | + BUILD_INPUTS=( + pnpm-lock.yaml + pnpm-workspace.yaml + package.json + vite.config.ts + tsconfig.json + scripts/layer-order.mjs + scripts/build-defines.mjs + ) + if ! git diff --quiet "origin/${{ github.base_ref }}...HEAD" -- "${BUILD_INPUTS[@]}"; then + echo "A build input outside src/ changed — skipping the base build (not comparable)." + git diff --name-only "origin/${{ github.base_ref }}...HEAD" -- "${BUILD_INPUTS[@]}" + exit 0 + fi + rm -rf src dist + git checkout "origin/${{ github.base_ref }}" -- src + pnpm run build + node .github/scripts/pr-preview/measure-dist.mjs \ + --dist dist \ + --output bundle-base.json + + - name: Upload Storybook artifact + uses: actions/upload-artifact@v7 + with: + name: pr-preview-storybook + path: storybook-static/ + if-no-files-found: error + retention-days: 7 + + - name: Upload analysis artifact + uses: actions/upload-artifact@v7 + with: + name: pr-preview-analysis + path: | + analysis.json + bundle-head.json + bundle-base.json + if-no-files-found: error + retention-days: 7 + + # ── the write half ───────────────────────────────────────────────────────── + # `publish` and `report` hold the only write tokens in this workflow, one + # scope each, and they are independent so a gh-pages failure still leaves a + # report and vice versa. Read the SECURITY note in the header before adding a + # step to either: neither may execute PR-authored source. + + publish: + needs: build + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: write + # No shared concurrency group here: GitHub keeps one pending job per group + # and cancels the older one, so a burst of PRs would lose previews. The + # push retry below is what serializes gh-pages. + steps: + - name: Check out the maintenance scripts + uses: actions/checkout@v7 + with: + sparse-checkout: .github/scripts/pr-preview + + - name: Download Storybook artifact + uses: actions/download-artifact@v8 + with: + name: pr-preview-storybook + path: storybook-dist + + - name: Deploy to gh-pages + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + # A manual push with retry-on-conflict rather than a Pages action, so + # a concurrent cleanup or sibling preview just retries instead of + # failing the run. + set -euo pipefail + REPO_URL="https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.git" + WORK_DIR="$PWD" + PAGES_DIR="${RUNNER_TEMP}/gh-pages" + MAX_ATTEMPTS=8 + + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + for attempt in $(seq 1 $MAX_ATTEMPTS); do + echo "::group::Attempt ${attempt} of ${MAX_ATTEMPTS}" + cd "$WORK_DIR" + rm -rf "$PAGES_DIR" + + if git clone --depth=1 --single-branch --branch gh-pages "$REPO_URL" "$PAGES_DIR" 2>/dev/null; then + : + else + # First ever deploy: start the branch with no history to inherit. + echo "No gh-pages branch — creating it." + git init "$PAGES_DIR" + git -C "$PAGES_DIR" checkout -b gh-pages + git -C "$PAGES_DIR" remote add origin "$REPO_URL" + fi + + rm -rf "${PAGES_DIR}/pr/${PR_NUMBER}" + mkdir -p "${PAGES_DIR}/pr/${PR_NUMBER}/storybook" + cp -r storybook-dist/. "${PAGES_DIR}/pr/${PR_NUMBER}/storybook/" + # Tell Pages not to run the content through Jekyll, which would drop + # Storybook's `_`-prefixed asset directories. + touch "${PAGES_DIR}/.nojekyll" + node .github/scripts/pr-preview/write-index.mjs --root "$PAGES_DIR" + + cd "$PAGES_DIR" + git add -A + git commit -m "Deploy PR #${PR_NUMBER} preview" \ + || { echo "Nothing to commit."; echo "::endgroup::"; exit 0; } + + if git push origin gh-pages; then + echo "Deployed on attempt ${attempt}." + echo "::endgroup::" + exit 0 + fi + + delay=$((attempt + RANDOM % 5)) + echo "Push failed (likely a concurrent update); retrying in ${delay}s." + echo "::endgroup::" + sleep "$delay" + done + + echo "::error::Failed to deploy the PR preview after ${MAX_ATTEMPTS} attempts." + exit 1 + + report: + needs: build + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + # No setup-node — the report scripts use nothing beyond `node:fs` / + # `node:child_process`, so the runner image's Node is enough. + - name: Check out the report scripts + uses: actions/checkout@v7 + with: + sparse-checkout: .github/scripts/pr-preview + + - name: Download analysis artifact + uses: actions/download-artifact@v8 + with: + name: pr-preview-analysis + path: pr-analysis + + - name: Post or update the PR Analysis Report + uses: actions/github-script@v9 + with: + script: | + const fs = require('node:fs'); + const { execFileSync } = require('node:child_process'); + + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const headSha = context.payload.pull_request.head.sha; + + // Everything the report needs that is not a measurement is derived + // here, from the event payload, not from an artifact. + fs.writeFileSync('pr-meta.json', JSON.stringify({ + prNumber: String(prNumber), + shortHash: headSha.slice(0, 7), + storybookUrl: `https://${owner}.github.io/${repo}/pr/${prNumber}/storybook/`, + runUrl: `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`, + })); + + const args = [ + '.github/scripts/pr-preview/generate-pr-comment.mjs', + '--meta', 'pr-meta.json', + '--analysis', 'pr-analysis/analysis.json', + ]; + if (fs.existsSync('pr-analysis/bundle-head.json')) { + args.push('--bundle-head', 'pr-analysis/bundle-head.json'); + } + if (fs.existsSync('pr-analysis/bundle-base.json')) { + args.push('--bundle-base', 'pr-analysis/bundle-base.json'); + } + + const body = execFileSync('node', args, { encoding: 'utf8' }); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: prNumber, per_page: 100, + }); + const existing = comments.find( + (c) => c.user.type === 'Bot' && c.body.includes('PR Analysis Report'), + ); + + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + core.info(`Updated the PR Analysis Report on #${prNumber}.`); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body }); + core.info(`Posted the PR Analysis Report on #${prNumber}.`); + } diff --git a/.gitignore b/.gitignore index 1e30d3e..c901c8d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ coverage/ fixture/node_modules/ fixture/dist/ fixture/pnpm-lock.yaml +storybook-static/ +*storybook.log diff --git a/.prettierignore b/.prettierignore index 85289f4..86d05b8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,4 @@ LICENSE src/theme/*/built/ packages/cli/test/upgrade/fixtures/ .claude/ +storybook-static/ diff --git a/.storybook/DocsContainer.tsx b/.storybook/DocsContainer.tsx new file mode 100644 index 0000000..83cdf4c --- /dev/null +++ b/.storybook/DocsContainer.tsx @@ -0,0 +1,35 @@ +import { useEffect, useState, type PropsWithChildren } from "react"; +import { + DocsContainer as BaseContainer, + type DocsContainerProps, +} from "@storybook/addon-docs/blocks"; +import { DARK_MODE_EVENT_NAME } from "@vueless/storybook-dark-mode"; +import { addons } from "storybook/preview-api"; + +import { storybookDarkTheme, storybookLightTheme } from "./storybookTheme"; + +const channel = addons.getChannel(); + +/** Docs pages follow the dark-mode toggle, like the stories do. */ +export function DocsContainer({ + context, + children, +}: PropsWithChildren) { + const [isDarkMode, setIsDarkMode] = useState(false); + + useEffect(() => { + channel.on(DARK_MODE_EVENT_NAME, setIsDarkMode); + return () => { + channel.off(DARK_MODE_EVENT_NAME, setIsDarkMode); + }; + }, []); + + return ( + + {children} + + ); +} diff --git a/.storybook/decorators.tsx b/.storybook/decorators.tsx new file mode 100644 index 0000000..0e12321 --- /dev/null +++ b/.storybook/decorators.tsx @@ -0,0 +1,80 @@ +import { useEffect, useMemo, useState } from "react"; +import type { Decorator } from "@storybook/react-vite"; +import { useDarkMode } from "@vueless/storybook-dark-mode"; + +import { InternationalizationProvider } from "../src/astryx/i18n"; +import type { Catalog } from "../src/astryx/i18n"; +import { Theme } from "../src/astryx/theme"; +import { neutralTheme } from "../src/astryx/theme/neutral/built"; +import { mergeMessages, uiCommonMessages } from "../src/i18n"; +import { lablupTheme } from "../src/theme/lablup/built/lablup"; + +export type ThemeStyle = "lablup" | "neutral"; + +/** + * The "Theme" toolbar: the Lablup brand, or Astryx's neutral baseline as the + * "does this component assume the brand?" check. Both are the pre-built + * themes a consumer mounts, paired with their `theme.css` in storybook.css. + */ +const themes = { lablup: lablupTheme, neutral: neutralTheme }; + +// Astryx's own catalogs, loaded when a locale is first picked and kept for the +// session, so switching stories does not reload them. ui-common's catalog +// (`uiCommonMessages`) is small and always present. +const astryxCatalogs = import.meta.glob( + "../node_modules/@astryxdesign/core/locales/*.json", + { import: "default" }, +); +const loadedCatalogs: Record = {}; + +function useAstryxCatalog(locale: string): Catalog | undefined { + const [, setLoaded] = useState(0); + + useEffect(() => { + const load = + astryxCatalogs[`../node_modules/@astryxdesign/core/locales/${locale}.json`]; + if (!load || loadedCatalogs[locale]) return; + let cancelled = false; + void load().then((catalog) => { + loadedCatalogs[locale] = catalog; + if (!cancelled) setLoaded((n) => n + 1); + }); + return () => { + cancelled = true; + }; + }, [locale]); + + return loadedCatalogs[locale]; +} + +function StoryProviders({ + Story, + context, +}: { + Story: Parameters[0]; + context: Parameters[1]; +}) { + const isDarkMode = useDarkMode(); + const { locale, themeStyle } = context.globals as { + locale: string; + themeStyle: ThemeStyle; + }; + const astryxCatalog = useAstryxCatalog(locale); + const messages = useMemo( + () => + mergeMessages(astryxCatalog ? { [locale]: astryxCatalog } : {}, uiCommonMessages), + [locale, astryxCatalog], + ); + + return ( + + + + + + ); +} + +export const withGlobalProviders: Decorator = (Story, context) => ( + +); diff --git a/.storybook/localeConfig.ts b/.storybook/localeConfig.ts new file mode 100644 index 0000000..8b9b2c7 --- /dev/null +++ b/.storybook/localeConfig.ts @@ -0,0 +1,13 @@ +import { uiCommonMessages } from "../src/i18n"; + +const names = (locale: string) => new Intl.DisplayNames([locale], { type: "language" }); + +/** + * The locale toolbar's list: every locale ui-common ships a catalog for + * (`src/i18n/locales`, English first), each named in its own language. Values + * are Astryx locale names, the ones a consumer passes to + * `InternationalizationProvider`. + */ +export const localeItems = Object.keys(uiCommonMessages) + .sort((a, b) => (a === "en" ? -1 : b === "en" ? 1 : a.localeCompare(b))) + .map((value) => ({ value, title: names(value).of(value) ?? value })); diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 0000000..afa7a0f --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,29 @@ +import type { StorybookConfig } from "@storybook/react-vite"; + +const config: StorybookConfig = { + stories: [ + { + titlePrefix: "Docs Overview", + directory: "../src", + files: "Introduction.mdx", + }, + "../src/**/*.stories.@(ts|tsx)", + ], + addons: ["@storybook/addon-docs", "@vueless/storybook-dark-mode"], + framework: { + name: "@storybook/react-vite", + options: { + // The repository's own vite.config.ts is the library build (lib mode, + // dts emission, the dist copies), none of which belongs in Storybook. + builder: { viteConfigPath: ".storybook/vite.config.ts" }, + }, + }, + docs: { + defaultName: "Documentation", + }, + typescript: { + reactDocgen: "react-docgen-typescript", + }, +}; + +export default config; diff --git a/.storybook/manager.ts b/.storybook/manager.ts new file mode 100644 index 0000000..706bc23 --- /dev/null +++ b/.storybook/manager.ts @@ -0,0 +1,10 @@ +import { addons } from "storybook/manager-api"; + +import { storybookLightTheme } from "./storybookTheme"; + +addons.setConfig({ + theme: storybookLightTheme, + sidebar: { + showRoots: true, + }, +}); diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 0000000..3f7037f --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1,12 @@ + + + + + diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 0000000..cf736a2 --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,53 @@ +import type { Preview } from "@storybook/react-vite"; + +import "./storybook.css"; +import { DocsContainer } from "./DocsContainer"; +import { withGlobalProviders } from "./decorators"; +import { localeItems } from "./localeConfig"; +import { storybookDarkTheme, storybookLightTheme } from "./storybookTheme"; + +const preview: Preview = { + tags: ["autodocs"], + decorators: [withGlobalProviders], + parameters: { + darkMode: { + dark: storybookDarkTheme, + light: storybookLightTheme, + current: "light", + stylePreview: true, + }, + docs: { + container: DocsContainer, + }, + layout: "padded", + }, + globalTypes: { + locale: { + name: "Locale", + description: "Locale passed to Astryx's InternationalizationProvider", + toolbar: { + icon: "globe", + items: localeItems, + dynamicTitle: true, + }, + }, + themeStyle: { + name: "Theme", + description: "The Lablup brand theme vs. Astryx's neutral baseline", + toolbar: { + icon: "paintbrush", + items: [ + { value: "lablup", title: "Lablup" }, + { value: "neutral", title: "Astryx (neutral)" }, + ], + dynamicTitle: true, + }, + }, + }, + initialGlobals: { + locale: "en", + themeStyle: "lablup", + }, +}; + +export default preview; diff --git a/.storybook/storybook.css b/.storybook/storybook.css new file mode 100644 index 0000000..784501c --- /dev/null +++ b/.storybook/storybook.css @@ -0,0 +1,20 @@ +/* The stylesheets a consumer loads (README, "Set up"), in the same order. + Storybook never loads an app entry stylesheet, so this file is its only + one. The `@layer` statement comes first, as in every sheet ui-common ships; + src/layerOrder.test.ts holds it to scripts/layer-order.mjs. */ +@layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities; + +@import "../src/astryx/reset.css"; +@import "../src/astryx/astryx.css"; +@import "../src/astryx/theme/neutral/theme.css"; +@import "../src/theme/lablup/built/theme.css"; +@import "../src/ui-common.css"; +@import "../src/astryx/lab/lab.css"; + +/* The body follows the mounted theme, as in an app. Unlayered on purpose: + it is the one page-level rule the preview needs. */ +body { + background-color: var(--color-background-body); + color: var(--color-text-primary); + font-family: var(--font-family-body); +} diff --git a/.storybook/storybookTheme.ts b/.storybook/storybookTheme.ts new file mode 100644 index 0000000..01f7147 --- /dev/null +++ b/.storybook/storybookTheme.ts @@ -0,0 +1,55 @@ +import { create } from "storybook/theming/create"; + +// Storybook's own UI (sidebar, toolbar, docs pages), in the Lablup accent. +// The stories themselves are themed by the decorator, not by this file. +const common = { + brandTitle: "Lablup UI Common", + brandUrl: "https://github.com/lablup/ui-common", + brandTarget: "_blank" as const, + fontBase: "'Ubuntu Sans', 'Pretendard Variable', 'Helvetica Neue', Arial, sans-serif", + fontCode: "'Roboto Mono', 'SF Mono', 'Fira Code', Consolas, monospace", + appBorderRadius: 4, + inputBorderRadius: 4, +}; + +export const storybookLightTheme = create({ + base: "light", + ...common, + colorPrimary: "#FF7A00", + colorSecondary: "#DC6B03", + appBg: "#f5f5f5", + appContentBg: "#ffffff", + appPreviewBg: "#ffffff", + appBorderColor: "#e0e0e0", + textColor: "#141414", + textInverseColor: "#ffffff", + textMutedColor: "#666666", + barBg: "#ffffff", + barTextColor: "#666666", + barSelectedColor: "#FF7A00", + barHoverColor: "#FF7A00", + inputBg: "#ffffff", + inputBorder: "#d9d9d9", + inputTextColor: "#141414", +}); + +export const storybookDarkTheme = create({ + base: "dark", + ...common, + colorPrimary: "#FF9D00", + colorSecondary: "#DC6B03", + appBg: "#111217", + appContentBg: "#181b1f", + appPreviewBg: "#181b1f", + appBorderColor: "#2c2f36", + textColor: "#e0e0e0", + textInverseColor: "#111217", + textMutedColor: "#9b9b9b", + barBg: "#1f2229", + barTextColor: "#9b9b9b", + barSelectedColor: "#FF9D00", + barHoverColor: "#FF9D00", + inputBg: "#1f2229", + inputBorder: "#3d424d", + inputTextColor: "#e0e0e0", +}); diff --git a/.storybook/vite.config.ts b/.storybook/vite.config.ts new file mode 100644 index 0000000..a23f0c6 --- /dev/null +++ b/.storybook/vite.config.ts @@ -0,0 +1,13 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +import { buildDefines } from "../scripts/build-defines.mjs"; + +/** + * Storybook's Vite config. Only what the components need at runtime: React, + * and the build constants the duplicate-copy warning reads (src/instance.ts). + */ +export default defineConfig({ + plugins: [react()], + define: buildDefines(), +}); diff --git a/docs/storybook.md b/docs/storybook.md new file mode 100644 index 0000000..41fe573 --- /dev/null +++ b/docs/storybook.md @@ -0,0 +1,154 @@ +# Writing stories + +Storybook documents ui-common's own components. Config lives in `.storybook/`; +stories live beside the component they cover. + +``` +pnpm storybook # dev server on http://localhost:6006 +pnpm build-storybook # static build in storybook-static/ +``` + +## Where + +- One stories file per component directory, named after the directory: + `src/components/StatCard/StatCard.stories.tsx`. A directory is one entry in + the Storybook sidebar. +- Forks of Astryx components (`src/forks//`) follow the same rule. + +### A directory with several components + +`Skeleton/` exports `SkeletonCard`, `SkeletonText`, `SkeletonRow` and +`SkeletonChart`. It still gets one file, `Skeleton.stories.tsx`, titled +`Components/Skeleton`: + +- `component` is the one most used; the rest go in `subcomponents`, so the + Documentation page shows every member's props. +- One story per member, named after it (`Card`, `Text`, `Row`, `Chart`), + rendering that member with `render`. A member's variants or states follow, + prefixed with its name (`CardVariants`). + +```tsx +const meta = { + title: "Components/Skeleton", + component: SkeletonCard, + subcomponents: { SkeletonText, SkeletonRow, SkeletonChart }, +} satisfies Meta; + +export const Card: Story = {}; +export const Text: Story = { render: () => }; +``` + +## Meta + +```tsx +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { StatCard } from "./StatCard"; + +const meta = { + title: "Components/StatCard", + component: StatCard, + args: { label: "Active sessions", value: 1284 }, + argTypes: { + tone: { + control: "select", + options: ["default", "success", "warning", "danger", "info"], + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; +``` + +- `title`: `Components/` for `src/components`, `Forks/` for + `src/forks`. +- `satisfies Meta`, not `: Meta<…>`, so `args` stay type-checked. +- `args`: every required prop, with realistic values. +- `argTypes`: only for union props, as `select` or `inline-radio` with the + values spelled out, and for the rule below. Everything else gets its control + from the prop types. +- A prop that takes a React element (`children`, `icon`, `actions`, `footer`, + …) gets `control: false` when the stories pass it one. Controls would show + the element as an object, including the component's whole source. The prop + still renders and stays in the props table; only the edit field goes. +- Import the component relatively (`./StatCard`), never from + `@lablup/ui-common`. +- A generic component takes its type argument in both places: + `component: DataGrid` and `satisfies Meta>`. +- If `tsc` reports TS2742 on `meta` (the inferred type names an Astryx internal + path), annotate instead: `const meta: Meta = {…}` with + `type Story = StoryObj`. + +## Stories + +- `Default` comes first and renders with `meta.args` alone. +- Then one story per state a reviewer needs to see: `Loading`, `WithError`, + `Empty`, `Disabled`, and so on. Name it after the state, in PascalCase. +- To compare variants, one story that renders them side by side (`Tones`) beats + one story per variant. +- Callback props take `fn()` from `storybook/test`, so calls show in the + Actions panel. +- Interactive state is React state, in a `function Render(args)` or a small + named component in the stories file. Never Storybook's `useArgs`: the global + decorator re-renders stories (dark mode, locale), and Storybook hooks throw + when that happens. + +## Overlays + +Modals, drawers, popovers and notification stacks portal out of the story. +Use the helpers in `src/stories/helpers.tsx`: + +```tsx +parameters: overlayDocs("400px"), +render: function Render(args) { + const [isOpen, setOpen] = useOpenState(args.isOpen, args.onOpenChange); + return ( + <> +