@veluai/velu 0.2.25 → 0.2.27

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.
Files changed (114) hide show
  1. package/README.md +80 -80
  2. package/dist/cli.js +44 -44
  3. package/package.json +64 -64
  4. package/runtime/velu-ui/base.css +320 -320
  5. package/runtime/velu-ui/components/Accordion.jsx +64 -64
  6. package/runtime/velu-ui/components/ApiClient.jsx +207 -207
  7. package/runtime/velu-ui/components/ApiField.jsx +87 -87
  8. package/runtime/velu-ui/components/ApiPath.jsx +63 -63
  9. package/runtime/velu-ui/components/ApiReferencePage.jsx +384 -384
  10. package/runtime/velu-ui/components/ApiSamples.jsx +36 -36
  11. package/runtime/velu-ui/components/ApiSidebar.jsx +125 -125
  12. package/runtime/velu-ui/components/AskBar.jsx +71 -71
  13. package/runtime/velu-ui/components/Callout.jsx +114 -114
  14. package/runtime/velu-ui/components/Card.jsx +131 -131
  15. package/runtime/velu-ui/components/ChangelogFilters.jsx +75 -75
  16. package/runtime/velu-ui/components/Chatbot.jsx +972 -972
  17. package/runtime/velu-ui/components/CodeBlock.jsx +375 -375
  18. package/runtime/velu-ui/components/Columns.jsx +56 -56
  19. package/runtime/velu-ui/components/ContextMenu.jsx +327 -327
  20. package/runtime/velu-ui/components/ErrorCard.jsx +138 -138
  21. package/runtime/velu-ui/components/Field.jsx +81 -81
  22. package/runtime/velu-ui/components/Image.jsx +163 -163
  23. package/runtime/velu-ui/components/Logo.jsx +31 -31
  24. package/runtime/velu-ui/components/MethodBadge.jsx +31 -31
  25. package/runtime/velu-ui/components/NavSelect.jsx +108 -108
  26. package/runtime/velu-ui/components/NotFound.jsx +63 -63
  27. package/runtime/velu-ui/components/PageFeedback.jsx +219 -219
  28. package/runtime/velu-ui/components/PageFooter.jsx +145 -145
  29. package/runtime/velu-ui/components/PageHeader.jsx +422 -422
  30. package/runtime/velu-ui/components/PageNav.jsx +77 -77
  31. package/runtime/velu-ui/components/PoweredBy.jsx +51 -51
  32. package/runtime/velu-ui/components/Prompt.jsx +115 -115
  33. package/runtime/velu-ui/components/Search.jsx +460 -460
  34. package/runtime/velu-ui/components/Sidebar.jsx +246 -246
  35. package/runtime/velu-ui/components/SocialLinks.jsx +90 -90
  36. package/runtime/velu-ui/components/Steps.jsx +65 -65
  37. package/runtime/velu-ui/components/ThemeToggle.jsx +48 -48
  38. package/runtime/velu-ui/components/Toc.jsx +537 -537
  39. package/runtime/velu-ui/components/TocBar.jsx +195 -195
  40. package/runtime/velu-ui/components/Tree.jsx +87 -87
  41. package/runtime/velu-ui/components/TryItBar.jsx +102 -102
  42. package/runtime/velu-ui/components/Update.jsx +92 -92
  43. package/runtime/velu-ui/components/accordion.css +92 -92
  44. package/runtime/velu-ui/components/api-page.css +208 -208
  45. package/runtime/velu-ui/components/api.css +635 -635
  46. package/runtime/velu-ui/components/ask-bar.css +103 -103
  47. package/runtime/velu-ui/components/card.css +105 -105
  48. package/runtime/velu-ui/components/changelog-filters.css +43 -43
  49. package/runtime/velu-ui/components/chatbot.css +687 -687
  50. package/runtime/velu-ui/components/code-block.css +263 -263
  51. package/runtime/velu-ui/components/context-menu.css +199 -199
  52. package/runtime/velu-ui/components/docs-layout.css +822 -822
  53. package/runtime/velu-ui/components/field.css +82 -82
  54. package/runtime/velu-ui/components/image.css +237 -237
  55. package/runtime/velu-ui/components/nav-select.css +157 -157
  56. package/runtime/velu-ui/components/not-found.css +94 -94
  57. package/runtime/velu-ui/components/page-feedback.css +261 -261
  58. package/runtime/velu-ui/components/page-footer.css +130 -130
  59. package/runtime/velu-ui/components/page-header.css +558 -558
  60. package/runtime/velu-ui/components/page-nav.css +50 -50
  61. package/runtime/velu-ui/components/powered-by.css +92 -92
  62. package/runtime/velu-ui/components/prompt.css +99 -99
  63. package/runtime/velu-ui/components/search.css +307 -307
  64. package/runtime/velu-ui/components/sidebar.css +205 -205
  65. package/runtime/velu-ui/components/steps.css +77 -77
  66. package/runtime/velu-ui/components/theme-toggle.css +102 -102
  67. package/runtime/velu-ui/components/toc-bar.css +234 -234
  68. package/runtime/velu-ui/components/tree.css +49 -49
  69. package/runtime/velu-ui/components/update.css +111 -111
  70. package/runtime/velu-ui/index.js +58 -58
  71. package/runtime/velu-ui/lib/api-send.js +118 -118
  72. package/runtime/velu-ui/lib/brand-icons.jsx +103 -103
  73. package/runtime/velu-ui/lib/component-schemas.js +100 -100
  74. package/runtime/velu-ui/lib/copyText.js +64 -64
  75. package/runtime/velu-ui/lib/docs-assistant.js +273 -273
  76. package/runtime/velu-ui/lib/lang-icons.jsx +147 -147
  77. package/runtime/velu-ui/lib/page-feedback.js +72 -72
  78. package/runtime/velu-ui/lib/pagefind.js +116 -116
  79. package/runtime/velu-ui/lib/prism-langs.js +957 -957
  80. package/runtime/velu-ui/lib/prism-loader.js +74 -74
  81. package/runtime/velu-ui/lib/resolveIcon.jsx +29 -29
  82. package/runtime/velu-ui/lib/scrollIntoNearestView.js +66 -66
  83. package/runtime/velu-ui/mdx-components.jsx +107 -107
  84. package/runtime/velu-ui/primitives/Cluster.jsx +49 -49
  85. package/runtime/velu-ui/primitives/Stack.jsx +63 -63
  86. package/runtime/velu-ui/primitives/Switcher.jsx +57 -57
  87. package/runtime/velu-ui/primitives/stack.css +3 -3
  88. package/runtime/velu-ui/primitives/switcher.css +25 -25
  89. package/runtime/velu-ui/styles.css +48 -48
  90. package/runtime/velu-ui/tokens.css +4 -4
  91. package/schema/velu.schema.json +419 -419
  92. package/src/lib/extract-mdx-error.js +170 -170
  93. package/src/lib/issues.js +159 -159
  94. package/src/lib/known-components.js +35 -35
  95. package/src/navigation.js +459 -459
  96. package/src/runtime/App.jsx +1754 -1754
  97. package/src/runtime/ErrorBoundary.jsx +54 -54
  98. package/src/runtime/client-entry.jsx +27 -27
  99. package/src/runtime/server-entry.jsx +24 -24
  100. package/src/template.html +48 -48
  101. package/templates/starter/ai-tools/claude-code.mdx +26 -26
  102. package/templates/starter/ai-tools/cursor.mdx +17 -17
  103. package/templates/starter/api-reference/introduction.mdx +43 -43
  104. package/templates/starter/development.mdx +19 -19
  105. package/templates/starter/essentials/code.mdx +29 -29
  106. package/templates/starter/essentials/images.mdx +29 -29
  107. package/templates/starter/essentials/markdown.mdx +25 -25
  108. package/templates/starter/essentials/navigation.mdx +39 -39
  109. package/templates/starter/essentials/settings.mdx +30 -30
  110. package/templates/starter/favicon.svg +6 -6
  111. package/templates/starter/index.mdx +31 -31
  112. package/templates/starter/openapi.json +160 -160
  113. package/templates/starter/quickstart.mdx +31 -31
  114. package/templates/starter/velu.json +40 -40
