@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.
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +384 -34
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/layouts/grid.d.ts +1 -1
- package/dist/tags/badge.d.ts +20 -0
- package/dist/tags/badge.d.ts.map +1 -0
- package/dist/tags/badge.js +61 -0
- package/dist/tags/badge.js.map +1 -0
- package/dist/tags/nav.d.ts.map +1 -1
- package/dist/tags/nav.js +358 -55
- package/dist/tags/nav.js.map +1 -1
- package/package.json +3 -3
package/dist/config.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
943
|
-
*
|
|
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
|
-
|
|
1183
|
-
const
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
const
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
return
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
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
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
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
|
-
|
|
1205
|
-
|
|
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
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
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
|
|
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)
|