@writedocs/generator 0.9.4 → 0.10.1

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.
@@ -0,0 +1,58 @@
1
+ // Hidden navigation items (`"hidden": true` on a tab, product, version,
2
+ // language or dropdown): reachable only by their address.
3
+ //
4
+ // - Nothing lists one: its switcher, tab row or menu option is left out
5
+ // everywhere, and `/` never redirects into it (firstSlugAmong() in
6
+ // lib/config.ts skips it).
7
+ // - Inside one, the level it sits on isn't shown - that switcher would
8
+ // list the other items at its level. Everything below it works as
9
+ // usual. (buildSelectors() marks that level `hidden`; the placement in
10
+ // lib/selector-placement.js leaves it out.)
11
+ // - Its pages are a search space of their own: they search only among
12
+ // themselves, and the site's public search never finds them. With
13
+ // `"searchPublic": true` its search finds the public pages too. The
14
+ // build writes one index per space (cli/run-pagefind.js); a page names
15
+ // its space in the markup, and src/scripts/search.ts loads that index.
16
+ //
17
+ // Hidden isn't private: anyone with the address can read the pages. The
18
+ // sitemap, llms.txt and the MCP index still list them unless a page sets
19
+ // `noindex`.
20
+
21
+ /** Whether a navigation item is hidden. */
22
+ export function isHidden(item) {
23
+ return Boolean(item && typeof item === 'object' && item.hidden === true);
24
+ }
25
+
26
+ const NAME_FIELD = { tab: 'tab', version: 'version', language: 'language', dropdown: 'dropdown', product: 'product' };
27
+
28
+ function slug(text) {
29
+ return String(text ?? '')
30
+ .normalize('NFKD')
31
+ .replace(/[̀-ͯ]/g, '')
32
+ .toLowerCase()
33
+ .replace(/[^a-z0-9]+/g, '-')
34
+ .replace(/^-+|-+$/g, '');
35
+ }
36
+
37
+ /** A path's id, from the names of its levels - "product-admin",
38
+ * "tab-api--version-v2". Readable in the index folder's name, and the
39
+ * same on every build. */
40
+ export function spaceIdOf(path) {
41
+ return path
42
+ .map((segment) => {
43
+ const item = segment.items[segment.index] ?? {};
44
+ return `${segment.kind}-${slug(item[NAME_FIELD[segment.kind]]) || segment.index}`;
45
+ })
46
+ .join('--');
47
+ }
48
+
49
+ /** The search space a section belongs to: the innermost hidden item on its
50
+ * path - `{ id, searchPublic }` - or null for the public pages. */
51
+ export function spaceOf(path = []) {
52
+ let space = null;
53
+ path.forEach((segment, i) => {
54
+ const item = segment.items[segment.index];
55
+ if (isHidden(item)) space = { id: spaceIdOf(path.slice(0, i + 1)), searchPublic: item.searchPublic === true };
56
+ });
57
+ return space;
58
+ }
@@ -43,9 +43,15 @@ const CONTAINERS = {
43
43
  },
44
44
  };
45
45
 
46
+ const HIDDEN = {
47
+ hidden: 'Reachable only by its address: no tab, switcher or menu lists it, and inside it this level isn\'t shown. Its pages search only among themselves.',
48
+ searchPublic: 'For a hidden item: its search also finds the site\'s public pages.',
49
+ };
50
+
46
51
  const containerEntries = Object.entries(CONTAINERS).flatMap(([type, fields]) => [
47
52
  ...Object.entries(fields).map(([field, text]) => [`${type}.${field}`, text]),
48
53
  ...Object.entries(CHILDREN).map(([field, text]) => [`${type}.${field}`, text]),
54
+ ...Object.entries(HIDDEN).map(([field, text]) => [`${type}.${field}`, text]),
49
55
  ]);
50
56
 