package/src/navigation.js CHANGED
@@ -1,459 +1,459 @@
1
- import GithubSlugger from 'github-slugger';
2
- import ISO6391 from 'iso-639-1';
3
-
4
- /**
5
- * navigation.js — the PURE, framework-agnostic navigation model. No
6
- * `fs`, no React, no Node-only imports: this file is imported by BOTH
7
- * the Node-side Vite plugin (to normalize + walk) AND the browser
8
- * runtime (to resolve the active page → sidebar/header). Sharing the
9
- * exact same code on both sides is what keeps SSR and client render
10
- * in lockstep (no hydration drift).
11
- *
12
- * Model: `velu.json`'s `navigation` is normalized into a canonical
13
- * tree whose containment order is FIXED, regardless of how the author
14
- * nested things, and identical to the URL-prefix order:
15
- *
16
- * AXIS_ORDER: product → version → language → tabs → groups → pages
17
- * URL: /<product?>/<version?>/<language?>/<page-path>
18
- *
19
- * Default axis values contribute no URL segment. Dropdowns are out of
20
- * scope by design.
21
- */
22
-
23
- export const AXIS_ORDER = ['product', 'version', 'language'];
24
-
25
- // `language` in velu.json is stored as an ISO 639-1 code (e.g. "en");
26
- // the switcher shows the code + its native name. iso-639-1 covers the
27
- // full set of codes; unknown/invalid codes fall back to the code.
28
- function languageName(code) {
29
- const c = String(code).toLowerCase();
30
- return ISO6391.getNativeName(c) || code;
31
- }
32
-
33
- /** Fresh slugger per call → pure (no cross-call dedup state). */
34
- export function slugify(s) {
35
- return new GithubSlugger().slug(String(s ?? ''));
36
- }
37
-
38
- /**
39
- * Map a content page path to its in-context URL leaf:
40
- * "index" → "/"
41
- * "sync/index" → "/sync"
42
- * "sync/github" → "/sync/github"
43
- * Defined ONCE so collect + resolve never disagree.
44
- */
45
- export function urlForPath(pagePath) {
46
- const segs = String(pagePath)
47
- .replace(/^\/+|\/+$/g, '')
48
- .split('/')
49
- .filter(Boolean);
50
- if (segs[segs.length - 1] === 'index') segs.pop();
51
- return '/' + segs.join('/');
52
- }
53
-
54
- /** Normalize a request/location path: drop query+hash, collapse a
55
- * trailing slash (except root), ensure a leading slash. Used on both
56
- * sides so the active-page lookup matches identically. */
57
- export function normalizeUrl(url) {
58
- let u = String(url).split('#')[0].split('?')[0];
59
- // Decode percent-encoding so non-ASCII slugs (e.g. an "Español"
60
- // language → "/español") match whether the browser sends the path
61
- // encoded ("/espa%C3%B1ol") or decoded.
62
- try {
63
- u = decodeURI(u);
64
- } catch {
65
- /* malformed escape — leave as-is */
66
- }
67
- if (!u.startsWith('/')) u = '/' + u;
68
- if (u.length > 1) u = u.replace(/\/+$/, '');
69
- return u || '/';
70
- }
71
-
72
- const titleCase = (s) =>
73
- String(s)
74
- .replace(/[-_]+/g, ' ')
75
- .replace(/\b\w/g, (c) => c.toUpperCase());
76
-
77
- const lastSeg = (url) => {
78
- const segs = String(url).split('/').filter(Boolean);
79
- return segs[segs.length - 1] ?? '';
80
- };
81
-
82
- // ── Normalization ──────────────────────────────────────────────────
83
-
84
- /**
85
- * Build the canonical tree from a raw `navigation` object. At each
86
- * level we honor a fixed precedence (products > versions > languages >
87
- * tabs > leaf), so canonically-authored configs (axes nested in
88
- * AXIS_ORDER, or only one axis) collapse to the intended tree. A leaf
89
- * level mixes anchors + groups + pages as siblings.
90
- */
91
- export function normalizeNavigation(raw) {
92
- const root = { kind: 'root', children: normalizeChildren(raw ?? {}) };
93
- applyDefaults(root.children);
94
- return root;
95
- }
96
-
97
- function normalizeChildren(obj) {
98
- // Anchors are pinned links that may sit alongside ANY level (incl.
99
- // next to tabs as "global" anchors), so collect them regardless of
100
- // which grouping branch this container uses.
101
- const anchors = Array.isArray(obj.anchors)
102
- ? obj.anchors.map(normalizeAnchor)
103
- : [];
104
- if (Array.isArray(obj.products))
105
- return [...anchors, ...obj.products.map(normalizeProduct)];
106
- if (Array.isArray(obj.versions))
107
- return [...anchors, ...obj.versions.map(normalizeVersion)];
108
- if (Array.isArray(obj.languages))
109
- return [...anchors, ...obj.languages.map(normalizeLanguage)];
110
- if (Array.isArray(obj.tabs))
111
- return [...anchors, ...obj.tabs.map(normalizeTab)];
112
- const out = [...anchors];
113
- if (Array.isArray(obj.groups)) out.push(...obj.groups.map(normalizeGroup));
114
- if (Array.isArray(obj.pages)) out.push(...obj.pages.map(normalizePageEntry));
115
- return out;
116
- }
117
-
118
- function normalizeProduct(p) {
119
- return {
120
- kind: 'product',
121
- label: p.name ?? p.product,
122
- slug: slugify(p.product),
123
- icon: p.icon,
124
- color: p.color,
125
- default: Boolean(p.default),
126
- children: normalizeChildren(p),
127
- };
128
- }
129
-
130
- function normalizeVersion(v) {
131
- return {
132
- kind: 'version',
133
- label: v.version,
134
- slug: slugify(v.version),
135
- default: Boolean(v.default),
136
- children: normalizeChildren(v),
137
- };
138
- }
139
-
140
- function normalizeLanguage(l) {
141
- const code = String(l.language);
142
- return {
143
- kind: 'language',
144
- code,
145
- label: languageName(code),
146
- slug: slugify(code),
147
- default: Boolean(l.default),
148
- children: normalizeChildren(l),
149
- };
150
- }
151
-
152
- function normalizeTab(t) {
153
- return {
154
- kind: 'tab',
155
- label: t.tab,
156
- slug: slugify(t.tab),
157
- icon: t.icon,
158
- href: t.href,
159
- children: normalizeChildren(t),
160
- };
161
- }
162
-
163
- function normalizeAnchor(a) {
164
- return {
165
- kind: 'anchor',
166
- label: a.anchor,
167
- icon: a.icon,
168
- href: a.href,
169
- color: a.color,
170
- };
171
- }
172
-
173
- function normalizeGroup(g) {
174
- const pages = (g.pages ?? []).map(normalizePageEntry);
175
- // A group `root` is its landing page. velu-ui's Sidebar section
176
- // heading isn't a link, so surface root as the group's FIRST item
177
- // (its label comes from the root page's frontmatter, like any page).
178
- if (g.root) pages.unshift({ kind: 'page', pagePath: g.root });
179
- return {
180
- kind: 'group',
181
- label: g.group,
182
- icon: g.icon,
183
- expanded: Boolean(g.expanded),
184
- root: g.root,
185
- children: pages,
186
- };
187
- }
188
-
189
- /** A `pages[]` entry is a string (page path), a generated OpenAPI page
190
- * ({ apiPage, label, method }), or a nested group. */
191
- function normalizePageEntry(entry) {
192
- if (typeof entry === 'string') return { kind: 'page', pagePath: entry };
193
- if (entry && entry.apiPage) {
194
- return { kind: 'page', pagePath: entry.apiPage, label: entry.label, method: entry.method, api: true };
195
- }
196
- return normalizeGroup(entry);
197
- }
198
-
199
- /**
200
- * Ensure each axis sibling-set has exactly one default: if the author
201
- * marked none, the first declared becomes default (so an axis never
202
- * prefixes every URL). Recurses through the whole tree.
203
- */
204
- function applyDefaults(children) {
205
- for (const kind of AXIS_ORDER) {
206
- const sibs = children.filter((c) => c.kind === kind);
207
- if (sibs.length && !sibs.some((s) => s.default)) sibs[0].default = true;
208
- }
209
- for (const c of children) if (c.children) applyDefaults(c.children);
210
- }
211
-
212
- // ── Walking ────────────────────────────────────────────────────────
213
-
214
- /**
215
- * Walk the tree to every page leaf, computing its full URL (with the
216
- * accumulated NON-default axis prefix) and its ancestor chain. Pure —
217
- * the Node side adds fs existence on top (see navigation-node.js); the
218
- * runtime uses it directly for resolution. Returns:
219
- * [{ pagePath, url, ctx, ancestors:Node[] }]
220
- */
221
- export function walkPages(tree) {
222
- const out = [];
223
- const visit = (node, ctx, ancestors) => {
224
- if (node.kind === 'page') {
225
- out.push({
226
- pagePath: node.pagePath,
227
- url: urlInCtx(node.pagePath, ctx),
228
- ctx: { ...ctx },
229
- ancestors,
230
- node,
231
- });
232
- return;
233
- }
234
- let next = ctx;
235
- if (node.kind === 'product')
236
- next = { ...ctx, product: node.default ? null : node.slug };
237
- else if (node.kind === 'version')
238
- next = { ...ctx, version: node.default ? null : node.slug };
239
- else if (node.kind === 'language')
240
- next = { ...ctx, language: node.default ? null : node.slug };
241
- const nextAncestors =
242
- node.kind === 'root' ? ancestors : [...ancestors, node];
243
- for (const c of node.children ?? []) visit(c, next, nextAncestors);
244
- };
245
- visit(tree, { product: null, version: null, language: null }, []);
246
- return out;
247
- }
248
-
249
- /** Compose a page URL from a page path + an axis context. */
250
- function urlInCtx(pagePath, ctx) {
251
- const prefix = [ctx.product, ctx.version, ctx.language].filter(Boolean);
252
- const leaf = urlForPath(pagePath).split('/').filter(Boolean);
253
- return '/' + [...prefix, ...leaf].join('/');
254
- }
255
-
256
- // ── Resolution (runtime) ───────────────────────────────────────────
257
-
258
- /**
259
- * Resolve the active page for a URL into everything the chrome needs.
260
- * `pagesMap` is url → { frontmatter, ... } (from the virtual module),
261
- * used to source sidebar labels from page frontmatter titles.
262
- *
263
- * Returns null when no page matches (404). Otherwise:
264
- * {
265
- * url, frontmatter,
266
- * sidebarSections, anchors,
267
- * tabs, activeTab,
268
- * products, activeProduct,
269
- * versions, activeVersion,
270
- * languages, activeLanguage,
271
- * breadcrumb,
272
- * }
273
- */
274
- export function resolve(url, tree, pagesMap = {}) {
275
- const norm = normalizeUrl(url);
276
- const entries = walkPages(tree);
277
- const match = entries.find((e) => e.url === norm);
278
- if (!match) return null;
279
-
280
- const { ctx, ancestors } = match;
281
- const ancOf = (kind) => ancestors.filter((a) => a.kind === kind).pop();
282
- const tabAnc = ancOf('tab');
283
- const axisAnc = ancestors
284
- .filter((a) => AXIS_ORDER.includes(a.kind))
285
- .pop();
286
- const productAnc = ancOf('product');
287
- const versionAnc = ancOf('version');
288
- const languageAnc = ancOf('language');
289
-
290
- // Sidebar/anchor container = nearest tab, else nearest axis node,
291
- // else the root. Its group/page children form the sidebar; its
292
- // anchor children form the context-zone anchors.
293
- const container = tabAnc || axisAnc || tree;
294
- const containerKids = container.children ?? [];
295
- const sidebarSections = buildSidebar(
296
- containerKids.filter((n) => n.kind === 'group' || n.kind === 'page'),
297
- pagesMap,
298
- ctx,
299
- );
300
- // Whether THIS tab's sidebar holds any API page. The chrome uses it to
301
- // pick ONE sidebar style for the whole tab (flat API list vs nested docs
302
- // list) rather than flipping per selected page — so a tab that mixes an
303
- // intro page with generated API operations reads as a single, stable
304
- // sidebar. Derived from the sidebar items (the API ones carry `method`).
305
- const sidebarHasApi = sidebarSectionsHaveApi(sidebarSections);
306
- // Anchors are gathered from every container along the active path —
307
- // root (global), the active product/version/language, and the active
308
- // tab — so global anchors show on every page while tab-scoped ones
309
- // show only within their tab.
310
- const anchors = [tree, productAnc, versionAnc, languageAnc, tabAnc]
311
- .filter(Boolean)
312
- .flatMap((c) => (c.children ?? []).filter((n) => n.kind === 'anchor'))
313
- .map((a) => ({ label: a.label, icon: a.icon, href: a.href, external: true }));
314
-
315
- // Tabs live directly under the innermost axis node (or root).
316
- const tabParent = axisAnc || tree;
317
- const tabNodes = (tabParent.children ?? []).filter((n) => n.kind === 'tab');
318
- const firstUrlUnder = (node) => {
319
- const e = entries.find((en) => en.ancestors.includes(node));
320
- return e ? e.url : node.href ?? '#';
321
- };
322
- const tabs = tabNodes.map((t) => ({
323
- label: t.label,
324
- icon: t.icon,
325
- href: firstUrlUnder(t),
326
- }));
327
- const activeTab = tabAnc ? firstUrlUnder(tabAnc) : undefined;
328
-
329
- // Switchers: sibling axis options + the URL to land on when chosen
330
- // (first page of that option's subtree). Each option set is scoped
331
- // to the active outer axis (versions within the active product, etc).
332
- const axisOptions = (kind, parent) =>
333
- (parent?.children ?? tree.children ?? [])
334
- .filter((n) => n.kind === kind)
335
- .map((n) => ({
336
- label: n.label,
337
- code: n.code, // language code (undefined for product/version)
338
- slug: n.slug,
339
- icon: n.icon,
340
- default: n.default,
341
- href: firstUrlUnder(n),
342
- active: n === ancOf(kind),
343
- }));
344
- const products = axisOptions('product', tree);
345
- const versions = axisOptions('version', productAnc ?? tree);
346
- const languages = axisOptions('language', versionAnc ?? productAnc ?? tree);
347
-
348
- // Breadcrumb: tab → group ancestors → page title.
349
- const groupAncestors = ancestors.filter((a) => a.kind === 'group');
350
- const pageTitle = pagesMap[norm]?.frontmatter?.title ?? titleCase(lastSeg(norm));
351
- const breadcrumb = [
352
- ...(tabAnc ? [{ label: tabAnc.label }] : []),
353
- ...groupAncestors.map((g) => ({ label: g.label })),
354
- { label: pageTitle },
355
- ];
356
-
357
- // Prev / next = neighbors in the sidebar's reading order (the
358
- // flattened page sequence of the active section).
359
- const ordered = flattenSidebar(sidebarSections);
360
- const idx = ordered.findIndex((p) => p.href === norm);
361
- const prev = idx > 0 ? ordered[idx - 1] : undefined;
362
- const next = idx >= 0 && idx < ordered.length - 1 ? ordered[idx + 1] : undefined;
363
-
364
- return {
365
- url: norm,
366
- frontmatter: pagesMap[norm]?.frontmatter ?? {},
367
- sidebarSections,
368
- sidebarHasApi,
369
- anchors,
370
- tabs,
371
- activeTab,
372
- products,
373
- activeProduct: productAnc?.label,
374
- versions,
375
- activeVersion: versionAnc?.label,
376
- languages,
377
- activeLanguage: languageAnc?.label,
378
- activeLanguageCode: languageAnc?.code,
379
- breadcrumb,
380
- prev,
381
- next,
382
- };
383
- }
384
-
385
- /** True when any item in the sidebar (at any nesting depth) is an API page —
386
- * i.e. carries an HTTP `method`. Lets the chrome render the whole tab with
387
- * the API sidebar style instead of switching per selected page. */
388
- function sidebarSectionsHaveApi(sections) {
389
- const walk = (items) =>
390
- (items ?? []).some((it) => it.method || (it.items && walk(it.items)));
391
- return sections.some((s) => walk(s.items));
392
- }
393
-
394
- /** Flatten sidebar sections (incl. nested groups) to an ordered list of
395
- * page links {label, href} — the page reading order for prev/next. */
396
- function flattenSidebar(sections) {
397
- const out = [];
398
- const walk = (items) => {
399
- for (const it of items) {
400
- if (it.items) walk(it.items);
401
- else out.push({ label: it.label, href: it.href });
402
- }
403
- };
404
- for (const s of sections) walk(s.items);
405
- return out;
406
- }
407
-
408
- /**
409
- * Map a list of (group|page) nodes to the velu-ui Sidebar `sections`
410
- * shape: [{ title, icon, items:[{ label, href, icon, items }] }].
411
- * Loose top-level pages (not inside a group) collect into a leading
412
- * untitled section. Page labels come from frontmatter title, falling
413
- * back to a title-cased URL segment (the SAME fallback on both render
414
- * sides). Sidebar derives open/active state from `activeHref`, so we
415
- * don't compute it here.
416
- */
417
- export function buildSidebar(nodes, pagesMap, ctx) {
418
- const sections = [];
419
- let loose = null;
420
- for (const n of nodes) {
421
- if (n.kind === 'group') {
422
- sections.push({
423
- title: n.label,
424
- icon: n.icon,
425
- items: itemsFor(n.children, pagesMap, ctx),
426
- });
427
- } else if (n.kind === 'page') {
428
- if (!loose) {
429
- loose = { title: '', items: [] };
430
- sections.push(loose);
431
- }
432
- loose.items.push(itemFor(n, pagesMap, ctx));
433
- }
434
- }
435
- return sections;
436
- }
437
-
438
- function itemsFor(nodes, pagesMap, ctx) {
439
- return (nodes ?? []).map((n) =>
440
- n.kind === 'group'
441
- ? {
442
- label: n.label,
443
- icon: n.icon,
444
- items: itemsFor(n.children, pagesMap, ctx),
445
- }
446
- : itemFor(n, pagesMap, ctx),
447
- );
448
- }
449
-
450
- function itemFor(pageNode, pagesMap, ctx) {
451
- const href = urlInCtx(pageNode.pagePath, ctx);
452
- const fm = pagesMap[href]?.frontmatter;
453
- // Generated API pages carry their own label + HTTP method (for the sidebar
454
- // method badge); normal pages source their label from frontmatter.
455
- const label = pageNode.label ?? fm?.title ?? titleCase(lastSeg(href));
456
- const item = { label, href };
457
- if (pageNode.method) item.method = pageNode.method;
458
- return item;
459
- }
1
+ import GithubSlugger from 'github-slugger';
2
+ import ISO6391 from 'iso-639-1';
3
+
4
+ /**
5
+ * navigation.js — the PURE, framework-agnostic navigation model. No
6
+ * `fs`, no React, no Node-only imports: this file is imported by BOTH
7
+ * the Node-side Vite plugin (to normalize + walk) AND the browser
8
+ * runtime (to resolve the active page → sidebar/header). Sharing the
9
+ * exact same code on both sides is what keeps SSR and client render
10
+ * in lockstep (no hydration drift).
11
+ *
12
+ * Model: `velu.json`'s `navigation` is normalized into a canonical
13
+ * tree whose containment order is FIXED, regardless of how the author
14
+ * nested things, and identical to the URL-prefix order:
15
+ *
16
+ * AXIS_ORDER: product → version → language → tabs → groups → pages
17
+ * URL: /<product?>/<version?>/<language?>/<page-path>
18
+ *
19
+ * Default axis values contribute no URL segment. Dropdowns are out of
20
+ * scope by design.
21
+ */
22
+
23
+ export const AXIS_ORDER = ['product', 'version', 'language'];
24
+
25
+ // `language` in velu.json is stored as an ISO 639-1 code (e.g. "en");
26
+ // the switcher shows the code + its native name. iso-639-1 covers the
27
+ // full set of codes; unknown/invalid codes fall back to the code.
28
+ function languageName(code) {
29
+ const c = String(code).toLowerCase();
30
+ return ISO6391.getNativeName(c) || code;
31
+ }
32
+
33
+ /** Fresh slugger per call → pure (no cross-call dedup state). */
34
+ export function slugify(s) {
35
+ return new GithubSlugger().slug(String(s ?? ''));
36
+ }
37
+
38
+ /**
39
+ * Map a content page path to its in-context URL leaf:
40
+ * "index" → "/"
41
+ * "sync/index" → "/sync"
42
+ * "sync/github" → "/sync/github"
43
+ * Defined ONCE so collect + resolve never disagree.
44
+ */
45
+ export function urlForPath(pagePath) {
46
+ const segs = String(pagePath)
47
+ .replace(/^\/+|\/+$/g, '')
48
+ .split('/')
49
+ .filter(Boolean);
50
+ if (segs[segs.length - 1] === 'index') segs.pop();
51
+ return '/' + segs.join('/');
52
+ }
53
+
54
+ /** Normalize a request/location path: drop query+hash, collapse a
55
+ * trailing slash (except root), ensure a leading slash. Used on both
56
+ * sides so the active-page lookup matches identically. */
57
+ export function normalizeUrl(url) {
58
+ let u = String(url).split('#')[0].split('?')[0];
59
+ // Decode percent-encoding so non-ASCII slugs (e.g. an "Español"
60
+ // language → "/español") match whether the browser sends the path
61
+ // encoded ("/espa%C3%B1ol") or decoded.
62
+ try {
63
+ u = decodeURI(u);
64
+ } catch {
65
+ /* malformed escape — leave as-is */
66
+ }
67
+ if (!u.startsWith('/')) u = '/' + u;
68
+ if (u.length > 1) u = u.replace(/\/+$/, '');
69
+ return u || '/';
70
+ }
71
+
72
+ const titleCase = (s) =>
73
+ String(s)
74
+ .replace(/[-_]+/g, ' ')
75
+ .replace(/\b\w/g, (c) => c.toUpperCase());
76
+
77
+ const lastSeg = (url) => {
78
+ const segs = String(url).split('/').filter(Boolean);
79
+ return segs[segs.length - 1] ?? '';
80
+ };
81
+
82
+ // ── Normalization ──────────────────────────────────────────────────
83
+
84
+ /**
85
+ * Build the canonical tree from a raw `navigation` object. At each
86
+ * level we honor a fixed precedence (products > versions > languages >
87
+ * tabs > leaf), so canonically-authored configs (axes nested in
88
+ * AXIS_ORDER, or only one axis) collapse to the intended tree. A leaf
89
+ * level mixes anchors + groups + pages as siblings.
90
+ */
91
+ export function normalizeNavigation(raw) {
92
+ const root = { kind: 'root', children: normalizeChildren(raw ?? {}) };
93
+ applyDefaults(root.children);
94
+ return root;
95
+ }
96
+
97
+ function normalizeChildren(obj) {
98
+ // Anchors are pinned links that may sit alongside ANY level (incl.
99
+ // next to tabs as "global" anchors), so collect them regardless of
100
+ // which grouping branch this container uses.
101
+ const anchors = Array.isArray(obj.anchors)
102
+ ? obj.anchors.map(normalizeAnchor)
103
+ : [];
104
+ if (Array.isArray(obj.products))
105
+ return [...anchors, ...obj.products.map(normalizeProduct)];
106
+ if (Array.isArray(obj.versions))
107
+ return [...anchors, ...obj.versions.map(normalizeVersion)];
108
+ if (Array.isArray(obj.languages))
109
+ return [...anchors, ...obj.languages.map(normalizeLanguage)];
110
+ if (Array.isArray(obj.tabs))
111
+ return [...anchors, ...obj.tabs.map(normalizeTab)];
112
+ const out = [...anchors];
113
+ if (Array.isArray(obj.groups)) out.push(...obj.groups.map(normalizeGroup));
114
+ if (Array.isArray(obj.pages)) out.push(...obj.pages.map(normalizePageEntry));
115
+ return out;
116
+ }
117
+
118
+ function normalizeProduct(p) {
119
+ return {
120
+ kind: 'product',
121
+ label: p.name ?? p.product,
122
+ slug: slugify(p.product),
123
+ icon: p.icon,
124
+ color: p.color,
125
+ default: Boolean(p.default),
126
+ children: normalizeChildren(p),
127
+ };
128
+ }
129
+
130
+ function normalizeVersion(v) {
131
+ return {
132
+ kind: 'version',
133
+ label: v.version,
134
+ slug: slugify(v.version),
135
+ default: Boolean(v.default),
136
+ children: normalizeChildren(v),
137
+ };
138
+ }
139
+
140
+ function normalizeLanguage(l) {
141
+ const code = String(l.language);
142
+ return {
143
+ kind: 'language',
144
+ code,
145
+ label: languageName(code),
146
+ slug: slugify(code),
147
+ default: Boolean(l.default),
148
+ children: normalizeChildren(l),
149
+ };
150
+ }
151
+
152
+ function normalizeTab(t) {
153
+ return {
154
+ kind: 'tab',
155
+ label: t.tab,
156
+ slug: slugify(t.tab),
157
+ icon: t.icon,
158
+ href: t.href,
159
+ children: normalizeChildren(t),
160
+ };
161
+ }
162
+
163
+ function normalizeAnchor(a) {
164
+ return {
165
+ kind: 'anchor',
166
+ label: a.anchor,
167
+ icon: a.icon,
168
+ href: a.href,
169
+ color: a.color,
170
+ };
171
+ }
172
+
173
+ function normalizeGroup(g) {
174
+ const pages = (g.pages ?? []).map(normalizePageEntry);
175
+ // A group `root` is its landing page. velu-ui's Sidebar section
176
+ // heading isn't a link, so surface root as the group's FIRST item
177
+ // (its label comes from the root page's frontmatter, like any page).
178
+ if (g.root) pages.unshift({ kind: 'page', pagePath: g.root });
179
+ return {
180
+ kind: 'group',
181
+ label: g.group,
182
+ icon: g.icon,
183
+ expanded: Boolean(g.expanded),
184
+ root: g.root,
185
+ children: pages,
186
+ };
187
+ }
188
+
189
+ /** A `pages[]` entry is a string (page path), a generated OpenAPI page
190
+ * ({ apiPage, label, method }), or a nested group. */
191
+ function normalizePageEntry(entry) {
192
+ if (typeof entry === 'string') return { kind: 'page', pagePath: entry };
193
+ if (entry && entry.apiPage) {
194
+ return { kind: 'page', pagePath: entry.apiPage, label: entry.label, method: entry.method, api: true };
195
+ }
196
+ return normalizeGroup(entry);
197
+ }
198
+
199
+ /**
200
+ * Ensure each axis sibling-set has exactly one default: if the author
201
+ * marked none, the first declared becomes default (so an axis never
202
+ * prefixes every URL). Recurses through the whole tree.
203
+ */
204
+ function applyDefaults(children) {
205
+ for (const kind of AXIS_ORDER) {
206
+ const sibs = children.filter((c) => c.kind === kind);
207
+ if (sibs.length && !sibs.some((s) => s.default)) sibs[0].default = true;
208
+ }
209
+ for (const c of children) if (c.children) applyDefaults(c.children);
210
+ }
211
+
212
+ // ── Walking ────────────────────────────────────────────────────────
213
+
214
+ /**
215
+ * Walk the tree to every page leaf, computing its full URL (with the
216
+ * accumulated NON-default axis prefix) and its ancestor chain. Pure —
217
+ * the Node side adds fs existence on top (see navigation-node.js); the
218
+ * runtime uses it directly for resolution. Returns:
219
+ * [{ pagePath, url, ctx, ancestors:Node[] }]
220
+ */
221
+ export function walkPages(tree) {
222
+ const out = [];
223
+ const visit = (node, ctx, ancestors) => {
224
+ if (node.kind === 'page') {
225
+ out.push({
226
+ pagePath: node.pagePath,
227
+ url: urlInCtx(node.pagePath, ctx),
228
+ ctx: { ...ctx },
229
+ ancestors,
230
+ node,
231
+ });
232
+ return;
233
+ }
234
+ let next = ctx;
235
+ if (node.kind === 'product')
236
+ next = { ...ctx, product: node.default ? null : node.slug };
237
+ else if (node.kind === 'version')
238
+ next = { ...ctx, version: node.default ? null : node.slug };
239
+ else if (node.kind === 'language')
240
+ next = { ...ctx, language: node.default ? null : node.slug };
241
+ const nextAncestors =
242
+ node.kind === 'root' ? ancestors : [...ancestors, node];
243
+ for (const c of node.children ?? []) visit(c, next, nextAncestors);
244
+ };
245
+ visit(tree, { product: null, version: null, language: null }, []);
246
+ return out;
247
+ }
248
+
249
+ /** Compose a page URL from a page path + an axis context. */
250
+ function urlInCtx(pagePath, ctx) {
251
+ const prefix = [ctx.product, ctx.version, ctx.language].filter(Boolean);
252
+ const leaf = urlForPath(pagePath).split('/').filter(Boolean);
253
+ return '/' + [...prefix, ...leaf].join('/');
254
+ }
255
+
256
+ // ── Resolution (runtime) ───────────────────────────────────────────
257
+
258
+ /**
259
+ * Resolve the active page for a URL into everything the chrome needs.
260
+ * `pagesMap` is url → { frontmatter, ... } (from the virtual module),
261
+ * used to source sidebar labels from page frontmatter titles.
262
+ *
263
+ * Returns null when no page matches (404). Otherwise:
264
+ * {
265
+ * url, frontmatter,
266
+ * sidebarSections, anchors,
267
+ * tabs, activeTab,
268
+ * products, activeProduct,
269
+ * versions, activeVersion,
270
+ * languages, activeLanguage,
271
+ * breadcrumb,
272
+ * }
273
+ */
274
+ export function resolve(url, tree, pagesMap = {}) {
275
+ const norm = normalizeUrl(url);
276
+ const entries = walkPages(tree);
277
+ const match = entries.find((e) => e.url === norm);
278
+ if (!match) return null;
279
+
280
+ const { ctx, ancestors } = match;
281
+ const ancOf = (kind) => ancestors.filter((a) => a.kind === kind).pop();
282
+ const tabAnc = ancOf('tab');
283
+ const axisAnc = ancestors
284
+ .filter((a) => AXIS_ORDER.includes(a.kind))
285
+ .pop();
286
+ const productAnc = ancOf('product');
287
+ const versionAnc = ancOf('version');
288
+ const languageAnc = ancOf('language');
289
+
290
+ // Sidebar/anchor container = nearest tab, else nearest axis node,
291
+ // else the root. Its group/page children form the sidebar; its
292
+ // anchor children form the context-zone anchors.
293
+ const container = tabAnc || axisAnc || tree;
294
+ const containerKids = container.children ?? [];
295
+ const sidebarSections = buildSidebar(
296
+ containerKids.filter((n) => n.kind === 'group' || n.kind === 'page'),
297
+ pagesMap,
298
+ ctx,
299
+ );
300
+ // Whether THIS tab's sidebar holds any API page. The chrome uses it to
301
+ // pick ONE sidebar style for the whole tab (flat API list vs nested docs
302
+ // list) rather than flipping per selected page — so a tab that mixes an
303
+ // intro page with generated API operations reads as a single, stable
304
+ // sidebar. Derived from the sidebar items (the API ones carry `method`).
305
+ const sidebarHasApi = sidebarSectionsHaveApi(sidebarSections);
306
+ // Anchors are gathered from every container along the active path —
307
+ // root (global), the active product/version/language, and the active
308
+ // tab — so global anchors show on every page while tab-scoped ones
309
+ // show only within their tab.
310
+ const anchors = [tree, productAnc, versionAnc, languageAnc, tabAnc]
311
+ .filter(Boolean)
312
+ .flatMap((c) => (c.children ?? []).filter((n) => n.kind === 'anchor'))
313
+ .map((a) => ({ label: a.label, icon: a.icon, href: a.href, external: true }));
314
+
315
+ // Tabs live directly under the innermost axis node (or root).
316
+ const tabParent = axisAnc || tree;
317
+ const tabNodes = (tabParent.children ?? []).filter((n) => n.kind === 'tab');
318
+ const firstUrlUnder = (node) => {
319
+ const e = entries.find((en) => en.ancestors.includes(node));
320
+ return e ? e.url : node.href ?? '#';
321
+ };
322
+ const tabs = tabNodes.map((t) => ({
323
+ label: t.label,
324
+ icon: t.icon,
325
+ href: firstUrlUnder(t),
326
+ }));
327
+ const activeTab = tabAnc ? firstUrlUnder(tabAnc) : undefined;
328
+
329
+ // Switchers: sibling axis options + the URL to land on when chosen
330
+ // (first page of that option's subtree). Each option set is scoped
331
+ // to the active outer axis (versions within the active product, etc).
332
+ const axisOptions = (kind, parent) =>
333
+ (parent?.children ?? tree.children ?? [])
334
+ .filter((n) => n.kind === kind)
335
+ .map((n) => ({
336
+ label: n.label,
337
+ code: n.code, // language code (undefined for product/version)
338
+ slug: n.slug,
339
+ icon: n.icon,
340
+ default: n.default,
341
+ href: firstUrlUnder(n),
342
+ active: n === ancOf(kind),
343
+ }));
344
+ const products = axisOptions('product', tree);
345
+ const versions = axisOptions('version', productAnc ?? tree);
346
+ const languages = axisOptions('language', versionAnc ?? productAnc ?? tree);
347
+
348
+ // Breadcrumb: tab → group ancestors → page title.
349
+ const groupAncestors = ancestors.filter((a) => a.kind === 'group');
350
+ const pageTitle = pagesMap[norm]?.frontmatter?.title ?? titleCase(lastSeg(norm));
351
+ const breadcrumb = [
352
+ ...(tabAnc ? [{ label: tabAnc.label }] : []),
353
+ ...groupAncestors.map((g) => ({ label: g.label })),
354
+ { label: pageTitle },
355
+ ];
356
+
357
+ // Prev / next = neighbors in the sidebar's reading order (the
358
+ // flattened page sequence of the active section).
359
+ const ordered = flattenSidebar(sidebarSections);
360
+ const idx = ordered.findIndex((p) => p.href === norm);
361
+ const prev = idx > 0 ? ordered[idx - 1] : undefined;
362
+ const next = idx >= 0 && idx < ordered.length - 1 ? ordered[idx + 1] : undefined;
363
+
364
+ return {
365
+ url: norm,
366
+ frontmatter: pagesMap[norm]?.frontmatter ?? {},
367
+ sidebarSections,
368
+ sidebarHasApi,
369
+ anchors,
370
+ tabs,
371
+ activeTab,
372
+ products,
373
+ activeProduct: productAnc?.label,
374
+ versions,
375
+ activeVersion: versionAnc?.label,
376
+ languages,
377
+ activeLanguage: languageAnc?.label,
378
+ activeLanguageCode: languageAnc?.code,
379
+ breadcrumb,
380
+ prev,
381
+ next,
382
+ };
383
+ }
384
+
385
+ /** True when any item in the sidebar (at any nesting depth) is an API page —
386
+ * i.e. carries an HTTP `method`. Lets the chrome render the whole tab with
387
+ * the API sidebar style instead of switching per selected page. */
388
+ function sidebarSectionsHaveApi(sections) {
389
+ const walk = (items) =>
390
+ (items ?? []).some((it) => it.method || (it.items && walk(it.items)));
391
+ return sections.some((s) => walk(s.items));
392
+ }
393
+
394
+ /** Flatten sidebar sections (incl. nested groups) to an ordered list of
395
+ * page links {label, href} — the page reading order for prev/next. */
396
+ function flattenSidebar(sections) {
397
+ const out = [];
398
+ const walk = (items) => {
399
+ for (const it of items) {
400
+ if (it.items) walk(it.items);
401
+ else out.push({ label: it.label, href: it.href });
402
+ }
403
+ };
404
+ for (const s of sections) walk(s.items);
405
+ return out;
406
+ }
407
+
408
+ /**
409
+ * Map a list of (group|page) nodes to the velu-ui Sidebar `sections`
410
+ * shape: [{ title, icon, items:[{ label, href, icon, items }] }].
411
+ * Loose top-level pages (not inside a group) collect into a leading
412
+ * untitled section. Page labels come from frontmatter title, falling
413
+ * back to a title-cased URL segment (the SAME fallback on both render
414
+ * sides). Sidebar derives open/active state from `activeHref`, so we
415
+ * don't compute it here.
416
+ */
417
+ export function buildSidebar(nodes, pagesMap, ctx) {
418
+ const sections = [];
419
+ let loose = null;
420
+ for (const n of nodes) {
421
+ if (n.kind === 'group') {
422
+ sections.push({
423
+ title: n.label,
424
+ icon: n.icon,
425
+ items: itemsFor(n.children, pagesMap, ctx),
426
+ });
427
+ } else if (n.kind === 'page') {
428
+ if (!loose) {
429
+ loose = { title: '', items: [] };
430
+ sections.push(loose);
431
+ }
432
+ loose.items.push(itemFor(n, pagesMap, ctx));
433
+ }
434
+ }
435
+ return sections;
436
+ }
437
+
438
+ function itemsFor(nodes, pagesMap, ctx) {
439
+ return (nodes ?? []).map((n) =>
440
+ n.kind === 'group'
441
+ ? {
442
+ label: n.label,
443
+ icon: n.icon,
444
+ items: itemsFor(n.children, pagesMap, ctx),
445
+ }
446
+ : itemFor(n, pagesMap, ctx),
447
+ );
448
+ }
449
+
450
+ function itemFor(pageNode, pagesMap, ctx) {
451
+ const href = urlInCtx(pageNode.pagePath, ctx);
452
+ const fm = pagesMap[href]?.frontmatter;
453
+ // Generated API pages carry their own label + HTTP method (for the sidebar
454
+ // method badge); normal pages source their label from frontmatter.
455
+ const label = pageNode.label ?? fm?.title ?? titleCase(lastSeg(href));
456
+ const item = { label, href };
457
+ if (pageNode.method) item.method = pageNode.method;
458
+ return item;
459
+ }