feat(ui): the viewer's screens as @colbymchenry/codegraph-ui, behind one adapter (CG-61)
`ui/src` now builds two ways from one tree: the static app `codegraph ui` serves, and — via `svelte-package` — a Svelte library the Pro app imports. A forked component would be a second answer to the same question about the same graph, so there is no fork. Everything a screen knows arrives through a `GraphAdapter`: eleven methods answering the wire shapes verbatim, with `createHttpAdapter()` (the loopback JSON API) as the default and a host's in-process engine reads as the point. `lib/api.ts` became a one-line-per-call facade over it, which is why no call site in the views changed. The payload types moved to `lib/wire.ts` — no imports, no runtime — so a host can depend on the vocabulary alone. Two more seams and one guard: - `lib/navigation.ts` holds the href builders behind a `NavigationDriver`, so a host addresses its own URL space. The app's half — the hash parser and the live route, which attach window listeners at module scope — stays in `router.svelte.ts` and is pruned out of the package: rendering a Symbol view must not install a hash router in somebody else's application. - `lib/theme.css` carries the design tokens and maps Svelte Flow's `--xy-*` variables onto them, so a host never sees library defaults. Dark now also answers to a bare `[data-theme]`, which is how `<CodegraphUi theme>` themes a container rather than the document. - `scripts/check-ui-package.mjs` prunes the app's shell, resolves the extensionless specifiers svelte-package leaves behind, and asserts that nothing but `lib/adapter.js` reaches the network. The search box, its keyboard and its panel are one component now (`SearchPalette`), because splitting them is what breaks a palette. `__tests__/ui-package.test.ts` mounts the three screens from the package entry against a mock adapter in jsdom; it runs as a second vitest project so the `browser` resolve condition it needs cannot reach the engine's suites. Versioned with the engine. Prepared, not published: `private: true` is the guard and `pack-npm.sh` only packs a tarball under CODEGRAPH_PACK_UI=1.
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Finish and verify the `@colbymchenry/codegraph-ui` build (task CG-61).
|
||||
*
|
||||
* `svelte-package` compiles the whole of `ui/src`, which is the right input —
|
||||
* the components a host imports and the ones `codegraph ui` renders are the
|
||||
* same files, and splitting them into two trees is how the two screens start
|
||||
* to drift. But it means the emitted `dist/` also carries the standalone app's
|
||||
* shell, and one of those files is a hazard rather than dead weight:
|
||||
* `lib/router.svelte.js` attaches `hashchange`/`popstate` listeners at module
|
||||
* scope. A host must never inherit a hash router just by rendering a Symbol
|
||||
* view. So this script does three jobs, in order:
|
||||
*
|
||||
* 1. PRUNE the app-only files from the package.
|
||||
* 2. RESOLVE the extensionless relative specifiers `svelte-package` leaves
|
||||
* behind, so the package works under Node's own ESM resolution and under
|
||||
* a consumer on `moduleResolution: node16`, not only inside a bundler.
|
||||
* 3. ASSERT the result: the entry, the theme, every path in `exports`, the
|
||||
* five named components, and — the one that matters most — that nothing
|
||||
* outside `lib/adapter.js` talks to the network. The whole point of the
|
||||
* package is that a host's own adapter is the only way data arrives; a
|
||||
* stray `fetch` anywhere else is a screen that ignores it.
|
||||
*
|
||||
* Run by `npm run build:lib -w ui`. Exits non-zero on any failure.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const UI = fileURLToPath(new URL('../ui', import.meta.url));
|
||||
const DIST = join(UI, 'dist');
|
||||
|
||||
/**
|
||||
* The standalone viewer's shell — everything that is only reachable from
|
||||
* `main.ts`. Listed by hand rather than derived, because getting it wrong in
|
||||
* the derived direction (pruning something a component needs) is silent until
|
||||
* a host imports it.
|
||||
*/
|
||||
const APP_ONLY = [
|
||||
'main.js',
|
||||
'main.d.ts',
|
||||
'App.svelte',
|
||||
'App.svelte.d.ts',
|
||||
'app.css',
|
||||
'components/TopBar.svelte',
|
||||
'components/TopBar.svelte.d.ts',
|
||||
'lib/router.svelte.js',
|
||||
'lib/router.svelte.d.ts',
|
||||
];
|
||||
|
||||
/** Extensions that already resolve; anything else is rewritten to `<spec>.js`. */
|
||||
const RESOLVES = ['.js', '.mjs', '.cjs', '.json', '.css', '.svg', '.png'];
|
||||
|
||||
const fail = (message) => {
|
||||
console.error(`[check-ui-package] ${message}`);
|
||||
process.exitCode = 1;
|
||||
};
|
||||
|
||||
if (!existsSync(DIST)) {
|
||||
fail(`no ${relative(UI, DIST)} — run \`npm run build:lib -w ui\``);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 1. prune */
|
||||
|
||||
for (const entry of APP_ONLY) {
|
||||
const path = join(DIST, entry);
|
||||
if (existsSync(path)) rmSync(path, { recursive: true });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ walk it */
|
||||
|
||||
function* files(dir) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) yield* files(path);
|
||||
else yield path;
|
||||
}
|
||||
}
|
||||
|
||||
const all = [...files(DIST)];
|
||||
|
||||
/* ---------------------------------------------------------------- 2. resolve */
|
||||
|
||||
/**
|
||||
* `from './lib/adapter'` -> `from './lib/adapter.js'`, and
|
||||
* `from './lib/trail.svelte'` -> `from './lib/trail.svelte.js'` (the emitted
|
||||
* file for a `.svelte.ts` rune module).
|
||||
*
|
||||
* Driven by the filesystem rather than by the extension alone: `.svelte` is a
|
||||
* real file for a component and a compiled `.js` for a rune module, and only
|
||||
* looking is right for both.
|
||||
*/
|
||||
function resolveSpecifiers(source, fromFile) {
|
||||
return source.replace(
|
||||
/(\bfrom\s*|\bimport\s*\(\s*)(['"])(\.[^'"]*)\2/g,
|
||||
(match, head, quote, spec) => {
|
||||
if (RESOLVES.some((ext) => spec.endsWith(ext))) return match;
|
||||
const target = resolve(dirname(fromFile), spec);
|
||||
if (existsSync(target) && statSync(target).isFile()) return match;
|
||||
if (!existsSync(`${target}.js`)) return match;
|
||||
return `${head}${quote}${spec}.js${quote}`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let rewritten = 0;
|
||||
for (const path of all) {
|
||||
if (!/\.(js|d\.ts|svelte)$/.test(path)) continue;
|
||||
const before = readFileSync(path, 'utf8');
|
||||
const after = resolveSpecifiers(before, path);
|
||||
if (after !== before) {
|
||||
writeFileSync(path, after);
|
||||
rewritten += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- 3. assert */
|
||||
|
||||
const manifest = JSON.parse(readFileSync(join(UI, 'package.json'), 'utf8'));
|
||||
|
||||
// Every path the exports map promises has to be there. A missing one is a
|
||||
// package that installs cleanly and then fails at the consumer's first import.
|
||||
for (const [name, entry] of Object.entries(manifest.exports ?? {})) {
|
||||
const targets = typeof entry === 'string' ? [entry] : Object.values(entry);
|
||||
for (const target of targets) {
|
||||
if (!target.startsWith('./')) continue;
|
||||
if (!existsSync(join(UI, target))) fail(`exports["${name}"] -> ${target} is missing`);
|
||||
}
|
||||
}
|
||||
|
||||
// The five components the task names, plus the two seams they are useless
|
||||
// without. Checked in the emitted JS, so a rename in index.ts that misses a
|
||||
// component fails here rather than in the Pro app.
|
||||
const entry = existsSync(join(DIST, 'index.js'))
|
||||
? readFileSync(join(DIST, 'index.js'), 'utf8')
|
||||
: '';
|
||||
for (const name of [
|
||||
'SymbolView',
|
||||
'FlowStrip',
|
||||
'ArchitectureMap',
|
||||
'TrailBar',
|
||||
'SearchPalette',
|
||||
'CodegraphUi',
|
||||
'setGraphAdapter',
|
||||
'createHttpAdapter',
|
||||
'setNavigationDriver',
|
||||
]) {
|
||||
if (!new RegExp(`\\b${name}\\b`).test(entry)) fail(`dist/index.js does not export ${name}`);
|
||||
}
|
||||
|
||||
// Nothing the app dragged in survives. A component still importing one of the
|
||||
// pruned modules would resolve to nothing in a host.
|
||||
for (const path of all) {
|
||||
if (!existsSync(path)) continue;
|
||||
const text = readFileSync(path, 'utf8');
|
||||
for (const pruned of ['router.svelte', 'TopBar.svelte', 'app.css']) {
|
||||
const importing = new RegExp(`(from|import\\()\\s*['"][^'"]*${pruned}`);
|
||||
if (importing.test(text)) {
|
||||
fail(`${relative(DIST, path)} still imports ${pruned}, which is app-only`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The data seam. `lib/adapter.js` is the ONE place that may reach the network;
|
||||
// anywhere else means a screen that ignores the host's adapter.
|
||||
for (const path of all) {
|
||||
if (!existsSync(path) || !path.endsWith('.js')) continue;
|
||||
if (path.endsWith(join('lib', 'adapter.js'))) continue;
|
||||
const text = readFileSync(path, 'utf8')
|
||||
// Comments talk about `fetch` and `EventSource` on purpose; only code counts.
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|\s)\/\/[^\n]*/g, '');
|
||||
if (/\bnew EventSource\b|\bfetch\s*\(/.test(text)) {
|
||||
fail(`${relative(DIST, path)} reaches the network directly — it must go through the adapter`);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.exitCode) {
|
||||
console.error('[check-ui-package] FAILED');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const count = [...files(DIST)].length;
|
||||
console.log(
|
||||
`[check-ui-package] ok — ${count} files, ${rewritten} rewritten, ` +
|
||||
`${APP_ONLY.length} app-only pruned (v${manifest.version})`
|
||||
);
|
||||
@@ -125,3 +125,29 @@ VERSION="$VERSION" SCOPE="$SCOPE" TARGETS="${targets[*]}" \
|
||||
|
||||
echo "[pack-npm] ${SCOPE}/codegraph@${VERSION} (${#targets[@]} platform packages in optionalDependencies)"
|
||||
echo "[pack-npm] output: $NPM"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# @colbymchenry/codegraph-ui — the viewer's components as a Svelte library.
|
||||
#
|
||||
# Staged into release/npm-ui/, NOT release/npm/: the workflow publishes
|
||||
# `release/npm/codegraph-*` by glob, and a directory named codegraph-ui in
|
||||
# there would be swept into that loop the moment it existed.
|
||||
#
|
||||
# OFF by default. The package is prepared, versioned with the engine and
|
||||
# tested (CG-61), but publishing it is a decision the maintainer has not
|
||||
# made — and `ui/package.json` still carries `"private": true`, which is what
|
||||
# actually stops an accidental `npm publish`. Set CODEGRAPH_PACK_UI=1 to build
|
||||
# the tarball; publishing it additionally means removing that flag.
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "${CODEGRAPH_PACK_UI:-0}" = "1" ]; then
|
||||
UIREL="$REL/npm-ui"
|
||||
rm -rf "$UIREL"
|
||||
mkdir -p "$UIREL"
|
||||
( cd "$ROOT" && npm run build:lib --workspace ui )
|
||||
# `npm pack` honours "files" and works on a private package; `npm publish`
|
||||
# does not, which is exactly the guard we want to keep for now.
|
||||
( cd "$ROOT/ui" && npm pack --pack-destination "$UIREL" >/dev/null )
|
||||
echo "[pack-npm] ${SCOPE}/codegraph-ui@${VERSION} packed (not published) -> $UIREL"
|
||||
else
|
||||
echo "[pack-npm] skipping ${SCOPE}/codegraph-ui (set CODEGRAPH_PACK_UI=1 to pack it)"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Keep `@colbymchenry/codegraph-ui` on the engine's version number.
|
||||
*
|
||||
* The component package draws its screens from the engine's own JSON API, and
|
||||
* that API is versioned with the binary that serves it — a payload field can
|
||||
* appear or change shape in any engine release. So the two ship as one number:
|
||||
* `@colbymchenry/codegraph-ui@1.6.0` is the reader for `codegraph@1.6.0`, and a
|
||||
* host can pin them together without a compatibility table.
|
||||
*
|
||||
* This SYNCS rather than asserts, deliberately. The documented release flow is
|
||||
* "edit the version in package.json, run the Release workflow" — often as a
|
||||
* single-file edit in the GitHub web UI — and a check that failed the build
|
||||
* because a second file had not been edited would turn that into a two-step
|
||||
* dance for no gain. The same reasoning the workflow's package-lock sync step
|
||||
* already runs on.
|
||||
*
|
||||
* Idempotent: a re-run with the versions already equal writes nothing.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = fileURLToPath(new URL('../package.json', import.meta.url));
|
||||
const ui = fileURLToPath(new URL('../ui/package.json', import.meta.url));
|
||||
|
||||
const engineVersion = JSON.parse(readFileSync(root, 'utf8')).version;
|
||||
const raw = readFileSync(ui, 'utf8');
|
||||
const manifest = JSON.parse(raw);
|
||||
|
||||
if (manifest.version === engineVersion) {
|
||||
console.log(`[sync-ui-version] ui already at ${engineVersion}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// A targeted replacement, not a re-serialise: rewriting the whole file would
|
||||
// reformat a manifest a human maintains and bury the one-line change in noise.
|
||||
const next = raw.replace(
|
||||
/("version"\s*:\s*)"[^"]*"/,
|
||||
(_match, prefix) => `${prefix}"${engineVersion}"`
|
||||
);
|
||||
if (next === raw) {
|
||||
console.error('[sync-ui-version] could not find a "version" field in ui/package.json');
|
||||
process.exit(1);
|
||||
}
|
||||
writeFileSync(ui, next);
|
||||
console.log(`[sync-ui-version] ui ${manifest.version} -> ${engineVersion}`);
|
||||
Reference in New Issue
Block a user