51
57
  const logo = (where) => ({
@@ -35,6 +35,7 @@ import { visit } from 'unist-util-visit';
35
35
  import { findAllPages } from './pages.js';
36
36
  import { createJsonLocator, contextMenuOptions } from './config-schema.js';
37
37
  import { writedocsTempDir } from './writedocs-temp-dir.js';
38
+ import { folderAddresses } from './folder-redirects.js';
38
39
 
39
40
  // The same github-slugger Astro builds page URLs and heading ids with -
40
41
  // resolved through Astro itself, so the two can't drift apart.
@@ -295,6 +296,11 @@ export async function checkLinks(contentDir, configText) {
295
296
  const special = new Set(['/llms.txt', '/llms-full.txt', '/404.html', '/404/']);
296
297
  if (config.domain) ['/sitemap.xml', '/sitemap-index.xml'].forEach((p) => special.add(p));
297
298
  const knownUrls = [...pages.keys(), ...generated, ...redirects];
299
+ // Folder addresses with no page of their own redirect to one under them
300
+ // (lib/folder-redirects.js), so a link to one isn't broken.
301
+ const folders = new Set(
302
+ [...folderAddresses([...pages.keys(), ...generated].map((url) => url.replace(/^\/+|\/+$/g, '')))].map((folder) => `/${folder}/`)
303
+ );
298
304
 
299
305
  // Anchors a page can be linked to: its own, plus those of every snippet
300
306
  // it imports (they render inside it).
@@ -398,7 +404,7 @@ export async function checkLinks(contentDir, configText) {
398
404
  return;
399
405
  }
400
406
  // "/" always exists: the home page, or a redirect to the first page.
401
- if (pageKey === '/' || generated.has(pageKey) || redirects.has(pageKey) || special.has(pageKey.replace(/\/$/, '') || '/')) return;
407
+ if (pageKey === '/' || generated.has(pageKey) || redirects.has(pageKey) || folders.has(pageKey) || special.has(pageKey.replace(/\/$/, '') || '/')) return;
402
408
  if (special.has(pathname)) return;
403
409
 
404
410
  // Mintlify-style relative link: resolved from the file's folder
@@ -6,6 +6,8 @@
6
6
  // 'sidebar' the top of the sidebar: products inside a tab or a dropdown,
7
7
  // like Mintlify's - they pick what the sidebar shows
8
8
  // 'topbar' a switcher next to the site name (everything else)
9
+ // 'none' not shown: the page is inside a hidden item on this level
10
+ // (lib/hidden-sections.js)
9
11
  //
10
12
  // Without a sidebar on the page (`mode: custom` / `blank`), a 'sidebar'
11
13
  // switcher goes to the top bar instead. The mobile menu lists every level
@@ -13,6 +15,7 @@
13
15
 
14
16
  export function selectorPlacements(selectors, { sidebar = true } = {}) {
15
17
  return selectors.map((sel, i) => {
18
+ if (sel.hidden) return 'none';
16
19
  const parent = selectors[i - 1]?.kind;
17
20
  if (sel.kind === 'tab') return 'tabs';
18
21
  if (sel.kind === 'dropdown' && parent === 'tab') return 'tab-menu';
@@ -26,5 +29,5 @@ export function selectorPlacements(selectors, { sidebar = true } = {}) {
26
29
  * top bar hides the tabs and switchers, so any page with navigation to
27
30
  * reach needs it - with a sidebar or without (`mode: custom`). */
28
31
  export function hasMobileMenu({ sidebar, selectors = [], globalDropdowns = [] }) {
29
- return sidebar || selectors.length > 0 || globalDropdowns.length > 0;
32
+ return sidebar || selectors.some((sel) => !sel.hidden) || globalDropdowns.length > 0;
30
33
  }
@@ -71,6 +71,8 @@ import ApiPlayground from "../components/ApiPlayground.astro";
71
71
  import ApiReferencePanel from "../components/ApiReferencePanel.astro";
72
72
  import { searchScope, sectionTrail, ALL_SCOPES } from "../lib/search-scope.js";
73
73
  import { selectorPlacements } from "../lib/selector-placement.js";
74
+ import { spaceOf } from "../lib/hidden-sections.js";
75
+ import { folderRedirects } from "../lib/folder-redirects.js";
74
76
 
75
77
  // A page is hand-written (the `pages` collection, sourced from anywhere
76
78
  // in the project - docs/ has no special status, see findAllPages() in
@@ -254,12 +256,34 @@ export async function getStaticPaths() {
254
256
  };
255
257
  });
256
258
 
257
- return [...rootRedirect, ...pageRoutes, ...hiddenRoutes];
259
+ // Folder addresses with pages under them but none of their own
260
+ // (`/docs/creator/`) redirect to the first page under them
261
+ // (lib/folder-redirects.js). Preferred in the reader's order: navigation
262
+ // order, public sections before hidden ones, pages outside the
263
+ // navigation last.
264
+ const slugOf = (route: { params: { slug?: string } }) => route.params.slug ?? "index";
265
+ const inHiddenSection = sections.map((section) => spaceOf(section.path) !== null);
266
+ const contentRoutes = pageRoutes.filter((route) => !("redirectTo" in route.props));
267
+ const folderTargets = [
268
+ ...contentRoutes.filter((route) => !inHiddenSection[(route.props as { sectionIndex: number }).sectionIndex]).map(slugOf),
269
+ ...contentRoutes.filter((route) => inHiddenSection[(route.props as { sectionIndex: number }).sectionIndex]).map(slugOf),
270
+ ...hiddenRoutes.map(slugOf),
271
+ ];
272
+ const takenAddresses = new Set([
273
+ ...[...pageRoutes, ...hiddenRoutes].map(slugOf),
274
+ ...(config.redirects ?? []).map((r) => r.source.replace(/^\/+|\/+$/g, "")),
275
+ ]);
276
+ const folderRoutes = [...folderRedirects(folderTargets, takenAddresses)].map(([folder, target]) => ({
277
+ params: { slug: folder },
278
+ props: { redirectTo: `/${target}/`, temporary: true },
279
+ }));
280
+
281
+ return [...rootRedirect, ...pageRoutes, ...hiddenRoutes, ...folderRoutes];
258
282
  }
259
283
 
260
284
  interface Props {
261
285
  redirectTo?: string;
262
- // The automatic "/" redirect: temporary, see below.
286
+ // The automatic "/" and folder redirects: temporary, see below.
263
287
  temporary?: boolean;
264
288
  entry?: DocsEntry;
265
289
  prev?: { slug: string; group: string | null } | null;
@@ -386,6 +410,9 @@ const selectors = buildSelectors(activeSection.path, hrefForSlug, activePagePosi
386
410
  // shows the reader's own first. A hidden page belongs to no section.
387
411
  const pageSearchScope = isHidden ? { key: ALL_SCOPES, label: "" } : searchScope(activeSection.path);
388
412
  const pageSectionTrail = isHidden ? "" : sectionTrail(activeSection.path, breadcrumbs);
413
+ // A page under a hidden navigation item searches its own index, not the
414
+ // site's (lib/hidden-sections.js, cli/run-pagefind.js).
415
+ const pageSearchSpace = isHidden ? null : spaceOf(activeSection.path);
389
416
  const languageSegment = isHidden ? undefined : activeSection.path.find((segment) => segment.kind === "language");
390
417
  const pageLang = languageSegment ? (languageSegment.items[languageSegment.index] as { language: string }).language : undefined;
391
418
  const globalDropdowns = buildGlobalDropdowns(
@@ -517,7 +544,7 @@ const components = {
517
544
  {entry.data.openapi && <ApiPlayground operation={entry.data.openapi} contentDir={contentDir} />}
518
545
  {/* After the content: a result's excerpt shows the section only when
519
546
  that's what matched. */}
520
- <div hidden data-wd-search-scope={pageSearchScope.key} data-wd-search-scope-label={pageSearchScope.label} data-pagefind-filter={`scope:${pageSearchScope.key}`}>
547
+ <div hidden data-wd-search-scope={pageSearchScope.key} data-wd-search-scope-label={pageSearchScope.label} data-wd-search-space={pageSearchSpace?.id} data-wd-search-public={pageSearchSpace?.searchPublic ? "true" : undefined} data-pagefind-filter={`scope:${pageSearchScope.key}`}>
521
548
  {pageSectionTrail && <span data-pagefind-meta="section">{pageSectionTrail}</span>}
522
549
  </div>
523
550
  </div>
@@ -543,7 +570,7 @@ const components = {
543
570
  </div>
544
571
  <Content components={components} />
545
572
  {entry.data.openapi && <ApiPlayground operation={entry.data.openapi} contentDir={contentDir} />}
546
- <div hidden data-wd-search-scope={pageSearchScope.key} data-wd-search-scope-label={pageSearchScope.label} data-pagefind-filter={`scope:${pageSearchScope.key}`}>
573
+ <div hidden data-wd-search-scope={pageSearchScope.key} data-wd-search-scope-label={pageSearchScope.label} data-wd-search-space={pageSearchSpace?.id} data-wd-search-public={pageSearchSpace?.searchPublic ? "true" : undefined} data-pagefind-filter={`scope:${pageSearchScope.key}`}>
547
574
  {pageSectionTrail && <span data-pagefind-meta="section">{pageSectionTrail}</span>}
548
575
  </div>
549
576
  {!entry.data.hideFooterPagination && (
@@ -53,7 +53,10 @@ export function initSearch(root: ParentNode) {
53
53
  let loadPromise: Promise<any> | null = null;
54
54
  function ensurePagefind() {
55
55
  if (!loadPromise) {
56
- const pagefindUrl = '/pagefind/pagefind.js';
56
+ // A page under a hidden navigation item searches its own space's index
57
+ // (cli/run-pagefind.js, lib/hidden-sections.js); every other page, the site's.
58
+ const space = document.querySelector<HTMLElement>('[data-wd-search-scope]')?.dataset.wdSearchSpace;
59
+ const pagefindUrl = space ? `/pagefind-spaces/${encodeURIComponent(space)}/pagefind.js` : '/pagefind/pagefind.js';
57
60
  loadPromise = import(/* @vite-ignore */ pagefindUrl)
58
61
  .then((mod: any) => {
59
62
  pagefind = mod;