Skip to content

Commit e75482b

Browse files
dmealingclaude
andcommitted
fix(codegen-ts): layout-aware field links in template-source docs
Field hrefs now route through docPageHref(layout,...) like the Payload link, so they resolve under package layout (../<pkg>/Owner.md) instead of a bare ./Owner.md. Annotator keeps the flat ./ default when no resolver is injected, so flat output is byte-identical. Partial {{>ref}} hrefs route through the same docPageHref so they resolve under package layout too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f330eb3 commit e75482b

2 files changed

Lines changed: 56 additions & 14 deletions

File tree

server/typescript/packages/codegen-ts/src/generators/template-doc-builder.ts

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import type { Provider } from "@metaobjectsdev/render";
3232
import { GENERATED_HEADER } from "../constants.js";
3333
import type { OutputLayout } from "../import-path.js";
3434
import { docPageHref, docPageNode, type DocPageNode } from "../docs-paths.js";
35+
import { fieldAnchorSlug } from "./field-anchor.js";
3536
import type { TemplateDocData, TemplateOutputPart } from "./template-doc-data.js";
3637
import { buildEnrichedPayloadTree } from "./template-payload-tree.js";
3738
import { annotateTemplate, type AnnotatePayloadField } from "./template-source-annotate.js";
@@ -174,6 +175,8 @@ export function buildTemplateDocData(
174175
const section = buildTemplateSourceSection({
175176
provider: opts.provider,
176177
root,
178+
layout,
179+
template,
177180
payloadName,
178181
// Document: the single @textRef (unlabeled). Email: one labeled part each.
179182
refs:
@@ -202,6 +205,12 @@ interface SourceRefSpec {
202205
interface BuildSectionArgs {
203206
provider: Provider;
204207
root: MetaRoot;
208+
/** Page-placement layout — threaded so field/partial hrefs route through the
209+
* SAME docPageHref the Payload cross-link uses (resolves under package layout). */
210+
layout: OutputLayout;
211+
/** The `template.output` node whose page is being built — the FROM page for
212+
* every relative href on this section. */
213+
template: MetaData;
205214
payloadName: string;
206215
refs: SourceRefSpec[];
207216
}
@@ -217,9 +226,23 @@ interface BuildSectionArgs {
217226
* resolved, so the caller omits the section entirely.
218227
*/
219228
function buildTemplateSourceSection(args: BuildSectionArgs): string | undefined {
220-
const { provider, root, payloadName, refs } = args;
229+
const { provider, root, layout, template, payloadName, refs } = args;
221230
const tree: AnnotatePayloadField[] = buildEnrichedPayloadTree(root, payloadName);
222-
const resolvePartialHref = makePartialHrefResolver(root);
231+
const fromNode = docPageNode(template);
232+
233+
// Layout-aware field href: route through the SAME docPageHref the Payload
234+
// cross-link uses, so the link lands on the owner VO's REAL page in BOTH
235+
// layouts (flat → `./Owner.md`, package → `../<pkg>/Owner.md`). Resolve the
236+
// owner's package off the root (by short name); fall back to a package-less
237+
// node when it can't be resolved — mirroring the Payload link's fallback.
238+
const fieldHref = (owner: string, name: string): string => {
239+
const ownerObj = root.findObject(owner);
240+
const toNode: DocPageNode =
241+
ownerObj !== undefined ? docPageNode(ownerObj) : { name: owner };
242+
return `${docPageHref(layout, fromNode, toNode)}#${fieldAnchorSlug(name)}`;
243+
};
244+
245+
const resolvePartialHref = makePartialHrefResolver(root, layout, fromNode);
223246
const isMultipart = refs.length > 1 || refs.some((r) => r.label !== undefined);
224247

225248
const blocks: string[] = [];
@@ -229,6 +252,7 @@ function buildTemplateSourceSection(args: BuildSectionArgs): string | undefined
229252
const tokens = annotateTemplate(source, tree, {
230253
ownerVoName: payloadName,
231254
resolvePartialHref,
255+
fieldHref,
232256
});
233257
const parts: string[] = [];
234258
if (isMultipart && spec.label !== undefined) parts.push(`### ${spec.label}`);
@@ -244,15 +268,20 @@ function buildTemplateSourceSection(args: BuildSectionArgs): string | undefined
244268
}
245269

246270
/**
247-
* A `{{>ref}}` partial-href resolver: returns `./<TemplateName>.md` when `ref`
248-
* is a mustache source documented by SOME `template.output` node (it is one of
249-
* that template's source refs — @textRef or an email part ref), else undefined
250-
* (the partial is highlight-only). This makes a partial that inlines another
251-
* documented template link to that template's page.
271+
* A `{{>ref}}` partial-href resolver: returns a relative href to the page of the
272+
* `template.output` node that documents `ref` (it is one of that template's
273+
* source refs — @textRef or an email part ref), else undefined (the partial is
274+
* highlight-only). The href routes through the SAME `docPageHref(layout, …)` the
275+
* field/Payload links use, so it resolves in BOTH layouts (flat → `./Name.md`,
276+
* package → a correct relative path like `../comms/OrderEmail.md`).
252277
*/
253-
function makePartialHrefResolver(root: MetaRoot): (ref: string) => string | undefined {
254-
// Map each documented source ref → the template node's short name.
255-
const refToTemplate = new Map<string, string>();
278+
function makePartialHrefResolver(
279+
root: MetaRoot,
280+
layout: OutputLayout,
281+
fromNode: DocPageNode,
282+
): (ref: string) => string | undefined {
283+
// Map each documented source ref → the template node that documents it.
284+
const refToTemplate = new Map<string, MetaData>();
256285
for (const child of root.ownChildren()) {
257286
if (child.type !== TYPE_TEMPLATE || child.subType !== TEMPLATE_SUBTYPE_OUTPUT) continue;
258287
for (const attr of [
@@ -263,12 +292,12 @@ function makePartialHrefResolver(root: MetaRoot): (ref: string) => string | unde
263292
]) {
264293
const v = child.ownAttr(attr);
265294
if (typeof v === "string" && v.length > 0 && !refToTemplate.has(v)) {
266-
refToTemplate.set(v, child.name);
295+
refToTemplate.set(v, child);
267296
}
268297
}
269298
}
270299
return (ref: string) => {
271-
const name = refToTemplate.get(ref);
272-
return name !== undefined ? `./${name}.md` : undefined;
300+
const node = refToTemplate.get(ref);
301+
return node !== undefined ? docPageHref(layout, fromNode, docPageNode(node)) : undefined;
273302
};
274303
}

server/typescript/packages/codegen-ts/src/generators/template-source-annotate.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ export interface AnnotateOptions {
8080
* Optional — when absent, partials are captured ref-only (no href).
8181
*/
8282
resolvePartialHref?: (ref: string) => string | undefined;
83+
/**
84+
* Override how a resolved field's doc-page href is built (owner page + the
85+
* shared `#field-<name>` fragment). Optional — when absent, the flat default
86+
* `./<owner>.md#field-<name>` is used (byte-identical to today). A caller with
87+
* the output layout + page placement in scope injects a layout-aware resolver
88+
* (the SAME `docPageHref(layout, …)` the Payload cross-link uses) so the link
89+
* resolves under package layout too.
90+
*/
91+
fieldHref?: (owner: string, name: string) => string;
8392
}
8493

8594
// A Mustache parse token: [type, value, start, end, subTokens?, closeStart?, ...].
@@ -111,6 +120,10 @@ export function annotateTemplate(
111120
const out: TplToken[] = [];
112121
let cursor = 0;
113122

123+
// How a resolved field's href is built: the injected layout-aware resolver
124+
// when provided, else the flat default (byte-identical to today's output).
125+
const buildFieldHref = opts.fieldHref ?? fieldHref;
126+
114127
// Emit verbatim source between `cursor` and `to` as a text token, advancing
115128
// the cursor. This recovers literal text AND any span Mustache trimmed as
116129
// standalone (e.g. the newline after a standalone partial/section), so the
@@ -129,7 +142,7 @@ export function annotateTemplate(
129142
const hit = resolveTemplateVariable(stack, path);
130143
if (!hit) return undefined;
131144
const field = toResolvedField(hit);
132-
return { field, href: fieldHref(field.owner, field.name) };
145+
return { field, href: buildFieldHref(field.owner, field.name) };
133146
}
134147

135148
// Build a variable token ({{x}} or {{&x}}/{{{x}}}). The implicit iterator

0 commit comments

Comments
 (0)