mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-05-06 01:28:35 -05:00
fix(dev): make npm run dev serve full question content from local YAMLs
Before this change, the StaffML Next.js dev server fetched scenario and
details (including napkin_math) from the production Cloudflare Worker
even when contributors had local YAML edits — so changes weren't visible
without shipping. The opt-in static-fallback path existed but was wired
incorrectly: getStaticFullDetail used a Function-constructor dynamic
import of ../data/corpus.json, which Turbopack rewrote to a non-existent
/_next/static/data/corpus.json URL and 404'd at runtime.
Fix in three parts:
1. Loader (interviews/staffml/src/lib/corpus.ts): replace the broken
dynamic import with fetch('/data/corpus.json'). On failure, throw a
clear error pointing at `vault build --local`.
2. Build (interviews/vault-cli/src/vault_cli/commands/build.py): mirror
the generated corpus.json into interviews/staffml/public/data/ so
Next serves it as a static asset. Add --local as a clearer alias for
--local-json and update the help text to spell out the dev workflow.
3. Wiring (interviews/staffml/package.json + scripts/build-local-corpus.mjs):
predev now runs `vault build --local` automatically, with a soft-fail
path if the vault CLI isn't installed (so first-time contributors
still get a working dev server, just with the worker fallback). The
committed .env.development sets NEXT_PUBLIC_VAULT_FALLBACK=static so
the static path is the default in dev. Both copies of corpus.json are
gitignored as build artifacts (the YAMLs are the source of truth).
This commit is contained in:
13
interviews/staffml/.env.development
Normal file
13
interviews/staffml/.env.development
Normal file
@@ -0,0 +1,13 @@
|
||||
# Local-dev environment for `npm run dev`.
|
||||
#
|
||||
# This file is .gitignored by Next.js convention (.env.development.local) and
|
||||
# auto-loaded only by `next dev`. It opts the dev server into the local-static
|
||||
# fallback, which makes it serve question scenario/details from
|
||||
# public/data/corpus.json (regenerated on each `npm run dev` by the predev
|
||||
# hook running `vault build --local`).
|
||||
#
|
||||
# Without this var, the dev server falls back to the production Cloudflare
|
||||
# Worker for question content, which means local YAML edits aren't visible
|
||||
# until they ship — exactly the gotcha this file exists to prevent.
|
||||
|
||||
NEXT_PUBLIC_VAULT_FALLBACK=static
|
||||
7
interviews/staffml/.gitignore
vendored
7
interviews/staffml/.gitignore
vendored
@@ -15,6 +15,13 @@ tsconfig.tsbuildinfo
|
||||
# is the source of truth; don't commit the copy.
|
||||
public/question-visuals/
|
||||
|
||||
# Build output — `vault build --local` materializes the full corpus from
|
||||
# vault/questions/*.yaml. Both copies are regenerated on every `npm run dev`
|
||||
# by the predev hook, so they're build artifacts, not source. The vault
|
||||
# YAMLs are the source of truth.
|
||||
src/data/corpus.json
|
||||
public/data/corpus.json
|
||||
|
||||
# Playwright test artifacts (screenshots, videos, traces on failure).
|
||||
# The `tests/` directory itself IS committed.
|
||||
test-results/
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.1-dev",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"predev": "node scripts/sync-periodic-table.mjs",
|
||||
"predev": "node scripts/sync-periodic-table.mjs && node scripts/build-local-corpus.mjs",
|
||||
"dev": "next dev",
|
||||
"prebuild": "node scripts/sync-periodic-table.mjs",
|
||||
"build": "next build",
|
||||
@@ -11,7 +11,8 @@
|
||||
"lint": "next lint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"sync:periodic-table": "node scripts/sync-periodic-table.mjs"
|
||||
"sync:periodic-table": "node scripts/sync-periodic-table.mjs",
|
||||
"build:local-corpus": "node scripts/build-local-corpus.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-sigma/core": "^5.0.6",
|
||||
|
||||
55
interviews/staffml/scripts/build-local-corpus.mjs
Normal file
55
interviews/staffml/scripts/build-local-corpus.mjs
Normal file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Auto-run before `npm run dev` so the local Next.js dev server can
|
||||
* serve the question corpus from disk via NEXT_PUBLIC_VAULT_FALLBACK=static.
|
||||
*
|
||||
* What it does:
|
||||
* 1. Looks for the `vault` CLI on PATH.
|
||||
* 2. Runs `vault build --local` from the repo root, which writes:
|
||||
* interviews/staffml/src/data/corpus.json (legacy bundle)
|
||||
* interviews/staffml/public/data/corpus.json (the path the loader fetches)
|
||||
* and mirrors visual SVGs into public/question-visuals/.
|
||||
*
|
||||
* Skipped silently if `vault` is not installed (e.g. on a fresh checkout
|
||||
* that hasn't run `pip install -e interviews/vault-cli` yet). The dev
|
||||
* server still boots; it just falls back to the production worker for
|
||||
* scenario/details, which is the same behavior contributors got before
|
||||
* this hook was wired in.
|
||||
*
|
||||
* Override: set STAFFML_SKIP_LOCAL_CORPUS=1 to bypass entirely.
|
||||
*/
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
if (process.env.STAFFML_SKIP_LOCAL_CORPUS === "1") {
|
||||
console.log("[build-local-corpus] STAFFML_SKIP_LOCAL_CORPUS=1, skipping");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
|
||||
|
||||
const which = spawnSync("which", ["vault"], { encoding: "utf8" });
|
||||
if (which.status !== 0 || !which.stdout.trim()) {
|
||||
console.log(
|
||||
"[build-local-corpus] `vault` CLI not on PATH; skipping local corpus rebuild.\n" +
|
||||
" To enable full-content rendering against your local YAMLs, run:\n" +
|
||||
" pip install -e interviews/vault-cli\n" +
|
||||
" then re-run `npm run dev`."
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log("[build-local-corpus] running `vault build --local` ...");
|
||||
const r = spawnSync("vault", ["build", "--local"], {
|
||||
cwd: REPO_ROOT,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (r.status !== 0) {
|
||||
console.error("[build-local-corpus] vault build failed; dev server will fall back to the worker.");
|
||||
// Soft-fail: don't block dev server startup just because the local corpus
|
||||
// isn't available. The worker fallback still gives a usable site.
|
||||
process.exit(0);
|
||||
}
|
||||
console.log("[build-local-corpus] done.");
|
||||
File diff suppressed because one or more lines are too long
@@ -1,26 +1,26 @@
|
||||
{
|
||||
"releaseId": "dev",
|
||||
"releaseHash": "d514a795f68e5c76d8bc7242783fac27d4017e8c0561a09edd75c3dd6620c7db",
|
||||
"releaseHash": "2ab29a09eb919139616b11e5c3b9d843dd9520ec155f6a23a1261f719441f933",
|
||||
"schemaVersion": "1",
|
||||
"policyVersion": "1",
|
||||
"buildDate": "2026-05-04T12:49:59Z",
|
||||
"questionCount": 9446,
|
||||
"buildDate": "2026-05-05T13:28:43Z",
|
||||
"questionCount": 9521,
|
||||
"chainCount": 843,
|
||||
"conceptCount": 87,
|
||||
"trackDistribution": {
|
||||
"cloud": 4028,
|
||||
"edge": 2079,
|
||||
"global": 313,
|
||||
"mobile": 1824,
|
||||
"tinyml": 1202
|
||||
"cloud": 4077,
|
||||
"edge": 2093,
|
||||
"global": 317,
|
||||
"mobile": 1826,
|
||||
"tinyml": 1208
|
||||
},
|
||||
"levelDistribution": {
|
||||
"L4": 2570,
|
||||
"L1": 534,
|
||||
"L2": 1043,
|
||||
"L3": 2347,
|
||||
"L5": 2140,
|
||||
"L6+": 812
|
||||
"L4": 2591,
|
||||
"L1": 543,
|
||||
"L2": 1053,
|
||||
"L3": 2360,
|
||||
"L5": 2157,
|
||||
"L6+": 817
|
||||
},
|
||||
"areaCount": 13,
|
||||
"taxonomyVersion": "87-topics"
|
||||
|
||||
@@ -521,18 +521,20 @@ function shouldUseStaticDetails(): boolean {
|
||||
|
||||
async function getStaticFullDetail(id: string, summary: Question): Promise<Question | undefined> {
|
||||
if (!_staticDetailsCache) {
|
||||
// Function-constructor dynamic import: hides the path from Turbopack's
|
||||
// static analyzer so prod builds don't require corpus.json to exist.
|
||||
// corpus.json is materialized on disk only when a contributor runs
|
||||
// `vault build --local-json` locally with NEXT_PUBLIC_VAULT_FALLBACK=
|
||||
// static. If the file is missing at runtime, the import rejects and
|
||||
// the caller surfaces an error to the UI.
|
||||
const dynImport = new Function(
|
||||
"p",
|
||||
"return import(p)",
|
||||
) as (p: string) => Promise<{ default: Question[] }>;
|
||||
const mod = await dynImport("../data/corpus.json");
|
||||
_staticDetailsCache = new Map(mod.default.map((q) => [q.id, q]));
|
||||
// Fetch corpus.json from /data/corpus.json (served from public/). This
|
||||
// file is written by `vault build --local-json` and exists only in local
|
||||
// dev. Production deploys neither emit nor bundle it; the worker fetch
|
||||
// path handles those. If the file is missing at runtime the fetch fails
|
||||
// and the caller surfaces an error to the UI.
|
||||
const res = await fetch("/data/corpus.json");
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Static corpus.json not available at /data/corpus.json (status ${res.status}). ` +
|
||||
"Run \`vault build --local-json\` from the repo root to regenerate it.",
|
||||
);
|
||||
}
|
||||
const data = (await res.json()) as Question[];
|
||||
_staticDetailsCache = new Map(data.map((q) => [q.id, q]));
|
||||
}
|
||||
const full = _staticDetailsCache.get(id);
|
||||
if (!full) return undefined;
|
||||
|
||||
@@ -36,11 +36,15 @@ def register(app: typer.Typer) -> None:
|
||||
local_json: bool = typer.Option(
|
||||
False,
|
||||
"--local-json",
|
||||
help="Also write a site-readable corpus.json at "
|
||||
"interviews/staffml/src/data/corpus.json so the StaffML "
|
||||
"frontend can serve full question content from disk during "
|
||||
"local dev (with NEXT_PUBLIC_VAULT_FALLBACK=static). "
|
||||
"Production never reads this file; it is dev-only.",
|
||||
"--local",
|
||||
help="Materialize the local-dev artifacts so the StaffML frontend "
|
||||
"can serve full question content from disk: writes "
|
||||
"interviews/staffml/src/data/corpus.json AND mirrors it to "
|
||||
"interviews/staffml/public/data/corpus.json (the path the "
|
||||
"Next.js loader actually fetches with "
|
||||
"NEXT_PUBLIC_VAULT_FALLBACK=static). Production never reads "
|
||||
"either file; this is dev-only. The shorter --local alias "
|
||||
"is preferred.",
|
||||
),
|
||||
) -> None:
|
||||
"""Compile all YAML questions under vault/questions/ to a SQLite file.
|
||||
@@ -73,6 +77,19 @@ def register(app: typer.Typer) -> None:
|
||||
f"[dim]local corpus.json: {local_result['count']} questions → "
|
||||
f"{local_result['output']}[/dim]"
|
||||
)
|
||||
# Mirror corpus.json into public/data/ so Next can serve it as a
|
||||
# static asset. The frontend's getStaticFullDetail() fetches
|
||||
# /data/corpus.json (set NEXT_PUBLIC_VAULT_FALLBACK=static to
|
||||
# opt in) — Turbopack does not bundle the src/data/ copy because
|
||||
# it would balloon the prod bundle, so the public mirror is the
|
||||
# only reliable runtime path in local dev.
|
||||
public_out = Path("interviews/staffml/public/data/corpus.json")
|
||||
public_out.parent.mkdir(parents=True, exist_ok=True)
|
||||
public_out.write_bytes(local_out.read_bytes())
|
||||
console.print(
|
||||
f"[dim]public mirror: {local_result['count']} questions → "
|
||||
f"{public_out}[/dim]"
|
||||
)
|
||||
# Mirror visual assets alongside the JSON. The frontend
|
||||
# references /question-visuals/<track>/<file>.svg directly
|
||||
# from Next.js's public/ tree — no hydration or worker
|
||||
|
||||
Reference in New Issue
Block a user