@refrakt-md/runes 0.14.2 → 0.14.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAA+B,MAAM,uBAAuB,CAAC;AAEtF,OAAO,KAAK,EAAE,mBAAmB,EAAmB,cAAc,EAAkB,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAiF/H;gFACgF;AAChF,eAAO,MAAM,UAAU,EAAE,WAitBxB,CAAC;AAEF,sGAAsG;AACtG,eAAO,MAAM,UAAU,aAAa,CAAC;AAIrC,gEAAgE;AAChE,MAAM,WAAW,YAAY;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,YAAY,EAAE,CAAC;CACzB;AAs3BD,UAAU,YAAY;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,OAAO,CAAC;IACf,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACrC;AAiLD;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CACnC,UAAU,EAAE,OAAO,EACnB,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE;IACT,eAAe,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IACvC,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAChI,QAAQ,EAAE,YAAY,EAAE,CAAC;IACzB,QAAQ,EAAE,QAAQ,CAAC,cAAc,CAAC,CAAC;CACnC,EACD,GAAG,EAAE,eAAe;AACpB;;yEAEyE;AACzE,cAAc,CAAC,EAAE,OAAO,EAAE,GACxB,OAAO,CAYT;AAED;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,EAAE,mBAqJ/B,CAAC"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAA+B,MAAM,uBAAuB,CAAC;AAEtF,OAAO,KAAK,EAAE,mBAAmB,EAAmB,cAAc,EAAkB,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAiF/H;gFACgF;AAChF,eAAO,MAAM,UAAU,EAAE,WAitBxB,CAAC;AAEF,sGAAsG;AACtG,eAAO,MAAM,UAAU,aAAa,CAAC;AAIrC,gEAAgE;AAChE,MAAM,WAAW,YAAY;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,YAAY,EAAE,CAAC;CACzB;AA0vCD,UAAU,YAAY;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,OAAO,CAAC;IACf,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACrC;AAiLD;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CACnC,UAAU,EAAE,OAAO,EACnB,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE;IACT,eAAe,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IACvC,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAChI,QAAQ,EAAE,YAAY,EAAE,CAAC;IACzB,QAAQ,EAAE,QAAQ,CAAC,cAAc,CAAC,CAAC;CACnC,EACD,GAAG,EAAE,eAAe;AACpB;;yEAEyE;AACzE,cAAc,CAAC,EAAE,OAAO,EAAE,GACxB,OAAO,CAqBT;AAED;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,EAAE,mBAgK/B,CAAC"}
package/dist/config.js CHANGED
@@ -897,7 +897,7 @@ function resolveAutoNavs(renderable, pageUrl, pagesByUrl, ctx) {
897
897
  if (tag.attributes?.['data-rune'] === 'nav') {
898
898
  const hasSentinel = tag.children?.some((c) => Tag.isTag(c) && c.attributes?.['data-field'] === NAV_AUTO_SENTINEL);
899
899
  if (hasSentinel) {
900
- return buildAutoNav(pageUrl, pagesByUrl, ctx);
900
+ return buildAutoNav(pageUrl, pagesByUrl, ctx, tag);
901
901
  }
902
902
  }
903
903
  // Recurse into children
@@ -906,8 +906,12 @@ function resolveAutoNavs(renderable, pageUrl, pagesByUrl, ctx) {
906
906
  return tag;
907
907
  return { ...tag, children: newChildren };
908
908
  }
909
- /** Build a resolved nav Tag from the direct children of the current page */
910
- function buildAutoNav(pageUrl, pagesByUrl, ctx) {
909
+ /** Build a resolved nav Tag from the direct children of the current page.
910
+ * Preserves contextual attributes from the original sentinel-bearing nav
911
+ * (layout, data-auto, data-source-path) so downstream resolvers — including
912
+ * the cards/auto enrichment pass — see the same configuration the author
913
+ * declared. */
914
+ function buildAutoNav(pageUrl, pagesByUrl, ctx, originalTag) {
911
915
  // Find direct children: pages whose parentUrl is this page's URL (excluding self)
912
916
  const children = Array.from(pagesByUrl.values()).filter(p => p.parentUrl === pageUrl && p.url !== pageUrl);
913
917
  if (children.length === 0) {
@@ -923,7 +927,7 @@ function buildAutoNav(pageUrl, pagesByUrl, ctx) {
923
927
  });
924
928
  });
925
929
  const itemsList = new Tag('ul', {}, listItems);
926
- return createComponentRenderable({ rune: 'nav',
930
+ const newNav = createComponentRenderable({ rune: 'nav',
927
931
  tag: 'nav',
928
932
  properties: {
929
933
  group: [],
@@ -931,6 +935,123 @@ function buildAutoNav(pageUrl, pagesByUrl, ctx) {
931
935
  },
932
936
  children: [itemsList],
933
937
  });
938
+ // Preserve contextual attributes from the original nav so layout / auto /
939
+ // source-path survive sentinel replacement.
940
+ const carry = ['layout', 'data-layout', 'data-auto', 'data-source-path', 'data-collapsible', 'data-default-open'];
941
+ for (const k of carry) {
942
+ const v = originalTag?.attributes?.[k];
943
+ if (v !== undefined) {
944
+ newNav.attributes[k] = v;
945
+ }
946
+ }
947
+ return newNav;
948
+ }
949
+ // ─── Nav slug resolution (SPEC-055) ───
950
+ /** Strip trailing slash, normalize index suffixes, lowercase. Used for URL
951
+ * comparison only — the canonical href written into the DOM keeps the
952
+ * page's original casing from the registry. */
953
+ function normaliseNavUrl(url) {
954
+ let u = url.trim();
955
+ if (u.length > 1 && u.endsWith('/'))
956
+ u = u.slice(0, -1);
957
+ if (u.endsWith('/index'))
958
+ u = u.slice(0, -'/index'.length) || '/';
959
+ return u.toLowerCase();
960
+ }
961
+ /** Derive a nav's base URL directory from its source file path.
962
+ *
963
+ * - `_layout.md` → `/`
964
+ * - `docs/_layout.md` → `/docs/`
965
+ * - `docs/themes/_layout.md` → `/docs/themes/`
966
+ * - `docs/getting-started.md` → `/docs/`
967
+ * - `docs/themes/configuration.md` → `/docs/themes/`
968
+ */
969
+ function deriveNavBaseDir(sourcePath) {
970
+ const parts = sourcePath.split('/').filter(Boolean);
971
+ parts.pop(); // drop filename
972
+ if (parts.length === 0)
973
+ return '/';
974
+ return '/' + parts.join('/') + '/';
975
+ }
976
+ function levenshtein(a, b) {
977
+ if (a === b)
978
+ return 0;
979
+ if (a.length === 0)
980
+ return b.length;
981
+ if (b.length === 0)
982
+ return a.length;
983
+ const prev = new Array(b.length + 1);
984
+ const curr = new Array(b.length + 1);
985
+ for (let j = 0; j <= b.length; j++)
986
+ prev[j] = j;
987
+ for (let i = 1; i <= a.length; i++) {
988
+ curr[0] = i;
989
+ for (let j = 1; j <= b.length; j++) {
990
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
991
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
992
+ }
993
+ for (let j = 0; j <= b.length; j++)
994
+ prev[j] = curr[j];
995
+ }
996
+ return prev[b.length];
997
+ }
998
+ /** Find up to 3 suggestion URLs for an unresolvable bare slug.
999
+ * Priority: (1) same final-segment pages outside base, (2) Levenshtein ≤ 2
1000
+ * typo matches within base. */
1001
+ function findNavSlugSuggestions(slug, baseDir, pagesByUrl) {
1002
+ const baseNorm = baseDir.endsWith('/') ? baseDir : baseDir + '/';
1003
+ const slugLower = slug.toLowerCase();
1004
+ const sameSlug = [];
1005
+ const typoMatches = [];
1006
+ const expectedAtBase = (baseNorm + slug).replace(/\/+/g, '/').toLowerCase();
1007
+ for (const page of pagesByUrl.values()) {
1008
+ const url = page.url;
1009
+ const urlLower = url.toLowerCase();
1010
+ const tail = url.split('/').filter(Boolean).pop() ?? '';
1011
+ const tailLower = tail.toLowerCase();
1012
+ // Same-slug candidates: any page whose final segment equals the slug,
1013
+ // except the (missing) expected URL at base — those would have resolved
1014
+ // already, and a stale match would be misleading.
1015
+ if (tailLower === slugLower && urlLower !== expectedAtBase) {
1016
+ sameSlug.push(url);
1017
+ continue;
1018
+ }
1019
+ // Typo candidates: pages under base with a near-match final segment.
1020
+ if (urlLower.startsWith(baseNorm.toLowerCase())) {
1021
+ const d = levenshtein(slugLower, tailLower);
1022
+ if (d > 0 && d <= 2)
1023
+ typoMatches.push({ url, distance: d });
1024
+ }
1025
+ }
1026
+ sameSlug.sort();
1027
+ typoMatches.sort((a, b) => a.distance - b.distance || a.url.localeCompare(b.url));
1028
+ const suggestions = [...sameSlug, ...typoMatches.map(t => t.url)];
1029
+ return suggestions.slice(0, 3);
1030
+ }
1031
+ /** SPEC-055 nav-location-relative slug resolution.
1032
+ *
1033
+ * Rules:
1034
+ * - Slugs starting with `/` → absolute path, passthrough
1035
+ * - Slugs containing `/` → multi-segment relative to baseDir
1036
+ * - Bare slug → must resolve uniquely at baseDir
1037
+ *
1038
+ * Returns the resolved canonical URL (from the registry, preserving case) or
1039
+ * an error with attemptedUrl and suggestions. */
1040
+ function resolveNavSlug(slug, baseDir, pagesByUrl) {
1041
+ if (slug.startsWith('/')) {
1042
+ return { ok: true, url: slug };
1043
+ }
1044
+ const base = baseDir.endsWith('/') ? baseDir : baseDir + '/';
1045
+ const candidate = (base + slug).replace(/\/+/g, '/');
1046
+ const candidateNorm = normaliseNavUrl(candidate);
1047
+ // Look up by normalised URL — match against registry pages.
1048
+ for (const page of pagesByUrl.values()) {
1049
+ if (normaliseNavUrl(page.url) === candidateNorm) {
1050
+ return { ok: true, url: page.url };
1051
+ }
1052
+ }
1053
+ const suggestions = findNavSlugSuggestions(slug.includes('/') ? slug.split('/').pop() : slug, base, pagesByUrl);
1054
+ return { ok: false, reason: 'not-found', attemptedUrl: candidate, suggestions };
934
1055
  }
935
1056
  // ─── Collapsible nav auto-open ───
936
1057
  function sharedPrefixLength(a, b) {
@@ -939,8 +1060,10 @@ function sharedPrefixLength(a, b) {
939
1060
  i++;
940
1061
  return i;
941
1062
  }
942
- /** Resolve a slug-only nav item to a page URL using the pagesByUrl registry.
943
- * Mirrors the runtime resolution in @refrakt-md/behaviors RfNav. */
1063
+ /** @deprecated Legacy global-search slug resolver, retained for backwards
1064
+ * compatibility with auto-open / cards / pagination resolvers that haven't
1065
+ * been migrated to use pre-resolved hrefs yet. SPEC-055's
1066
+ * `resolveNavSlug` is the canonical resolver for new code. */
944
1067
  function resolveSlugToUrl(slug, pagesByUrl, currentUrl) {
945
1068
  const candidates = Array.from(pagesByUrl.values()).filter(p => p.url.endsWith('/' + slug) || p.url === '/' + slug);
946
1069
  if (candidates.length === 0)
@@ -1167,6 +1290,33 @@ function getNavItemUrl(item, pagesByUrl, currentUrl) {
1167
1290
  }
1168
1291
  return null;
1169
1292
  }
1293
+ /** Attach an icon (prepended) and a description (appended) inside a nav item's
1294
+ * existing `<a>` link, drawn from the page's frontmatter. Used for the
1295
+ * layout-agnostic `auto=true` enrichment (SPEC-054). Unlike the cards
1296
+ * full-replacement, this preserves any inline author content (badges,
1297
+ * custom link text) and just augments the link. Idempotent — re-runs do
1298
+ * nothing because the same data-name children would already be present. */
1299
+ function augmentNavItemFromFrontmatter(item, pageMeta) {
1300
+ const newChildren = (item.children ?? []).map((c) => {
1301
+ if (!Tag.isTag(c))
1302
+ return c;
1303
+ const t = c;
1304
+ if (t.name !== 'a')
1305
+ return c;
1306
+ // Skip if this <a> has already been enriched (idempotency).
1307
+ const hasIcon = (t.children ?? []).some((x) => Tag.isTag(x) && x.attributes?.['data-name'] === 'icon');
1308
+ const hasDescription = (t.children ?? []).some((x) => Tag.isTag(x) && x.attributes?.['data-name'] === 'description');
1309
+ const newLinkChildren = [...(t.children ?? [])];
1310
+ if (pageMeta.icon && !hasIcon) {
1311
+ newLinkChildren.unshift(new Tag('rf-icon', { name: pageMeta.icon, 'data-name': 'icon', class: 'rf-nav-item__icon' }, []));
1312
+ }
1313
+ if (pageMeta.description && !hasDescription) {
1314
+ newLinkChildren.push(new Tag('span', { 'data-name': 'description', class: 'rf-nav-item__description' }, [pageMeta.description]));
1315
+ }
1316
+ return { ...t, children: newLinkChildren };
1317
+ });
1318
+ return { ...item, children: newChildren };
1319
+ }
1170
1320
  function resolveCardsNavs(renderable, pageUrl, pagesByUrl) {
1171
1321
  if (!Tag.isTag(renderable)) {
1172
1322
  if (Array.isArray(renderable)) {
@@ -1178,42 +1328,229 @@ function resolveCardsNavs(renderable, pageUrl, pagesByUrl) {
1178
1328
  return renderable;
1179
1329
  }
1180
1330
  const tag = renderable;
1181
- if (tag.attributes?.['data-rune'] === 'nav' &&
1182
- (tag.attributes?.['data-layout'] === 'cards' || tag.attributes?.['layout'] === 'cards')) {
1183
- const enrichItem = (node) => {
1184
- if (!Tag.isTag(node)) {
1185
- if (Array.isArray(node))
1186
- return node.map(enrichItem);
1187
- return node;
1188
- }
1189
- const t = node;
1190
- if (t.attributes?.['data-rune'] === 'nav-item') {
1191
- const ref = getNavItemUrl(t, pagesByUrl, pageUrl);
1192
- if (!ref)
1193
- return t;
1194
- if (ref.isExternal) {
1195
- // External link — title only, no enrichment
1196
- return enrichNavItemAsCard(t, null, ref.url);
1331
+ if (tag.attributes?.['data-rune'] === 'nav') {
1332
+ const layout = tag.attributes?.['data-layout'] ?? tag.attributes?.['layout'];
1333
+ const isCards = layout === 'cards';
1334
+ const isAuto = tag.attributes?.['data-auto'] === 'true';
1335
+ // Cards layout: full replacement (icon + title + description inside the link).
1336
+ // Any other layout with auto=true: augment existing links with frontmatter
1337
+ // (icon prepended, description appended). Layouts without either: no enrichment.
1338
+ if (isCards || isAuto) {
1339
+ const enrichItem = (node) => {
1340
+ if (!Tag.isTag(node)) {
1341
+ if (Array.isArray(node))
1342
+ return node.map(enrichItem);
1343
+ return node;
1344
+ }
1345
+ const t = node;
1346
+ if (t.attributes?.['data-rune'] === 'nav-item') {
1347
+ const ref = getNavItemUrl(t, pagesByUrl, pageUrl);
1348
+ if (!ref)
1349
+ return t;
1350
+ if (isCards) {
1351
+ if (ref.isExternal)
1352
+ return enrichNavItemAsCard(t, null, ref.url);
1353
+ const page = pagesByUrl.get(ref.url);
1354
+ const meta = page
1355
+ ? { url: page.url, title: page.title, description: page.description, icon: page.icon }
1356
+ : null;
1357
+ return enrichNavItemAsCard(t, meta, ref.url);
1358
+ }
1359
+ // Auto-augment for non-cards layouts. External links don't have
1360
+ // frontmatter to enrich from — leave untouched.
1361
+ if (ref.isExternal)
1362
+ return t;
1363
+ const page = pagesByUrl.get(ref.url);
1364
+ if (!page)
1365
+ return t;
1366
+ const meta = {
1367
+ url: page.url,
1368
+ title: page.title,
1369
+ description: page.description,
1370
+ icon: page.icon,
1371
+ };
1372
+ return augmentNavItemFromFrontmatter(t, meta);
1197
1373
  }
1198
- const page = pagesByUrl.get(ref.url);
1199
- const meta = page
1200
- ? { url: page.url, title: page.title, description: page.description, icon: page.icon }
1201
- : null;
1202
- return enrichNavItemAsCard(t, meta, ref.url);
1374
+ const newChildren = (t.children ?? []).map(enrichItem);
1375
+ if (newChildren.every((c, i) => c === t.children[i]))
1376
+ return t;
1377
+ return { ...t, children: newChildren };
1378
+ };
1379
+ const newChildren = (tag.children ?? []).map(enrichItem);
1380
+ return { ...tag, children: newChildren };
1381
+ }
1382
+ }
1383
+ const newChildren = (tag.children ?? []).map((c) => resolveCardsNavs(c, pageUrl, pagesByUrl));
1384
+ if (newChildren.every((c, i) => c === tag.children[i]))
1385
+ return tag;
1386
+ return { ...tag, children: newChildren };
1387
+ }
1388
+ // ─── Build-time nav slug resolution + active state (SPEC-055) ───
1389
+ function isExternalUrl(url) {
1390
+ return /^[a-z]+:\/\//i.test(url) || url.startsWith('mailto:') || url.startsWith('//');
1391
+ }
1392
+ /** Format a nav resolution error for ctx.error. Includes the source file,
1393
+ * attempted URL, and closest-match suggestions. */
1394
+ function formatNavResolutionError(slug, sourcePath, attemptedUrl, suggestions) {
1395
+ const where = sourcePath ? ` in ${sourcePath}` : '';
1396
+ const head = `Nav item \`${slug}\`${where} cannot be resolved (no page at \`${attemptedUrl}\`).`;
1397
+ if (suggestions.length === 0)
1398
+ return head;
1399
+ const lines = ['', 'Did you mean one of:', ...suggestions.map(s => ` - ${s}`), '', 'Use a multi-segment slug (e.g. `section/page`) or an explicit `[Label](/path)` link.'];
1400
+ return head + '\n' + lines.join('\n');
1401
+ }
1402
+ /** Walk a nav subtree, resolve every NavItem's `data-slug` to a real `<a href>`
1403
+ * using SPEC-055 rules. Items with explicit `<a>` link children pass through
1404
+ * unchanged. Unresolvable slugs emit a ctx.error and leave the item as
1405
+ * fallback text so the build can continue and surface every error. */
1406
+ function resolveNavItemsInSubtree(navTag, pagesByUrl, ctx, pageUrl) {
1407
+ const sourcePath = navTag.attributes?.['data-source-path'];
1408
+ const baseDir = deriveNavBaseDir(sourcePath ?? '');
1409
+ const visit = (node) => {
1410
+ if (!Tag.isTag(node)) {
1411
+ if (Array.isArray(node)) {
1412
+ const next = node.map(visit);
1413
+ return next.every((c, i) => c === node[i]) ? node : next;
1203
1414
  }
1204
- const newChildren = (t.children ?? []).map(enrichItem);
1205
- if (newChildren.every((c, i) => c === t.children[i]))
1415
+ return node;
1416
+ }
1417
+ const t = node;
1418
+ if (t.attributes?.['data-rune'] === 'nav-item') {
1419
+ // Skip items that already carry an explicit <a> link (passthrough).
1420
+ const existingHref = findNavItemHref(t);
1421
+ if (existingHref)
1206
1422
  return t;
1207
- return { ...t, children: newChildren };
1208
- };
1209
- const newChildren = (tag.children ?? []).map(enrichItem);
1210
- return { ...tag, children: newChildren };
1423
+ const slug = readNavItemSlug(t);
1424
+ if (!slug)
1425
+ return t;
1426
+ const result = resolveNavSlug(slug, baseDir, pagesByUrl);
1427
+ if (!result.ok) {
1428
+ ctx.error(formatNavResolutionError(slug, sourcePath, result.attemptedUrl, result.suggestions), pageUrl);
1429
+ return t;
1430
+ }
1431
+ const page = pagesByUrl.get(result.url);
1432
+ const title = page?.title ?? slug;
1433
+ // The engine's NavItem.postTransform adds the `rf-nav-item__link` class to
1434
+ // any `<a>` child of a nav-item that has no slug span — so we deliberately
1435
+ // omit the class here to avoid duplication.
1436
+ const link = new Tag('a', { href: result.url }, [title]);
1437
+ // Replace the slug span + fallback text with the resolved link.
1438
+ const newChildren = (t.children ?? []).filter((c) => {
1439
+ if (typeof c === 'string')
1440
+ return false; // drop slug fallback text
1441
+ if (Tag.isTag(c)) {
1442
+ const ct = c;
1443
+ if (ct.name === 'span' && ct.attributes?.['data-field'] === 'slug')
1444
+ return false;
1445
+ }
1446
+ return true;
1447
+ });
1448
+ return { ...t, children: [link, ...newChildren] };
1449
+ }
1450
+ const newChildren = (t.children ?? []).map(visit);
1451
+ if (newChildren.every((c, i) => c === t.children[i]))
1452
+ return t;
1453
+ return { ...t, children: newChildren };
1454
+ };
1455
+ return visit(navTag);
1456
+ }
1457
+ /** Walk a renderable tree and resolve nav slugs in every `<rf-nav>` /
1458
+ * `<nav data-rune="nav">` encountered. */
1459
+ function resolveNavSlugs(renderable, pagesByUrl, ctx, pageUrl) {
1460
+ if (!Tag.isTag(renderable)) {
1461
+ if (Array.isArray(renderable)) {
1462
+ const next = renderable.map(c => resolveNavSlugs(c, pagesByUrl, ctx, pageUrl));
1463
+ return next.every((c, i) => c === renderable[i]) ? renderable : next;
1464
+ }
1465
+ return renderable;
1211
1466
  }
1212
- const newChildren = (tag.children ?? []).map((c) => resolveCardsNavs(c, pageUrl, pagesByUrl));
1467
+ const tag = renderable;
1468
+ if (tag.attributes?.['data-rune'] === 'nav') {
1469
+ return resolveNavItemsInSubtree(tag, pagesByUrl, ctx, pageUrl);
1470
+ }
1471
+ const newChildren = (tag.children ?? []).map((c) => resolveNavSlugs(c, pagesByUrl, ctx, pageUrl));
1213
1472
  if (newChildren.every((c, i) => c === tag.children[i]))
1214
1473
  return tag;
1215
1474
  return { ...tag, children: newChildren };
1216
1475
  }
1476
+ /** Apply active-state attributes (`aria-current="page"`, `data-active="ancestor"`)
1477
+ * to each nav's items, per SPEC-055. At most one item per nav gets aria-current
1478
+ * (exact URL match); at most one of the remaining items gets data-active
1479
+ * (longest strict-prefix match). */
1480
+ function applyNavActiveState(renderable, pageUrl) {
1481
+ const pageNorm = normaliseNavUrl(pageUrl);
1482
+ const markNav = (navTag) => {
1483
+ // Collect all nav-item <a> links inside this nav.
1484
+ const links = [];
1485
+ const collect = (node) => {
1486
+ if (!Tag.isTag(node)) {
1487
+ if (Array.isArray(node))
1488
+ node.forEach(collect);
1489
+ return;
1490
+ }
1491
+ const t = node;
1492
+ if (t.name === 'a' && typeof t.attributes?.href === 'string') {
1493
+ const href = String(t.attributes.href);
1494
+ if (!isExternalUrl(href)) {
1495
+ links.push({ tag: t, href });
1496
+ }
1497
+ }
1498
+ (t.children ?? []).forEach(collect);
1499
+ };
1500
+ (navTag.children ?? []).forEach(collect);
1501
+ // Pass 1: exact match → aria-current="page". At most one.
1502
+ let currentLink = null;
1503
+ for (const { tag, href } of links) {
1504
+ if (normaliseNavUrl(href) === pageNorm) {
1505
+ currentLink = tag;
1506
+ break;
1507
+ }
1508
+ }
1509
+ // Pass 2: longest strict prefix among the remaining links → data-active="ancestor".
1510
+ let ancestorLink = null;
1511
+ let ancestorHrefLen = 0;
1512
+ for (const { tag, href } of links) {
1513
+ if (tag === currentLink)
1514
+ continue;
1515
+ const hrefNorm = normaliseNavUrl(href);
1516
+ if (hrefNorm === '/' && pageNorm === '/')
1517
+ continue;
1518
+ // Strict prefix: hrefNorm is a proper prefix of pageNorm followed by '/'.
1519
+ const isPrefix = hrefNorm !== pageNorm &&
1520
+ (pageNorm.startsWith(hrefNorm + '/') || (hrefNorm === '/' && pageNorm.startsWith('/')));
1521
+ if (isPrefix && hrefNorm.length > ancestorHrefLen) {
1522
+ ancestorLink = tag;
1523
+ ancestorHrefLen = hrefNorm.length;
1524
+ }
1525
+ }
1526
+ // Mutate attributes in place — the link tags are nested within the navTag,
1527
+ // so mutation propagates without rebuilding the whole tree.
1528
+ if (currentLink)
1529
+ currentLink.attributes['aria-current'] = 'page';
1530
+ if (ancestorLink)
1531
+ ancestorLink.attributes['data-active'] = 'ancestor';
1532
+ return navTag;
1533
+ };
1534
+ const visit = (node) => {
1535
+ if (!Tag.isTag(node)) {
1536
+ if (Array.isArray(node)) {
1537
+ node.forEach(visit);
1538
+ }
1539
+ return node;
1540
+ }
1541
+ const t = node;
1542
+ if (t.attributes?.['data-rune'] === 'nav') {
1543
+ markNav(t);
1544
+ // Don't recurse into a nav we just processed — nested navs are unusual
1545
+ // but if present, would be marked by their own pass.
1546
+ return t;
1547
+ }
1548
+ (t.children ?? []).forEach(visit);
1549
+ return t;
1550
+ };
1551
+ visit(renderable);
1552
+ return renderable;
1553
+ }
1217
1554
  /** Walk a renderable tree collecting all nav-rune tags and their item URLs in order.
1218
1555
  * Skips navs with non-sequential layouts (menubar / columns / cards) — those
1219
1556
  * aren't reading sequences. */
@@ -1653,12 +1990,21 @@ export function resolveCoreSentinels(renderable, pageUrl, coreData, ctx,
1653
1990
  navSearchScope) {
1654
1991
  let result = resolveAutoBreadcrumbs(renderable, pageUrl, coreData.breadcrumbPaths, coreData.pagesByUrl, ctx);
1655
1992
  result = resolveAutoNavs(result, pageUrl, coreData.pagesByUrl, ctx);
1993
+ // SPEC-055 — resolve bare-slug nav items to real <a href> links before any
1994
+ // downstream resolver consumes the tree. Must run after auto-nav (which
1995
+ // expands `{% nav auto %}` into item children) but before collapsible /
1996
+ // cards (which both rely on resolved hrefs).
1997
+ result = resolveNavSlugs(result, coreData.pagesByUrl, ctx, pageUrl);
1656
1998
  result = resolveCollapsibleNavs(result, pageUrl, coreData.pagesByUrl);
1657
1999
  result = resolveCardsNavs(result, pageUrl, coreData.pagesByUrl);
1658
2000
  const searchRoot = navSearchScope && navSearchScope.length > 0
1659
2001
  ? [result, ...navSearchScope]
1660
2002
  : result;
1661
2003
  result = resolveAutoPagination(result, pageUrl, coreData.pagesByUrl, searchRoot);
2004
+ // SPEC-055 — mark aria-current / data-active="ancestor" on resolved nav
2005
+ // links per page. Runs after slug + auto-pagination resolution so the
2006
+ // item href set is final.
2007
+ result = applyNavActiveState(result, pageUrl);
1662
2008
  result = resolveBlogPosts(result, coreData.allPosts, ctx, pageUrl);
1663
2009
  result = resolveXrefs(result, pageUrl, coreData.registry, ctx);
1664
2010
  return result;
@@ -1743,9 +2089,13 @@ export const corePipelineHooks = {
1743
2089
  return page;
1744
2090
  let renderable = resolveAutoBreadcrumbs(page.renderable, page.url, coreData.breadcrumbPaths, coreData.pagesByUrl, ctx);
1745
2091
  renderable = resolveAutoNavs(renderable, page.url, coreData.pagesByUrl, ctx);
2092
+ // SPEC-055 build-time slug resolution (see note in resolveCoreSentinels).
2093
+ renderable = resolveNavSlugs(renderable, coreData.pagesByUrl, ctx, page.url);
1746
2094
  renderable = resolveCollapsibleNavs(renderable, page.url, coreData.pagesByUrl);
1747
2095
  renderable = resolveCardsNavs(renderable, page.url, coreData.pagesByUrl);
1748
2096
  renderable = resolveAutoPagination(renderable, page.url, coreData.pagesByUrl, renderable);
2097
+ // SPEC-055 build-time active state.
2098
+ renderable = applyNavActiveState(renderable, page.url);
1749
2099
  renderable = resolveBlogPosts(renderable, coreData.allPosts, ctx, page.url);
1750
2100
  renderable = resolveXrefs(renderable, page.url, coreData.registry, ctx);
1751
2101
  if (renderable === page.renderable)