2828import { describe , expect , it } from "bun:test" ;
2929import { readdirSync , readFileSync } from "node:fs" ;
3030import { join , resolve } from "node:path" ;
31+ import { dirname , posix as posixPath } from "node:path" ;
3132import { InMemoryStringSource , MetaDataLoader } from "@metaobjectsdev/metadata" ;
3233import {
3334 ERR_VAR_NOT_ON_PAYLOAD ,
3435 type PayloadField ,
3536 verify ,
3637} from "@metaobjectsdev/render" ;
3738import type { GenContext } from "../../src/generator.js" ;
39+ import {
40+ docPageHref ,
41+ docPageNode ,
42+ docPageOutputPath ,
43+ } from "../../src/docs-paths.js" ;
3844import { docsFile } from "../../src/generators/docs-file.js" ;
45+ import type { OutputLayout } from "../../src/import-path.js" ;
3946import { buildEnrichedPayloadTree } from "../../src/generators/template-payload-tree.js" ;
4047import {
4148 type AnnotatePayloadField ,
@@ -52,15 +59,25 @@ const CORPUS = resolve(
5259 "../../../../../../fixtures/conformance" ,
5360) ;
5461const FIXTURE = "template-source-conformance" ;
62+ // Multi-package mirror of FIXTURE: the payload VOs live in `acme::shop`, the
63+ // two template.output nodes in `acme::comms`. Under package layout the doc
64+ // pages are written under their package dirs, so a template page links a field
65+ // on a VO in a DIFFERENT directory — the cross-package href case.
66+ const FIXTURE_PACKAGE = "template-source-conformance-package" ;
5567
5668interface Emitted {
5769 path : string ;
5870 content : string ;
5971}
6072
73+ // `outputLayout` defaults to "flat" so the existing flat cases are unchanged.
74+ // We thread it the SAME way the real `meta docs` command does (cli/docs.ts):
75+ // onto `config.outputLayout` (where the generator reads placement from) AND
76+ // into `makeRenderContext` — so this test exercises the exact production path.
6177function makeCtx (
6278 root : Awaited < ReturnType < MetaDataLoader [ "load" ] > > [ "root" ] ,
6379 projectRoot : string ,
80+ outputLayout : OutputLayout = "flat" ,
6481) : GenContext {
6582 const renderContext = makeRenderContext ( {
6683 dialect : "sqlite" ,
@@ -69,6 +86,7 @@ function makeCtx(
6986 dbImport : "~/db" ,
7087 pkMap : buildPkMap ( root ) ,
7188 relationMap : buildRelationMap ( root ) ,
89+ outputLayout,
7290 } ) ;
7391 return {
7492 entities : root . objects ( ) ,
@@ -79,15 +97,16 @@ function makeCtx(
7997 extStyle : "none" ,
8098 dbImport : "~/db" ,
8199 dialect : "sqlite" ,
100+ outputLayout,
82101 } as never ,
83102 renderContext,
84103 warn : ( ) => { } ,
85104 projectRoot,
86105 } ;
87106}
88107
89- async function loadFixture ( ) {
90- const inputDir = join ( CORPUS , FIXTURE , "input" ) ;
108+ async function loadFixture ( fixture : string = FIXTURE ) {
109+ const inputDir = join ( CORPUS , fixture , "input" ) ;
91110 const inputFiles = readdirSync ( inputDir ) . filter ( ( f ) => f . endsWith ( ".json" ) ) ;
92111 const sources = inputFiles . map (
93112 ( f ) =>
@@ -101,9 +120,14 @@ async function loadFixture() {
101120 return { root : res . root , inputDir } ;
102121}
103122
104- async function emit ( ) : Promise < Emitted [ ] > {
105- const { root, inputDir } = await loadFixture ( ) ;
106- return ( await docsFile ( ) . generate ( makeCtx ( root , inputDir ) ) ) as Emitted [ ] ;
123+ async function emit (
124+ fixture : string = FIXTURE ,
125+ outputLayout : OutputLayout = "flat" ,
126+ ) : Promise < Emitted [ ] > {
127+ const { root, inputDir } = await loadFixture ( fixture ) ;
128+ return ( await docsFile ( ) . generate (
129+ makeCtx ( root , inputDir , outputLayout ) ,
130+ ) ) as Emitted [ ] ;
107131}
108132
109133// ── Link parsing (precise — no false-accept) ─────────────────────────────────
@@ -404,3 +428,156 @@ describe("teeth — the integrity check fails on real drift", () => {
404428 expect ( [ ...unlinked ] . sort ( ) ) . toEqual ( [ ...flagged ] . sort ( ) ) ;
405429 } ) ;
406430} ) ;
431+
432+ // ── 4. Package layout — field links resolve cross-package ─────────────────────
433+ //
434+ // Under `outputLayout: "package"` the doc pages are written under their package
435+ // dirs (`acme/shop/Order.md`, `acme/comms/OrderPage.md`). A template page in
436+ // `acme/comms/` linking a field on a VO in `acme/shop/` MUST emit a relative
437+ // href that, resolved against the template page's OWN output path, lands on the
438+ // VO's REAL emitted page (`../shop/Order.md#field-ref`) — NOT a bare
439+ // `./Order.md` (which from `acme/comms/` points at the wrong directory). The
440+ // sibling "Payload" link already does this via docPageHref; the per-`{{var}}`
441+ // field links must match. `docPageOutputPath`/`docPageHref` are the source of
442+ // truth for expected placement.
443+
444+ // Parse field-doc links allowing BOTH `./` and `../` relative forms (package
445+ // layout yields `../shop/Order.md#field-…`). Captures the FULL relative href
446+ // (path + fragment) plus the page filename + anchor, on both surfaces.
447+ const PKG_MD_LINK =
448+ / \[ [ ^ \] ] * \] \( ( \. \. ? \/ [ ^ ) # ] * ?( [ ^ / ) # ] + \. m d ) # ( f i e l d - [ A - Z a - z 0 - 9 _ ] + ) ) \) / g;
449+ const PKG_HREF_LINK =
450+ / h r e f = " ( \. \. ? \/ [ ^ " # ] * ?( [ ^ / " # ] + \. m d ) # ( f i e l d - [ A - Z a - z 0 - 9 _ ] + ) ) " / g;
451+
452+ interface PkgFieldLink {
453+ href : string ; // full relative href incl. fragment, e.g. ../shop/Order.md#field-ref
454+ page : string ; // target page filename, e.g. Order.md
455+ anchor : string ; // e.g. field-ref
456+ surface : "md" | "href" ;
457+ }
458+
459+ function parsePkgFieldLinks ( content : string ) : PkgFieldLink [ ] {
460+ const out : PkgFieldLink [ ] = [ ] ;
461+ for ( const m of content . matchAll ( PKG_MD_LINK ) ) {
462+ out . push ( { href : m [ 1 ] ! , page : m [ 2 ] ! , anchor : m [ 3 ] ! , surface : "md" } ) ;
463+ }
464+ for ( const m of content . matchAll ( PKG_HREF_LINK ) ) {
465+ out . push ( { href : m [ 1 ] ! , page : m [ 2 ] ! , anchor : m [ 3 ] ! , surface : "href" } ) ;
466+ }
467+ return out ;
468+ }
469+
470+ describe ( "package layout — field links resolve cross-package" , ( ) => {
471+ it ( "every {{var}} field link on a template page resolves to the field-owner's REAL package-layout page" , async ( ) => {
472+ const layout : OutputLayout = "package" ;
473+ const { root, inputDir } = await loadFixture ( FIXTURE_PACKAGE ) ;
474+ const files = ( await docsFile ( ) . generate (
475+ makeCtx ( root , inputDir , layout ) ,
476+ ) ) as Emitted [ ] ;
477+ const emittedPaths = new Set ( files . map ( ( f ) => f . path ) ) ;
478+
479+ // Sanity: pages ARE folded under their package dirs.
480+ for ( const p of [
481+ "acme/shop/Order.md" ,
482+ "acme/shop/Customer.md" ,
483+ "acme/comms/OrderPage.md" ,
484+ "acme/comms/OrderEmail.md" ,
485+ ] ) {
486+ expect ( emittedPaths . has ( p ) , `missing package-layout page ${ p } ` ) . toBe (
487+ true ,
488+ ) ;
489+ }
490+
491+ // Index: page output path → set of field anchors it emits.
492+ const anchorByPath = new Map < string , Set < string > > ( ) ;
493+ const ID = / i d = " ( f i e l d - [ A - Z a - z 0 - 9 _ ] + ) " / g;
494+ for ( const f of files ) {
495+ const set = new Set < string > ( ) ;
496+ for ( const m of f . content . matchAll ( ID ) ) set . add ( m [ 1 ] ! ) ;
497+ anchorByPath . set ( f . path , set ) ;
498+ }
499+
500+ const templatePages = files . filter ( ( f ) =>
501+ f . path . startsWith ( "acme/comms/" ) ,
502+ ) ;
503+ expect ( templatePages . length , "no template pages emitted" ) . toBe ( 2 ) ;
504+
505+ let checked = 0 ;
506+ for ( const page of templatePages ) {
507+ // The template node that produced this page (carries its package, so its
508+ // placement folds under `acme/comms/`). Looked up off the root's own
509+ // children (templates aren't `findObject`-able).
510+ const templateName = page . path . split ( "/" ) . pop ( ) ! . replace ( / \. m d $ / , "" ) ;
511+ const templateNode = root
512+ . ownChildren ( )
513+ . find ( ( c ) => c . name === templateName ) ;
514+ expect (
515+ templateNode ,
516+ `could not find template node for ${ page . path } ` ,
517+ ) . toBeDefined ( ) ;
518+ const fromNode = docPageNode ( templateNode ! ) ;
519+ // The from-node MUST place under the template's own dir.
520+ expect ( docPageOutputPath ( layout , fromNode ) ) . toBe ( page . path ) ;
521+
522+ const links = parsePkgFieldLinks ( page . content ) ;
523+ // Each template page must carry field links on BOTH surfaces.
524+ expect (
525+ links . length ,
526+ `${ page . path } : no field links parsed (page content may use a bare ./<Owner>.md href the package-aware regex rejects — that IS the bug)` ,
527+ ) . toBeGreaterThan ( 0 ) ;
528+
529+ for ( const link of links ) {
530+ // The owner VO is the target page's short name (Order.md → Order).
531+ const ownerName = link . page . replace ( / \. m d $ / , "" ) ;
532+ const ownerObj = root . findObject ( ownerName ) ;
533+ expect (
534+ ownerObj ,
535+ `${ page . path } : link target ${ link . page } is not a known VO` ,
536+ ) . toBeDefined ( ) ;
537+
538+ // Source of truth for WHERE the owner page is + WHAT href should point
539+ // at it from this template page (`fromNode` computed above).
540+ const toNode = docPageNode ( ownerObj ! ) ;
541+ const expectedOwnerPath = docPageOutputPath ( layout , toNode ) ;
542+ const expectedHref = `${ docPageHref ( layout , fromNode , toNode ) } #${ link . anchor } ` ;
543+
544+ // (a) The href must NOT be a bare `./<Owner>.md…` — under package layout
545+ // it must carry the owner's package dir relative to the template page.
546+ expect (
547+ link . href . startsWith ( `./${ link . page } #` ) ,
548+ `${ page . path } : field link ${ link . href } is a bare ./${ link . page } — ignores package layout (owner page is ${ expectedOwnerPath } )` ,
549+ ) . toBe ( false ) ;
550+
551+ // (b) The href must equal what docPageHref derives (the same helper the
552+ // Payload link already uses) for this from→to pair.
553+ expect (
554+ link . href ,
555+ `${ page . path } : field link ${ link . href } != expected ${ expectedHref } ` ,
556+ ) . toBe ( expectedHref ) ;
557+
558+ // (c) Resolving the href against the template page's own dir must land
559+ // on the owner's ACTUAL emitted page path.
560+ const resolved = posixPath . normalize (
561+ posixPath . join ( dirname ( page . path ) , link . href . split ( "#" ) [ 0 ] ! ) ,
562+ ) ;
563+ expect (
564+ resolved ,
565+ `${ page . path } : field link ${ link . href } resolves to ${ resolved } , not the emitted owner page ${ expectedOwnerPath } ` ,
566+ ) . toBe ( expectedOwnerPath ) ;
567+
568+ // (d) That resolved page is in the emitted set AND carries the anchor.
569+ expect (
570+ emittedPaths . has ( resolved ) ,
571+ `${ page . path } : resolved target ${ resolved } is not an emitted page` ,
572+ ) . toBe ( true ) ;
573+ expect (
574+ anchorByPath . get ( resolved ) ?. has ( link . anchor ) ,
575+ `${ page . path } : ${ resolved } is missing anchor ${ link . anchor } ` ,
576+ ) . toBe ( true ) ;
577+
578+ checked ++ ;
579+ }
580+ }
581+ expect ( checked , "no cross-package field links checked" ) . toBeGreaterThan ( 0 ) ;
582+ } ) ;
583+ } ) ;
0 commit comments