@refrakt-md/runes 0.11.3 → 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',
@@ -689,6 +700,9 @@ export const coreConfig = {
689
700
  const label = readMeta(node, 'label') || '';
690
701
  const height = readMeta(node, 'height') || 'auto';
691
702
  const designTokens = readMeta(node, 'design-tokens') || '';
703
+ const securityMode = readMeta(node, 'security-mode') || 'trusted';
704
+ const allowJs = readMeta(node, 'allow-js') || 'true';
705
+ const sandboxOrigin = readMeta(node, 'sandbox-origin') || '';
692
706
  // Keep non-meta children (fallback pre) and extract source panels
693
707
  const fallbackChildren = [];
694
708
  const sourcePanelOrigins = [];
@@ -729,6 +743,9 @@ export const coreConfig = {
729
743
  'data-height': height,
730
744
  ...(designTokens ? { 'data-design-tokens': designTokens } : {}),
731
745
  ...(sourcePanelOrigins.length > 0 ? { 'data-source-origins': sourcePanelOrigins.join('\n') } : {}),
746
+ 'data-security-mode': securityMode,
747
+ 'data-allow-js': allowJs,
748
+ ...(sandboxOrigin ? { 'data-sandbox-origin': sandboxOrigin } : {}),
732
749
  },
733
750
  children,
734
751
  };
@@ -910,6 +927,559 @@ function buildAutoNav(pageUrl, pagesByUrl, ctx) {
910
927
  children: [itemsList],
911
928
  });
912
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
+ }
913
1483
  function walkBlogTags(node, fn) {
914
1484
  if (Tag.isTag(node)) {
915
1485
  fn(node);
@@ -1062,9 +1632,35 @@ function resolveBlogPosts(renderable, allPosts, ctx, pageUrl) {
1062
1632
  });
1063
1633
  return modified ? result : renderable;
1064
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
+ }
1065
1661
  /**
1066
1662
  * Core cross-page pipeline hooks.
1067
- * Run for every site, before any community package hooks.
1663
+ * Run for every site, before any plugin hooks.
1068
1664
  * Registers page and heading entities, aggregates the page tree and breadcrumb paths,
1069
1665
  * and resolves blog post listings.
1070
1666
  */
@@ -1088,6 +1684,7 @@ export const corePipelineHooks = {
1088
1684
  description: page.frontmatter.description,
1089
1685
  date: page.frontmatter.date,
1090
1686
  order: page.frontmatter.order,
1687
+ icon: page.frontmatter.icon,
1091
1688
  },
1092
1689
  });
1093
1690
  for (const h of page.headings) {
@@ -1111,6 +1708,9 @@ export const corePipelineHooks = {
1111
1708
  url: e.data.url,
1112
1709
  title: e.data.title,
1113
1710
  parentUrl: e.data.parentUrl,
1711
+ description: e.data.description,
1712
+ icon: e.data.icon,
1713
+ order: e.data.order,
1114
1714
  }));
1115
1715
  const pageTree = buildPageTree(pages);
1116
1716
  const breadcrumbPaths = buildBreadcrumbPaths(pages);
@@ -1138,6 +1738,9 @@ export const corePipelineHooks = {
1138
1738
  return page;
1139
1739
  let renderable = resolveAutoBreadcrumbs(page.renderable, page.url, coreData.breadcrumbPaths, coreData.pagesByUrl, ctx);
1140
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);
1141
1744
  renderable = resolveBlogPosts(renderable, coreData.allPosts, ctx, page.url);
1142
1745
  renderable = resolveXrefs(renderable, page.url, coreData.registry, ctx);
1143
1746
  if (renderable === page.renderable)