Public: anyone can read this; the crew can change it.

A `package.json` opens as its manifest: name and version, scripts, dependencies with their ranges, engines #11275

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11208-a-package-json-opens-as-its-manifest-nam → main 8 commits fork 3 threads · 3 unresolved

  • Merged. scout/11208-a-package-json-opens-as-its-manifest-nam was merged into main as ea75315.

Review

Sign in to review this change.
9 files changed +278 −5
docs/design/areas/render.md +4
@@ -44,6 +44,10 @@ A .zip, .tar or .tar.gz is drawn by `src/web/render/archive-view.ts` as the exis
4444
4545 `src/web/render/env.ts` draws one existing `.table` (`data-dense`): name, example, comment, with group headings as full-width `th` rows. `.env-view` is only a hook on the wrapper and has no rule. How it works: `docs/features/file-env.md`.
4646
47 ## Package manifest (package.json)
48
49 `src/web/render/package-json.ts` draws an `h2`, a `muted small` facts line, then per section an `h3` and one existing `.table` (`data-dense`) in a `.table-scroll`; commands and ranges are `code.break`. `.package-view` is only a hook and has no rule. How it works: `docs/features/file-package-json.md`.
50
4751 ## OpenAPI operations (openapi.json, *.openapi.yaml)
4852
4953 `src/web/render/openapi.ts` draws one `h3` per tag and under it one existing `.table` (`data-dense`) of method, path, summary and a `deprecated` mark; the cap line is a `muted small` paragraph. `.openapi-view` is only a hook on the wrapper and has no rule. How it works: `docs/features/file-openapi.md`.
docs/features/file-package-json.md added +11
@@ -0,0 +1,11 @@
1 # A `package.json` opens as its manifest
2
3 A file named exactly `package.json` opens as rows instead of raw text: name and version, one line of facts (`type`, `engines`, license, package manager), then **Scripts** (name, command in mono), **Dependencies**, **Dev dependencies**, and peer/optional dependencies, each with its range. Source is one tap away (`?view=source`). `package-lock.json` and other `.json` keep the config form.
4
5 - Malformed JSON, JSON that is not an object, or a manifest with no name, facts or sections keeps the source view with no banner.
6 - At most 500 rows per section, then `N more not shown`. Files over 1 MB keep Source.
7 - Every cell is escaped; drawn on the server; no script, no new CSS (`.package-view` is a hook with no rule). Commands and ranges are `code.break` (wrap anywhere) in the existing `.table-scroll`, so a phone never gets a wide page.
8
9 **Terminal:** the workspace opens the same rows (`packageTable` in `src/tui/screens/workspace.ts`, over the same `parsePackage`); `s` flips to the source.
10
11 Code: `src/web/render/package-json.ts`, `src/web/render/kinds.ts`. Tests: `test/render-package-json.test.ts`, `test/tui-workspace.test.ts`.
src/tui/screens/workspace.ts +20
@@ -69,6 +69,7 @@ import { RECORD_CAP, parseLcov, worstFirst, type Ratio } from "../../web/render/
6969 import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts";
7070 import { fileSize } from "../../web/ui.ts";
7171 import { parseEml } from "../../web/render/eml-parse.ts";
72 import { parsePackage, ROW_CAP as PKG_ROW_CAP } from "../../web/render/package-json.ts";
7273 import { parseEnv, ROW_CAP as ENV_ROW_CAP } from "../../web/render/env.ts";
7374 import { CARD_CAP, parseVcf } from "../../web/render/vcf-parse.ts";
7475 import { CUE_CAP, parseSubtitles } from "../../web/render/subtitles.ts";
@@ -390,6 +391,22 @@ function envTable(source: string): Table | null {
390391 return { header: ["Name", "Example", "Comment"], rows, more: doc.total - doc.rows.length, delimiter: ",", columns: 3 };
391392 }
392393
394 /** A `package.json` as Section/Name/Value rows; null when it is not a JSON object. Shares the web's parser. */
395 function packageTable(source: string): Table | null {
396 const doc = parsePackage(source);
397 if (doc === undefined) return null;
398 const rows: string[][] = [];
399 if (doc.name !== "") rows.push(["package", doc.name, doc.version]);
400 if (doc.facts.length > 0) rows.push(["facts", doc.facts.join(" · "), ""]);
401 let more = 0;
402 for (const s of doc.sections) {
403 for (const [k, v] of s.rows.slice(0, PKG_ROW_CAP)) rows.push([s.title, k, v]);
404 more += s.total - s.rows.length;
405 }
406 if (rows.length === 0) return null;
407 return { header: ["Section", "Name", "Value"], rows, more, delimiter: ",", columns: 3 };
408 }
409
393410 /** `.eml`: a message, drawn as rows of its headers, body and attachments. */
394411 function isEmlPath(path: string): boolean {
395412 return /\.eml$/i.test(path);
@@ -770,6 +787,9 @@ class Tab {
770787 } else if (isEnvExample(this.path)) {
771788 this.table = envTable(new TextDecoder().decode(source));
772789 if (this.table !== null) this.grid = layoutTable(this.table);
790 } else if (kindByName(this.path) === "package") {
791 this.table = packageTable(new TextDecoder().decode(source));
792 if (this.table !== null) this.grid = layoutTable(this.table);
773793 } else if (isEmlPath(this.path)) {
774794 this.table = emlTable(source);
775795 if (this.table !== null) this.grid = layoutTable(this.table);
src/web/render/kinds.ts +7
@@ -42,6 +42,7 @@ export type FileKind =
4242 | "junit"
4343 | "env"
4444 | "openapi"
45 | "package"
4546 | "makefile"
4647 | "dockerfile"
4748 | "workflow"
@@ -86,6 +87,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
8687 junit: 5 * MB,
8788 env: 1 * MB,
8889 openapi: 1 * MB,
90 package: 1 * MB,
8991 makefile: 1 * MB,
9092 dockerfile: 1 * MB,
9193 workflow: 1 * MB,
@@ -223,6 +225,9 @@ export const isEnvExample = (path: string): boolean =>
223225 /** `openapi.json|yaml|yml` and `*.openapi.*`: the name of an OpenAPI document; its content decides whether it is drawn as one. */
224226 const isOpenApiName = (path: string): boolean => /(^|\/|\.)openapi\.(json|ya?ml)$/i.test(path);
225227
228 /** A manifest is named exactly `package.json`; `package-lock.json` and the rest stay config. */
229 const isPackageName = (path: string): boolean => /(^|\/)package\.json$/.test(path);
230
226231 /** `Makefile`, `GNUmakefile` and `*.mk`, by name. */
227232 const isMakefileName = (path: string): boolean => /(^|\/)(makefile|gnumakefile|[^/]+\.mk)$/i.test(path);
228233 /** `Dockerfile`, `Containerfile` and `*.Dockerfile`, in any case. */
@@ -303,6 +308,7 @@ export function fileKind(path: string, head: Uint8Array): FileKind {
303308 if (isEnvExample(path)) return "env";
304309 if (isJunit(path, head)) return "junit";
305310 if (isOpenApiName(path)) return "openapi";
311 if (isPackageName(path)) return "package";
306312 if (isMakefileName(path)) return "makefile";
307313 if (isDockerfileName(path)) return "dockerfile";
308314 if (isWorkflowName(path)) return "workflow";
@@ -322,6 +328,7 @@ export function kindByName(path: string): FileKind {
322328 if (isLcovName(path)) return "lcov";
323329 if (isEnvExample(path)) return "env";
324330 if (isOpenApiName(path)) return "openapi";
331 if (isPackageName(path)) return "package";
325332 if (isMakefileName(path)) return "makefile";
326333 if (isDockerfileName(path)) return "dockerfile";
327334 if (isWorkflowName(path)) return "workflow";
src/web/render/package-json.ts added +107
@@ -0,0 +1,107 @@
1 /**
2 * A `package.json` as its manifest: name and version, the facts in one line
3 * (`type`, `engines`, `license`), then scripts, dependencies, devDependencies
4 * and peer/optional dependencies as rows. Source is one tap away.
5 * Pure over the text; a file that is not a JSON object is not drawn at all
6 * (`parsePackage` returns undefined and `rendererFor` declines it: the source view, no banner);
7 * so is a manifest with nothing to list.
8 * Every cell is escaped by the template; at most `ROW_CAP` rows per section.
9 */
10
11 import { html, type Html } from "../html.ts";
12 import { fileSize } from "../ui.ts";
13 import type { Rendered, RenderInput } from "./renderers.ts";
14
15 export const ROW_CAP = 500;
16
17 export interface PackageSection {
18 readonly title: string;
19 /** Name and value (a command, or a version range), in the file's order. */
20 readonly rows: readonly (readonly [string, string])[];
21 /** Entries in the file, drawn or not. */
22 readonly total: number;
23 }
24
25 export interface PackageDoc {
26 readonly name: string;
27 readonly version: string;
28 readonly description: string;
29 /** `type: module`, `node >=20`, `MIT`: the one-line facts. */
30 readonly facts: readonly string[];
31 readonly sections: readonly PackageSection[];
32 }
33
34 const isObject = (v: unknown): v is Record<string, unknown> =>
35 typeof v === "object" && v !== null && !Array.isArray(v);
36 const str = (v: unknown): string => (typeof v === "string" ? v : "");
37
38 /** The string-valued entries of an object field; anything else is skipped. */
39 function section(title: string, v: unknown): PackageSection | undefined {
40 if (!isObject(v)) return undefined;
41 const all = Object.entries(v).filter((e): e is [string, string] => typeof e[1] === "string");
42 if (all.length === 0) return undefined;
43 return { title, rows: all.slice(0, ROW_CAP), total: all.length };
44 }
45
46 /** Read a manifest. Pure; never throws; undefined when the text is not a JSON object. */
47 export function parsePackage(source: string): PackageDoc | undefined {
48 let raw: unknown;
49 try {
outdated

Literal BOM character in a regex: invisible in review. Use /^\uFEFF/.

50 raw = JSON.parse(source.replace(/^\uFEFF/, ""));
51 } catch {
52 return undefined;
53 }
54 if (!isObject(raw)) return undefined;
55 const facts: string[] = [];
56 if (typeof raw["type"] === "string") facts.push(`type ${raw["type"]}`);
57 if (isObject(raw["engines"]))
58 for (const [k, v] of Object.entries(raw["engines"])) if (typeof v === "string") facts.push(`${k} ${v}`);
59 if (typeof raw["license"] === "string") facts.push(raw["license"]);
60 if (typeof raw["packageManager"] === "string") facts.push(raw["packageManager"]);
61 const sections = [
62 section("Scripts", raw["scripts"]),
63 section("Dependencies", raw["dependencies"]),
64 section("Dev dependencies", raw["devDependencies"]),
65 section("Peer dependencies", raw["peerDependencies"]),
66 section("Optional dependencies", raw["optionalDependencies"]),
67 ].filter((s): s is PackageSection => s !== undefined);
68 const name = str(raw["name"]);
69 // Nothing to show as rows: the source view, on the web and in the terminal alike.
70 if (name === "" && facts.length === 0 && sections.length === 0) return undefined;
71 return { name, version: str(raw["version"]), description: str(raw["description"]), facts, sections };
72 }
73
74 /**
75 * The manifest in a file's bytes, parsed once per body: `rendererFor` asks it
76 * whether to draw at all (no: the source view, with no banner), and the
outdated

Principle 8/box 3: the fallback carries a message, so the source view shows a banner. The issue and your own doc say no banner. Drop the message.

77 * renderer is then handed the same bytes and reads the same answer.
78 */
79 const parsed = new WeakMap<Uint8Array, PackageDoc | null>();
80 export function readPackage(body: Uint8Array): PackageDoc | undefined {
81 let doc = parsed.get(body);
82 if (doc === undefined) {
83 doc = parsePackage(new TextDecoder().decode(body)) ?? null;
84 parsed.set(body, doc);
85 }
86 return doc ?? undefined;
87 }
88
89 export function packageJson(input: RenderInput): Rendered {
90 const doc = readPackage(input.body);
91 // Only reached when called directly: `rendererFor` declines such a file first.
92 if (doc === undefined) return { content: html``, lines: 0, fallback: "This file is not a JSON object to draw." };
93 const scripts = (s: PackageSection): boolean => s.title === "Scripts";
94 const blocks: Html[] = doc.sections.map((s) => {
95 const rows = s.rows.map(([k, v]) =>
96 scripts(s)
97 ? html`<tr><th scope="row"><code>${k}</code></th><td><code>${v}</code></td></tr>`
98 : html`<tr><th scope="row" class="break">${k}</th><td><code class="break">${v}</code></td></tr>`,
99 );
100 const more = s.total - s.rows.length;
101 return html`<h3>${s.title}</h3><div class="table-scroll"><table class="table" data-dense aria-label="${s.title}"><thead><tr><th scope="col">Name</th><th scope="col">${scripts(s) ? "Command" : "Range"}</th></tr></thead><tbody>${rows}</tbody></table></div>${more > 0 ? html`<p class="muted small">${more} more not shown</p>` : html``}`;
102 });
103 const title = doc.name === "" ? "package.json" : doc.version === "" ? doc.name : `${doc.name} ${doc.version}`;
104 const content = html`<div class="package-view"><h2>${title}</h2>${doc.description !== "" ? html`<p>${doc.description}</p>` : html``}${doc.facts.length > 0 ? html`<p class="muted small">${doc.facts.join(" · ")}</p>` : html``}${blocks}</div>`;
105 const n = doc.sections.reduce((a, s) => a + s.total, 0);
106 return { content, lines: 0, summary: `${n} ${n === 1 ? "entry" : "entries"} · ${fileSize(input.size)}` };
107 }
src/web/render/renderers.ts +8 −4
@@ -29,6 +29,7 @@ import { makefile } from "./makefile.ts";
2929 import { markdown } from "./markdown.ts";
3030 import { model3d } from "./model3d.ts";
3131 import { notebook } from "./notebook.ts";
32 import { packageJson, readPackage } from "./package-json.ts";
3233 import { patch } from "./patch.ts";
3334 import { pdf } from "./pdf.ts";
3435 import { dockerfile } from "./dockerfile.ts";
@@ -106,6 +107,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
106107 env: envView,
107108 sql: sqlView,
108109 openapi,
110 package: packageJson,
109111 makefile,
110112 dockerfile,
111113 workflow,
@@ -157,10 +159,12 @@ export function rendererFor(
157159 // A kind may draw only some of its files: `config` draws JSON, YAML and
158160 // TOML as a form, and leaves the rest (INI, .env …) their source.
159161 // A log with no escape in it keeps the source view, exactly as plain text.
160 const render =
161 (kind === "config" && !isFormConfig(path)) || (kind === "log" && blob.kind !== "too_large" && !hasEscape(blob.body))
162 ? undefined
163 : renderers[kind];
162 // A package.json that is no manifest (malformed, or nothing to list) too.
163 const declined =
outdated

Parses the whole file here and again in packageJson. Extract a helper, or let the renderer decline once, and break up this line.

164 (kind === "config" && !isFormConfig(path)) ||
165 (kind === "log" && blob.kind !== "too_large" && !hasEscape(blob.body)) ||
166 (kind === "package" && blob.kind !== "too_large" && readPackage(blob.body) === undefined);
167 const render = declined ? undefined : renderers[kind];
164168 if (render === undefined) return undefined;
165169 return { render, body: blob.kind === "too_large" ? NO_BYTES : blob.body };
166170 }
test/render-openapi.test.ts +1 −1
@@ -27,7 +27,7 @@ describe("openapi", () => {
2727 expect(fileKind(p, enc("{}"))).toBe("openapi");
2828 expect(kindByName(p)).toBe("openapi");
2929 }
30 expect(fileKind("package.json", enc("{}"))).toBe("config");
30 expect(fileKind("tsconfig.json", enc("{}"))).toBe("config");
3131 expect(fileKind("myopenapi.json", enc("{}"))).toBe("config");
3232 expect(RENDERERS.openapi).toBe(openapi);
3333 });
test/render-package-json.test.ts added +102
@@ -0,0 +1,102 @@
1 import { describe, expect, test } from "bun:test";
2 import { packageJson, parsePackage, ROW_CAP } from "../src/web/render/package-json.ts";
3 import { fileKind, kindByName } from "../src/web/render/kinds.ts";
4 import { RENDERERS, rendererFor } from "../src/web/render/renderers.ts";
5 import { renderBlob } from "../src/web/pages/repo.ts";
6 import type { RepoScope } from "../src/web/scope.ts";
7
8 const enc = (s: string) => new TextEncoder().encode(s);
9 const draw = (text: string) => {
10 const body = enc(text);
11 const r = packageJson({ path: "package.json", size: body.length, body, media: "", download: "" });
12 return { html: String(r.content), summary: r.summary, fallback: r.fallback };
13 };
14 const MANIFEST = JSON.stringify({
15 name: "demo", version: "1.2.3", type: "module", engines: { node: ">=20" },
16 scripts: { test: "bun test", build: "<script>alert(1)</script>" },
17 dependencies: { hono: "^4.0.0" }, devDependencies: { typescript: "~5.4" },
18 });
19
20 describe("package.json", () => {
21 test("the registry names exactly package.json", () => {
22 expect(fileKind("package.json", enc("{}"))).toBe("package");
23 expect(fileKind("apps/web/package.json", enc("{}"))).toBe("package");
24 expect(kindByName("package.json")).toBe("package");
25 expect(fileKind("package-lock.json", enc("{}"))).toBe("config");
26 expect(fileKind("mypackage.json", enc("{}"))).toBe("config");
27 expect(RENDERERS.package).toBe(packageJson);
28 });
29 test("rows for scripts, dependencies with ranges, and the facts line", () => {
30 const h = draw(MANIFEST).html;
31 expect(h).toContain("demo 1.2.3");
32 expect(h).toContain("type module · node &gt;=20");
33 expect(h).toContain("<code>test</code></th><td><code>bun test</code>");
34 expect(h).toContain("hono");
35 expect(h).toContain("^4.0.0");
36 expect(h).toContain("Dev dependencies");
37 expect(h).toContain("~5.4");
38 });
39 test("every value is escaped", () => {
40 const h = draw(MANIFEST).html;
41 expect(h).not.toContain("<script>");
42 expect(h).toContain("&lt;script&gt;");
43 expect(draw('{"name":"<img src=x>"}').html).not.toContain("<img");
44 });
45 test("malformed JSON, a non-object or an empty manifest keep the source view", () => {
46 for (const bad of ["{ nope", "[1]", "{}", '{"description":"x"}']) {
47 expect(parsePackage(bad)).toBeUndefined();
48 const body = enc(bad);
49 expect(rendererFor("package.json", { kind: "text", body, size: body.length } as never)).toBeUndefined();
50 }
51 const body = enc(MANIFEST);
52 expect(rendererFor("package.json", { kind: "text", body, size: body.length } as never)?.render).toBe(packageJson);
53 expect(draw(MANIFEST).fallback).toBeUndefined();
54 });
55 test("a BOM is stripped", () => {
56 expect(parsePackage("\uFEFF" + MANIFEST)?.name).toBe("demo");
57 });
58 test("facts: license and packageManager; peer and optional sections", () => {
59 const doc = parsePackage(JSON.stringify({
60 name: "x", license: "MIT", packageManager: "[email protected]",
61 peerDependencies: { react: "^18 || ~19" }, optionalDependencies: { fsevents: "*" },
62 }));
63 expect(doc?.facts).toEqual(["MIT", "[email protected]"]);
64 const h = draw('{"name":"x","peerDependencies":{"react":"^18 || ~19"},"optionalDependencies":{"fsevents":"*"}}').html;
65 expect(h).toContain("Peer dependencies");
66 expect(h).toContain("^18 || ~19");
67 expect(h).toContain("Optional dependencies");
68 expect(h).toContain("fsevents");
69 });
70 test("ranges and commands are wrapping code inside a scrolling wrapper, not numbers", () => {
71 const h = draw(MANIFEST).html;
72 expect(h).toContain('<code class="break">^4.0.0</code>');
73 expect(h).toContain('class="table-scroll"');
74 expect(h).not.toContain('class="num"');
75 });
76 test("cap per section", () => {
77 const deps = Object.fromEntries(Array.from({ length: ROW_CAP + 3 }, (_, i) => [`p${i}`, "1"]));
78 expect(draw(JSON.stringify({ dependencies: deps })).html).toContain("3 more not shown");
79 });
80 });
81
82 describe("package.json on the blob page", () => {
83 const scope = { base: "/o/r", facts: { name: "r" }, caps: { write: false }, kind: "local" } as unknown as RepoScope;
84 const page = (text: string) => {
85 const body = enc(text);
86 return String(renderBlob(scope, { refs: { branches: ["main"], tags: [] }, ref: "main", path: "package.json", blob: { kind: "text", body, size: body.length } }));
87 };
88 test("a malformed manifest is its source, with no banner and no Rendered tab", () => {
89 for (const bad of ['{ "name": "x",', "{}"]) {
90 const h = page(bad);
91 expect(h).not.toContain("Showing the source");
92 expect(h).not.toContain("package-view");
93 expect(h).not.toMatch(/>\s*Rendered\s*</);
94 expect(h).toContain('id="L1"');
95 }
96 });
97 test("a manifest is drawn, Rendered current", () => {
98 const h = page(MANIFEST);
99 expect(h).toContain("package-view");
100 expect(h).toMatch(/<[^>]*aria-current[^>]*>\s*Rendered/);
101 });
102 });
test/tui-workspace.test.ts +18
@@ -719,6 +719,24 @@ describe("the terminal workspace", () => {
719719 }
720720 });
721721
722 test("a package.json opens as rows of scripts and dependencies, and s flips to the source", async () => {
723 const made = await createRepo(forge, alice, { name: "pkgrepo", visibility: "public", description: "tests" });
724 if (!made.ok) throw new Error("repo failed");
725 await commitToBranch(forge, alice, made.value, { message: "seed", files: [{ path: "package.json", content: '{"name":"demo","scripts":{"test":"bun test"},"dependencies":{"hono":"^4.0.0"}}' }] });
726 const h = boot();
727 try {
728 await h.settle();
729 await open(h, "pkgrepo");
730 await openFile(h, "package.json");
731 expect(h.text()).toContain("Dependencies");
732 expect(h.text()).toContain("^4.0.0");
733 await h.press("s");
734 expect(h.text()).toContain('"name":"demo"');
735 } finally {
736 h.stop();
737 }
738 });
739
722740 test("a JUnit-named file that is not a report opens as source and says why", async () => {
723741 const made = await createRepo(forge, alice, { name: "badjunit", visibility: "public", description: "tests" });
724742 if (!made.ok) throw new Error("repo failed");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs