diff --git a/docs/cli/view.mdx b/docs/cli/view.mdx index 4837b343..b956a3d3 100644 --- a/docs/cli/view.mdx +++ b/docs/cli/view.mdx @@ -131,6 +131,10 @@ For the per-count detail behind the verdict, open `#/health`. To narrow the conc **Page metadata.** Each page shows its frontmatter fields - kind, contributing sources, confidence score, provenance state, creation and update timestamps - in the metadata rail on the right of the page. +**A typed entity page shows the fields its own type declares**, above that list and in the order the profile declares them. Values render by their declared type: an array becomes a list, an `enum` becomes a state chip, a boolean reads as Yes or No, and a field carrying a [`format`](/configuration/profiles#field-formats) becomes an external link. A field the record does not carry is left out rather than shown empty, and an `artifactRef` is named but marked unverified - the viewer does not yet read the artifact store to check it. + +Those fields lead because the list below them is the default profile's vocabulary and describes a contract a typed page was never under. Anything that list would have shown still shows, minus any key the profile declares as a field of its own - so nothing disappears and nothing is stated twice. + **Freshness badges.** Pages whose underlying sources have changed since the last compile are labelled `STALE`. Pages whose sources were all deleted are labelled `ORPHANED`. Pages that declare contradictions in their frontmatter are labelled `CONTRADICTED`. Archived candidates show an `ARCHIVED` badge. **Provenance and citation chips.** Each paragraph's `^[source.md]` citation renders as a clickable chip. On loopback (`127.0.0.1`), citation chips include an editor link that opens the source file at the relevant line range directly in your default editor. Specific claim citations (`^[source.md:42-58]`) pin to the exact line range. diff --git a/docs/configuration/profiles.mdx b/docs/configuration/profiles.mdx index ae3d29f4..9b177617 100644 --- a/docs/configuration/profiles.mdx +++ b/docs/configuration/profiles.mdx @@ -76,6 +76,34 @@ Supported field types: Fields can be required with `required: true`, or by listing them in the entity's `requiredFields` array. Both forms are enforced on typed page writes. +### Field formats + +`format` tells a read surface how to linkify a field's text. It is valid only on +`string` and `string[]`, and takes one of three values: + +| Format | Renders as | +|---|---| +| `url` | The value itself, linked — only when it parses as an absolute `http`/`https` URL | +| `doi` | A link through `https://doi.org/` | +| `arxiv` | A link through `https://arxiv.org/abs/` | + +```json +{ "doi": { "type": "string", "format": "doi" } } +``` + +The vocabulary is closed and the resolver origins are fixed — a profile names a +resolver the reader already knows rather than supplying a URL template, so a +profile can never point a reader at an origin of its choosing. An unknown format +is rejected at load, and a value that does not match its format's grammar renders +as plain text rather than as a link built anyway. + + + Adding `format` to an existing profile changes that profile's digest, and its + template's digest if it is a builtin. It does **not** affect in-flight workflow + runs: run classification compares the digest of the individual workflow + definition, not the profile's. + + ### Page titles `titleField` names the frontmatter key an entity type carries its display title diff --git a/src/profile/schema/profile.v1.schema.json b/src/profile/schema/profile.v1.schema.json index e3d4cb8b..cf1eb945 100644 --- a/src/profile/schema/profile.v1.schema.json +++ b/src/profile/schema/profile.v1.schema.json @@ -67,6 +67,7 @@ "type": { "$ref": "#/$defs/fieldType" }, "required": { "type": "boolean" }, "default": {}, + "format": { "enum": ["url", "doi", "arxiv"] }, "artifactTypes": { "type": "array", "items": { "type": "string" } }, "enum": { "type": "array", "items": { "type": "string" } }, "min": { "type": "number" }, diff --git a/src/profile/templates/builtin/autosci.ts b/src/profile/templates/builtin/autosci.ts index 7e8009e4..795b9039 100644 --- a/src/profile/templates/builtin/autosci.ts +++ b/src/profile/templates/builtin/autosci.ts @@ -8,12 +8,12 @@ import { autosciArtifacts } from "./autosci/artifacts.js"; import { autosciEntities } from "./autosci/entities.js"; import { autosciRelations } from "./autosci/relations.js"; import { autosciWorkflowActions, autosciWorkflows } from "./autosci/workflows.js"; -import { withoutTitleFields } from "../title-fields.js"; +import { withoutFieldFormats, withoutTitleFields } from "../prior-releases.js"; const profile: ProfilePack = { schemaVersion: 1, profileId: "autosci", - profileVersion: "0.2.0", + profileVersion: "0.3.0", displayName: "AutoSci", entities: autosciEntities, relations: autosciRelations, @@ -29,40 +29,56 @@ const profile: ProfilePack = { workflowActions: autosciWorkflowActions, }; +const DESCRIPTION = + "AutoSci-style research profile with papers, ideas, experiments, manuscripts, artifacts, workflows, and Crossref import."; + +/** One superseded AutoSci release, built from the current pack's shared envelope. */ +function priorRelease(version: string, entities: ProfilePack["entities"]): ProfileTemplatePackage { + return { + schemaVersion: 1, + templateId: "autosci", + version, + displayName: "AutoSci", + publisher: "atomicstrata", + sourceType: "builtin", + license: "MIT", + minLlmwikiVersion: "1.0.0", + description: DESCRIPTION, + profile: { ...profile, profileVersion: version, entities }, + }; +} + /** - * The superseded `0.1.0` release, retained so secure update planning can resolve - * the EXACT installed release rather than reinterpreting it as the newest - * package sharing its template id. Without it, `planBuiltinTemplateUpdate` - * throws for every project still on `0.1.0`. Its entity block is `0.2.0` minus - * the title declarations — see {@link withoutTitleFields}. + * Every superseded release, retained so secure update planning can resolve the + * EXACT installed release rather than reinterpreting it as the newest package + * sharing its template id. Without them, `planBuiltinTemplateUpdate` throws for + * every project still on one. + * + * Each entity block is DERIVED from the current one by undoing exactly the + * change that release did not have, and the derivations compose backwards: + * `0.2.0` is `0.3.0` without the field formats, and `0.1.0` is that without the + * title declarations. Each published digest is pinned in + * `test/profile-template-releases.test.ts`, so a later edit that corrupts + * a derivation fails there rather than silently mis-describing an installed + * project. See {@link withoutFieldFormats}. */ -const AUTOSCI_TEMPLATE_0_1_0: ProfileTemplatePackage = { - schemaVersion: 1, - templateId: "autosci", - version: "0.1.0", - displayName: "AutoSci", - publisher: "atomicstrata", - sourceType: "builtin", - license: "MIT", - minLlmwikiVersion: "1.0.0", - description: - "AutoSci-style research profile with papers, ideas, experiments, manuscripts, artifacts, workflows, and Crossref import.", - profile: { ...profile, profileVersion: "0.1.0", entities: withoutTitleFields(autosciEntities) }, -}; +const AUTOSCI_0_2_0_ENTITIES = withoutFieldFormats(autosciEntities); -/** Every published AutoSci release, newest last. */ -export const AUTOSCI_TEMPLATE_RELEASES: readonly ProfileTemplatePackage[] = [AUTOSCI_TEMPLATE_0_1_0]; +export const AUTOSCI_TEMPLATE_RELEASES: readonly ProfileTemplatePackage[] = [ + priorRelease("0.1.0", withoutTitleFields(AUTOSCI_0_2_0_ENTITIES)), + priorRelease("0.2.0", AUTOSCI_0_2_0_ENTITIES), +]; /** Builtin install package for AutoSci-derived research projects. */ export const AUTOSCI_TEMPLATE: ProfileTemplatePackage = { schemaVersion: 1, templateId: "autosci", - version: "0.2.0", + version: "0.3.0", displayName: "AutoSci", publisher: "atomicstrata", sourceType: "builtin", license: "MIT", minLlmwikiVersion: "1.0.0", - description: "AutoSci-style research profile with papers, ideas, experiments, manuscripts, artifacts, workflows, and Crossref import.", + description: DESCRIPTION, profile, }; diff --git a/src/profile/templates/builtin/autosci/entities.ts b/src/profile/templates/builtin/autosci/entities.ts index bfbaadd4..35937e99 100644 --- a/src/profile/templates/builtin/autosci/entities.ts +++ b/src/profile/templates/builtin/autosci/entities.ts @@ -15,8 +15,8 @@ export const autosciEntities = { authors: { type: "string[]", required: true }, year: { type: "integer" }, venue: { type: "string" }, - doi: { type: "string" }, - arxivId: { type: "string" }, + doi: { type: "string", format: "doi" }, + arxivId: { type: "string", format: "arxiv" }, triageNote: { type: "string" }, distilledSummary: { type: "string" }, stage: { type: "enum", enum: ["imported", "triaged", "distilled"], required: true }, @@ -37,7 +37,7 @@ export const autosciEntities = { fields: { title: { type: "string" }, kind: { type: "enum", enum: ["paper", "repo", "video", "web"] }, - locator: { type: "string" }, + locator: { type: "string", format: "url" }, stage: { type: "enum", enum: ["imported", "triaged"] }, }, lifecycle: { diff --git a/src/profile/templates/builtin/newsroom.ts b/src/profile/templates/builtin/newsroom.ts index 6c99241b..58af07bc 100644 --- a/src/profile/templates/builtin/newsroom.ts +++ b/src/profile/templates/builtin/newsroom.ts @@ -4,7 +4,7 @@ */ import type { ProfilePack } from "../../types.js"; import type { ProfileTemplatePackage } from "../types.js"; -import { withoutTitleFields } from "../title-fields.js"; +import { withoutTitleFields } from "../prior-releases.js"; export const newsroomEntities = { articles: { diff --git a/src/profile/templates/title-fields.ts b/src/profile/templates/prior-releases.ts similarity index 58% rename from src/profile/templates/title-fields.ts rename to src/profile/templates/prior-releases.ts index 215cd945..c8ed2af3 100644 --- a/src/profile/templates/title-fields.ts +++ b/src/profile/templates/prior-releases.ts @@ -1,6 +1,6 @@ /** - * @file src/profile/templates/title-fields.ts - * @description Deriving a builtin template's PRE-`titleField` release. + * @file src/profile/templates/prior-releases.ts + * @description Deriving a builtin template's PRIOR releases from its current one. * * Lives OUTSIDE `builtin/` on purpose. That directory is the template-DATA * allowlist `test/profile-template-genericity.test.ts` enumerates — the only @@ -44,3 +44,36 @@ export function withoutTitleFields(entities: ProfilePack["entities"]): ProfilePa }), ); } + +/** + * The same entity block with every field's `format` declaration removed. + * + * The second such derivation, and it composes with the first: a release two + * versions back is `withoutTitleFields(withoutFieldFormats(current))`. Each is a + * single named change, so the composition reads as the changelog it is. + * + * @param entities - The CURRENT release's entity block. + * @returns The block as the pre-`format` release published it. + */ +export function withoutFieldFormats(entities: ProfilePack["entities"]): ProfilePack["entities"] { + return Object.fromEntries( + Object.entries(entities).map(([type, def]) => { + // Spread-then-overwrite would give a field-less type an explicit + // `fields: undefined`, i.e. a KEY the published release did not have. + // Canonicalization happens to drop it, but a retained release should be + // shaped like what it published rather than rely on that. + if (def.fields === undefined) return [type, def]; + return [type, { ...def, fields: strippedFields(def.fields) }]; + }), + ); +} + +/** A field map with each field's `format` declaration removed. */ +function strippedFields(fields: Record): Record { + return Object.fromEntries( + Object.entries(fields).map(([name, field]) => { + const { format: _dropped, ...rest } = field; + return [name, rest]; + }), + ); +} diff --git a/src/profile/types.ts b/src/profile/types.ts index 388fa8ce..c71dff81 100644 --- a/src/profile/types.ts +++ b/src/profile/types.ts @@ -36,9 +36,28 @@ export type FieldType = | "artifactRef" | "artifactRef[]"; +/** + * Declarative PRESENTATION hints for a text field: enough for a read surface to + * build an external link generically, and nothing more. + * + * A CLOSED vocabulary rather than a URL template. A template would be an + * author-supplied string a renderer interpolates into an href — executable + * profile behaviour reaching a read surface — whereas these three name a + * resolver the READER already knows, so the origin stays out of profile control. + * An unknown value is rejected at load, so no renderer has to guess. + */ +export type FieldFormat = "url" | "doi" | "arxiv"; + /** Declarative definition of a single frontmatter field on an entity type. */ export interface FieldDef { type: FieldType; + /** + * How a read surface may linkify this field's text. Valid only on `string` and + * `string[]` (see `validateTitleField`'s neighbour in validate.ts): a format is + * a hint about how to read text, and on any other type it would be config no + * renderer could act on. + */ + format?: FieldFormat; required?: boolean; default?: unknown; enum?: string[]; diff --git a/src/profile/validate.ts b/src/profile/validate.ts index 4ddf51a1..9502aec5 100644 --- a/src/profile/validate.ts +++ b/src/profile/validate.ts @@ -183,6 +183,38 @@ function validateRequiredFields(entityType: string, def: EntityTypeDef): void { } } +/** + * Field types a declarative `format` can describe. A format is a hint about how + * to read TEXT, so on a boolean, number, date, enum or artifactRef it would be + * config no renderer could act on — rejected at load rather than ignored later. + */ +const FORMATTABLE_TYPES: ReadonlySet = new Set(["string", "string[]"]); + +/** + * A declared `format` must sit on a field whose type it can describe. + * + * Applied to EVERY `FieldDef` carrier, not just entity fields: the schema + * resolves relation `attributes` and artifact `metadata` to the same + * `$defs/fieldDef`, so all three would otherwise accept a format the contract + * says is invalid — and artifact metadata is projected onto the wire. Same + * placement discipline as {@link assertArtifactTypesScoped}, which is wired into + * all three for the same reason. + */ +function assertFieldFormatScoped(where: string, name: string, field: FieldDef): void { + if (field.format === undefined) return; + assert( + FORMATTABLE_TYPES.has(field.type), + `${where} field '${name}': format '${field.format}' is not valid on type '${field.type}'`, + ); +} + +/** Every declared `format` on an entity's fields must describe a text type. */ +function validateFieldFormats(entityType: string, def: EntityTypeDef): void { + for (const [name, field] of Object.entries(def.fields ?? {})) { + assertFieldFormatScoped(`entity '${entityType}'`, name, field); + } +} + /** * `titleField` must name a DECLARED field that can actually hold a title. * @@ -365,6 +397,7 @@ function validateEntity(entityType: string, def: EntityTypeDef, declaredArtifact validateArtifactTypesScope(entityType, def, declaredArtifactTypes); validateRequiredFields(entityType, def); validateTitleField(entityType, def); + validateFieldFormats(entityType, def); validateContentTiers(entityType, def); const warnings = def.lifecycle ? validateLifecycle(entityType, def.lifecycle, def.fields) : []; return { canonicalDirectory, warnings }; @@ -405,6 +438,7 @@ function validateRelationAttributes(rel: string, def: RelationTypeDef, declaredA for (const [name, field] of Object.entries(attributes)) { assertFieldDefFinite(`relation '${rel}' attribute '${name}'`, field); assertArtifactTypesScoped(`relation '${rel}'`, name, field, declaredArtifactTypes); + assertFieldFormatScoped(`relation '${rel}'`, name, field); } for (const name of def.requiredAttributes ?? []) { assert(name in attributes, `relation '${rel}' requiredAttributes references undeclared attribute '${name}'`); @@ -590,6 +624,7 @@ function validateArtifacts(profile: ProfilePack, declaredArtifactTypes: Set` itself needs no rule — the rail's + * own `.support-rail dt/dd` already style it — so only the pieces the fixed list + * has no equivalent for are defined here. + * + * The label is MONO because it is a frontmatter key, not prose: it is the exact + * string the author typed and the reader may have to type back. Same reasoning + * as the run id on a workflow row. + */ +/* Scoped to `.support-rail` to out-specify `.support-rail dt` in + * viewer-chrome.css (0-1-1) — a bare `.entity-field-label` (0-1-0) loses to it + * and the label would silently render in the sans face this comment denies. */ +.support-rail .entity-field-label { font: var(--type-metadata); color: var(--fg-dim); } +.entity-field-list { list-style: none; margin: 0; padding: 0; } +.entity-field-item { color: var(--fg-body); } +/* A formatted field leaves the viewer, so it looks like a link rather than + * inheriting the rail's plain body colour. `--accent-text`, not `--accent`: + * the latter is the graphics fill (graph nodes, gradients), and text uses the + * readable-on-background variant. */ +.entity-field-link { color: var(--accent-text); text-decoration: underline; } + +/* An enum value is a state, not free text — so it takes the neutral chip recipe + * this stylesheet already uses for `.workflow-flag`, rather than #/pipeline's, + * which is tuned for a denser panel and sits in its own sheet. */ +.entity-field-state { + display: inline-block; + font: var(--type-badge); + color: var(--fg-dim); + background: var(--bg-chip); + border: 1px solid var(--border-chip); + padding: 3px 6px; + border-radius: var(--radius-badge); +} + +/* An artifact ref is a machine handle; the note beside it says it is unchecked. + * `--fg-dim` rather than a fainter rung: the whole point of the note is that it + * gets read, and the fainter tokens do not clear 4.5:1 at this size. */ +.entity-field-ref { font: var(--type-provenance); color: var(--fg-body); } +.entity-field-unresolved { + display: block; + font: var(--type-metadata); + color: var(--fg-dim); + font-style: italic; +} + .support-rail-warnings { margin-top: var(--space-16); padding-top: var(--space-12); diff --git a/src/viewer/assets/viewer-entity-fields.js b/src/viewer/assets/viewer-entity-fields.js new file mode 100644 index 00000000..e4cc7f6c --- /dev/null +++ b/src/viewer/assets/viewer-entity-fields.js @@ -0,0 +1,230 @@ +/** + * llmwiki viewer — a typed entity page's DECLARED fields. + * + * The support rail's own list is nine fixed keys — `kind`, `sources`, + * `confidence`, `provenanceState`, `contradictedBy`, `tags`, `aliases`, + * `createdAt`, `updatedAt` — which is the DEFAULT profile's vocabulary. A + * paper's authors, year, DOI and stage are none of them, so a typed page used to + * show its body beside a rail describing a contract it was never under. + * + * This renders what the page's own type declares, dispatching on the declared + * `FieldDef.type` and NEVER on a field NAME. That is the design constraint, not + * a stylistic preference: a renderer that knew what `doi` meant would work for + * one profile and quietly do nothing for the next, and `src/` may not name a + * domain vocabulary at all (test/no-research-branch-in-core.test.ts). + * + * Three value states are reachable and no more. A record violating its declared + * field contract never becomes a viewer page — `collectTypedViewerInputs` filters + * it and it surfaces as a `field-violation` problem instead — so a rendered + * field is either PRESENT, ABSENT-and-optional (the row is omitted, matching the + * rail's existing rule that a sparse page shows a short list rather than a wall + * of "(none)"), or an ARTIFACT REFERENCE this view does not resolve. There is no + * fourth branch because nothing can enter it. + */ + +import { el } from "./viewer-dom.js"; +import { formatHref } from "./viewer-field-format.js"; + +/** Rendered in place of a raw boolean; a bare `false` reads as a rendering fault. */ +const BOOLEAN_LABELS = { true: "Yes", false: "No" }; + +/** Said of an artifact reference this view names but has not verified. */ +const UNRESOLVED_NOTE = "not verified in this view"; + +/** + * Value renderers keyed by DECLARED field type. A type absent from this table + * falls back to text, so a field type added to the profile schema renders + * plainly instead of vanishing. + * + * NULL-prototype, because the key is read off the wire: on a plain object + * literal a declared type of `__proto__` would resolve to `Object.prototype` and + * throw when called, and `constructor` would resolve to `Object` and be invoked. + * Neither is reachable from a well-formed envelope; neither should be able to + * break a page render either. + */ +const FIELD_RENDERERS = Object.assign(Object.create(null), { + "string[]": renderList, + "artifactRef[]": renderArtifactRefList, + boolean: renderBoolean, + enum: renderState, + artifactRef: renderArtifactRef, +}); + +/** + * The declared-field list for one typed page, or `null` when there is nothing to + * render — the type declares no fields, or the record carries none of them. + * + * @param {{name: string, type: string}[]} fieldDefs - The type's declared fields, in declaration order. + * @param {Record} frontmatter - The page's raw frontmatter. + * @returns {HTMLElement|null} A `
`, or null. + */ +export function buildEntityFields(fieldDefs, frontmatter) { + const present = presentFields(fieldDefs, frontmatter); + if (present.length === 0) return null; + const list = el("dl", "entity-fields"); + list.setAttribute("data-entity-fields", ""); + for (const def of present) { + // The DECLARED key verbatim, never a prettified name: it is what the author + // typed in frontmatter, and inventing a display name the profile never + // declared is exactly the substitution this surface exists to avoid. + list.appendChild(el("dt", "entity-field-label", def.name)); + list.appendChild(renderValue(def, ownValue(frontmatter, def.name))); + } + return list; +} + +/** + * The names this page renders as declared fields — used by the rail to stand its + * own fixed row down for a key the profile declares, so nothing is stated twice. + * + * @param {{name: string, type: string}[]} fieldDefs - The type's declared fields. + * @param {Record} frontmatter - The page's raw frontmatter. + * @returns {Set} The declared names actually rendered. + */ +export function renderedFieldNames(fieldDefs, frontmatter) { + return new Set(presentFields(fieldDefs, frontmatter).map((def) => def.name)); +} + +/** The declared fields the record actually carries a usable value for. */ +function presentFields(fieldDefs, frontmatter) { + const defs = Array.isArray(fieldDefs) ? fieldDefs : []; + return defs.filter((def) => isPresent(ownValue(frontmatter, def?.name))); +} + +/** + * One declared field's value off a record, or `undefined` when the record does + * not carry it. + * + * `Object.hasOwn` rather than a bare index, because both sides arrive from + * outside: `frontmatter` is JSON off the wire, so it carries `Object.prototype`, + * and `def.name` is a field name the PROFILE declares, which the schema does not + * constrain (`fields` has no `propertyNames`). A type declaring `constructor` + * would otherwise resolve `Object.prototype.constructor` on every page of that + * type and render `function Object() { [native code] }` as the field's value — + * a value the record does not carry, displayed as though it does, which is the + * one thing this surface exists not to do. `toString` and `valueOf` do the same. + * + * `FIELD_RENDERERS` above already takes the null-prototype form of this + * precaution for the declared TYPE; this is the same precaution for the + * declared NAME, which is the half the profile schema leaves open. + * + * @param {Record} record - The page's raw frontmatter. + * @param {string} name - The declared field name. + * @returns {unknown} The own value, or `undefined`. + */ +function ownValue(record, name) { + if (record === null || typeof record !== "object") return undefined; + return Object.hasOwn(record, name) ? record[name] : undefined; +} + +/** + * Emptiness tests by value shape. A shape absent from this table is present by + * virtue of existing — a number, a boolean, an object all carry information. + */ +const EMPTINESS_TESTS = [ + { matches: (value) => typeof value === "string", isEmpty: (value) => value.trim().length === 0 }, + { matches: Array.isArray, isEmpty: (value) => value.length === 0 }, +]; + +/** Present means: not absent, not a blank string, not an empty list. */ +function isPresent(value) { + if (value === undefined || value === null) return false; + const test = EMPTINESS_TESTS.find((entry) => entry.matches(value)); + return test === undefined || !test.isEmpty(value); +} + +/** Render one field's value into its `
`, dispatching on the declared type. */ +function renderValue(def, value) { + const cell = el("dd", "entity-field-value"); + const render = FIELD_RENDERERS[def.type]; + (typeof render === "function" ? render : renderText)(cell, value, def.format); + return cell; +} + +/** + * Fallback and the scalar case: the value as its own text, never reformatted — + * or as an external link when the field declares a format that resolves. + */ +function renderText(cell, value, format) { + cell.appendChild(buildScalar(value, format)); +} + +/** + * One scalar value: an anchor when its declared format resolves to a safe href, + * otherwise a text node. + * + * `formatHref` returns null for anything it is not certain about — an unknown + * format, a non-http scheme, an id that could steer its resolver path — so the + * fallback here is the value AS TEXT rather than a link built anyway. `noopener + * noreferrer` because the target is page-supplied, and `_blank` because the + * viewer is a local snapshot a reader should not lose their place in. + */ +function buildScalar(value, format) { + const text = String(value); + const href = formatHref(format, text); + if (href === null) return document.createTextNode(text); + const link = el("a", "entity-field-link", text); + link.href = href; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + return link; +} + +/** A boolean as a word. */ +function renderBoolean(cell, value) { + cell.textContent = BOOLEAN_LABELS[String(value)] ?? String(value); +} + +/** An enum value as a state chip: it is one of a closed set, not free text. */ +function renderState(cell, value) { + cell.appendChild(el("span", "entity-field-state", String(value))); +} + +/** + * A `
    ` over `value`'s entries, each `
  • ` filled by `buildEntry`. + * + * A non-array value is wrapped rather than rejected: a page declaring a list + * type but carrying one bare value renders as a one-item list, which is what the + * record means, instead of vanishing. + */ +function buildItemList(value, buildEntry) { + const items = Array.isArray(value) ? value : [value]; + const list = el("ul", "entity-field-list"); + for (const item of items) { + const entry = el("li", "entity-field-item"); + entry.appendChild(buildEntry(item)); + list.appendChild(entry); + } + return list; +} + +/** An array as a list; joining into one string would hide where an entry ends. */ +function renderList(cell, value, format) { + cell.appendChild(buildItemList(value, (item) => buildScalar(item, format))); +} + +/** + * An artifact reference, NAMED but not resolved. + * + * Resolving one needs a request-time read of the artifact store and a + * pinned-hash verification, which this surface does not do. So the ref is shown + * as the record spells it and marked unresolved — presenting it plainly would + * imply its bytes had been checked, which is the one thing this cannot claim. + */ +function renderArtifactRef(cell, value) { + cell.appendChild(el("span", "entity-field-ref", String(value))); + cell.appendChild(el("span", "entity-field-unresolved", UNRESOLVED_NOTE)); +} + +/** + * A LIST of artifact references — each one named and marked unresolved. + * + * Deliberately not `renderList`: that renders entries as ordinary scalars, which + * would show an `artifactRef[]` indistinguishably from verified values and undo + * the whole point of {@link renderArtifactRef}. The unresolved note sits once + * under the list rather than on every row, since it describes all of them. + */ +function renderArtifactRefList(cell, value) { + cell.appendChild(buildItemList(value, (item) => el("span", "entity-field-ref", String(item)))); + cell.appendChild(el("span", "entity-field-unresolved", UNRESOLVED_NOTE)); +} diff --git a/src/viewer/assets/viewer-field-format.js b/src/viewer/assets/viewer-field-format.js new file mode 100644 index 00000000..80bda058 --- /dev/null +++ b/src/viewer/assets/viewer-field-format.js @@ -0,0 +1,93 @@ +/** + * llmwiki viewer — declared field formats to external hrefs. + * + * The ONLY place the viewer builds a URL out of page content, which is why it is + * its own module and why every branch fails closed. A profile declares the + * FORMAT (`url`, `doi`, `arxiv`); the VALUE comes from a wiki page, i.e. from + * whatever an author or a connector wrote. The value is therefore treated as + * untrusted: a `url` must parse and carry an http(s) scheme, and a `doi`/`arxiv` + * id must match a conservative grammar before it is concatenated into a resolver + * path. The resolver ORIGIN is a constant here and never author-controlled — + * that is the reason the vocabulary is a closed enum rather than a URL template. + * + * Returning `null` means "render this as text". That is the answer whenever the + * guard is not certain, because a value shown as text is merely unhelpful while + * a value linked wrongly is a live `javascript:` or an off-site redirect. + * + * Load validation already rejects an unknown format, and this guards anyway: + * `/api/pages` is a wire boundary, and code on the far side of one does not + * assume a validator ran on the other. + */ + +/** Schemes a `url` field may link to. Anything else renders as text. */ +const LINKABLE_SCHEMES = new Set(["http:", "https:"]); + +/** + * A DOI: the `10./` form. + * + * The suffix is deliberately PERMISSIVE — any run of non-whitespace. A DOI + * suffix may contain almost anything, slashes included (`10.5061/dryad.abc/1` + * is a real DOI), so an over-tight grammar silently degrades valid identifiers + * to plain text. Safety does not come from the grammar here: it comes from + * {@link resolvedUnder}, which rejects any value that leaves the fixed origin. + */ +const DOI_PATTERN = /^10\.\d{4,9}\/\S+$/; + +/** An arXiv id: modern `2401.01234v2`, or legacy `math.GT/0309136`. */ +const ARXIV_PATTERN = /^(\d{4}\.\d{4,5}(v\d+)?|[a-z-]+(\.[A-Z]{2})?\/\d{7}(v\d+)?)$/; + +/** + * Per-format resolvers, on a NULL-prototype object so an inherited `Object` + * property (`__proto__`, `constructor`, `toString`) cannot be mistaken for a + * declared format and invoked. + */ +const RESOLVERS = Object.assign(Object.create(null), { + url: passThroughHttpUrl, + doi: (value) => (DOI_PATTERN.test(value) ? resolvedUnder("https://doi.org/", value) : null), + arxiv: (value) => (ARXIV_PATTERN.test(value) ? resolvedUnder("https://arxiv.org/abs/", value) : null), +}); + +/** + * Resolve `value` against a FIXED resolver base and return the result only if it + * stayed on that origin. + * + * This is the actual containment, rather than the identifier grammar: a value + * that is absolute (`https://evil.test`), protocol-relative (`//evil.test`), or + * anything else that re-bases lands on a different origin and is refused. + * Traversal WITHIN the origin (`../x`) is harmless and allowed. Returning + * `url.href` also normalises the percent-encoding, so the string that was + * validated is the string that gets navigated. + */ +function resolvedUnder(base, value) { + try { + const url = new URL(value, base); + return url.origin === new URL(base).origin ? url.href : null; + } catch { + return null; + } +} + +/** + * The external href for a declared format and a page-supplied value, or `null` + * when no link should be built. + * + * @param {string} format - The declared `FieldDef.format`. + * @param {unknown} value - The raw frontmatter value. + * @returns {string|null} An absolute http(s) URL, or null to render as text. + */ +export function formatHref(format, value) { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (trimmed.length === 0) return null; + const resolve = RESOLVERS[format]; + return typeof resolve === "function" ? resolve(trimmed) : null; +} + +/** A value that parses as an absolute http(s) URL, or null. */ +function passThroughHttpUrl(value) { + try { + return LINKABLE_SCHEMES.has(new URL(value).protocol) ? value : null; + } catch { + return null; + } +} diff --git a/src/viewer/assets/viewer-rail.js b/src/viewer/assets/viewer-rail.js index 31538ad2..ce6a716c 100644 --- a/src/viewer/assets/viewer-rail.js +++ b/src/viewer/assets/viewer-rail.js @@ -30,6 +30,8 @@ * `renderSupportRail`; every other route calls `clearSupportRail`. */ +import { buildEntityFields, renderedFieldNames } from "./viewer-entity-fields.js"; + const SUPPORT_SELECTOR = "[data-support-rail]"; const RAIL_FIELDS = [ @@ -71,18 +73,53 @@ export function renderDashboardRail(panels) { /** * Render the page metadata into the support rail. Replaces whatever * was there before — callers don't need to clear separately. + * + * On a TYPED entity page the page's own declared fields lead, because + * {@link RAIL_FIELDS} below is the DEFAULT profile's vocabulary and describes a + * contract a typed page was never under. The fixed list still runs after them, + * minus any key the profile declares: an undeclared extra a page happens to + * carry (a stray `tags`, a hand-written `updatedAt`) keeps rendering exactly as + * before, and nothing is stated twice. + * + * The type's own title field is skipped: the page heading already shows that + * value, so a row repeating it would state the same thing twice in the same + * viewport — the very duplication this function avoids between the two lists. + * + * @param {object} payload - The `/api/page/:dir/:slug` response. + * @param {{name: string, type: string}[]} [fieldDefs] - The declared fields of + * this page's entity type, from `profilePipeline`. Absent on a default page. + * @param {string} [titleField] - The key this type titles pages by, if any. */ -export function renderSupportRail(payload) { +export function renderSupportRail(payload, fieldDefs, titleField) { const support = document.querySelector(SUPPORT_SELECTOR); if (!support) return; support.innerHTML = ""; appendFreshnessBadges(support, payload); - appendFrontmatterDl(support, extractFrontmatter(payload)); + const frontmatter = extractFrontmatter(payload); + const shown = withoutTitleField(fieldDefs, titleField); + const declared = buildEntityFields(shown, frontmatter); + if (declared) support.appendChild(declared); + // Suppression is computed from the UNFILTERED declarations: a `titleField` + // that happens to name a fixed-list key (`kind`, `tags`, …) is hidden from the + // declared block because the heading shows it, and must stay hidden in the + // fixed list too — otherwise removing it from one list would resurrect it in + // the other, beside the heading it duplicates. + appendFrontmatterDl(support, frontmatter, renderedFieldNames(fieldDefs, frontmatter)); const warnings = extractWarnings(payload); if (warnings.length > 0) support.appendChild(buildRailWarnings(warnings)); appendFreshnessCaption(support, payload); } +/** + * The declared fields minus the one the heading already shows. Returns the list + * unchanged when the type declares no title field, so a type whose title is not + * a declared field keeps every row it had. + */ +function withoutTitleField(fieldDefs, titleField) { + if (!Array.isArray(fieldDefs) || typeof titleField !== "string") return fieldDefs; + return fieldDefs.filter((def) => def?.name !== titleField); +} + /** Clear the support rail entirely (used on non-page routes). */ export function clearSupportRail() { const support = document.querySelector(SUPPORT_SELECTOR); @@ -101,10 +138,17 @@ function extractWarnings(payload) { return payload.warnings; } -/** Build and attach the frontmatter
    when at least one field rendered. */ -function appendFrontmatterDl(support, fm) { +/** + * Build and attach the fixed-list
    when at least one field rendered, + * skipping any key the page's profile declares — those are already rendered + * above, by their declared type rather than by this list's assumption about them. + */ +function appendFrontmatterDl(support, fm, declaredNames) { const dl = document.createElement("dl"); - for (const field of RAIL_FIELDS) appendRailField(dl, field, fm[field.key]); + for (const field of RAIL_FIELDS) { + if (declaredNames.has(field.key)) continue; + appendRailField(dl, field, fm[field.key]); + } if (dl.children.length > 0) support.appendChild(dl); } diff --git a/src/viewer/assets/viewer.js b/src/viewer/assets/viewer.js index 177079e0..833387fe 100644 --- a/src/viewer/assets/viewer.js +++ b/src/viewer/assets/viewer.js @@ -176,11 +176,15 @@ function entityListRoute(key) { return { kind: "entityList", type }; } +/** The envelope's declared entity-type rows, or an empty list before it settles. */ +function declaredTypeRows() { + const entityTypes = bootstrapData.pages?.profilePipeline?.entityTypes; + return Array.isArray(entityTypes) ? entityTypes : []; +} + /** The entity type ids the cached envelope declares; empty until it settles. */ function declaredEntityTypes() { - const entityTypes = bootstrapData.pages?.profilePipeline?.entityTypes; - if (!Array.isArray(entityTypes)) return []; - return entityTypes.map((entry) => entry?.type); + return declaredTypeRows().map((entry) => entry?.type); } /** Resolve a `#//` hash; non-matches return home. */ @@ -381,7 +385,7 @@ function handleIndexError(main, err) { async function renderPagePane(main, directory, slug) { try { const payload = await fetchJson(pageApiPath(directory, slug)); - renderPagePayload(main, payload, slug); + renderPagePayload(main, payload, slug, await declaredFieldsFor(payload.entityType)); } catch (err) { handlePageError(main, err, directory, slug); } @@ -393,7 +397,7 @@ function pageApiPath(directory, slug) { } /** Render the body of a successful /api/page response into the main pane. */ -function renderPagePayload(main, payload, slug) { +function renderPagePayload(main, payload, slug, fieldDefs) { const title = payload.title || slug; main.innerHTML = ""; main.appendChild(heading("h1", title)); @@ -403,7 +407,43 @@ function renderPagePayload(main, payload, slug) { appendWarnings(main, payload.warnings || []); const body = appendRenderedBody(main, payload.html); removeDuplicateLeadingHeading(body, title); - renderSupportRail(payload); + renderSupportRail(payload, fieldDefs, titleFieldFor(payload.entityType)); +} + +/** + * The fields the active profile declares for `entityType`, or undefined. + * + * AWAITS the envelope rather than reading whatever `bootstrapData` holds right + * now. `main()` renders the route twice — once immediately, once after + * `/api/pages` settles — and `unsettledOrPageRoute` deliberately lets a page + * route resolve without the envelope. A synchronous read would therefore make + * the two passes render DIFFERENT rails, and since each pass issues its own + * `/api/page` fetch with no ordering guarantee between them, a cold deep link + * whose first response landed second would be left permanently without its + * declared fields. Awaiting makes both passes produce the same rail, so which + * one wins stops mattering. Same idiom the index and dashboard routes use. + * + * Resolved here rather than in the rail because this module is already the one + * place that reads `bootstrapData`; the rail stays a pure renderer of what it is + * handed. A default page carries no `entityType`, so it never awaits and its + * rail is byte-identical to before. + */ +async function declaredFieldsFor(entityType) { + if (typeof entityType !== "string") return undefined; + if (bootstrapData.pages === null) await loadBootstrapData(); + return declaredTypeRows().find((entry) => entry?.type === entityType)?.fields; +} + +/** + * The frontmatter key this entity type titles pages by, or undefined. + * + * Read synchronously from the cached envelope: it only ever SUPPRESSES a rail + * row that duplicates the heading, so a miss before the envelope settles costs + * one redundant row on the first of two paints rather than a wrong one. + */ +function titleFieldFor(entityType) { + if (typeof entityType !== "string") return undefined; + return declaredTypeRows().find((entry) => entry?.type === entityType)?.titleField; } /** Question banner shown above the body for saved-query pages. */ diff --git a/src/viewer/pipeline.ts b/src/viewer/pipeline.ts index 360f4809..efeab41a 100644 --- a/src/viewer/pipeline.ts +++ b/src/viewer/pipeline.ts @@ -21,7 +21,26 @@ */ import type { ProfileSummaryBlock } from "../profile/block.js"; -import type { EntityTypeDef, ProfilePack, RelationTypeDef } from "../profile/types.js"; +import type { + ArtifactTypeDef, + EntityTypeDef, + FieldDef, + ProfilePack, + RelationTypeDef, +} from "../profile/types.js"; + +/** + * Facets of {@link FieldDef} the wire deliberately DROPS. + * + * `default` can carry an arbitrary author-supplied value that was never written + * with a read surface in mind; `min`/`max` are write-time validation bounds no + * renderer draws. Naming them here rather than omitting them silently is what + * makes {@link PROJECTED_FIELD_FACETS} exhaustive — see the note there. + */ +type DroppedFieldFacet = "default" | "min" | "max"; + +/** Every {@link FieldDef} facet that reaches the client. */ +type ProjectedFieldFacet = Exclude; /** One entity type's declared lifecycle, as the client receives it. */ export interface PipelineLifecycle { @@ -41,6 +60,31 @@ export interface PipelineLifecycle { declaredStates?: string[]; } +/** + * One declared field as the client receives it: its frontmatter key plus every + * projected facet the type actually declares. + * + * Derived from {@link FieldDef} rather than hand-listed, so the two cannot drift: + * a facet added to `FieldDef` appears here automatically and must then be either + * projected or added to {@link DroppedFieldFacet}. + */ +export type PipelineFieldDef = { name: string } & Pick; + +/** + * One artifact type's declaration, as the client receives it. + * + * `maxBytes` is not projected: it is a read/write ceiling enforced on the + * handle, not something a reader is shown. It belongs here the day a surface + * renders it, not before. + */ +export interface PipelineArtifactTypeDef { + type: string; + fileName: string; + contentKind: ArtifactTypeDef["contentKind"]; + /** Declared metadata contract, through the SAME field projection; absent when none. */ + metadata?: PipelineFieldDef[]; +} + /** One entity type's declaration, before counts are joined onto it. */ export interface PipelineEntityTypeDef { type: string; @@ -54,6 +98,20 @@ export interface PipelineEntityTypeDef { * write pages somewhere the collector never reads. */ directory: string; + /** + * The frontmatter key this type's display title is read from; absent when the + * type declares none and the literal `title` key applies. + */ + titleField?: string; + /** + * Declared fields IN DECLARATION ORDER — the order the profile author chose. + * Absent when the type declares none. + * + * An ordered array rather than a map: object key order is not a contract a + * client should have to trust, and the author's order is the only one not + * invented by this projection. + */ + fields?: PipelineFieldDef[]; lifecycle?: PipelineLifecycle; } @@ -69,6 +127,8 @@ export interface PipelineRelationTypeDef { export interface PipelineDefinitions { entityTypes: PipelineEntityTypeDef[]; relationTypes: PipelineRelationTypeDef[]; + /** Declared artifact types; absent entirely for a profile declaring none. */ + artifactTypes?: PipelineArtifactTypeDef[]; } /** One entity type row on the wire: its declaration plus its two counts. */ @@ -89,6 +149,13 @@ export interface PipelineEnvelope { entityTypes: PipelineEntityTypeRow[]; /** Omitted entirely for a profile that declares no relation types. */ relationTypes?: PipelineRelationTypeRow[]; + /** + * Declared artifact types, carried through unchanged from the definitions — + * there is no count to join onto them, since an artifact is reached through an + * entity's `artifactRef` field rather than enumerated. Omitted entirely for a + * profile that declares none. + */ + artifactTypes?: PipelineArtifactTypeDef[]; } /** @@ -100,6 +167,9 @@ export interface PipelineEnvelope { * its author chose and the only one not invented here). */ export function buildPipelineDefinitions(profile: ProfilePack): PipelineDefinitions { + const artifactTypes = Object.entries(profile.artifacts ?? {}).map(([type, def]) => + artifactTypeDefinition(type, def as ArtifactTypeDef), + ); return { entityTypes: Object.entries(profile.entities).map(([type, def]) => entityTypeDefinition(type, def as EntityTypeDef), @@ -107,17 +177,102 @@ export function buildPipelineDefinitions(profile: ProfilePack): PipelineDefiniti relationTypes: Object.entries(profile.relations ?? {}).map(([type, def]) => relationTypeDefinition(type, def as RelationTypeDef), ), + ...(artifactTypes.length > 0 ? { artifactTypes } : {}), }; } +/** + * Every {@link FieldDef} facet that reaches the client, as an EXHAUSTIVE record. + * + * A `Record` rather than an array: an array can be a + * subset and compile, so a facet added to `FieldDef` would silently never be + * projected. This shape fails to compile until the new facet is either listed + * here or added to {@link DroppedFieldFacet} — the projection cannot lose a + * field by omission, which is the same structural closure `directory` has. + */ +const PROJECTED_FIELD_FACETS: Record = { + type: true, + required: true, + enum: true, + artifactTypes: true, + format: true, +}; + +/** + * Project one declared field: its name plus every projected facet it declares. + * + * Copies by facet list rather than by hand-written literal so + * {@link PROJECTED_FIELD_FACETS} is the single place the boundary is stated. The + * assembled object is cast once, at the end: TypeScript cannot see that iterating + * the record's keys produces exactly `PipelineFieldDef`'s optional properties. + */ +function fieldDefinition(name: string, def: FieldDef): PipelineFieldDef { + const projected: Record = { name }; + for (const facet of Object.keys(PROJECTED_FIELD_FACETS) as ProjectedFieldFacet[]) { + if (def[facet] !== undefined) projected[facet] = def[facet]; + } + return projected as PipelineFieldDef; +} + +/** Project a declared field map into the wire's ordered array, or `undefined` when empty. */ +function fieldDefinitions(fields: Record | undefined): PipelineFieldDef[] | undefined { + const projected = Object.entries(fields ?? {}).map(([name, def]) => fieldDefinition(name, def)); + return projected.length > 0 ? projected : undefined; +} + +/** Project one artifact type's declaration, omitting a metadata contract it does not declare. */ +function artifactTypeDefinition(type: string, def: ArtifactTypeDef): PipelineArtifactTypeDef { + const metadata = fieldDefinitions(def.metadata); + return { + type, + fileName: def.fileName, + contentKind: def.contentKind, + ...(metadata ? { metadata } : {}), + }; +} + +/** + * The schema facets shared by both branches of {@link entityTypeDefinition} — + * everything a type declares about its own records, as opposed to its lifecycle. + */ +function declaredSchema(def: EntityTypeDef): Partial { + const fields = fieldDefinitions(def.fields); + return { + ...(def.titleField !== undefined ? { titleField: def.titleField } : {}), + ...(fields ? { fields } : {}), + }; +} + +/** + * Read `key` off `record` only when the record OWNS it. + * + * Every lookup below indexes a plain object with a PROFILE-DECLARED name — an + * entity type, a relation type, a lifecycle field. The profile schema puts no + * `propertyNames` constraint on any of those name positions, so a bare index + * resolves inherited members: a type named `constructor` reads + * `Object.prototype.constructor`, which is a function, and then reads as a + * truthy `stateCounts`, as a `count`/`pageCount` that `JSON.stringify` silently + * drops from the row, or as an `enum` read off a function. Confining to own + * properties makes each of those an honest "not declared" instead. Same guard + * and same reasoning as `validateTitleField` (`src/profile/validate.ts`), which + * hit this on `titleField`. + * + * @param record - The map to read, or `undefined`. + * @param key - A profile-supplied name. + * @returns The own value, or `undefined`. + */ +function own(record: Record | undefined, key: string): T | undefined { + return record !== undefined && Object.hasOwn(record, key) ? record[key] : undefined; +} + /** Project one entity type's declaration, omitting `lifecycle` when it declares none. */ function entityTypeDefinition(type: string, def: EntityTypeDef): PipelineEntityTypeDef { - if (def.lifecycle === undefined) return { type, directory: def.directory }; + const base = { type, directory: def.directory, ...declaredSchema(def) }; + if (def.lifecycle === undefined) return base; const { field, initial, terminal, transitions } = def.lifecycle; - const declared = def.fields?.[field]?.enum; + const declared = own(def.fields, field)?.enum; return { - type, - directory: def.directory, + ...base, lifecycle: { field, initial, @@ -151,11 +306,12 @@ export function buildPipelineEnvelope( if (!definitions || !summary) return undefined; const relationTypes = definitions.relationTypes.map((def) => ({ ...def, - count: summary.relationCounts?.[def.type] ?? 0, + count: own(summary.relationCounts, def.type) ?? 0, })); return { entityTypes: definitions.entityTypes.map((def) => entityTypeRow(def, summary)), ...(relationTypes.length > 0 ? { relationTypes } : {}), + ...(definitions.artifactTypes ? { artifactTypes: definitions.artifactTypes } : {}), }; } @@ -169,10 +325,10 @@ function entityTypeRow( def: PipelineEntityTypeDef, summary: ProfileSummaryBlock, ): PipelineEntityTypeRow { - const stateCounts = summary.lifecycleStates?.[def.type]; + const stateCounts = own(summary.lifecycleStates, def.type); return { ...def, - pageCount: summary.entityCounts[def.type] ?? 0, + pageCount: own(summary.entityCounts, def.type) ?? 0, ...(stateCounts ? { stateCounts } : {}), }; } diff --git a/test/fixtures/viewer-jsdom.ts b/test/fixtures/viewer-jsdom.ts index 5245fafc..e097ffec 100644 --- a/test/fixtures/viewer-jsdom.ts +++ b/test/fixtures/viewer-jsdom.ts @@ -90,6 +90,10 @@ const MODULE_ORDER = [ "viewer-routes.js", "viewer-nav-types.js", "viewer-dashboard-vocabulary.js", + // Imported by viewer-entity-fields.js, which sorts before it. + "viewer-field-format.js", + // Imported by viewer-rail.js, and sorts after it in directory order. + "viewer-entity-fields.js", "viewer-rail.js", "viewer-pattern.js", "viewer-stat-card.js", diff --git a/test/profile-field-format-validate.test.ts b/test/profile-field-format-validate.test.ts new file mode 100644 index 00000000..379165e7 --- /dev/null +++ b/test/profile-field-format-validate.test.ts @@ -0,0 +1,147 @@ +/** + * @file test/profile-field-format-validate.test.ts + * @description `FieldDef.format` — the minimal declarative link vocabulary. + * + * The schema could not tell an ordinary string from a URL, a DOI, or an arXiv + * id, so a read surface had no way to link one without knowing what `doi` means + * — which is precisely the domain knowledge core code may not carry. + * + * A CLOSED enum rather than an author-supplied URL template. A template would be + * a string a renderer interpolates into an href, i.e. executable profile + * behaviour reaching a read surface, and the point of the field is presentation + * metadata. An unknown value fails validation rather than degrading silently: + * `$defs/fieldDef` is `additionalProperties: false`, so fail-closed is the house + * default here, not an extra decision. + * + * Restricted to `string`/`string[]`: a format is a hint about how to read TEXT. + * On a boolean, a number, a date or an artifactRef it would be config no + * renderer could act on, so it is rejected at load rather than ignored later. + */ + +import { describe, expect, it } from "vitest"; +import { validateProfileShape } from "../src/profile/validate.js"; +import type { FieldDef, ProfilePack } from "../src/profile/types.js"; + +/** A pack whose `papers.locator` field carries the given definition. */ +function packWith(locator: FieldDef): ProfilePack { + return { + schemaVersion: 1, + profileId: "research", + entities: { papers: { directory: "wiki/papers", fields: { locator } } }, + }; +} + +/** Validate a pack whose `locator` carries `field`, returning the thrown error's message. */ +function messageFor(field: Record): string { + try { + validateProfileShape(packWith(field as unknown as FieldDef)); + return ""; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } +} + +describe("FieldDef.format accepts the declared vocabulary", () => { + it("accepts each format on a string field", () => { + for (const format of ["url", "doi", "arxiv"] as const) { + expect(() => validateProfileShape(packWith({ type: "string", format }))).not.toThrow(); + } + }); + + it("accepts a format on a string[] field", () => { + expect(() => validateProfileShape(packWith({ type: "string[]", format: "url" }))).not.toThrow(); + }); + + it("still accepts a field declaring no format at all", () => { + expect(() => validateProfileShape(packWith({ type: "string" }))).not.toThrow(); + }); + + it("still accepts a non-text field, so long as it declares no format", () => { + expect(() => validateProfileShape(packWith({ type: "boolean" }))).not.toThrow(); + }); +}); + +describe("FieldDef.format is fail-closed", () => { + it("rejects an unknown format rather than ignoring it", () => { + expect(messageFor({ type: "string", format: "isbn" })).toMatch(/format/i); + }); + + it("rejects a format on a field type it cannot describe", () => { + for (const type of ["boolean", "number", "integer", "date", "enum", "artifactRef"]) { + expect(messageFor({ type, format: "url" }), type).toMatch(/format/i); + } + }); + + it("names the offending entity and field, not just the rule", () => { + const message = messageFor({ type: "boolean", format: "url" }); + expect(message).toContain("papers"); + expect(message).toContain("locator"); + }); +}); + +/** + * The schema resolves relation `attributes` and artifact `metadata` to the same + * `$defs/fieldDef`, so all three carriers accept the key. The contract says + * text-only, and artifact metadata is projected onto the wire — so the check has + * to sit on every carrier, the way `assertArtifactTypesScoped` already does. + */ +describe("the rule covers every FieldDef carrier, not just entity fields", () => { + it("rejects a badly typed format on a relation attribute", () => { + const pack: ProfilePack = { + schemaVersion: 1, + profileId: "research", + entities: { papers: { directory: "wiki/papers" }, sources: { directory: "wiki/sources" } }, + relations: { + cites: { + from: ["papers"], + to: ["sources"], + direction: "directed", + attributes: { score: { type: "number", format: "doi" } as unknown as FieldDef }, + }, + }, + }; + expect(() => validateProfileShape(pack)).toThrow(/format/i); + }); + + it("rejects a badly typed format on artifact metadata", () => { + const pack: ProfilePack = { + schemaVersion: 1, + profileId: "research", + entities: { papers: { directory: "wiki/papers" } }, + artifacts: { + result: { + fileName: "result.json", + contentKind: "json", + maxBytes: 1024, + metadata: { verified: { type: "boolean", format: "url" } as unknown as FieldDef }, + }, + }, + }; + expect(() => validateProfileShape(pack)).toThrow(/format/i); + }); + + it("accepts a well-typed format on both carriers", () => { + const pack: ProfilePack = { + schemaVersion: 1, + profileId: "research", + entities: { papers: { directory: "wiki/papers" }, sources: { directory: "wiki/sources" } }, + relations: { + cites: { + from: ["papers"], + to: ["sources"], + direction: "directed", + attributes: { via: { type: "string", format: "url" } }, + }, + }, + artifacts: { + result: { + fileName: "result.json", + contentKind: "json", + maxBytes: 1024, + metadata: { source: { type: "string", format: "url" } }, + }, + }, + }; + expect(() => validateProfileShape(pack)).not.toThrow(); + }); +}); diff --git a/test/profile-template-title-releases.test.ts b/test/profile-template-releases.test.ts similarity index 50% rename from test/profile-template-title-releases.test.ts rename to test/profile-template-releases.test.ts index de76a523..baa7fcd5 100644 --- a/test/profile-template-title-releases.test.ts +++ b/test/profile-template-releases.test.ts @@ -1,7 +1,10 @@ /** - * @file test/profile-template-title-releases.test.ts - * @description Both shipped non-default templates declare a `titleField` per - * entity type at `0.2.0`, and both retain their `0.1.0` predecessor. + * @file test/profile-template-releases.test.ts + * @description The shipped non-default templates' release lines, and the + * retention that keeps every superseded release resolvable. + * + * Both templates gained a `titleField` per entity type at `0.2.0`; AutoSci then + * gained `doi`/`arxiv`/`url` field formats at `0.3.0`. * * Neither template could title a page before this. AutoSci's `people` carries * `name`; newsroom carries `headline`, `name` and `reporter` and declares no @@ -32,12 +35,21 @@ import { } from "../src/profile/templates/registry.js"; import { profileDigest } from "../src/profile/digest.js"; -/** The `profileDigest` each template's `0.1.0` published, computed pre-change. */ -const PUBLISHED_0_1_0_DIGESTS: Record = { - autosci: "bf48a4e77f9f40e06d8c56514851e678c59dfa217e4542f38d0fc8ef5e9e5489", - newsroom: "866882812c21c769e469d3f842b5bbdcc1d14cab4195cdb701d4c4181e4000ae", +/** + * Every superseded release's published `profileDigest`, each computed from the + * source AS IT WAS at that version rather than from the derivation under test. + */ +const PUBLISHED_DIGESTS: Record> = { + autosci: { + "0.1.0": "bf48a4e77f9f40e06d8c56514851e678c59dfa217e4542f38d0fc8ef5e9e5489", + "0.2.0": "7492d386a0fb8df534849981c40ebbe1a578aadc9bff30898c989bb164731798", + }, + newsroom: { "0.1.0": "866882812c21c769e469d3f842b5bbdcc1d14cab4195cdb701d4c4181e4000ae" }, }; +/** The version each template currently ships. */ +const CURRENT_VERSIONS: Record = { autosci: "0.3.0", newsroom: "0.2.0" }; + /** The title field each template's types are expected to declare. */ const EXPECTED_TITLE_FIELDS: Record> = { newsroom: { articles: "headline", desks: "name", bylines: "reporter" }, @@ -51,17 +63,41 @@ function current(id: string) { return getBuiltinTemplate(id)!; } -/** The retained `0.1.0` release for `id`. */ +/** A retained release of `id` by version. */ +function retained(id: string, version: string) { + return getBuiltinTemplateRelease(id, version, "atomicstrata"); +} + +/** The retained `0.1.0` release for `id` — the pre-`titleField` one. */ function published010(id: string) { - return getBuiltinTemplateRelease(id, "0.1.0", "atomicstrata"); + return retained(id, "0.1.0"); } -/** The same entity block with every `titleField` removed, for comparison. */ -function stripTitles(entities: Record>) { +/** + * The same entity block with every declaration the release line ADDED removed — + * `titleField` per type and `format` per field. + * + * Comparing the stripped forms states the thing the digest pins cannot: that + * apart from those declarations, no other drift crept into a retained release. + * It is deliberately not a re-application of the derivation under test, which + * would be circular. + */ +function stripAdded(entities: Record>) { return Object.fromEntries( Object.entries(entities).map(([type, def]) => { - const { titleField: _dropped, ...rest } = def; - return [type, rest]; + const { titleField: _title, fields, ...rest } = def; + return [type, { ...rest, fields: stripFormats(fields as Record>) }]; + }), + ); +} + +/** A field map with every `format` removed; `undefined` stays `undefined`. */ +function stripFormats(fields: Record> | undefined) { + if (fields === undefined) return undefined; + return Object.fromEntries( + Object.entries(fields).map(([name, field]) => { + const { format: _dropped, ...rest } = field; + return [name, rest]; }), ); } @@ -85,15 +121,15 @@ describe.each(TEMPLATE_IDS)("%s 0.2.0 titles every type by a field it declares", } }); - it("advances both the package and profile version", () => { - expect(current(id).version).toBe("0.2.0"); - expect(current(id).profile.profileVersion).toBe("0.2.0"); + it("keeps the package and profile version in step", () => { + expect(current(id).version).toBe(CURRENT_VERSIONS[id]); + expect(current(id).profile.profileVersion).toBe(CURRENT_VERSIONS[id]); }); - it("installs 0.2.0 as the latest, listing it exactly once", () => { + it("installs the newest release, listing it exactly once", () => { const listed = listBuiltinTemplates().filter((template) => template.templateId === id); expect(listed).toHaveLength(1); - expect(listed[0].version).toBe("0.2.0"); + expect(listed[0].version).toBe(CURRENT_VERSIONS[id]); }); }); @@ -103,8 +139,12 @@ describe.each(TEMPLATE_IDS)("%s 0.1.0 stays exactly resolvable", (id) => { expect(published010(id)?.profile.profileVersion).toBe("0.1.0"); }); - it("digests to what 0.1.0 actually published", () => { - expect(profileDigest(published010(id)!.profile)).toBe(PUBLISHED_0_1_0_DIGESTS[id]); + it("digests every retained release to what that release actually published", () => { + for (const [version, digest] of Object.entries(PUBLISHED_DIGESTS[id])) { + const release = retained(id, version); + expect(release, `${id}@${version}`).toBeDefined(); + expect(profileDigest(release!.profile), `${id}@${version}`).toBe(digest); + } }); it("carries no titleField, so a 0.1.0 install is not read as modified", () => { @@ -113,14 +153,18 @@ describe.each(TEMPLATE_IDS)("%s 0.1.0 stays exactly resolvable", (id) => { } }); - it("differs from 0.2.0 by nothing but the title declarations", () => { - expect(stripTitles(published010(id)!.profile.entities as never)).toEqual( - stripTitles(current(id).profile.entities as never), - ); + it("differs from the current release by nothing but those declarations", () => { + for (const version of Object.keys(PUBLISHED_DIGESTS[id])) { + expect(stripAdded(retained(id, version)!.profile.entities as never), version).toEqual( + stripAdded(current(id).profile.entities as never), + ); + } }); - it("is still recognised as shipped bytes, not a locally modified profile", () => { - expect(isShippedBuiltinProfile(published010(id)!.profile)).toBe(true); + it("recognises every retained release as shipped bytes, not a modified profile", () => { + for (const version of Object.keys(PUBLISHED_DIGESTS[id])) { + expect(isShippedBuiltinProfile(retained(id, version)!.profile), version).toBe(true); + } expect(isShippedBuiltinProfile(current(id).profile)).toBe(true); }); }); diff --git a/test/viewer-contrast.test.ts b/test/viewer-contrast.test.ts index 1f74f781..0479432f 100644 --- a/test/viewer-contrast.test.ts +++ b/test/viewer-contrast.test.ts @@ -133,6 +133,11 @@ const BODY_TEXT_PAIRS: [string, string][] = [ ["--fg-body", "--bg-card"], ["--fg-muted", "--bg-card"], ["--fg", "--bg-shell"], + // .entity-field-link (viewer-content.css), body-sized, in the support rail. + // The rail declares no background, so it inherits the app shell's. This is + // the only interactive text the declared-field block renders, so it is the + // one pairing there a reader has to be able to read AND recognise as a link. + ["--accent-text", "--bg-shell"], ]; describe("muted-token contrast — --fg-ghost / --fg-faint / --warn-muted / --fg-disabled", () => { diff --git a/test/viewer-css-specificity.test.ts b/test/viewer-css-specificity.test.ts new file mode 100644 index 00000000..112a6f25 --- /dev/null +++ b/test/viewer-css-specificity.test.ts @@ -0,0 +1,121 @@ +/** + * @file test/viewer-css-specificity.test.ts + * @description Rules that must win the cascade against a competing rule. + * + * The JSDOM suites assert DOM STRUCTURE. They never load the stylesheets and + * never compute style, so a rule that is written correctly and then loses the + * cascade is invisible to every other test in this repo — the DOM is right, the + * pixels are wrong, and nothing goes red. + * + * That is not hypothetical: `.entity-field-label` (0-1-0) was silently + * overridden by `.support-rail dt` (0-1-1) in viewer-chrome.css, so the + * frontmatter-key labels rendered in the sans face while the rule's own comment + * insisted they were mono. Review caught it; no test could have. + * + * HONEST SCOPE: this checks specificity and source order, which is what that bug + * was. It does NOT resolve custom properties, shorthand expansion, inheritance, + * or `@media` — so it is a targeted guard for one failure mode, not a substitute + * for looking at the page. + */ + +import { describe, expect, it } from "vitest"; +import { readFile } from "fs/promises"; +import path from "path"; + +const ASSETS = path.resolve("src/viewer/assets"); + +/** + * Pairs where `winner` must out-rank `loser` for the declared look to survive. + * `property` names what is at stake, so a failure says what breaks rather than + * just which numbers differ. + */ +const MUST_WIN: { winner: string; loser: string; property: string; note: string }[] = [ + { + winner: ".support-rail .entity-field-label", + loser: ".support-rail dt", + property: "font", + note: "declared-field labels are mono — they are frontmatter keys, not prose", + }, + { + winner: ".support-rail .entity-field-label", + loser: ".support-rail dd", + property: "color", + note: "the label is dimmer than the value it labels", + }, +]; + +/** WCAG-irrelevant but cascade-relevant: (ids, classes+attrs+pseudo-classes, elements). */ +function specificity(selector: string): [number, number, number] { + const cleaned = selector.replace(/::[a-z-]+/g, " "); + const ids = cleaned.match(/#[\w-]+/g)?.length ?? 0; + const classes = cleaned.match(/\.[\w-]+|\[[^\]]+\]|:[a-z-]+(\([^)]*\))?/g)?.length ?? 0; + const elements = cleaned + .replace(/#[\w-]+|\.[\w-]+|\[[^\]]+\]|:[a-z-]+(\([^)]*\))?/g, " ") + .split(/[\s>+~,]+/) + .filter((part) => /^[a-z][\w-]*$/i.test(part)).length; + return [ids, classes, elements]; +} + +/** True when `a` ranks at or above `b`. */ +function atLeast(a: [number, number, number], b: [number, number, number]): boolean { + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) return a[i] > b[i]; + } + return true; +} + +/** Every stylesheet the shell loads, concatenated in load order. */ +async function stylesheets(): Promise<{ name: string; text: string }[]> { + const shell = await readFile(path.join(ASSETS, "index.html"), "utf-8"); + const names = Array.from(shell.matchAll(/href="\/assets\/([\w.-]+\.css)"/g)).map((m) => m[1]); + return Promise.all( + names.map(async (name) => ({ name, text: await readFile(path.join(ASSETS, name), "utf-8") })), + ); +} + +/** The sheet a selector is declared in, plus its index in load order. */ +async function declaredIn(selector: string): Promise<{ name: string; order: number } | null> { + const sheets = await stylesheets(); + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp(`(^|[,}\\n])\\s*${escaped}\\s*[,{]`, "m"); + const index = sheets.findIndex((sheet) => pattern.test(sheet.text)); + return index === -1 ? null : { name: sheets[index].name, order: index }; +} + +describe("selectors that must win the cascade", () => { + it("loads at least two stylesheets, or the ordering check means nothing", async () => { + expect((await stylesheets()).length).toBeGreaterThan(1); + }); + + it.each(MUST_WIN)("$winner beats $loser for $property — $note", async ({ winner, loser }) => { + const winnerSite = await declaredIn(winner); + const loserSite = await declaredIn(loser); + expect(winnerSite, `${winner} is not declared in any loaded stylesheet`).not.toBeNull(); + expect(loserSite, `${loser} is not declared in any loaded stylesheet`).not.toBeNull(); + + const winnerRank = specificity(winner); + const loserRank = specificity(loser); + // Equal specificity is only safe when the winner also loads later; higher + // specificity wins regardless of order. + const wins = + atLeast(winnerRank, loserRank) && + (winnerRank.join() !== loserRank.join() || winnerSite!.order >= loserSite!.order); + expect( + wins, + `${winner} (${winnerRank.join("-")} in ${winnerSite!.name}) does not beat ` + + `${loser} (${loserRank.join("-")} in ${loserSite!.name})`, + ).toBe(true); + }); +}); + +describe("the specificity helper itself", () => { + it.each([ + [".a", [0, 1, 0]], + ["div", [0, 0, 1]], + [".support-rail dt", [0, 1, 1]], + [".support-rail .entity-field-label", [0, 2, 0]], + ["#x .a div", [1, 1, 1]], + ])("ranks %s as %j", (selector, expected) => { + expect(specificity(selector as string)).toEqual(expected); + }); +}); diff --git a/test/viewer-entity-fields-contract.test.ts b/test/viewer-entity-fields-contract.test.ts new file mode 100644 index 00000000..b8109e30 --- /dev/null +++ b/test/viewer-entity-fields-contract.test.ts @@ -0,0 +1,141 @@ +/** + * @file test/viewer-entity-fields-contract.test.ts + * @description The seam between the server's schema projection and the client's + * declared-field renderer. + * + * Everything else about this feature is tested on ONE side of that seam. + * `viewer-profile-schema.test.ts` pins what `buildPipelineEnvelope` emits; + * `viewer-entity-fields.test.ts` pins what the renderer draws — from a + * HAND-WRITTEN envelope. Both stay green if the two disagree: emit `fields` as a + * map instead of an ordered array, or rename `name` to `key`, and the projection + * suite passes, the renderer suite passes, and the feature is dead in the + * browser with nothing red. + * + * So this file writes a profile, runs the REAL projection over it, and hands the + * result to the REAL renderer as its `/api/pages` body. Nothing in the middle is + * hand-written, which is the only way the contract is actually asserted. + */ + +import { describe, expect, it } from "vitest"; +import { buildPipelineDefinitions, buildPipelineEnvelope } from "../src/viewer/pipeline.js"; +import { + flushMicrotasks, + jsonResponse, + mountViewerDom, + type FetchResponder, +} from "./fixtures/viewer-jsdom.js"; +import type { ProfilePack } from "../src/profile/types.js"; + +/** A profile exercising every facet the renderer branches on. */ +const PROFILE: ProfilePack = { + schemaVersion: 1, + profileId: "newsroom", + entities: { + articles: { + directory: "wiki/articles", + titleField: "headline", + fields: { + headline: { type: "string", required: true }, + wordCount: { type: "integer" }, + syndicated: { type: "boolean" }, + topics: { type: "string[]" }, + stage: { type: "enum", enum: ["draft", "filed"] }, + homepage: { type: "string", format: "url" }, + proofs: { type: "artifactRef", artifactTypes: ["photo"] }, + }, + }, + }, + artifacts: { photo: { fileName: "photo.json", contentKind: "json", maxBytes: 1024 } }, +}; + +/** The frontmatter of the page under test — one value per declared facet. */ +const FRONTMATTER = { + headline: "Harbour lease records released", + wordCount: 900, + syndicated: false, + topics: ["politics", "local"], + stage: "filed", + homepage: "https://example.org/story", + proofs: "photo:alpha@abc123", +}; + +/** + * Serve the envelope the SERVER would actually build, plus a typed page. + * + * `buildPipelineEnvelope` is called for real, with a summary shaped as the + * profile collector produces one — so the `profilePipeline` block the client + * receives here is byte-for-byte what `/api/pages` emits. + */ +const responder: FetchResponder = (url) => { + if (url.endsWith("/api/pages")) { + const pipeline = buildPipelineEnvelope(buildPipelineDefinitions(PROFILE), { + profileId: "newsroom", + entityCounts: { articles: 1 }, + } as never); + return jsonResponse({ + project: { title: "demo", rootName: "demo" }, + profileId: "newsroom", + counts: {}, + pages: [], + recentPages: [], + index: { available: false }, + profilePipeline: pipeline, + }); + } + if (url.endsWith("/api/health")) return jsonResponse({ lint: null }); + if (url.includes("/api/page/")) { + return jsonResponse({ + pageDirectory: "articles", + entityType: "articles", + slug: "harbour", + title: FRONTMATTER.headline, + html: "

    Released on 3 August.

    ", + warnings: [], + frontmatter: FRONTMATTER, + }); + } + return null; +}; + +/** Mount the typed page against the real projection and return the rail. */ +async function rail(): Promise { + const { dom } = await mountViewerDom(responder); + dom.window.location.hash = "#/articles/harbour"; + await flushMicrotasks(); + return dom.window.document.querySelector("[data-support-rail]") as HTMLElement; +} + +describe("the real projection drives the real renderer", () => { + it("renders a block at all, which a shape mismatch would silently prevent", async () => { + expect((await rail()).querySelector("[data-entity-fields]")).not.toBeNull(); + }); + + it("renders the declared fields in the profile's declaration order", async () => { + const labels = Array.from((await rail()).querySelectorAll("[data-entity-fields] dt")).map( + (node) => node.textContent, + ); + // `headline` is absent: it is the declared title, already shown as the heading. + expect(labels).toEqual(["wordCount", "syndicated", "topics", "stage", "homepage", "proofs"]); + }); + + it("branches on each declared type as projected", async () => { + const block = (await rail()).querySelector("[data-entity-fields]") as HTMLElement; + expect(block.querySelector(".entity-field-state")?.textContent).toBe("filed"); + expect(block.querySelectorAll(".entity-field-list li")).toHaveLength(2); + expect(block.textContent).toContain("No"); + expect(block.querySelector(".entity-field-unresolved")).not.toBeNull(); + }); + + it("linkifies the field whose declared format survived the projection", async () => { + const block = (await rail()).querySelector("[data-entity-fields]") as HTMLElement; + const anchor = block.querySelector("a"); + expect(anchor?.getAttribute("href")).toBe("https://example.org/story"); + expect(anchor?.getAttribute("rel")).toBe("noopener noreferrer"); + }); + + it("suppresses the declared title, which the heading already carries", async () => { + const doc = (await rail()).ownerDocument; + expect(doc.querySelector("[data-main-pane] h1")?.textContent).toBe(FRONTMATTER.headline); + expect((await rail()).textContent).not.toContain("headline"); + }); +}); diff --git a/test/viewer-entity-fields.test.ts b/test/viewer-entity-fields.test.ts new file mode 100644 index 00000000..dc88bf10 --- /dev/null +++ b/test/viewer-entity-fields.test.ts @@ -0,0 +1,462 @@ +/** + * @file test/viewer-entity-fields.test.ts + * @description A typed entity page renders the fields its PROFILE declares. + * + * Before this, a typed page's own data was invisible. The support rail renders a + * FIXED nine-field list — `kind`, `sources`, `confidence`, `provenanceState`, + * `contradictedBy`, `tags`, `aliases`, `createdAt`, `updatedAt` — which is the + * DEFAULT profile's vocabulary. A paper's authors, year, DOI and stage are none + * of those, so the page showed its body and a rail describing a contract it was + * never under. + * + * The renderer dispatches on the declared `FieldDef.type` and NEVER on a field + * name. That is the whole design constraint: a renderer that knew what `doi` or + * `hypothesis` meant would work for one profile and quietly do nothing for the + * next, and `src/` is barred from naming a domain vocabulary at all + * (test/no-research-branch-in-core.test.ts). Every case below is therefore + * written against a made-up profile, not against a shipped one. + * + * Three value states are reachable and no more. A record violating its declared + * field contract never becomes a viewer page — `collectTypedViewerInputs` filters + * it and it surfaces as a `field-violation` problem — so a rendered field is + * either present, absent-and-optional (omitted), or an artifact reference this + * slice does not resolve. + */ + +import { describe, expect, it } from "vitest"; +import { + flushMicrotasks, + jsonResponse, + mountViewerDom, + type FetchResponder, +} from "./fixtures/viewer-jsdom.js"; + +/** A type declaring one field of every renderable shape, in a made-up vocabulary. */ +const FIELDS = [ + { name: "headline", type: "string" }, + { name: "wordCount", type: "integer" }, + { name: "rating", type: "number" }, + { name: "syndicated", type: "boolean" }, + { name: "filedOn", type: "date" }, + { name: "topics", type: "string[]" }, + { name: "stage", type: "enum", enum: ["draft", "filed"] }, + { name: "proofs", type: "artifactRef" }, +]; + +/** Options for {@link mountPage}: what the profile declares, and what the page carries. */ +interface PageCase { + /** Declared fields of `articles`; omitted entirely for a default-profile envelope. */ + fields?: Record[]; + /** The page's raw frontmatter. */ + frontmatter: Record; + /** The page's directory. A typed page also reports it as `entityType`. */ + directory?: string; + /** The key this type titles pages by, if any. */ + titleField?: string; +} + +/** + * Serve a bootstrap envelope and one page, then mount and route to that page. + * + * One builder for every case here — declared fields, no declarations, a default + * page, an overlapping key — because four near-identical responders drifted the + * moment one of them needed a different field type. + */ +async function mountPage({ fields, frontmatter, directory = "articles", titleField }: PageCase): Promise { + const typed = directory !== "concepts"; + const responder: FetchResponder = (url) => { + if (url.endsWith("/api/pages")) { + return jsonResponse({ + project: { title: "demo", rootName: "demo" }, + counts: {}, + pages: [], + recentPages: [], + index: { available: false }, + ...(fields + ? { + profilePipeline: { + entityTypes: [ + { + type: "articles", + directory: "wiki/articles", + pageCount: 1, + fields, + ...(titleField ? { titleField } : {}), + }, + ], + }, + } + : {}), + }); + } + if (url.endsWith("/api/health")) return jsonResponse({ lint: null }); + if (url.includes("/api/page/")) { + return jsonResponse({ + pageDirectory: directory, + ...(typed ? { entityType: directory } : {}), + slug: "alpha", + title: "Alpha", + html: "

    Body.

    ", + warnings: [], + frontmatter, + }); + } + return null; + }; + const { dom } = await mountViewerDom(responder); + dom.window.location.hash = `#/${directory}/alpha`; + await flushMicrotasks(); + return dom.window.document; +} + +/** Mount a page declaring {@link FIELDS} and return its declared-fields block. */ +async function fieldsFor(frontmatter: Record): Promise { + const doc = await mountPage({ fields: FIELDS, frontmatter }); + return doc.querySelector("[data-entity-fields]"); +} + +/** The `
    `/`
    ` text pairs of a declared-fields block, in render order. */ +function rows(block: HTMLElement | null): [string, string][] { + const terms = Array.from(block?.querySelectorAll("dt") ?? []).map((n) => n.textContent ?? ""); + const values = Array.from(block?.querySelectorAll("dd") ?? []).map((n) => n.textContent ?? ""); + return terms.map((term, index) => [term, values[index]]); +} + +const FULL_RECORD = { + headline: "Alpha", + wordCount: 900, + rating: 4.5, + syndicated: false, + filedOn: "2026-08-01", + topics: ["politics", "local"], + stage: "filed", +}; + +describe("a typed page renders the fields its profile declares", () => { + it("renders every declared field the record carries, in declaration order", async () => { + const block = await fieldsFor(FULL_RECORD); + expect(rows(block).map(([label]) => label)).toEqual([ + "headline", + "wordCount", + "rating", + "syndicated", + "filedOn", + "topics", + "stage", + ]); + }); + + it("labels each row with the declared key verbatim, not a prettified name", async () => { + const block = await fieldsFor({ wordCount: 900 }); + expect(rows(block).map(([label]) => label)).toEqual(["wordCount"]); + }); + + it("renders scalars as their own text", async () => { + const byLabel = Object.fromEntries(rows(await fieldsFor(FULL_RECORD))); + expect(byLabel.headline).toBe("Alpha"); + expect(byLabel.wordCount).toBe("900"); + expect(byLabel.rating).toBe("4.5"); + expect(byLabel.filedOn).toBe("2026-08-01"); + }); + + // A bare `false` in a metadata list reads as a rendering fault rather than as + // the value the page declares. + it("renders a boolean as a word, including when it is false", async () => { + expect(Object.fromEntries(rows(await fieldsFor({ syndicated: false }))).syndicated).toBe("No"); + expect(Object.fromEntries(rows(await fieldsFor({ syndicated: true }))).syndicated).toBe("Yes"); + }); + + it("renders an array as a list, so an entry boundary stays visible", async () => { + const block = await fieldsFor({ topics: ["politics", "local"] }); + expect(Array.from(block?.querySelectorAll("li") ?? []).map((n) => n.textContent)).toEqual([ + "politics", + "local", + ]); + }); + + it("renders an enum value as a state chip rather than bare text", async () => { + const block = await fieldsFor({ stage: "filed" }); + expect(block?.querySelector(".entity-field-state")?.textContent).toBe("filed"); + }); +}); + +describe("the three reachable value states", () => { + it("omits a declared field the record does not carry", async () => { + const block = await fieldsFor({ headline: "Alpha" }); + expect(rows(block).map(([label]) => label)).toEqual(["headline"]); + }); + + it("omits a declared field holding an empty string or empty list", async () => { + const block = await fieldsFor({ headline: "Alpha", topics: [], filedOn: " " }); + expect(rows(block).map(([label]) => label)).toEqual(["headline"]); + }); + + it("names an artifact reference but marks it unresolved, never verified", async () => { + const block = await fieldsFor({ proofs: "photo:alpha@abc123" }); + expect(block?.querySelector(".entity-field-ref")?.textContent).toBe("photo:alpha@abc123"); + expect(block?.querySelector(".entity-field-unresolved")?.textContent).toContain("not verified"); + }); + + it("renders no block at all when the record carries none of its declared fields", async () => { + expect(await fieldsFor({})).toBeNull(); + }); + + // The type is read off the wire, so an inherited `Object` property must not + // resolve to a renderer: `__proto__` would throw when called and `constructor` + // would invoke `Object`. Neither is reachable from a well-formed envelope, and + // neither may break the page render. + it("falls back to text for a declared type naming an inherited Object property", async () => { + for (const type of ["__proto__", "constructor", "toString"]) { + const doc = await mountPage({ + fields: [{ name: "odd", type }], + frontmatter: { odd: "a value" }, + }); + const block = doc.querySelector("[data-entity-fields]"); + expect(block?.textContent, type).toContain("a value"); + } + }); + + // The same hazard on the other half of a declaration. The profile schema + // constrains a field's TYPE to a closed enum but puts no `propertyNames` on + // `fields`, so the NAME is the open half: a type may declare `constructor`, + // and frontmatter arrives as JSON off the wire, carrying `Object.prototype`. + // A bare `frontmatter[def.name]` therefore resolves an inherited member and + // renders a value the record does not carry — the one thing this surface + // exists not to do. Same class as the `titleField` lookup fixed in #187. + it("omits a declared field naming an inherited Object property when the record lacks it", async () => { + for (const name of ["constructor", "toString", "valueOf"]) { + const doc = await mountPage({ + fields: [{ name, type: "string" }], + frontmatter: { headline: "Ada" }, + }); + const block = doc.querySelector("[data-entity-fields]"); + expect(block?.textContent ?? "", name).not.toContain("native code"); + expect(block?.textContent ?? "", name).not.toContain("function"); + } + }); + + it("still renders such a field when the record genuinely carries it", async () => { + const doc = await mountPage({ + fields: [{ name: "constructor", type: "string" }], + frontmatter: { constructor: "hand-built" }, + }); + + expect(rows(doc.querySelector("[data-entity-fields]"))).toEqual([["constructor", "hand-built"]]); + }); +}); + +describe("the block is confined to typed pages", () => { + it("renders nothing for a default concept page, which declares no fields", async () => { + const doc = await mountPage({ + directory: "concepts", + frontmatter: { tags: ["a"], sources: ["s.md"] }, + }); + expect(doc.querySelector("[data-entity-fields]")).toBeNull(); + }); + + it("still renders the default rail's own fields on that page", async () => { + const doc = await mountPage({ directory: "concepts", frontmatter: { tags: ["a", "b"] } }); + const rail = doc.querySelector("[data-support-rail]"); + expect(rail?.textContent).toContain("Tags"); + expect(rail?.textContent).toContain("a, b"); + }); +}); + +/** + * The fixed rail list and the declared list can name the same key: a profile is + * free to declare `tags` or `updatedAt`. Nothing a page displays today may + * disappear, and nothing may be stated twice — so a declared name wins and the + * fixed row for it stands down, while an UNDECLARED extra the page happens to + * carry keeps rendering exactly as before. + */ +describe("a declared field and the fixed rail list never state the same key twice", () => { + /** Mount a page whose type declares `tags` as a real field, and return the rail. */ + async function overlapRail(frontmatter: Record): Promise { + const doc = await mountPage({ fields: [{ name: "tags", type: "string[]" }], frontmatter }); + return doc.querySelector("[data-support-rail]") as HTMLElement; + } + + it("states a declared name once, in the declared block", async () => { + const rail = await overlapRail({ tags: ["politics"] }); + expect(rail.querySelectorAll("[data-entity-fields] dt")).toHaveLength(1); + expect(rail.textContent?.match(/politics/g)).toHaveLength(1); + }); + + it("drops the fixed row for a name the profile declares", async () => { + const rail = await overlapRail({ tags: ["politics"] }); + expect(rail.textContent).not.toContain("Tags"); + }); + + it("still renders an undeclared extra the page carries", async () => { + const rail = await overlapRail({ tags: ["politics"], aliases: ["alpha-story"] }); + expect(rail.textContent).toContain("Aliases"); + expect(rail.textContent).toContain("alpha-story"); + }); +}); + +/** + * A declared `format` turns a value into an external link. The guard lives in + * `viewer-field-format.js` and is unit-tested there; these pin that the renderer + * consults it, carries the safety attributes, and falls back to TEXT — never to + * a link built anyway — when the guard declines. + */ +describe("a declared format renders as a safe external link", () => { + /** Mount a page whose `locator` field declares `format`, typed for its value. */ + async function blockFor(format: string | undefined, locator: unknown): Promise { + const type = Array.isArray(locator) ? "string[]" : "string"; + const field = format === undefined ? { name: "locator", type } : { name: "locator", type, format }; + const doc = await mountPage({ fields: [field], frontmatter: { locator } }); + return doc.querySelector("[data-entity-fields]") as HTMLElement; + } + + it("links a doi through its resolver, showing the id the page declares", async () => { + const anchor = (await blockFor("doi", "10.1000/xyz123")).querySelector("a"); + expect(anchor?.getAttribute("href")).toBe("https://doi.org/10.1000/xyz123"); + expect(anchor?.textContent).toBe("10.1000/xyz123"); + }); + + it("carries noopener noreferrer, since the value comes from page content", async () => { + const anchor = (await blockFor("url", "https://example.org/a")).querySelector("a"); + expect(anchor?.getAttribute("rel")).toBe("noopener noreferrer"); + expect(anchor?.getAttribute("target")).toBe("_blank"); + }); + + it("renders a refused value as text, never as a link built anyway", async () => { + const block = await blockFor("url", "javascript:alert(1)"); + expect(block.querySelector("a")).toBeNull(); + expect(block.textContent).toContain("javascript:alert(1)"); + }); + + it("renders an unformatted field as text", async () => { + const block = await blockFor(undefined, "https://example.org/a"); + expect(block.querySelector("a")).toBeNull(); + }); + + it("links each entry of a formatted list independently", async () => { + const block = await blockFor("url", ["https://a.test/", "not-a-url"]); + const anchors = Array.from(block.querySelectorAll("a")); + expect(anchors.map((a) => a.getAttribute("href"))).toEqual(["https://a.test/"]); + expect(block.textContent).toContain("not-a-url"); + }); +}); + +/** + * Findings from review, each pinned so the fix cannot quietly regress. + */ +describe("the declared block survives a cold deep link", () => { + /** + * `main()` renders the route twice — once immediately, once after `/api/pages` + * settles — and `unsettledOrPageRoute` lets a page route resolve without the + * envelope. Each pass issues its own `/api/page` fetch, with no ordering + * guarantee, so this drives the awkward order: the FIRST page response is held + * until after the second has already painted. + * + * HONEST SCOPE: this does not discriminate the current implementation. The + * declared fields are resolved after the page fetch resolves, so a pass that + * could read a stale envelope is also a pass that paints before the + * settle-triggered one — the two conditions are mutually exclusive and the + * later paint always carries the fields. What it pins is the OUTCOME a cold + * deep link must reach whatever the ordering, which is what would break if the + * resolution were ever moved ahead of the fetch. + */ + it("ends with the declared fields when the first page response lands last", async () => { + let releaseEnvelope: () => void = () => {}; + let releaseFirstPage: () => void = () => {}; + const envelopeGate = new Promise((resolve) => (releaseEnvelope = resolve)); + const firstPageGate = new Promise((resolve) => (releaseFirstPage = resolve)); + let pageCalls = 0; + + const envelope = { + project: {}, + counts: {}, + pages: [], + recentPages: [], + index: {}, + profilePipeline: { + entityTypes: [ + { + type: "articles", + directory: "wiki/articles", + pageCount: 1, + fields: [{ name: "headline", type: "string" }], + }, + ], + }, + }; + const page = { + pageDirectory: "articles", + entityType: "articles", + slug: "alpha", + title: "Alpha", + html: "

    Body.

    ", + warnings: [], + frontmatter: { headline: "Alpha" }, + }; + + const responder: FetchResponder = (url) => { + if (url.endsWith("/api/pages")) { + return envelopeGate.then(() => jsonResponse(envelope)) as unknown as Response; + } + if (url.endsWith("/api/health")) return jsonResponse({ lint: null }); + if (url.includes("/api/page/")) { + pageCalls += 1; + return pageCalls === 1 + ? (firstPageGate.then(() => jsonResponse(page)) as unknown as Response) + : jsonResponse(page); + } + return null; + }; + + const { dom, flush } = await mountViewerDom(responder, "#/articles/alpha"); + releaseEnvelope(); + await flush(); + releaseFirstPage(); + await flush(); + await flush(); + const block = dom.window.document.querySelector("[data-entity-fields]"); + expect(block?.textContent).toContain("headline"); + }); +}); + +describe("an artifactRef list is marked unresolved like a single ref", () => { + // `renderList` would have rendered the entries as ordinary scalars, showing an + // `artifactRef[]` indistinguishably from a verified value — the exact claim + // `renderArtifactRef` exists to avoid making. + it("names each ref and says once that none were verified", async () => { + const doc = await mountPage({ + fields: [{ name: "results", type: "artifactRef[]" }], + frontmatter: { results: ["result:a@aaa", "result:b@bbb"] }, + }); + const block = doc.querySelector("[data-entity-fields]") as HTMLElement; + expect(Array.from(block.querySelectorAll(".entity-field-ref")).map((n) => n.textContent)).toEqual( + ["result:a@aaa", "result:b@bbb"], + ); + expect(block.querySelectorAll(".entity-field-unresolved")).toHaveLength(1); + }); +}); + +describe("the heading and the rail never state the title twice", () => { + /** Mount a page whose type titles by `headline`, and return the rail. */ + async function titledRail(): Promise { + const doc = await mountPage({ + fields: [ + { name: "headline", type: "string" }, + { name: "byline", type: "string" }, + ], + frontmatter: { headline: "Alpha", byline: "R. Reporter" }, + titleField: "headline", + }); + return doc.querySelector("[data-support-rail]") as HTMLElement; + } + + it("omits the row for the field the heading already shows", async () => { + const rail = await titledRail(); + expect(rail.textContent).not.toContain("headline"); + }); + + it("still renders every other declared field", async () => { + const rail = await titledRail(); + expect(rail.textContent).toContain("byline"); + expect(rail.textContent).toContain("R. Reporter"); + }); +}); diff --git a/test/viewer-field-format.test.ts b/test/viewer-field-format.test.ts new file mode 100644 index 00000000..01c37548 --- /dev/null +++ b/test/viewer-field-format.test.ts @@ -0,0 +1,135 @@ +/** + * @file test/viewer-field-format.test.ts + * @description `formatHref` — the only place the viewer builds a URL out of + * page content, and therefore the only place with a security contract. + * + * The FORMAT comes from the profile; the VALUE comes from a wiki page, i.e. from + * whatever an author or a connector wrote. So the value is untrusted input and + * every branch fails closed: a `url` must parse and carry an http(s) scheme, and + * a `doi`/`arxiv` id must match its identifier grammar AND resolve to the fixed + * resolver origin. + * + * The containment is the ORIGIN check, not the grammar. That distinction is + * load-bearing: an earlier version tried to contain by forbidding `/`, `?` and + * `#` in a DOI suffix, which does not make the link safer (the origin is a + * literal) and silently dropped real DOIs like `10.5061/dryad.abc/1` to plain + * text. The grammar now identifies; the origin check contains. + * + * Returning null means "render as text", which is the answer whenever the guard + * is not certain — a value shown as text is merely unhelpful, while a value + * linked wrongly is a live `javascript:` or an off-site redirect. + * + * The renderer guards independently of profile validation. Validation rejects an + * unknown format at load, but this module must not trust that a validator ran + * somewhere upstream: `/api/pages` is a wire boundary, and code on the far side + * of one re-checks. + */ + +import { describe, expect, it } from "vitest"; +import { formatHref } from "../src/viewer/assets/viewer-field-format.js"; + +describe("formatHref resolves the declared vocabulary", () => { + it("resolves a doi through the fixed doi.org origin", () => { + expect(formatHref("doi", "10.1000/xyz123")).toBe("https://doi.org/10.1000/xyz123"); + }); + + it("resolves a modern and a legacy arxiv id through the fixed arxiv origin", () => { + expect(formatHref("arxiv", "1706.03762")).toBe("https://arxiv.org/abs/1706.03762"); + expect(formatHref("arxiv", "2401.01234v2")).toBe("https://arxiv.org/abs/2401.01234v2"); + expect(formatHref("arxiv", "math.GT/0309136")).toBe("https://arxiv.org/abs/math.GT/0309136"); + }); + + it("passes an http(s) url through unchanged", () => { + expect(formatHref("url", "https://example.org/a?b=c#d")).toBe("https://example.org/a?b=c#d"); + expect(formatHref("url", "http://example.org/a")).toBe("http://example.org/a"); + }); + + it("trims surrounding whitespace before resolving", () => { + expect(formatHref("doi", " 10.1000/xyz123 ")).toBe("https://doi.org/10.1000/xyz123"); + }); +}); + +describe("formatHref refuses anything it is not certain about", () => { + it("refuses a non-http scheme rather than linking it", () => { + expect(formatHref("url", "javascript:alert(1)")).toBeNull(); + expect(formatHref("url", "data:text/html,")).toBeNull(); + expect(formatHref("url", "file:///etc/passwd")).toBeNull(); + expect(formatHref("url", "vbscript:msgbox(1)")).toBeNull(); + }); + + it("refuses a url that does not parse as absolute", () => { + expect(formatHref("url", "example.org/a")).toBeNull(); + expect(formatHref("url", "/relative/path")).toBeNull(); + }); + + // A resolver path is built by concatenation, so an id carrying a separator, + // whitespace, or a scheme could otherwise steer the final URL. + it("refuses a doi or arxiv id that could steer its resolver path", () => { + expect(formatHref("doi", "../../etc/passwd")).toBeNull(); + expect(formatHref("doi", "10.1000/x https://evil.test")).toBeNull(); + expect(formatHref("arxiv", "1706.03762 https://evil.test")).toBeNull(); + expect(formatHref("arxiv", "../1706.03762")).toBeNull(); + expect(formatHref("arxiv", "https://evil.test")).toBeNull(); + }); + + // Containment is the ORIGIN check, not the identifier grammar — so it must + // hold for a value that clears the grammar and still tries to re-base. + it("refuses anything that leaves the fixed resolver origin", () => { + expect(formatHref("doi", "10.1000/x")).toBe("https://doi.org/10.1000/x"); + for (const escape of ["//evil.test/x", "https://evil.test", "\\\\evil.test"]) { + expect(formatHref("doi", escape), escape).toBeNull(); + expect(formatHref("arxiv", escape), escape).toBeNull(); + } + }); + + it("refuses a doi that does not match the registrant grammar", () => { + expect(formatHref("doi", "not-a-doi")).toBeNull(); + expect(formatHref("doi", "10.1/short")).toBeNull(); + expect(formatHref("doi", "11.1000/wrong-prefix")).toBeNull(); + }); + + it("refuses an unknown format even though validation should have caught it", () => { + expect(formatHref("isbn", "978-3-16-148410-0")).toBeNull(); + expect(formatHref("", "https://example.org")).toBeNull(); + expect(formatHref(undefined as unknown as string, "https://example.org")).toBeNull(); + }); + + it("refuses an empty, blank, or non-string value", () => { + expect(formatHref("doi", "")).toBeNull(); + expect(formatHref("doi", " ")).toBeNull(); + expect(formatHref("url", 42 as unknown as string)).toBeNull(); + expect(formatHref("url", null as unknown as string)).toBeNull(); + }); + + // `__proto__` and friends resolve on a plain object literal; the resolver + // table must not treat them as declared formats. + it("refuses an inherited Object property masquerading as a format", () => { + expect(formatHref("__proto__", "https://example.org")).toBeNull(); + expect(formatHref("constructor", "https://example.org")).toBeNull(); + expect(formatHref("toString", "https://example.org")).toBeNull(); + }); +}); + +/** + * A DOI suffix may contain almost any character, slashes included. An + * over-tight grammar does not make the link safer — the origin is a fixed + * literal — it just drops valid identifiers to plain text with no explanation. + */ +describe("formatHref resolves real-world DOI suffixes", () => { + it("links a suffix containing a slash", () => { + expect(formatHref("doi", "10.5061/dryad.abc/1")).toBe("https://doi.org/10.5061/dryad.abc/1"); + }); + + it("links the punctuation-heavy legacy Wiley form", () => { + const doi = "10.1002/(SICI)1097-0258(19980815)17:15<1661::AID-SIM968>3.0.CO;2-2"; + const href = formatHref("doi", doi); + expect(href).not.toBeNull(); + expect(href!.startsWith("https://doi.org/10.1002/")).toBe(true); + }); + + it("normalises percent-encoding, so the validated string is the navigated one", () => { + const href = formatHref("doi", "10.1000/a b".replace(" ", "%20")); + expect(href).toBe("https://doi.org/10.1000/a%20b"); + }); + +}); diff --git a/test/viewer-pack.test.ts b/test/viewer-pack.test.ts index b4d422bb..5d0a797c 100644 --- a/test/viewer-pack.test.ts +++ b/test/viewer-pack.test.ts @@ -54,6 +54,11 @@ const REQUIRED_ASSETS = [ "dist/viewer/assets/viewer-pipeline-model.js", "dist/viewer/assets/viewer-pipeline.css", "dist/viewer/assets/viewer-graph.css", + // Imported by viewer-rail.js. A page route fails to render without them, and + // the failure would be a bare import 404 in the browser console rather than + // anything the server reports — which is why they are guarded here. + "dist/viewer/assets/viewer-entity-fields.js", + "dist/viewer/assets/viewer-field-format.js", ]; interface PackEntry { diff --git a/test/viewer-pipeline-envelope.test.ts b/test/viewer-pipeline-envelope.test.ts index add80058..bf5138c3 100644 --- a/test/viewer-pipeline-envelope.test.ts +++ b/test/viewer-pipeline-envelope.test.ts @@ -18,15 +18,30 @@ import { afterEach, describe, expect, it } from "vitest"; import { rm } from "node:fs/promises"; import { buildViewerSnapshot } from "../src/viewer/snapshot.js"; import { startViewerServer } from "../src/viewer/server.js"; -import { buildPipelineDefinitions } from "../src/viewer/pipeline.js"; +import { buildPipelineDefinitions, buildPipelineEnvelope } from "../src/viewer/pipeline.js"; import type { ProfilePack } from "../src/profile/types.js"; import { makeTempRoot } from "./fixtures/temp-root.js"; import { writeMarkdownPage, writeProfileFile } from "./fixtures/profile-fixtures.js"; import { PIPELINE_PROFILE, seedPipelinePages } from "./fixtures/pipeline-project.js"; +interface PipelineFieldRow { + name: string; + type: string; + required?: boolean; + enum?: string[]; + artifactTypes?: string[]; +} +interface PipelineArtifactRow { + type: string; + fileName: string; + contentKind: string; + metadata?: PipelineFieldRow[]; +} interface PipelineRow { type: string; directory: string; + titleField?: string; + fields?: PipelineFieldRow[]; pageCount: number; stateCounts?: Record; lifecycle?: { @@ -40,6 +55,7 @@ interface PipelineRow { interface PipelineEnvelope { entityTypes: PipelineRow[]; relationTypes?: { type: string; from: string[]; to: string[]; direction: string; count: number }[]; + artifactTypes?: PipelineArtifactRow[]; } const handles: { close(): Promise }[] = []; @@ -117,6 +133,33 @@ describe("/api/pages — profilePipeline on a profile project", () => { expect(onWire).toEqual(declared); }); + // Counts are joined onto declarations by indexing summary maps with a + // PROFILE-DECLARED type name, and the schema puts no `propertyNames` on the + // entity map — so a type named `constructor` resolves an inherited member on a + // bare index. The join then reports a function as the count, which + // `JSON.stringify` drops, so the row reaches the client missing the key it is + // required to carry. Same class as the `titleField` lookup fixed in #187. + it("counts a type whose name is an inherited Object property as zero, not as a function", () => { + const odd: ProfilePack = { + ...PIPELINE_PROFILE, + entities: { + ...PIPELINE_PROFILE.entities, + constructor: { ...PIPELINE_PROFILE.entities.desks, directory: "wiki/odd" }, + }, + }; + const envelope = buildPipelineEnvelope(buildPipelineDefinitions(odd), { + profileId: "p", + entityCounts: {}, + relationCounts: {}, + lifecycleStates: {}, + } as never); + const row = envelope?.entityTypes.find((r) => r.type === "constructor"); + + expect(row?.pageCount).toBe(0); + expect(row).not.toHaveProperty("stateCounts"); + expect(JSON.parse(JSON.stringify(row))).toHaveProperty("pageCount", 0); + }); + it("carries a directory that differs from the type id verbatim", () => { const renamed: ProfilePack = { ...PIPELINE_PROFILE, @@ -129,6 +172,23 @@ describe("/api/pages — profilePipeline on a profile project", () => { expect(definitions.entityTypes.find((d) => d.type === "desks")?.directory).toBe("desks-v2"); }); + // The declared SCHEMA half of the block. `buildPipelineDefinitions` is unit + // tested in viewer-profile-schema.test.ts; these two assert it survives the + // join onto counts and reaches the wire, which is a separate function. + it("carries each type's declared titleField and fields through to the wire", async () => { + const row = await rowFor("articles"); + expect(row.titleField).toBe(PIPELINE_PROFILE.entities.articles.titleField); + expect(row.fields?.map((field) => field.name)).toEqual( + Object.keys(PIPELINE_PROFILE.entities.articles.fields ?? {}), + ); + }); + + it("carries the profile's declared artifact types alongside the entity rows", async () => { + const pipeline = await pipelineEnvelope(); + const declared = Object.keys(PIPELINE_PROFILE.artifacts ?? {}); + expect(pipeline.artifactTypes?.map((row) => row.type) ?? []).toEqual(declared); + }); + it("carries relation endpoints and direction from the profile", async () => { const pipeline = await pipelineEnvelope(); expect(pipeline.relationTypes).toEqual([ diff --git a/test/viewer-profile-schema.test.ts b/test/viewer-profile-schema.test.ts new file mode 100644 index 00000000..40f18f53 --- /dev/null +++ b/test/viewer-profile-schema.test.ts @@ -0,0 +1,188 @@ +/** + * @file test/viewer-profile-schema.test.ts + * @description The declared-SCHEMA half of the `profilePipeline` block: each + * entity type's `titleField` and declared fields, plus the profile's artifact + * type declarations. + * + * These ride on `profilePipeline` rather than in a separate `profileSchema` + * block because four of the six things a schema read model needs — `type`, + * `directory`, the lifecycle, and the relation endpoints — already ship there. + * Two blocks would put the lifecycle on the wire twice and give a future reader + * two sources that can disagree about it. + * + * The field projection is BOUNDED: `default` can carry an arbitrary + * author-supplied value that was never meant for a read surface, and `min`/`max` + * are validation bounds nothing renders. That boundary is enforced by the type + * system rather than by these tests alone — a facet added to `FieldDef` fails to + * compile until it is explicitly projected or explicitly dropped — but the tests + * below pin the resulting wire shape so a widening is visible in a diff. + */ + +import { describe, expect, it } from "vitest"; +import { buildPipelineDefinitions, buildPipelineEnvelope } from "../src/viewer/pipeline.js"; +import type { ProfilePack } from "../src/profile/types.js"; + +/** A pack with one titled type carrying every projected field facet, plus artifacts. */ +const PACK: ProfilePack = { + schemaVersion: 1, + profileId: "newsroom", + entities: { + articles: { + directory: "wiki/articles", + titleField: "headline", + fields: { + headline: { type: "string", required: true }, + wordCount: { type: "integer", min: 0, max: 5000 }, + stage: { type: "enum", enum: ["draft", "filed"], default: "draft" }, + assets: { type: "artifactRef[]", artifactTypes: ["photo"] }, + homepage: { type: "string", format: "url" }, + }, + }, + desks: { directory: "wiki/desks" }, + }, + artifacts: { + photo: { + fileName: "photo.json", + contentKind: "json", + maxBytes: 65536, + metadata: { credit: { type: "string", required: true } }, + }, + transcript: { fileName: "transcript.txt", contentKind: "text", maxBytes: 262144 }, + }, +}; + +/** The projected row for one entity type. */ +function typeRow(type: string) { + return buildPipelineDefinitions(PACK).entityTypes.find((row) => row.type === type)!; +} + +describe("profilePipeline carries each type's declared schema", () => { + it("carries the declared titleField", () => { + expect(typeRow("articles").titleField).toBe("headline"); + }); + + it("carries fields as an ordered array, in declaration order", () => { + expect(typeRow("articles").fields?.map((field) => field.name)).toEqual([ + "headline", + "wordCount", + "stage", + "assets", + "homepage", + ]); + }); + + it("carries type, required, enum, artifactTypes and format verbatim", () => { + const byName = Object.fromEntries( + (typeRow("articles").fields ?? []).map((field) => [field.name, field]), + ); + expect(byName.headline).toEqual({ name: "headline", type: "string", required: true }); + expect(byName.stage).toEqual({ name: "stage", type: "enum", enum: ["draft", "filed"] }); + expect(byName.assets).toEqual({ + name: "assets", + type: "artifactRef[]", + artifactTypes: ["photo"], + }); + expect(byName.homepage).toEqual({ name: "homepage", type: "string", format: "url" }); + }); + + it("omits both keys for a type declaring neither", () => { + expect(typeRow("desks")).not.toHaveProperty("titleField"); + expect(typeRow("desks")).not.toHaveProperty("fields"); + }); +}); + +describe("the field projection is bounded", () => { + // A `default` is author-supplied data that never had a read surface in mind, + // and `min`/`max` are write-time bounds no renderer draws. Sending either + // would widen the surface for nothing. + it("drops a declared default", () => { + const stage = typeRow("articles").fields?.find((field) => field.name === "stage"); + expect(stage).not.toHaveProperty("default"); + }); + + it("drops numeric validation bounds", () => { + const wordCount = typeRow("articles").fields?.find((field) => field.name === "wordCount"); + expect(wordCount).toEqual({ name: "wordCount", type: "integer" }); + }); +}); + +describe("profilePipeline carries the profile's artifact declarations", () => { + /** The projected artifact rows. */ + function artifactRows() { + return buildPipelineDefinitions(PACK).artifactTypes ?? []; + } + + it("carries every declared artifact type, in declaration order", () => { + expect(artifactRows().map((row) => row.type)).toEqual(["photo", "transcript"]); + }); + + it("carries the filename and content kind", () => { + const photo = artifactRows().find((row) => row.type === "photo"); + expect(photo?.fileName).toBe("photo.json"); + expect(photo?.contentKind).toBe("json"); + }); + + it("carries the declared metadata schema through the same field projection", () => { + const photo = artifactRows().find((row) => row.type === "photo"); + expect(photo?.metadata).toEqual([{ name: "credit", type: "string", required: true }]); + }); + + it("omits metadata for an artifact type declaring none", () => { + expect(artifactRows().find((row) => row.type === "transcript")).not.toHaveProperty("metadata"); + }); + + it("omits the block entirely for a profile declaring no artifact types", () => { + const bare: ProfilePack = { + schemaVersion: 1, + profileId: "newsroom", + entities: { desks: { directory: "wiki/desks" } }, + }; + expect(buildPipelineDefinitions(bare)).not.toHaveProperty("artifactTypes"); + }); +}); + +/** + * The join onto counts is a separate function from the projection, and an + * artifact type has no count to join — so it can be dropped there without any + * projection test noticing. The shipped newsroom profile declares no artifact + * types, which is why the integration test over that fixture cannot cover this. + */ +describe("the envelope carries the schema through the count join", () => { + /** The wire block for `PACK`, with a summary that reports no pages at all. */ + function envelope() { + return buildPipelineEnvelope(buildPipelineDefinitions(PACK), { + profileId: "newsroom", + entityCounts: {}, + } as never); + } + + it("carries the artifact declarations, which have no count to join", () => { + expect(envelope()?.artifactTypes?.map((row) => row.type)).toEqual(["photo", "transcript"]); + }); + + it("carries each type's titleField and fields alongside its counts", () => { + const row = envelope()?.entityTypes.find((entry) => entry.type === "articles"); + expect(row?.titleField).toBe("headline"); + expect(row?.fields?.map((field) => field.name)).toEqual([ + "headline", + "wordCount", + "stage", + "assets", + "homepage", + ]); + expect(row?.pageCount).toBe(0); + }); + + it("omits the artifact block for a profile declaring none", () => { + const bare: ProfilePack = { + schemaVersion: 1, + profileId: "newsroom", + entities: { desks: { directory: "wiki/desks" } }, + }; + const built = buildPipelineEnvelope(buildPipelineDefinitions(bare), { + profileId: "newsroom", + entityCounts: {}, + } as never); + expect(built).not.toHaveProperty("artifactTypes"); + }); +});