// Walk one `template.output` node (+ root) into the NEUTRAL TemplateDocData // shape the `docs/template-page.md` Mustache template consumes. The attr reads // MIRROR render-helper-file.ts / templates/render-helper.ts (@kind, @payloadRef, // @textRef, @format, @maxChars, @requiredTags; for email @subjectRef / // @htmlBodyRef / @textBodyRef) — but this builder emits DESCRIPTION, never code: // no helper signatures, no language types. `capability` is a FIXED, neutral // sentence per @kind. import { type MetaData, type MetaRoot, TEMPLATE_ATTR_PAYLOAD_REF, TEMPLATE_ATTR_TEXT_REF, TEMPLATE_ATTR_FORMAT, TEMPLATE_ATTR_MAX_CHARS, TEMPLATE_ATTR_KIND, TEMPLATE_KIND_EMAIL, TEMPLATE_KIND_DOCUMENT, TEMPLATE_KIND_DEFAULT, TEMPLATE_ATTR_SUBJECT_REF, TEMPLATE_ATTR_HTML_BODY_REF, TEMPLATE_ATTR_TEXT_BODY_REF, TEMPLATE_ATTR_REQUIRED_TAGS, DOC_ATTR_DESCRIPTION, stripPackage, } from "@metaobjectsdev/metadata"; import { TYPE_TEMPLATE, TEMPLATE_SUBTYPE_OUTPUT, } from "@metaobjectsdev/metadata"; import type { Provider } from "@metaobjectsdev/render"; import { GENERATED_HEADER } from "../constants.js"; import type { OutputLayout } from "../import-path.js"; import { docPageHref, docPageNode, type DocPageNode } from "../docs-paths.js"; import { fieldAnchorSlug } from "./field-anchor.js"; import type { TemplateDocData, TemplateOutputPart } from "./template-doc-data.js"; import { buildEnrichedPayloadTree } from "./template-payload-tree.js"; import { annotateTemplate, type AnnotatePayloadField } from "./template-source-annotate.js"; import { renderSourceBlock, renderVariablesTable, renderRichLinkedHtml, } from "./template-source-render.js"; export interface BuildTemplateDocDataOpts { /** Page-placement layout. Defaults to "flat" (back-compat: same-dir links). */ layout?: OutputLayout; /** Root used to resolve the @payloadRef target's package for a correct * cross-link in package layout. Optional: flat layout never needs it. */ loadedRoot?: MetaRoot; /** The page provider — the SAME one the render-helper drift gate / page * rendering use (e.g. `projectProvider(projectRoot)`). Used to resolve each * referenced mustache's SOURCE TEXT for the "## Template source" section. * Optional: when absent (or a ref doesn't resolve) the section is omitted. */ provider?: Provider; } // FIXED, language-NEUTRAL capability sentences. NO type names, NO signatures. const CAPABILITY_DOCUMENT = "A render helper is generated for this template: it takes the payload and " + "returns the rendered output as a single string."; const CAPABILITY_EMAIL = "A render helper is generated for this template: it takes the payload and " + "returns the rendered email — subject, HTML body, and an optional text body."; /** Read an attr that may be a string or string[] (string-array attrs come back * as string[]; a bare comma string is split defensively). Returns a trimmed, * non-empty string list. */ function attrStringList(value: unknown): string[] { if (Array.isArray(value)) { return value.filter((v): v is string => typeof v === "string" && v.length > 0); } if (typeof value === "string" && value.trim() !== "") { return value.split(",").map((s) => s.trim()).filter((s) => s.length > 0); } return []; } function readMaxChars(value: unknown): number | undefined { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim() !== "") { const n = Number(value); if (Number.isFinite(n)) return n; } return undefined; } function templateDescription(t: MetaData): string | undefined { const v = t.attr(DOC_ATTR_DESCRIPTION); return typeof v === "string" && v.length > 0 ? v : undefined; } /** Page-placement node for a metadata object resolved by short name, with the * SAME package-less fallback the Payload cross-link and field hrefs share: when * the object can't be resolved off the root, fall back to a root-level node so a * link is still emitted. Keeps every inbound href routing through the one * `docPageHref(layout, …)` placement. */ function pageNodeByName(root: MetaRoot | undefined, name: string): DocPageNode { const obj = root?.findObject(name); return obj !== undefined ? docPageNode(obj) : { name }; } /** Build the TemplateDocData for one `template.output` node. */ export function buildTemplateDocData( template: MetaData, opts?: BuildTemplateDocDataOpts, ): TemplateDocData { const layout = opts?.layout ?? "flat"; const root = opts?.loadedRoot; // ADR-0039: resolving — a template may inherit its @kind/@*Ref/@format/etc via extends. const kindRaw = ((template.attr(TEMPLATE_ATTR_KIND) as string | undefined) ?? TEMPLATE_KIND_DEFAULT).toLowerCase(); const isEmail = kindRaw === TEMPLATE_KIND_EMAIL; const kind: "document" | "email" = isEmail ? TEMPLATE_KIND_EMAIL : TEMPLATE_KIND_DOCUMENT; const payloadRefRaw = template.attr(TEMPLATE_ATTR_PAYLOAD_REF); // ADR-0042: keep the FULL ref for RESOLUTION (an FQN @payloadRef to another // package must resolve exactly — the old bare-tail fallback that used to find // it is gone); strip to the bare name only for DISPLAY labels. const payloadRefFull = typeof payloadRefRaw === "string" && payloadRefRaw.length > 0 ? payloadRefRaw : "unknown"; const payloadName = payloadRefFull !== "unknown" ? stripPackage(payloadRefFull) : "unknown"; const requiredTags = attrStringList(template.attr(TEMPLATE_ATTR_REQUIRED_TAGS)); const maxChars = readMaxChars(template.attr(TEMPLATE_ATTR_MAX_CHARS)); let format = ""; let parts: TemplateOutputPart[] | undefined; const sourceRefs: string[] = []; if (isEmail) { const subjectRef = template.attr(TEMPLATE_ATTR_SUBJECT_REF); const htmlBodyRef = template.attr(TEMPLATE_ATTR_HTML_BODY_REF); const textBodyRef = template.attr(TEMPLATE_ATTR_TEXT_BODY_REF); parts = []; if (typeof subjectRef === "string") { parts.push({ label: "Subject", ref: subjectRef, format: "text", escaped: false }); sourceRefs.push(subjectRef); } if (typeof htmlBodyRef === "string") { parts.push({ label: "HTML body", ref: htmlBodyRef, format: "html", escaped: true }); sourceRefs.push(htmlBodyRef); } if (typeof textBodyRef === "string") { parts.push({ label: "Text body", ref: textBodyRef, format: "text", escaped: false }); sourceRefs.push(textBodyRef); } } else { format = ((template.attr(TEMPLATE_ATTR_FORMAT) as string | undefined) ?? "text").toLowerCase(); const textRef = template.attr(TEMPLATE_ATTR_TEXT_REF); if (typeof textRef === "string") sourceRefs.push(textRef); } // Cross-link to the payload entity's page. The href is derived from the SAME // page-placement function used to write that entity page, so it resolves in // BOTH layouts (package layout folds the correct relative path). const payloadLink = docPageHref( layout, docPageNode(template), pageNodeByName(root, payloadName), ); const data: TemplateDocData = { generatedMarker: ``, name: template.name, kind, isEmail, format, payload: { name: payloadName, link: payloadLink }, sourceRefs, capability: isEmail ? CAPABILITY_EMAIL : CAPABILITY_DOCUMENT, }; if (parts !== undefined) data.parts = parts; if (requiredTags.length > 0) { data.requiredTags = requiredTags; data.hasRequiredTags = true; } if (maxChars !== undefined) data.maxChars = maxChars; // ── "## Template source" section (Task 4) ──────────────────────────────── // Resolve each referenced mustache's SOURCE TEXT via the page provider, then // annotate it against the ENRICHED payload tree (owner/type/required per node) // and pre-render the three doc forms. Only when a provider + a resolvable // payload VO are available; a ref that doesn't resolve is skipped (no crash). if (opts?.provider !== undefined && root !== undefined && payloadName !== "unknown") { const section = buildTemplateSourceSection({ provider: opts.provider, root, layout, template, payloadName, payloadRefFull, // Document: the single @textRef (unlabeled). Email: one labeled part each. refs: parts !== undefined ? parts.map((p) => ({ label: p.label, ref: p.ref })) : sourceRefs.map((ref) => ({ ref })), }); if (section !== undefined) data.templateSourceSection = section; } const desc = templateDescription(template); if (desc !== undefined) { data.descriptionQuote = desc.split("\n").map((l) => `> ${l}`.trimEnd()).join("\n"); } return data; } // One referenced template source to document: an optional human label (email // part name) + the logical mustache ref (`@textRef` / `@subjectRef` / …). interface SourceRefSpec { label?: string; ref: string; } interface BuildSectionArgs { provider: Provider; root: MetaRoot; /** Page-placement layout — threaded so field/partial hrefs route through the * SAME docPageHref the Payload cross-link uses (resolves under package layout). */ layout: OutputLayout; /** The `template.output` node whose page is being built — the FROM page for * every relative href on this section. */ template: MetaData; /** Bare payload name — DISPLAY only (labels, owner names). */ payloadName: string; /** Full @payloadRef (FQN for a cross-package payload, bare for same-package) — * used for RESOLUTION so a cross-package payload tree still resolves (ADR-0042). */ payloadRefFull: string; refs: SourceRefSpec[]; } /** * Build the PRE-RENDERED "## Template source" section markdown. For each ref: * resolve its mustache source via the provider, annotate it against the enriched * payload tree, and emit the fenced source block + the linked variables table + * the rich `
` linked view. A document template yields one block; an * email yields one `###