@writedocs/generator 0.1.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/LICENSE +15 -0
- package/README.md +17 -0
- package/astro.config.mjs +419 -0
- package/bin/writedocs.js +73 -0
- package/package.json +79 -0
- package/src/assets/wd_watermark.png +0 -0
- package/src/assets/wd_watermark_dark.png +0 -0
- package/src/cli/build-auth.js +53 -0
- package/src/cli/build.js +40 -0
- package/src/cli/dev.js +12 -0
- package/src/cli/generate-api-pages.js +359 -0
- package/src/cli/init.js +81 -0
- package/src/cli/preflight.js +40 -0
- package/src/cli/run-astro.js +57 -0
- package/src/cli/run-pagefind.js +66 -0
- package/src/cli/write-redirects-file.js +80 -0
- package/src/components/Accordion.astro +164 -0
- package/src/components/AccordionGroup.astro +40 -0
- package/src/components/ApiLangSelect.astro +168 -0
- package/src/components/ApiPlayground.astro +281 -0
- package/src/components/ApiReferencePanel.astro +1754 -0
- package/src/components/ApiSchemaField.astro +54 -0
- package/src/components/AppIcon.astro +32 -0
- package/src/components/Badge.astro +128 -0
- package/src/components/Callout.astro +168 -0
- package/src/components/Card.astro +136 -0
- package/src/components/CardGroup.astro +20 -0
- package/src/components/CodeGroup.astro +184 -0
- package/src/components/CopyPageMenu.astro +246 -0
- package/src/components/Danger.astro +12 -0
- package/src/components/Expandable.astro +126 -0
- package/src/components/Frame.astro +102 -0
- package/src/components/Hint.astro +99 -0
- package/src/components/Icon.astro +70 -0
- package/src/components/Image.astro +147 -0
- package/src/components/Info.astro +12 -0
- package/src/components/Note.astro +12 -0
- package/src/components/Parameter.astro +119 -0
- package/src/components/RequestExample.astro +33 -0
- package/src/components/ResponseExample.astro +19 -0
- package/src/components/Searchbar.astro +117 -0
- package/src/components/Step.astro +10 -0
- package/src/components/Steps.astro +32 -0
- package/src/components/Tab.astro +9 -0
- package/src/components/Tabs.astro +52 -0
- package/src/components/Tip.astro +12 -0
- package/src/components/Video.astro +135 -0
- package/src/components/Warning.astro +12 -0
- package/src/components/index.ts +48 -0
- package/src/content.config.ts +223 -0
- package/src/layout/BaseLayout.astro +750 -0
- package/src/layout/components/AnalyticsScripts.astro +77 -0
- package/src/layout/components/AskAiWidget.astro +37 -0
- package/src/layout/components/Breadcrumbs.astro +97 -0
- package/src/layout/components/ImageZoom.astro +19 -0
- package/src/layout/components/MobileMenu.astro +200 -0
- package/src/layout/components/NavTree.astro +351 -0
- package/src/layout/components/SearchModal.astro +42 -0
- package/src/layout/components/Sidebar.astro +122 -0
- package/src/layout/components/SiteFooter.astro +85 -0
- package/src/layout/components/TableOfContents.astro +117 -0
- package/src/layout/components/TopBar.astro +311 -0
- package/src/layout/styles/banner.css +44 -0
- package/src/layout/styles/base.css +234 -0
- package/src/layout/styles/dropdown.css +133 -0
- package/src/layout/styles/footer.css +108 -0
- package/src/layout/styles/image-zoom.css +50 -0
- package/src/layout/styles/mobile-menu.css +258 -0
- package/src/layout/styles/search-modal.css +122 -0
- package/src/layout/styles/topbar.css +437 -0
- package/src/lib/config.ts +2131 -0
- package/src/lib/mdx-auto-hydrate.js +70 -0
- package/src/lib/mdx-inject-builtins.js +87 -0
- package/src/lib/mdx-substitute-variables.js +66 -0
- package/src/lib/mdx-title-anchor-ids.js +84 -0
- package/src/lib/mermaid-rehype.js +72 -0
- package/src/lib/openapi-render.ts +479 -0
- package/src/lib/shiki-code-block.js +102 -0
- package/src/lib/shiki-copy-button.js +45 -0
- package/src/lib/styles-asset-integration.js +210 -0
- package/src/lib/writedocs-temp-dir.js +93 -0
- package/src/pages/404.astro +62 -0
- package/src/pages/[...slug].astro +1270 -0
- package/src/pages/[...slug].md.ts +78 -0
- package/src/pages/llms-full.txt.ts +71 -0
- package/src/pages/llms.txt.ts +141 -0
- package/src/scripts/banner.ts +20 -0
- package/src/scripts/dropdowns.ts +61 -0
- package/src/scripts/image-zoom.ts +66 -0
- package/src/scripts/mobile-menu.ts +55 -0
- package/src/scripts/search.ts +155 -0
- package/src/scripts/sidebar-scroll.ts +65 -0
- package/src/scripts/theme-toggle.ts +35 -0
- package/src/scripts/topbar-offset.ts +141 -0
- package/src/styles/global.css +18 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { getCollection, type CollectionEntry } from 'astro:content';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import type { APIRoute } from 'astro';
|
|
5
|
+
import { loadDocsConfig, normalizeEntryId, findAllPages } from '../lib/config';
|
|
6
|
+
import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
|
|
7
|
+
|
|
8
|
+
// The raw-Markdown twin of [...slug].astro: every content page is also
|
|
9
|
+
// reachable at the exact same slug with a literal ".md" suffix (e.g.
|
|
10
|
+
// /guides/foo/ -> /guides/foo.md), serving its untouched MDX/Markdown
|
|
11
|
+
// source instead of rendered HTML - what the "Copy page"/"View as
|
|
12
|
+
// Markdown" menu (CopyPageMenu.astro, wired in from [...slug].astro)
|
|
13
|
+
// links to, and what an LLM fetching that URL directly gets. Gated
|
|
14
|
+
// entirely behind writedocs.json's `contextMenu` field being present (see
|
|
15
|
+
// contextMenuSchema in lib/config.ts) - a site that hasn't opted in gets
|
|
16
|
+
// no .md routes at all, not just a hidden menu.
|
|
17
|
+
//
|
|
18
|
+
// A literal ".md" filename suffix rather than a `[...slug]` capture
|
|
19
|
+
// covering it: naming the file `[...slug].md.ts` makes Astro treat ".md"
|
|
20
|
+
// as a static part of the route, not part of the `...slug` rest
|
|
21
|
+
// parameter - requesting /guides/foo.md resolves with `slug ===
|
|
22
|
+
// "guides/foo"`, exactly like the HTML router's own `entry.id`-derived
|
|
23
|
+
// slug, just without the trailing-slash convention HTML pages use.
|
|
24
|
+
//
|
|
25
|
+
// getStaticPaths here is intentionally self-contained rather than
|
|
26
|
+
// sharing a module-level helper with [...slug].astro's own
|
|
27
|
+
// getStaticPaths - see that file's own extensive comment on Astro's
|
|
28
|
+
// getStaticPaths bundling extraction silently dropping top-level
|
|
29
|
+
// helper functions that are only referenced from inside it.
|
|
30
|
+
type DocsEntry = CollectionEntry<'pages'> | CollectionEntry<'generatedDocs'>;
|
|
31
|
+
|
|
32
|
+
export async function getStaticPaths() {
|
|
33
|
+
const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
|
|
34
|
+
const config = loadDocsConfig(contentDir);
|
|
35
|
+
// No writedocs.json `contextMenu` - feature is off entirely, including these
|
|
36
|
+
// routes (not just the menu UI - see the file-level comment above).
|
|
37
|
+
if (!config.contextMenu) return [];
|
|
38
|
+
|
|
39
|
+
const hasGeneratedDocs = fs.existsSync(path.join(writedocsTempDir(contentDir), 'generated-docs'));
|
|
40
|
+
const hasPages = findAllPages(contentDir).length > 0;
|
|
41
|
+
const [pagesEntries, generatedDocsEntries] = await Promise.all([
|
|
42
|
+
hasPages ? getCollection('pages') : Promise.resolve([]),
|
|
43
|
+
hasGeneratedDocs ? getCollection('generatedDocs') : Promise.resolve([]),
|
|
44
|
+
]);
|
|
45
|
+
const entries: DocsEntry[] = [...pagesEntries, ...generatedDocsEntries];
|
|
46
|
+
|
|
47
|
+
return entries
|
|
48
|
+
// OpenAPI operation pages (generated stubs, or hand-written pages that
|
|
49
|
+
// set `openapi:` frontmatter) render almost entirely from the spec at
|
|
50
|
+
// request time (see ApiPlayground.astro) rather than from prose in
|
|
51
|
+
// entry.body - a raw .md dump of one would be near-empty and useless
|
|
52
|
+
// as an LLM-facing "view this page as text" export, so they're left
|
|
53
|
+
// out of this route entirely rather than serving something misleading.
|
|
54
|
+
.filter((entry) => !entry.data.openapi)
|
|
55
|
+
.map((entry) => ({
|
|
56
|
+
params: { slug: normalizeEntryId(entry.id) },
|
|
57
|
+
props: { entry },
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface Props {
|
|
62
|
+
entry: DocsEntry;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const GET: APIRoute = ({ props }) => {
|
|
66
|
+
const { entry } = props as Props;
|
|
67
|
+
// entry.body is the raw, frontmatter-stripped MDX/Markdown source text
|
|
68
|
+
// Astro's glob() loader already read off disk and stashed on every
|
|
69
|
+
// entry by default (see astro/dist/content/loaders/glob.js) - reusing
|
|
70
|
+
// it here means this route needs no separate file read/parse of its
|
|
71
|
+
// own, and always matches exactly what [...slug].astro rendered from
|
|
72
|
+
// (same entry, same collection query).
|
|
73
|
+
const body = entry.body ?? '';
|
|
74
|
+
const markdown = `# ${entry.data.title}\n\n${body}`;
|
|
75
|
+
return new Response(markdown, {
|
|
76
|
+
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
|
|
77
|
+
});
|
|
78
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { getCollection, type CollectionEntry } from 'astro:content';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import type { APIRoute } from 'astro';
|
|
5
|
+
import { loadDocsConfig, normalizeEntryId, findAllPages } from '../lib/config';
|
|
6
|
+
import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
|
|
7
|
+
|
|
8
|
+
// The "everything, concatenated" half of the llms.txt pair - see
|
|
9
|
+
// llms.txt.ts (right next to this file) for the lightweight index half,
|
|
10
|
+
// and that file's own top-of-file comment for the shared design notes
|
|
11
|
+
// (fixed non-dynamic route, unconditional/always generated, custom
|
|
12
|
+
// project-root override file support).
|
|
13
|
+
//
|
|
14
|
+
// Unlike llms.txt.ts, there's no `contextMenu`-dependent branch here:
|
|
15
|
+
// this route doesn't link to anything, it *is* the raw content, sourced
|
|
16
|
+
// the same way [...slug].md.ts sources a single page's raw content
|
|
17
|
+
// (`entry.body`, already read off disk by the glob() loader - no
|
|
18
|
+
// separate file read needed).
|
|
19
|
+
type DocsEntry = CollectionEntry<'pages'> | CollectionEntry<'generatedDocs'>;
|
|
20
|
+
|
|
21
|
+
export const GET: APIRoute = async () => {
|
|
22
|
+
const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
|
|
23
|
+
|
|
24
|
+
const customPath = path.join(contentDir, 'llms-full.txt');
|
|
25
|
+
if (fs.existsSync(customPath)) {
|
|
26
|
+
return new Response(fs.readFileSync(customPath, 'utf-8'), {
|
|
27
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const config = loadDocsConfig(contentDir);
|
|
32
|
+
|
|
33
|
+
const hasGeneratedDocs = fs.existsSync(path.join(writedocsTempDir(contentDir), 'generated-docs'));
|
|
34
|
+
const hasPages = findAllPages(contentDir).length > 0;
|
|
35
|
+
const [pagesEntries, generatedDocsEntries] = await Promise.all([
|
|
36
|
+
hasPages ? getCollection('pages') : Promise.resolve([]),
|
|
37
|
+
hasGeneratedDocs ? getCollection('generatedDocs') : Promise.resolve([]),
|
|
38
|
+
]);
|
|
39
|
+
const entries: DocsEntry[] = [...pagesEntries, ...generatedDocsEntries];
|
|
40
|
+
|
|
41
|
+
const sections = entries
|
|
42
|
+
// Same exclusion [...slug].md.ts applies to its own per-page raw
|
|
43
|
+
// Markdown route, for the same reason: an OpenAPI operation page
|
|
44
|
+
// (generated stub, or a hand-written page that opts into rendering
|
|
45
|
+
// <ApiPlayground /> via `openapi:` frontmatter) renders almost
|
|
46
|
+
// entirely from the spec at request time, so entry.body alone is
|
|
47
|
+
// near-empty and not a meaningful "full content" contribution here.
|
|
48
|
+
.filter((entry) => !entry.data.openapi)
|
|
49
|
+
// Same noindex exclusion llms.txt.ts applies to its own listing -
|
|
50
|
+
// see that file's comment.
|
|
51
|
+
.filter((entry) => !entry.data.seo?.noindex)
|
|
52
|
+
.map((entry: DocsEntry) => {
|
|
53
|
+
const slug = normalizeEntryId(entry.id);
|
|
54
|
+
const body = entry.body ?? '';
|
|
55
|
+
return { slug, title: entry.data.title, body };
|
|
56
|
+
})
|
|
57
|
+
.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
58
|
+
|
|
59
|
+
const lines: string[] = [`# ${config.name}`, ''];
|
|
60
|
+
if (config.description) lines.push(`> ${config.description}`, '');
|
|
61
|
+
|
|
62
|
+
for (const section of sections) {
|
|
63
|
+
lines.push('---', '', `# ${section.title}`, '', section.body.trim(), '');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const content = lines.join('\n') + '\n';
|
|
67
|
+
|
|
68
|
+
return new Response(content, {
|
|
69
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
70
|
+
});
|
|
71
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { getCollection, type CollectionEntry } from 'astro:content';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import type { APIRoute } from 'astro';
|
|
5
|
+
import { loadDocsConfig, normalizeEntryId, findAllPages, resolveSiteUrl } from '../lib/config';
|
|
6
|
+
import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
|
|
7
|
+
|
|
8
|
+
// A lightweight, LLM-facing index of every page on the site - the
|
|
9
|
+
// llms.txt convention (see https://llmstxt.org), also adopted by Mintlify
|
|
10
|
+
// and others: a plain-text file at the project root listing every page
|
|
11
|
+
// with a one-line description, so an AI tool can decide what's worth
|
|
12
|
+
// fetching without downloading the whole site. This is the "index" half
|
|
13
|
+
// of the pair; llms-full.txt (llms-full.txt.ts, right next to this file)
|
|
14
|
+
// is the "everything, concatenated" half.
|
|
15
|
+
//
|
|
16
|
+
// Fixed, non-dynamic route (no getStaticPaths/params needed) - Astro
|
|
17
|
+
// prerenders this once at the literal path /llms.txt, same as any other
|
|
18
|
+
// static-output route (this project's astro.config.mjs sets
|
|
19
|
+
// `output: 'static'`), and re-evaluates it per request in `writedocs dev`
|
|
20
|
+
// - see [...slug].md.ts for the same fixed-route-vs-catchall distinction
|
|
21
|
+
// applied to per-page raw Markdown.
|
|
22
|
+
//
|
|
23
|
+
// Unconditional - unlike the per-page .md routes in [...slug].md.ts,
|
|
24
|
+
// this doesn't depend on writedocs.json's `contextMenu` field. There's no UI
|
|
25
|
+
// footprint to gate (it's an extra static file, not a visible menu), so
|
|
26
|
+
// it's always generated. `contextMenu` only affects which URL each entry
|
|
27
|
+
// links to below (a real fetchable .md URL if the per-page raw-Markdown
|
|
28
|
+
// routes exist, the ordinary HTML page URL otherwise).
|
|
29
|
+
type DocsEntry = CollectionEntry<'pages'> | CollectionEntry<'generatedDocs'>;
|
|
30
|
+
|
|
31
|
+
// Mirrors Mintlify's own llms.txt behavior: truncate a page's frontmatter
|
|
32
|
+
// `description` at the first line break (a multi-paragraph description
|
|
33
|
+
// would blow out a one-line list entry) and at 300 characters (an
|
|
34
|
+
// arbitrary but reasonable cap - keeps every entry scannable regardless
|
|
35
|
+
// of how verbose an individual page's description happens to be).
|
|
36
|
+
const DESCRIPTION_MAX_CHARS = 300;
|
|
37
|
+
function truncateDescription(description: string | undefined): string | undefined {
|
|
38
|
+
if (!description) return undefined;
|
|
39
|
+
const firstLine = description.split('\n')[0].trim();
|
|
40
|
+
if (!firstLine) return undefined;
|
|
41
|
+
return firstLine.length > DESCRIPTION_MAX_CHARS
|
|
42
|
+
? firstLine.slice(0, DESCRIPTION_MAX_CHARS).trimEnd() + '…'
|
|
43
|
+
: firstLine;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Same cap Mintlify documents for its own auto-generated llms.txt - this
|
|
47
|
+
// file is meant to stay a lightweight index (one line per page), not
|
|
48
|
+
// balloon into something llms-full.txt-sized. Realistically unlikely to
|
|
49
|
+
// matter for most Writedocs sites (a site would need on the order of a
|
|
50
|
+
// thousand-plus pages with full-length descriptions to hit this), but
|
|
51
|
+
// cheap to guard against regardless.
|
|
52
|
+
const MAX_CHARS = 100_000;
|
|
53
|
+
|
|
54
|
+
export const GET: APIRoute = async () => {
|
|
55
|
+
const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
|
|
56
|
+
|
|
57
|
+
// A hand-authored llms.txt at the project root (next to writedocs.json)
|
|
58
|
+
// always wins outright over the generated one below - same override
|
|
59
|
+
// convention Mintlify documents for its own llms.txt. Lets a site
|
|
60
|
+
// author hand-curate this file (different ordering, editorial
|
|
61
|
+
// descriptions, extra sections) without losing the ability to fall
|
|
62
|
+
// back to the auto-generated version by just deleting the override.
|
|
63
|
+
const customPath = path.join(contentDir, 'llms.txt');
|
|
64
|
+
if (fs.existsSync(customPath)) {
|
|
65
|
+
return new Response(fs.readFileSync(customPath, 'utf-8'), {
|
|
66
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const config = loadDocsConfig(contentDir);
|
|
71
|
+
const siteUrl = resolveSiteUrl(config); // absolute origin if `domain` is set, else null
|
|
72
|
+
|
|
73
|
+
const hasGeneratedDocs = fs.existsSync(path.join(writedocsTempDir(contentDir), 'generated-docs'));
|
|
74
|
+
const hasPages = findAllPages(contentDir).length > 0;
|
|
75
|
+
const [pagesEntries, generatedDocsEntries] = await Promise.all([
|
|
76
|
+
hasPages ? getCollection('pages') : Promise.resolve([]),
|
|
77
|
+
hasGeneratedDocs ? getCollection('generatedDocs') : Promise.resolve([]),
|
|
78
|
+
]);
|
|
79
|
+
const entries: DocsEntry[] = [...pagesEntries, ...generatedDocsEntries];
|
|
80
|
+
|
|
81
|
+
const items = entries
|
|
82
|
+
// A page that opted out of search-engine indexing (frontmatter
|
|
83
|
+
// `seo.noindex: true`, already excluded from sitemap.xml - see
|
|
84
|
+
// astro.config.mjs's collectNoindexIds()) is excluded here for the
|
|
85
|
+
// same reason: llms.txt exists to help external tools discover
|
|
86
|
+
// pages, exactly what `noindex` asked not to happen.
|
|
87
|
+
.filter((entry) => !entry.data.seo?.noindex)
|
|
88
|
+
.map((entry: DocsEntry) => {
|
|
89
|
+
const slug = normalizeEntryId(entry.id);
|
|
90
|
+
// Same URL Astro's own router resolves this entry to - see
|
|
91
|
+
// hrefForSlug() in [...slug].astro for the HTML form, and
|
|
92
|
+
// [...slug].md.ts for the .md form (no trailing slash, "index.md"
|
|
93
|
+
// for the home page rather than the HTML convention's bare "/").
|
|
94
|
+
const path_ = config.contextMenu ? `/${slug}.md` : slug === 'index' ? '/' : `/${slug}/`;
|
|
95
|
+
const href = (siteUrl ?? '') + path_;
|
|
96
|
+
let description = truncateDescription(entry.data.description);
|
|
97
|
+
// Mirrors Mintlify's own behavior: an OpenAPI operation page's
|
|
98
|
+
// description gets its "METHOD /path" appended, since the page
|
|
99
|
+
// itself renders almost entirely from the spec at request time
|
|
100
|
+
// (see ApiPlayground.astro) rather than from frontmatter prose.
|
|
101
|
+
if (entry.data.openapi) {
|
|
102
|
+
description = description ? `${description} (${entry.data.openapi})` : entry.data.openapi;
|
|
103
|
+
}
|
|
104
|
+
return { title: entry.data.title, href, description, slug };
|
|
105
|
+
})
|
|
106
|
+
.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
107
|
+
|
|
108
|
+
const lines: string[] = [`# ${config.name}`, ''];
|
|
109
|
+
if (config.description) lines.push(`> ${config.description}`, '');
|
|
110
|
+
lines.push('## Docs', '');
|
|
111
|
+
for (const item of items) {
|
|
112
|
+
const entryLine = `- [${item.title}](${item.href})${item.description ? `: ${item.description}` : ''}`;
|
|
113
|
+
lines.push(entryLine);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let content = lines.join('\n') + '\n';
|
|
117
|
+
if (content.length > MAX_CHARS) {
|
|
118
|
+
// Truncate at the last full line inside the budget, then note how
|
|
119
|
+
// many entries got cut - rather than silently producing a
|
|
120
|
+
// mid-sentence-cutoff file, or (worse) one JSON/Markdown-breaking
|
|
121
|
+
// half-written link.
|
|
122
|
+
const truncatedLines = lines.slice(0, 4 + (config.description ? 2 : 0)); // "# name" / "" / ["> desc" / ""] / "## Docs" / ""
|
|
123
|
+
let runningLength = truncatedLines.join('\n').length + 1;
|
|
124
|
+
let omitted = 0;
|
|
125
|
+
for (const item of items) {
|
|
126
|
+
const entryLine = `- [${item.title}](${item.href})${item.description ? `: ${item.description}` : ''}`;
|
|
127
|
+
if (runningLength + entryLine.length + 1 > MAX_CHARS - 200) {
|
|
128
|
+
omitted++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
truncatedLines.push(entryLine);
|
|
132
|
+
runningLength += entryLine.length + 1;
|
|
133
|
+
}
|
|
134
|
+
truncatedLines.push('', `_Truncated — ${omitted} more page(s) omitted. See llms-full.txt or the site's own navigation for the complete list._`);
|
|
135
|
+
content = truncatedLines.join('\n') + '\n';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return new Response(content, {
|
|
139
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
140
|
+
});
|
|
141
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// writedocs.json's dismissible `banner` (bannerSchema in lib/config.ts) - pairs
|
|
2
|
+
// with the [data-banner-dismissed] flash-prevention script in BaseLayout's
|
|
3
|
+
// <head>, which is what actually hides the banner on repeat visits; this
|
|
4
|
+
// only needs to persist the choice and hide it for the rest of the current
|
|
5
|
+
// page view once clicked.
|
|
6
|
+
export function initBanner(root: ParentNode) {
|
|
7
|
+
const btn = root.querySelector<HTMLButtonElement>('[data-banner-dismiss]');
|
|
8
|
+
if (!btn || btn.dataset.wdInit) return;
|
|
9
|
+
btn.dataset.wdInit = 'true';
|
|
10
|
+
btn.addEventListener('click', () => {
|
|
11
|
+
document.documentElement.dataset.bannerDismissed = 'true';
|
|
12
|
+
try {
|
|
13
|
+
localStorage.setItem('wd-banner-dismissed', 'true');
|
|
14
|
+
} catch (e) {
|
|
15
|
+
// localStorage unavailable - banner still hides for this page view
|
|
16
|
+
// via the [data-banner-dismissed] attribute set above, just won't
|
|
17
|
+
// persist across reloads.
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Wires up every `.wd-dropdown` on the page: click-to-toggle, click-outside
|
|
2
|
+
// and Escape to close, and (via plain CSS alongside this) hover-to-reveal.
|
|
3
|
+
// Generic by design - not scoped to any one feature's markup. Originally
|
|
4
|
+
// written for the topbar's own switcher/tab dropdowns, but the same
|
|
5
|
+
// `.wd-dropdown`/`.wd-dropdown-trigger`/`.wd-dropdown-menu`/
|
|
6
|
+
// `.wd-dropdown-menu-panel` shape is now reused by TopBar.astro (switchers,
|
|
7
|
+
// tabs, the small-screen topbar.links ellipsis) and CopyPageMenu.astro, and
|
|
8
|
+
// exported from here so any future component - MobileMenu.astro included,
|
|
9
|
+
// even though it doesn't render any `.wd-dropdown` markup today - can import
|
|
10
|
+
// and call it too without needing to know whether some other component
|
|
11
|
+
// already has.
|
|
12
|
+
//
|
|
13
|
+
// That "might get called more than once per page" scenario is exactly why
|
|
14
|
+
// the two `document`-level listeners at the bottom are behind their own
|
|
15
|
+
// module-level guard, separate from the existing per-trigger
|
|
16
|
+
// `dataset.wdInit` guard: querying `.wd-dropdown` from more than one
|
|
17
|
+
// component's own `<script>` (each already idempotent per-trigger) would,
|
|
18
|
+
// without this, still add a fresh pair of document click/keydown listeners
|
|
19
|
+
// on every single call - harmless individually, but wasteful if it keeps
|
|
20
|
+
// happening across repeated calls (e.g. once per component that imports
|
|
21
|
+
// this, or once per `astro:page-load` firing from more than one place).
|
|
22
|
+
let globalListenersBound = false;
|
|
23
|
+
|
|
24
|
+
export function initDropdowns(root: ParentNode) {
|
|
25
|
+
const dropdowns = Array.from(root.querySelectorAll<HTMLElement>('.wd-dropdown'));
|
|
26
|
+
if (dropdowns.length === 0) return;
|
|
27
|
+
|
|
28
|
+
const closeAll = () => {
|
|
29
|
+
dropdowns.forEach((d) => {
|
|
30
|
+
d.classList.remove('open');
|
|
31
|
+
d.querySelector('.wd-dropdown-trigger')?.setAttribute('aria-expanded', 'false');
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
dropdowns.forEach((dropdown) => {
|
|
36
|
+
const trigger = dropdown.querySelector<HTMLButtonElement>('.wd-dropdown-trigger');
|
|
37
|
+
if (!trigger || trigger.dataset.wdInit) return;
|
|
38
|
+
trigger.dataset.wdInit = 'true';
|
|
39
|
+
trigger.addEventListener('click', (e) => {
|
|
40
|
+
e.stopPropagation();
|
|
41
|
+
const isOpen = dropdown.classList.contains('open');
|
|
42
|
+
closeAll();
|
|
43
|
+
if (!isOpen) {
|
|
44
|
+
dropdown.classList.add('open');
|
|
45
|
+
trigger.setAttribute('aria-expanded', 'true');
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (!globalListenersBound) {
|
|
51
|
+
globalListenersBound = true;
|
|
52
|
+
// Bound once against `document`, not `root` - closeAll() above already
|
|
53
|
+
// closes every dropdown found on the page regardless of which root a
|
|
54
|
+
// given call was scoped to, so there's no reason for these two to be
|
|
55
|
+
// scoped any narrower or bound more than once.
|
|
56
|
+
document.addEventListener('click', closeAll);
|
|
57
|
+
document.addEventListener('keydown', (e) => {
|
|
58
|
+
if (e.key === 'Escape') closeAll();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Click-to-zoom lightbox for content images - opened by ImageZoom.astro's
|
|
2
|
+
// own overlay markup, mounted once in BaseLayout.astro alongside
|
|
3
|
+
// SearchModal. Every <img> inside .wd-article becomes clickable and opens
|
|
4
|
+
// a fullscreen view of itself, *except*:
|
|
5
|
+
// - Card's own image (.wd-card-image) - a UI element of the card
|
|
6
|
+
// itself (its header art), not "content" a reader would want to
|
|
7
|
+
// inspect at full size.
|
|
8
|
+
// - Icon's own inline image (.wd-icon-image) - a small inline glyph
|
|
9
|
+
// sized in em to match surrounding text, not a photo/screenshot.
|
|
10
|
+
// - any image explicitly opted out via a `nozoom` attribute - written
|
|
11
|
+
// as `<img noZoom src="..." />` in source (Image.astro's own
|
|
12
|
+
// `noZoom` prop renders the same attribute - see its own comment),
|
|
13
|
+
// but HTML attribute names are ASCII-case-insensitive during
|
|
14
|
+
// parsing, so the DOM (and this selector) sees it as `nozoom`
|
|
15
|
+
// regardless of how it was cased in source.
|
|
16
|
+
// Scoped to .wd-article (not a bare `img` selector) so this never
|
|
17
|
+
// touches chrome images - the topbar/sidebar logo, sidebar icons, or
|
|
18
|
+
// anything else outside actual page content.
|
|
19
|
+
export function initImageZoom(root: ParentNode) {
|
|
20
|
+
const overlay = document.querySelector<HTMLElement>('#wd-zoom-overlay');
|
|
21
|
+
const overlayImg = document.querySelector<HTMLImageElement>('#wd-zoom-image');
|
|
22
|
+
if (!overlay || !overlayImg) return;
|
|
23
|
+
|
|
24
|
+
const images = root.querySelectorAll<HTMLImageElement>(
|
|
25
|
+
'.wd-article img:not(.wd-card-image):not(.wd-icon-image):not([nozoom])',
|
|
26
|
+
);
|
|
27
|
+
images.forEach((img) => {
|
|
28
|
+
if (img.dataset.wdInit) return;
|
|
29
|
+
img.dataset.wdInit = 'true';
|
|
30
|
+
img.classList.add('wd-zoomable');
|
|
31
|
+
img.addEventListener('click', () => open(img));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// The overlay itself only needs its own listeners bound once - re-running
|
|
35
|
+
// initImageZoom() on every astro:page-load (view transitions swap in a
|
|
36
|
+
// whole new set of <img> elements, but ImageZoom.astro's overlay markup
|
|
37
|
+
// is part of BaseLayout's own persistent chrome, not the swapped page
|
|
38
|
+
// content) would otherwise stack up duplicate close listeners.
|
|
39
|
+
if (overlay.dataset.wdInit) return;
|
|
40
|
+
overlay.dataset.wdInit = 'true';
|
|
41
|
+
|
|
42
|
+
function open(img: HTMLImageElement) {
|
|
43
|
+
if (!overlay || !overlayImg) return;
|
|
44
|
+
// currentSrc (not src) accounts for a <picture>/srcset-based image
|
|
45
|
+
// resolving to something other than its own plain `src` attribute -
|
|
46
|
+
// falls back to `src` for the common case where there's no srcset.
|
|
47
|
+
overlayImg.src = img.currentSrc || img.src;
|
|
48
|
+
overlayImg.alt = img.alt;
|
|
49
|
+
overlay.hidden = false;
|
|
50
|
+
document.body.style.overflow = 'hidden';
|
|
51
|
+
}
|
|
52
|
+
function close() {
|
|
53
|
+
if (!overlay || !overlayImg) return;
|
|
54
|
+
overlay.hidden = true;
|
|
55
|
+
document.body.style.overflow = '';
|
|
56
|
+
overlayImg.src = '';
|
|
57
|
+
}
|
|
58
|
+
// No target === overlay check (unlike SearchModal's own backdrop-click
|
|
59
|
+
// handling) - there's no separate interactive panel to protect here,
|
|
60
|
+
// the whole overlay (backdrop, image, close button) is meant to close
|
|
61
|
+
// on any click within it.
|
|
62
|
+
overlay.addEventListener('click', close);
|
|
63
|
+
document.addEventListener('keydown', (e) => {
|
|
64
|
+
if (e.key === 'Escape' && !overlay.hidden) close();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The <= 860px hamburger menu (MobileMenu.astro's own toggle button +
|
|
2
|
+
// overlay panel). The accordion rows inside the panel need no JS of their
|
|
3
|
+
// own - they're plain <details name="wd-mobile-accordion"> elements, which
|
|
4
|
+
// get native click/keyboard toggling and (in browsers supporting the
|
|
5
|
+
// `name` attribute's exclusive-accordion behavior) mutual exclusion for
|
|
6
|
+
// free, same as NavTree.astro's own collapsible groups already rely on
|
|
7
|
+
// <details> for. This function only owns the panel itself: open/close, the
|
|
8
|
+
// backdrop, Escape, and body scroll locking while it's open - same pattern
|
|
9
|
+
// initSearch() uses for its own modal.
|
|
10
|
+
export function initMobileMenu(root: ParentNode) {
|
|
11
|
+
const trigger = root.querySelector<HTMLButtonElement>('[data-mobile-menu-trigger]');
|
|
12
|
+
const menu = root.querySelector<HTMLElement>('#wd-mobile-menu');
|
|
13
|
+
const backdrop = menu?.querySelector<HTMLElement>('[data-mobile-menu-backdrop]');
|
|
14
|
+
// The panel's own close button (.wd-mobile-menu-header, next to its
|
|
15
|
+
// replica of the topbar brand) - the real hamburger-turned-X trigger up
|
|
16
|
+
// in the topbar is covered by the panel itself once open, so this is the
|
|
17
|
+
// only reachable way to close via click besides the backdrop.
|
|
18
|
+
const closeBtn = menu?.querySelector<HTMLButtonElement>('[data-mobile-menu-close]');
|
|
19
|
+
if (!trigger || !menu || !backdrop || trigger.dataset.wdInit) return;
|
|
20
|
+
trigger.dataset.wdInit = 'true';
|
|
21
|
+
|
|
22
|
+
function open() {
|
|
23
|
+
menu!.classList.add('open');
|
|
24
|
+
// `inert` (not just visibility/pointer-events, which only affect
|
|
25
|
+
// vision/mouse) is what keeps the closed panel's links out of Tab
|
|
26
|
+
// order and off a screen reader's radar - removed while open so its
|
|
27
|
+
// content becomes reachable again.
|
|
28
|
+
menu!.removeAttribute('inert');
|
|
29
|
+
trigger!.classList.add('open');
|
|
30
|
+
trigger!.setAttribute('aria-expanded', 'true');
|
|
31
|
+
document.body.style.overflow = 'hidden';
|
|
32
|
+
}
|
|
33
|
+
function close() {
|
|
34
|
+
menu!.classList.remove('open');
|
|
35
|
+
menu!.setAttribute('inert', '');
|
|
36
|
+
trigger!.classList.remove('open');
|
|
37
|
+
trigger!.setAttribute('aria-expanded', 'false');
|
|
38
|
+
document.body.style.overflow = '';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
trigger.addEventListener('click', () => (menu.classList.contains('open') ? close() : open()));
|
|
42
|
+
backdrop.addEventListener('click', close);
|
|
43
|
+
closeBtn?.addEventListener('click', close);
|
|
44
|
+
document.addEventListener('keydown', (e) => {
|
|
45
|
+
if (e.key === 'Escape' && menu.classList.contains('open')) close();
|
|
46
|
+
});
|
|
47
|
+
// Resizing past the breakpoint while open (rotating a tablet, or
|
|
48
|
+
// widening a desktop browser window) - the panel/backdrop are already
|
|
49
|
+
// hard-hidden above 860px via CSS regardless, but without this the body
|
|
50
|
+
// scroll lock and the trigger's own "open" state would stick around with
|
|
51
|
+
// no visible panel left to close them.
|
|
52
|
+
window.matchMedia('(min-width: 861px)').addEventListener('change', (e) => {
|
|
53
|
+
if (e.matches && menu.classList.contains('open')) close();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// SearchModal.astro's own trigger button (rendered inside TopBar.astro) +
|
|
2
|
+
// overlay/modal. Owns opening/closing the modal, the ⌘K/Ctrl K shortcut,
|
|
3
|
+
// lazy-loading Pagefind on first open, and rendering results.
|
|
4
|
+
export function initSearch(root: ParentNode) {
|
|
5
|
+
const trigger = root.querySelector<HTMLButtonElement>('[data-search-trigger]');
|
|
6
|
+
const overlay = root.querySelector<HTMLElement>('#wd-search-overlay');
|
|
7
|
+
const input = root.querySelector<HTMLInputElement>('#wd-search-input');
|
|
8
|
+
const resultsEl = root.querySelector<HTMLElement>('#wd-search-results');
|
|
9
|
+
if (!trigger || !overlay || !input || !resultsEl || trigger.dataset.wdInit) return;
|
|
10
|
+
trigger.dataset.wdInit = 'true';
|
|
11
|
+
|
|
12
|
+
// Most keyboards outside macOS/iOS don't have a command key - "Ctrl K"
|
|
13
|
+
// reads more naturally there than the ⌘ glyph.
|
|
14
|
+
const kbd = trigger.querySelector('.wd-search-kbd');
|
|
15
|
+
if (kbd && !/Mac|iPhone|iPad/.test(navigator.userAgent)) {
|
|
16
|
+
kbd.textContent = 'Ctrl K';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Loaded lazily on first open rather than on every page load - most
|
|
20
|
+
// visits never open search, and the module + its wasm binary are pure
|
|
21
|
+
// overhead until someone actually wants to search. Pagefind only writes
|
|
22
|
+
// this file as a postbuild step, after Astro's own build (and therefore
|
|
23
|
+
// Vite's bundling) has already finished - see run-pagefind.js - so it
|
|
24
|
+
// never exists at `writedocs dev` time, and doesn't exist yet even
|
|
25
|
+
// during a production build's own Vite transform pass.
|
|
26
|
+
//
|
|
27
|
+
// `/* @vite-ignore */` alone doesn't stop Vite from trying to resolve it
|
|
28
|
+
// anyway: that comment only suppresses static analysis for a
|
|
29
|
+
// *non-literal* dynamic import specifier - a literal string like
|
|
30
|
+
// '/pagefind/pagefind.js' still gets resolved at transform time
|
|
31
|
+
// regardless of the comment, which crashes `writedocs dev` outright once
|
|
32
|
+
// this file exists in the template. Routing the literal through a
|
|
33
|
+
// variable first makes the specifier opaque to static analysis,
|
|
34
|
+
// deferring resolution to the browser at runtime - where the .catch()
|
|
35
|
+
// below handles "not built yet" gracefully instead.
|
|
36
|
+
let pagefind: any = null;
|
|
37
|
+
let loadPromise: Promise<any> | null = null;
|
|
38
|
+
function ensurePagefind() {
|
|
39
|
+
if (!loadPromise) {
|
|
40
|
+
const pagefindUrl = '/pagefind/pagefind.js';
|
|
41
|
+
loadPromise = import(/* @vite-ignore */ pagefindUrl)
|
|
42
|
+
.then((mod: any) => {
|
|
43
|
+
pagefind = mod;
|
|
44
|
+
return mod.init();
|
|
45
|
+
})
|
|
46
|
+
.then(() => pagefind)
|
|
47
|
+
.catch(() => {
|
|
48
|
+
resultsEl.innerHTML =
|
|
49
|
+
'<div class="wd-search-empty">Search isn\'t available in this preview - run a full build to index it.</div>';
|
|
50
|
+
return null;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return loadPromise;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let currentResults: HTMLElement[] = [];
|
|
57
|
+
let activeIndex = -1;
|
|
58
|
+
|
|
59
|
+
function setActive(index: number) {
|
|
60
|
+
currentResults.forEach((el) => el.classList.remove('wd-search-result-active'));
|
|
61
|
+
activeIndex = index >= 0 && index < currentResults.length ? index : -1;
|
|
62
|
+
if (activeIndex >= 0) {
|
|
63
|
+
currentResults[activeIndex].classList.add('wd-search-result-active');
|
|
64
|
+
currentResults[activeIndex].scrollIntoView({ block: 'nearest' });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function runSearch(term: string) {
|
|
69
|
+
if (!term.trim()) {
|
|
70
|
+
resultsEl.innerHTML = '<div class="wd-search-hint">Start typing to search...</div>';
|
|
71
|
+
currentResults = [];
|
|
72
|
+
activeIndex = -1;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const pf = await ensurePagefind();
|
|
76
|
+
if (!pf) return;
|
|
77
|
+
// debouncedSearch resolves to null when a newer keystroke has already
|
|
78
|
+
// superseded this call - pagefind's own debounce, on top of (not
|
|
79
|
+
// instead of) the plain `input` listener below, so a fast typist never
|
|
80
|
+
// races two searches rendering out of order.
|
|
81
|
+
const search = await pf.debouncedSearch(term, {}, 150);
|
|
82
|
+
if (search === null) return;
|
|
83
|
+
if (search.results.length === 0) {
|
|
84
|
+
resultsEl.innerHTML = '<div class="wd-search-empty">No results.</div>';
|
|
85
|
+
currentResults = [];
|
|
86
|
+
activeIndex = -1;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const entries = await Promise.all(search.results.slice(0, 12).map((r: any) => r.data()));
|
|
90
|
+
resultsEl.innerHTML = '';
|
|
91
|
+
for (const data of entries) {
|
|
92
|
+
const a = document.createElement('a');
|
|
93
|
+
a.className = 'wd-search-result';
|
|
94
|
+
a.href = data.url;
|
|
95
|
+
const title = document.createElement('div');
|
|
96
|
+
title.className = 'wd-search-result-title';
|
|
97
|
+
title.textContent = data.meta?.title ?? data.url;
|
|
98
|
+
const excerpt = document.createElement('div');
|
|
99
|
+
excerpt.className = 'wd-search-result-excerpt';
|
|
100
|
+
// innerHTML (not textContent) is deliberate: pagefind returns this
|
|
101
|
+
// pre-sanitized with <mark> wrapping the matched terms, which is the
|
|
102
|
+
// whole point - a plain-text excerpt couldn't highlight anything.
|
|
103
|
+
excerpt.innerHTML = data.excerpt;
|
|
104
|
+
a.append(title, excerpt);
|
|
105
|
+
resultsEl.appendChild(a);
|
|
106
|
+
}
|
|
107
|
+
currentResults = Array.from(resultsEl.querySelectorAll('.wd-search-result'));
|
|
108
|
+
setActive(0);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function open() {
|
|
112
|
+
overlay.hidden = false;
|
|
113
|
+
document.body.style.overflow = 'hidden';
|
|
114
|
+
input.value = '';
|
|
115
|
+
resultsEl.innerHTML = '<div class="wd-search-hint">Start typing to search...</div>';
|
|
116
|
+
currentResults = [];
|
|
117
|
+
activeIndex = -1;
|
|
118
|
+
ensurePagefind();
|
|
119
|
+
input.focus();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function close() {
|
|
123
|
+
overlay.hidden = true;
|
|
124
|
+
document.body.style.overflow = '';
|
|
125
|
+
trigger.focus();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
trigger.addEventListener('click', open);
|
|
129
|
+
overlay.addEventListener('click', (e) => {
|
|
130
|
+
if (e.target === overlay) close();
|
|
131
|
+
});
|
|
132
|
+
input.addEventListener('input', () => runSearch(input.value));
|
|
133
|
+
input.addEventListener('keydown', (e) => {
|
|
134
|
+
if (e.key === 'ArrowDown') {
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
setActive(activeIndex + 1);
|
|
137
|
+
} else if (e.key === 'ArrowUp') {
|
|
138
|
+
e.preventDefault();
|
|
139
|
+
setActive(activeIndex - 1);
|
|
140
|
+
} else if (e.key === 'Enter' && activeIndex >= 0) {
|
|
141
|
+
currentResults[activeIndex]?.click();
|
|
142
|
+
} else if (e.key === 'Escape') {
|
|
143
|
+
close();
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
document.addEventListener('keydown', (e) => {
|
|
147
|
+
if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey)) {
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
if (overlay.hidden) open();
|
|
150
|
+
else close();
|
|
151
|
+
} else if (e.key === 'Escape' && !overlay.hidden) {
|
|
152
|
+
close();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|