@refrakt-md/runes 0.12.0 → 0.14.0

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.js CHANGED
@@ -3,7 +3,8 @@ import Markdoc from '@markdoc/markdoc';
3
3
  const { Tag } = Markdoc;
4
4
  import { createComponentRenderable } from './lib/index.js';
5
5
  import { BREADCRUMB_AUTO_SENTINEL } from './tags/breadcrumb.js';
6
- import { NAV_AUTO_SENTINEL } from './tags/nav.js';
6
+ import { NAV_AUTO_SENTINEL, NAV_COLLAPSED_AUTO } from './tags/nav.js';
7
+ import { PAGINATION_AUTO_SENTINEL } from './tags/pagination.js';
7
8
  import { resolveXrefs } from './xref-resolve.js';
8
9
  // ─── Budget postTransform helpers ───
9
10
  const BUDGET_CURRENCY_SYMBOLS = {
@@ -358,11 +359,21 @@ export const coreConfig = {
358
359
  Nav: {
359
360
  block: 'nav',
360
361
  defaultDensity: 'compact',
362
+ modifiers: {
363
+ layout: { source: 'attribute' },
364
+ },
361
365
  postTransform(node) {
362
366
  return { ...node, name: 'rf-nav' };
363
367
  },
364
368
  },
365
369
  NavGroup: { block: 'nav-group', parent: 'Nav' },
370
+ Pagination: {
371
+ block: 'pagination',
372
+ defaultDensity: 'compact',
373
+ modifiers: {
374
+ scope: { source: 'meta', default: 'siblings', noBemClass: true },
375
+ },
376
+ },
366
377
  NavItem: {
367
378
  block: 'nav-item',
368
379
  parent: 'Nav',
@@ -916,6 +927,559 @@ function buildAutoNav(pageUrl, pagesByUrl, ctx) {
916
927
  children: [itemsList],
917
928
  });
918
929
  }
930
+ // ─── Collapsible nav auto-open ───
931
+ function sharedPrefixLength(a, b) {
932
+ let i = 0;
933
+ while (i < a.length && i < b.length && a[i] === b[i])
934
+ i++;
935
+ return i;
936
+ }
937
+ /** Resolve a slug-only nav item to a page URL using the pagesByUrl registry.
938
+ * Mirrors the runtime resolution in @refrakt-md/behaviors RfNav. */
939
+ function resolveSlugToUrl(slug, pagesByUrl, currentUrl) {
940
+ const candidates = Array.from(pagesByUrl.values()).filter(p => p.url.endsWith('/' + slug) || p.url === '/' + slug);
941
+ if (candidates.length === 0)
942
+ return null;
943
+ if (candidates.length === 1)
944
+ return candidates[0].url;
945
+ return candidates
946
+ .slice()
947
+ .sort((a, b) => sharedPrefixLength(b.url, currentUrl) - sharedPrefixLength(a.url, currentUrl))[0]
948
+ .url;
949
+ }
950
+ /** Read a NavItem's slug from either `data-slug` (post-engine) or a nested
951
+ * `<span data-field="slug">` (pre-engine, e.g. layout regions). */
952
+ function readNavItemSlug(item) {
953
+ const direct = item.attributes?.['data-slug'];
954
+ if (direct)
955
+ return String(direct);
956
+ const findSpan = (node) => {
957
+ if (!Tag.isTag(node)) {
958
+ if (Array.isArray(node)) {
959
+ for (const c of node) {
960
+ const f = findSpan(c);
961
+ if (f)
962
+ return f;
963
+ }
964
+ }
965
+ return null;
966
+ }
967
+ const t = node;
968
+ if (t.name === 'span' && t.attributes?.['data-field'] === 'slug') {
969
+ const parts = [];
970
+ for (const c of t.children ?? []) {
971
+ if (typeof c === 'string')
972
+ parts.push(c);
973
+ }
974
+ return parts.join('').trim() || null;
975
+ }
976
+ for (const c of t.children ?? []) {
977
+ const f = findSpan(c);
978
+ if (f)
979
+ return f;
980
+ }
981
+ return null;
982
+ };
983
+ return findSpan(item);
984
+ }
985
+ /** Collect URLs covered by a NavGroup's items: explicit hrefs + resolved slugs. */
986
+ function collectGroupItemUrls(group, pagesByUrl, currentUrl) {
987
+ const urls = [];
988
+ const walk = (node) => {
989
+ if (!Tag.isTag(node)) {
990
+ if (Array.isArray(node))
991
+ node.forEach(walk);
992
+ return;
993
+ }
994
+ const t = node;
995
+ if (t.attributes?.['data-rune'] === 'nav-item') {
996
+ const href = findNavItemHref(t);
997
+ if (href) {
998
+ urls.push(href);
999
+ }
1000
+ else {
1001
+ const slug = readNavItemSlug(t);
1002
+ if (slug) {
1003
+ const resolved = resolveSlugToUrl(slug, pagesByUrl, currentUrl);
1004
+ if (resolved)
1005
+ urls.push(resolved);
1006
+ }
1007
+ }
1008
+ }
1009
+ (t.children ?? []).forEach(walk);
1010
+ };
1011
+ (group.children ?? []).forEach(walk);
1012
+ return urls;
1013
+ }
1014
+ function extractGroupTitle(group) {
1015
+ const findText = (node) => {
1016
+ if (typeof node === 'string')
1017
+ return node;
1018
+ if (!Tag.isTag(node)) {
1019
+ if (Array.isArray(node)) {
1020
+ for (const c of node) {
1021
+ const found = findText(c);
1022
+ if (found)
1023
+ return found;
1024
+ }
1025
+ }
1026
+ return null;
1027
+ }
1028
+ const t = node;
1029
+ if (t.attributes?.['data-field'] === 'title') {
1030
+ const parts = [];
1031
+ for (const c of t.children ?? []) {
1032
+ if (typeof c === 'string')
1033
+ parts.push(c);
1034
+ }
1035
+ if (parts.length > 0)
1036
+ return parts.join('').trim();
1037
+ }
1038
+ for (const c of t.children ?? []) {
1039
+ const found = findText(c);
1040
+ if (found)
1041
+ return found;
1042
+ }
1043
+ return null;
1044
+ };
1045
+ return findText(group) ?? '';
1046
+ }
1047
+ function urlMatchesGroup(currentUrl, itemUrls) {
1048
+ for (const url of itemUrls) {
1049
+ if (!url)
1050
+ continue;
1051
+ if (currentUrl === url)
1052
+ return true;
1053
+ const prefix = url.endsWith('/') ? url : url + '/';
1054
+ if (currentUrl.startsWith(prefix))
1055
+ return true;
1056
+ }
1057
+ return false;
1058
+ }
1059
+ /** Walk a renderable tree, resolving data-collapsed="auto" on NavGroup tags
1060
+ * inside collapsible Nav containers. */
1061
+ function resolveCollapsibleNavs(renderable, pageUrl, pagesByUrl) {
1062
+ if (!Tag.isTag(renderable)) {
1063
+ if (Array.isArray(renderable)) {
1064
+ const newChildren = renderable.map(c => resolveCollapsibleNavs(c, pageUrl, pagesByUrl));
1065
+ if (newChildren.every((c, i) => c === renderable[i]))
1066
+ return renderable;
1067
+ return newChildren;
1068
+ }
1069
+ return renderable;
1070
+ }
1071
+ const tag = renderable;
1072
+ if (tag.attributes?.['data-rune'] === 'nav' &&
1073
+ tag.attributes?.['data-collapsible'] === 'true') {
1074
+ const defaultOpenRaw = String(tag.attributes?.['data-default-open'] ?? '');
1075
+ const defaultOpen = defaultOpenRaw
1076
+ ? defaultOpenRaw.split(',').map(s => s.trim()).filter(Boolean)
1077
+ : [];
1078
+ const groups = [];
1079
+ const findGroups = (node) => {
1080
+ if (!Tag.isTag(node))
1081
+ return;
1082
+ const t = node;
1083
+ if (t.attributes?.['data-rune'] === 'nav-group') {
1084
+ groups.push(t);
1085
+ }
1086
+ (t.children ?? []).forEach(findGroups);
1087
+ };
1088
+ (tag.children ?? []).forEach(findGroups);
1089
+ for (const group of groups) {
1090
+ if (group.attributes?.['data-collapsed'] !== NAV_COLLAPSED_AUTO)
1091
+ continue;
1092
+ const title = extractGroupTitle(group);
1093
+ const inDefault = title && defaultOpen.includes(title);
1094
+ const itemUrls = collectGroupItemUrls(group, pagesByUrl, pageUrl);
1095
+ const matches = urlMatchesGroup(pageUrl, itemUrls);
1096
+ group.attributes['data-collapsed'] = (inDefault || matches) ? 'false' : 'true';
1097
+ }
1098
+ const newChildren = (tag.children ?? []).map((c) => resolveCollapsibleNavs(c, pageUrl, pagesByUrl));
1099
+ if (newChildren.every((c, i) => c === tag.children[i]))
1100
+ return tag;
1101
+ return { ...tag, children: newChildren };
1102
+ }
1103
+ const newChildren = (tag.children ?? []).map((c) => resolveCollapsibleNavs(c, pageUrl, pagesByUrl));
1104
+ if (newChildren.every((c, i) => c === tag.children[i]))
1105
+ return tag;
1106
+ return { ...tag, children: newChildren };
1107
+ }
1108
+ function enrichNavItemAsCard(item, pageMeta, itemUrl) {
1109
+ const titleText = pageMeta?.title ?? itemUrl ?? '';
1110
+ const description = pageMeta?.description;
1111
+ const icon = pageMeta?.icon;
1112
+ const href = pageMeta?.url ?? itemUrl ?? '';
1113
+ const linkChildren = [];
1114
+ if (icon) {
1115
+ linkChildren.push(new Tag('rf-icon', { name: icon, 'data-name': 'icon', class: 'rf-nav-item__icon' }, []));
1116
+ }
1117
+ linkChildren.push(new Tag('span', { 'data-name': 'title', class: 'rf-nav-item__title' }, [titleText]));
1118
+ if (description) {
1119
+ linkChildren.push(new Tag('span', { 'data-name': 'description', class: 'rf-nav-item__description' }, [description]));
1120
+ }
1121
+ const link = href
1122
+ ? new Tag('a', { href, class: 'rf-nav-item__link' }, linkChildren)
1123
+ : new Tag('span', { class: 'rf-nav-item__link' }, linkChildren);
1124
+ return { ...item, children: [link] };
1125
+ }
1126
+ function findNavItemHref(item) {
1127
+ const walk = (node) => {
1128
+ if (!Tag.isTag(node)) {
1129
+ if (Array.isArray(node)) {
1130
+ for (const c of node) {
1131
+ const found = walk(c);
1132
+ if (found)
1133
+ return found;
1134
+ }
1135
+ }
1136
+ return null;
1137
+ }
1138
+ const t = node;
1139
+ if (t.name === 'a' && t.attributes?.href)
1140
+ return String(t.attributes.href);
1141
+ for (const c of t.children ?? []) {
1142
+ const found = walk(c);
1143
+ if (found)
1144
+ return found;
1145
+ }
1146
+ return null;
1147
+ };
1148
+ return walk(item);
1149
+ }
1150
+ function getNavItemUrl(item, pagesByUrl, currentUrl) {
1151
+ // Prefer an explicit <a href> — covers `[Label](/url)` items whose link text
1152
+ // is wrapped in a slug span by the nav item transform.
1153
+ const href = findNavItemHref(item);
1154
+ if (href) {
1155
+ const isExternal = /^[a-z]+:\/\//i.test(href);
1156
+ return { url: href, isExternal };
1157
+ }
1158
+ const slug = readNavItemSlug(item);
1159
+ if (slug) {
1160
+ const resolved = resolveSlugToUrl(slug, pagesByUrl, currentUrl);
1161
+ return resolved ? { url: resolved, isExternal: false } : null;
1162
+ }
1163
+ return null;
1164
+ }
1165
+ function resolveCardsNavs(renderable, pageUrl, pagesByUrl) {
1166
+ if (!Tag.isTag(renderable)) {
1167
+ if (Array.isArray(renderable)) {
1168
+ const newChildren = renderable.map(c => resolveCardsNavs(c, pageUrl, pagesByUrl));
1169
+ if (newChildren.every((c, i) => c === renderable[i]))
1170
+ return renderable;
1171
+ return newChildren;
1172
+ }
1173
+ return renderable;
1174
+ }
1175
+ const tag = renderable;
1176
+ if (tag.attributes?.['data-rune'] === 'nav' &&
1177
+ (tag.attributes?.['data-layout'] === 'cards' || tag.attributes?.['layout'] === 'cards')) {
1178
+ const enrichItem = (node) => {
1179
+ if (!Tag.isTag(node)) {
1180
+ if (Array.isArray(node))
1181
+ return node.map(enrichItem);
1182
+ return node;
1183
+ }
1184
+ const t = node;
1185
+ if (t.attributes?.['data-rune'] === 'nav-item') {
1186
+ const ref = getNavItemUrl(t, pagesByUrl, pageUrl);
1187
+ if (!ref)
1188
+ return t;
1189
+ if (ref.isExternal) {
1190
+ // External link — title only, no enrichment
1191
+ return enrichNavItemAsCard(t, null, ref.url);
1192
+ }
1193
+ const page = pagesByUrl.get(ref.url);
1194
+ const meta = page
1195
+ ? { url: page.url, title: page.title, description: page.description, icon: page.icon }
1196
+ : null;
1197
+ return enrichNavItemAsCard(t, meta, ref.url);
1198
+ }
1199
+ const newChildren = (t.children ?? []).map(enrichItem);
1200
+ if (newChildren.every((c, i) => c === t.children[i]))
1201
+ return t;
1202
+ return { ...t, children: newChildren };
1203
+ };
1204
+ const newChildren = (tag.children ?? []).map(enrichItem);
1205
+ return { ...tag, children: newChildren };
1206
+ }
1207
+ const newChildren = (tag.children ?? []).map((c) => resolveCardsNavs(c, pageUrl, pagesByUrl));
1208
+ if (newChildren.every((c, i) => c === tag.children[i]))
1209
+ return tag;
1210
+ return { ...tag, children: newChildren };
1211
+ }
1212
+ /** Walk a renderable tree collecting all nav-rune tags and their item URLs in order.
1213
+ * Skips navs with non-sequential layouts (menubar / columns / cards) — those
1214
+ * aren't reading sequences. */
1215
+ function collectNavSequences(renderable, pagesByUrl, currentUrl) {
1216
+ const sequences = [];
1217
+ const walk = (node) => {
1218
+ if (!Tag.isTag(node)) {
1219
+ if (Array.isArray(node))
1220
+ node.forEach(walk);
1221
+ return;
1222
+ }
1223
+ const t = node;
1224
+ if (t.attributes?.['data-rune'] === 'nav') {
1225
+ const layout = t.attributes?.['layout'] ?? t.attributes?.['data-layout'];
1226
+ if (layout && layout !== 'vertical') {
1227
+ // Non-sequential layouts (menubar / columns / cards) — skip but recurse
1228
+ (t.children ?? []).forEach(walk);
1229
+ return;
1230
+ }
1231
+ // Each NavGroup is its own reading sequence. Top-level items (before
1232
+ // the first group) are cross-section links and not a reading order,
1233
+ // so we don't include them in any sequence.
1234
+ const groups = [];
1235
+ const findGroupsLocal = (n) => {
1236
+ if (!Tag.isTag(n)) {
1237
+ if (Array.isArray(n))
1238
+ n.forEach(findGroupsLocal);
1239
+ return;
1240
+ }
1241
+ const node = n;
1242
+ if (node.attributes?.['data-rune'] === 'nav-group') {
1243
+ groups.push(node);
1244
+ }
1245
+ (node.children ?? []).forEach(findGroupsLocal);
1246
+ };
1247
+ (t.children ?? []).forEach(findGroupsLocal);
1248
+ if (groups.length === 0) {
1249
+ // Flat nav (no headings) — collect all items as one sequence
1250
+ const items = [];
1251
+ const collectFlat = (n) => {
1252
+ if (!Tag.isTag(n)) {
1253
+ if (Array.isArray(n))
1254
+ n.forEach(collectFlat);
1255
+ return;
1256
+ }
1257
+ const node = n;
1258
+ if (node.attributes?.['data-rune'] === 'nav-item') {
1259
+ const href = findNavItemHref(node);
1260
+ if (href) {
1261
+ if (!/^[a-z]+:\/\//i.test(href))
1262
+ items.push(href);
1263
+ }
1264
+ else {
1265
+ const slug = readNavItemSlug(node);
1266
+ if (slug) {
1267
+ const resolved = resolveSlugToUrl(slug, pagesByUrl, currentUrl);
1268
+ if (resolved)
1269
+ items.push(resolved);
1270
+ }
1271
+ }
1272
+ }
1273
+ (node.children ?? []).forEach(collectFlat);
1274
+ };
1275
+ (t.children ?? []).forEach(collectFlat);
1276
+ if (items.length > 0)
1277
+ sequences.push(items);
1278
+ }
1279
+ else {
1280
+ for (const group of groups) {
1281
+ const items = [];
1282
+ const collectGroup = (n) => {
1283
+ if (!Tag.isTag(n)) {
1284
+ if (Array.isArray(n))
1285
+ n.forEach(collectGroup);
1286
+ return;
1287
+ }
1288
+ const node = n;
1289
+ if (node.attributes?.['data-rune'] === 'nav-item') {
1290
+ const href = findNavItemHref(node);
1291
+ if (href) {
1292
+ if (!/^[a-z]+:\/\//i.test(href))
1293
+ items.push(href);
1294
+ }
1295
+ else {
1296
+ const slug = readNavItemSlug(node);
1297
+ if (slug) {
1298
+ const resolved = resolveSlugToUrl(slug, pagesByUrl, currentUrl);
1299
+ if (resolved)
1300
+ items.push(resolved);
1301
+ }
1302
+ }
1303
+ }
1304
+ (node.children ?? []).forEach(collectGroup);
1305
+ };
1306
+ (group.children ?? []).forEach(collectGroup);
1307
+ if (items.length > 0)
1308
+ sequences.push(items);
1309
+ }
1310
+ }
1311
+ }
1312
+ (t.children ?? []).forEach(walk);
1313
+ };
1314
+ walk(renderable);
1315
+ return sequences;
1316
+ }
1317
+ /** Get siblings: pages sharing the same parentUrl, optionally widened to the whole section. */
1318
+ function getSiblingPages(currentUrl, pagesByUrl, scope) {
1319
+ const current = pagesByUrl.get(currentUrl);
1320
+ if (!current)
1321
+ return [];
1322
+ const pages = Array.from(pagesByUrl.values());
1323
+ let candidates;
1324
+ if (scope === 'section') {
1325
+ // Find the top-level section URL: walk up from current to a page whose parent is '/' or itself.
1326
+ let sectionRoot = currentUrl;
1327
+ let cursor = current;
1328
+ while (cursor && cursor.parentUrl && cursor.parentUrl !== '/') {
1329
+ const parent = pagesByUrl.get(cursor.parentUrl);
1330
+ if (!parent)
1331
+ break;
1332
+ sectionRoot = parent.url;
1333
+ cursor = parent;
1334
+ }
1335
+ const prefix = sectionRoot.endsWith('/') ? sectionRoot : sectionRoot + '/';
1336
+ candidates = pages.filter(p => p.url === sectionRoot || p.url.startsWith(prefix));
1337
+ }
1338
+ else {
1339
+ candidates = pages.filter(p => p.parentUrl === current.parentUrl);
1340
+ }
1341
+ // Sort by frontmatter order (asc, missing → end), tie-break by URL.
1342
+ candidates.sort((a, b) => {
1343
+ const ao = a.order ?? Number.POSITIVE_INFINITY;
1344
+ const bo = b.order ?? Number.POSITIVE_INFINITY;
1345
+ if (ao !== bo)
1346
+ return ao - bo;
1347
+ return a.url.localeCompare(b.url);
1348
+ });
1349
+ return candidates.map(p => ({ url: p.url, title: p.title }));
1350
+ }
1351
+ function pickPrevNextFromSequence(sequence, currentUrl, pagesByUrl) {
1352
+ const idx = sequence.indexOf(currentUrl);
1353
+ if (idx === -1)
1354
+ return { prev: null, next: null };
1355
+ const prevUrl = idx > 0 ? sequence[idx - 1] : null;
1356
+ const nextUrl = idx < sequence.length - 1 ? sequence[idx + 1] : null;
1357
+ const lookup = (u) => {
1358
+ if (!u)
1359
+ return null;
1360
+ const page = pagesByUrl.get(u);
1361
+ return page ? { url: page.url, title: page.title } : { url: u, title: u };
1362
+ };
1363
+ return { prev: lookup(prevUrl), next: lookup(nextUrl) };
1364
+ }
1365
+ function buildPaginationLink(target, direction, label) {
1366
+ const marker = direction === 'prev' ? '←' : '→';
1367
+ const linkText = label ?? target.title;
1368
+ const linkChildren = direction === 'prev'
1369
+ ? [
1370
+ new Tag('span', { 'data-name': 'marker' }, [marker]),
1371
+ new Tag('span', { 'data-name': 'label' }, [linkText]),
1372
+ ]
1373
+ : [
1374
+ new Tag('span', { 'data-name': 'label' }, [linkText]),
1375
+ new Tag('span', { 'data-name': 'marker' }, [marker]),
1376
+ ];
1377
+ return new Tag('a', {
1378
+ href: target.url,
1379
+ 'data-direction': direction,
1380
+ 'data-name': direction,
1381
+ }, linkChildren);
1382
+ }
1383
+ function resolveAutoPagination(renderable, pageUrl, pagesByUrl, rootRenderable) {
1384
+ if (!Tag.isTag(renderable)) {
1385
+ if (Array.isArray(renderable)) {
1386
+ const newChildren = renderable.map(c => resolveAutoPagination(c, pageUrl, pagesByUrl, rootRenderable));
1387
+ if (newChildren.every((c, i) => c === renderable[i]))
1388
+ return renderable;
1389
+ return newChildren;
1390
+ }
1391
+ return renderable;
1392
+ }
1393
+ const tag = renderable;
1394
+ if (tag.attributes?.['data-rune'] === 'pagination') {
1395
+ const hasSentinel = (tag.children ?? []).some((c) => Tag.isTag(c) && c.attributes?.['data-field'] === PAGINATION_AUTO_SENTINEL);
1396
+ if (hasSentinel) {
1397
+ const scopeMeta = (tag.children ?? []).find((c) => Tag.isTag(c) && c.attributes?.['data-field'] === 'scope');
1398
+ const scopeValue = scopeMeta
1399
+ ? String(scopeMeta.attributes?.content ?? '')
1400
+ : String(tag.attributes?.['data-scope'] ?? '');
1401
+ const scope = (scopeValue === 'section' ? 'section' : 'siblings');
1402
+ const prevLabelMeta = (tag.children ?? []).find((c) => Tag.isTag(c) && c.attributes?.['data-field'] === 'prev-label');
1403
+ const nextLabelMeta = (tag.children ?? []).find((c) => Tag.isTag(c) && c.attributes?.['data-field'] === 'next-label');
1404
+ const prevLabel = prevLabelMeta ? String(prevLabelMeta.attributes.content ?? '') : undefined;
1405
+ const nextLabel = nextLabelMeta ? String(nextLabelMeta.attributes.content ?? '') : undefined;
1406
+ // Skip when current page is a section index (has children)
1407
+ const hasChildren = Array.from(pagesByUrl.values()).some(p => p.parentUrl === pageUrl && p.url !== pageUrl);
1408
+ if (hasChildren) {
1409
+ return { ...tag, children: [] };
1410
+ }
1411
+ // 1. Explicit nav order from the layout cascade
1412
+ let sequence = null;
1413
+ const sequences = collectNavSequences(rootRenderable, pagesByUrl, pageUrl);
1414
+ for (const seq of sequences) {
1415
+ if (seq.includes(pageUrl)) {
1416
+ sequence = seq;
1417
+ break;
1418
+ }
1419
+ }
1420
+ let prev = null;
1421
+ let next = null;
1422
+ if (sequence) {
1423
+ ({ prev, next } = pickPrevNextFromSequence(sequence, pageUrl, pagesByUrl));
1424
+ }
1425
+ else {
1426
+ // 2. Sibling pages by frontmatter order → directory order
1427
+ const siblings = getSiblingPages(pageUrl, pagesByUrl, scope);
1428
+ const idx = siblings.findIndex(s => s.url === pageUrl);
1429
+ if (idx !== -1) {
1430
+ prev = idx > 0 ? siblings[idx - 1] : null;
1431
+ next = idx < siblings.length - 1 ? siblings[idx + 1] : null;
1432
+ }
1433
+ }
1434
+ const links = [];
1435
+ if (prev)
1436
+ links.push(buildPaginationLink(prev, 'prev', prevLabel));
1437
+ if (next)
1438
+ links.push(buildPaginationLink(next, 'next', nextLabel));
1439
+ // Preserve any non-sentinel/non-label meta children (none expected, but defensive)
1440
+ const consumed = new Set([PAGINATION_AUTO_SENTINEL, 'prev-label', 'next-label', 'scope']);
1441
+ const remaining = (tag.children ?? []).filter((c) => {
1442
+ if (!Tag.isTag(c))
1443
+ return true;
1444
+ if (c.name !== 'meta')
1445
+ return true;
1446
+ const field = c.attributes?.['data-field'];
1447
+ return !consumed.has(field);
1448
+ });
1449
+ return { ...tag, children: [...remaining, ...links] };
1450
+ }
1451
+ // Explicit prev/next mode — resolve any __slug:foo hrefs through pagesByUrl
1452
+ const newChildren = (tag.children ?? []).map((c) => {
1453
+ if (!Tag.isTag(c))
1454
+ return c;
1455
+ if (c.name === 'a' && typeof c.attributes?.href === 'string' && c.attributes.href.startsWith('__slug:')) {
1456
+ const slug = c.attributes.href.slice('__slug:'.length);
1457
+ const resolvedUrl = resolveSlugToUrl(slug, pagesByUrl, pageUrl);
1458
+ if (resolvedUrl) {
1459
+ const page = pagesByUrl.get(resolvedUrl);
1460
+ const newLabel = page?.title ?? slug;
1461
+ // Replace label text if user didn't override (label child has same text as slug)
1462
+ const newChildren = (c.children ?? []).map((child) => {
1463
+ if (Tag.isTag(child) && child.attributes?.['data-name'] === 'label') {
1464
+ const currentLabel = (child.children ?? []).filter((x) => typeof x === 'string').join('');
1465
+ if (currentLabel === slug) {
1466
+ return { ...child, children: [newLabel] };
1467
+ }
1468
+ }
1469
+ return child;
1470
+ });
1471
+ return { ...c, attributes: { ...c.attributes, href: resolvedUrl }, children: newChildren };
1472
+ }
1473
+ }
1474
+ return c;
1475
+ });
1476
+ return { ...tag, children: newChildren };
1477
+ }
1478
+ const newChildren = (tag.children ?? []).map((c) => resolveAutoPagination(c, pageUrl, pagesByUrl, rootRenderable));
1479
+ if (newChildren.every((c, i) => c === tag.children[i]))
1480
+ return tag;
1481
+ return { ...tag, children: newChildren };
1482
+ }
919
1483
  function walkBlogTags(node, fn) {
920
1484
  if (Tag.isTag(node)) {
921
1485
  fn(node);
@@ -1068,6 +1632,32 @@ function resolveBlogPosts(renderable, allPosts, ctx, pageUrl) {
1068
1632
  });
1069
1633
  return modified ? result : renderable;
1070
1634
  }
1635
+ /**
1636
+ * Apply core auto-resolutions (breadcrumb, nav, collapsible, cards, pagination,
1637
+ * blog, xref) to an arbitrary renderable tree using the same aggregated data
1638
+ * the pipeline produces.
1639
+ *
1640
+ * Used by callers that need to resolve sentinels in renderables outside the
1641
+ * per-page pipeline — most notably layout regions, which are parsed once but
1642
+ * need per-page URL context for auto-open and auto-pagination.
1643
+ */
1644
+ export function resolveCoreSentinels(renderable, pageUrl, coreData, ctx,
1645
+ /** Extra trees (e.g. layout regions + page content) to scan when looking
1646
+ * for nav sequences during auto-pagination. Required when calling against
1647
+ * a layout region where the sidebar nav lives in a different region. */
1648
+ navSearchScope) {
1649
+ let result = resolveAutoBreadcrumbs(renderable, pageUrl, coreData.breadcrumbPaths, coreData.pagesByUrl, ctx);
1650
+ result = resolveAutoNavs(result, pageUrl, coreData.pagesByUrl, ctx);
1651
+ result = resolveCollapsibleNavs(result, pageUrl, coreData.pagesByUrl);
1652
+ result = resolveCardsNavs(result, pageUrl, coreData.pagesByUrl);
1653
+ const searchRoot = navSearchScope && navSearchScope.length > 0
1654
+ ? [result, ...navSearchScope]
1655
+ : result;
1656
+ result = resolveAutoPagination(result, pageUrl, coreData.pagesByUrl, searchRoot);
1657
+ result = resolveBlogPosts(result, coreData.allPosts, ctx, pageUrl);
1658
+ result = resolveXrefs(result, pageUrl, coreData.registry, ctx);
1659
+ return result;
1660
+ }
1071
1661
  /**
1072
1662
  * Core cross-page pipeline hooks.
1073
1663
  * Run for every site, before any plugin hooks.
@@ -1094,6 +1684,7 @@ export const corePipelineHooks = {
1094
1684
  description: page.frontmatter.description,
1095
1685
  date: page.frontmatter.date,
1096
1686
  order: page.frontmatter.order,
1687
+ icon: page.frontmatter.icon,
1097
1688
  },
1098
1689
  });
1099
1690
  for (const h of page.headings) {
@@ -1117,6 +1708,9 @@ export const corePipelineHooks = {
1117
1708
  url: e.data.url,
1118
1709
  title: e.data.title,
1119
1710
  parentUrl: e.data.parentUrl,
1711
+ description: e.data.description,
1712
+ icon: e.data.icon,
1713
+ order: e.data.order,
1120
1714
  }));
1121
1715
  const pageTree = buildPageTree(pages);
1122
1716
  const breadcrumbPaths = buildBreadcrumbPaths(pages);
@@ -1144,6 +1738,9 @@ export const corePipelineHooks = {
1144
1738
  return page;
1145
1739
  let renderable = resolveAutoBreadcrumbs(page.renderable, page.url, coreData.breadcrumbPaths, coreData.pagesByUrl, ctx);
1146
1740
  renderable = resolveAutoNavs(renderable, page.url, coreData.pagesByUrl, ctx);
1741
+ renderable = resolveCollapsibleNavs(renderable, page.url, coreData.pagesByUrl);
1742
+ renderable = resolveCardsNavs(renderable, page.url, coreData.pagesByUrl);
1743
+ renderable = resolveAutoPagination(renderable, page.url, coreData.pagesByUrl, renderable);
1147
1744
  renderable = resolveBlogPosts(renderable, coreData.allPosts, ctx, page.url);
1148
1745
  renderable = resolveXrefs(renderable, page.url, coreData.registry, ctx);
1149
1746
  if (renderable === page.renderable)