@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.
Files changed (95) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +17 -0
  3. package/astro.config.mjs +419 -0
  4. package/bin/writedocs.js +73 -0
  5. package/package.json +79 -0
  6. package/src/assets/wd_watermark.png +0 -0
  7. package/src/assets/wd_watermark_dark.png +0 -0
  8. package/src/cli/build-auth.js +53 -0
  9. package/src/cli/build.js +40 -0
  10. package/src/cli/dev.js +12 -0
  11. package/src/cli/generate-api-pages.js +359 -0
  12. package/src/cli/init.js +81 -0
  13. package/src/cli/preflight.js +40 -0
  14. package/src/cli/run-astro.js +57 -0
  15. package/src/cli/run-pagefind.js +66 -0
  16. package/src/cli/write-redirects-file.js +80 -0
  17. package/src/components/Accordion.astro +164 -0
  18. package/src/components/AccordionGroup.astro +40 -0
  19. package/src/components/ApiLangSelect.astro +168 -0
  20. package/src/components/ApiPlayground.astro +281 -0
  21. package/src/components/ApiReferencePanel.astro +1754 -0
  22. package/src/components/ApiSchemaField.astro +54 -0
  23. package/src/components/AppIcon.astro +32 -0
  24. package/src/components/Badge.astro +128 -0
  25. package/src/components/Callout.astro +168 -0
  26. package/src/components/Card.astro +136 -0
  27. package/src/components/CardGroup.astro +20 -0
  28. package/src/components/CodeGroup.astro +184 -0
  29. package/src/components/CopyPageMenu.astro +246 -0
  30. package/src/components/Danger.astro +12 -0
  31. package/src/components/Expandable.astro +126 -0
  32. package/src/components/Frame.astro +102 -0
  33. package/src/components/Hint.astro +99 -0
  34. package/src/components/Icon.astro +70 -0
  35. package/src/components/Image.astro +147 -0
  36. package/src/components/Info.astro +12 -0
  37. package/src/components/Note.astro +12 -0
  38. package/src/components/Parameter.astro +119 -0
  39. package/src/components/RequestExample.astro +33 -0
  40. package/src/components/ResponseExample.astro +19 -0
  41. package/src/components/Searchbar.astro +117 -0
  42. package/src/components/Step.astro +10 -0
  43. package/src/components/Steps.astro +32 -0
  44. package/src/components/Tab.astro +9 -0
  45. package/src/components/Tabs.astro +52 -0
  46. package/src/components/Tip.astro +12 -0
  47. package/src/components/Video.astro +135 -0
  48. package/src/components/Warning.astro +12 -0
  49. package/src/components/index.ts +48 -0
  50. package/src/content.config.ts +223 -0
  51. package/src/layout/BaseLayout.astro +750 -0
  52. package/src/layout/components/AnalyticsScripts.astro +77 -0
  53. package/src/layout/components/AskAiWidget.astro +37 -0
  54. package/src/layout/components/Breadcrumbs.astro +97 -0
  55. package/src/layout/components/ImageZoom.astro +19 -0
  56. package/src/layout/components/MobileMenu.astro +200 -0
  57. package/src/layout/components/NavTree.astro +351 -0
  58. package/src/layout/components/SearchModal.astro +42 -0
  59. package/src/layout/components/Sidebar.astro +122 -0
  60. package/src/layout/components/SiteFooter.astro +85 -0
  61. package/src/layout/components/TableOfContents.astro +117 -0
  62. package/src/layout/components/TopBar.astro +311 -0
  63. package/src/layout/styles/banner.css +44 -0
  64. package/src/layout/styles/base.css +234 -0
  65. package/src/layout/styles/dropdown.css +133 -0
  66. package/src/layout/styles/footer.css +108 -0
  67. package/src/layout/styles/image-zoom.css +50 -0
  68. package/src/layout/styles/mobile-menu.css +258 -0
  69. package/src/layout/styles/search-modal.css +122 -0
  70. package/src/layout/styles/topbar.css +437 -0
  71. package/src/lib/config.ts +2131 -0
  72. package/src/lib/mdx-auto-hydrate.js +70 -0
  73. package/src/lib/mdx-inject-builtins.js +87 -0
  74. package/src/lib/mdx-substitute-variables.js +66 -0
  75. package/src/lib/mdx-title-anchor-ids.js +84 -0
  76. package/src/lib/mermaid-rehype.js +72 -0
  77. package/src/lib/openapi-render.ts +479 -0
  78. package/src/lib/shiki-code-block.js +102 -0
  79. package/src/lib/shiki-copy-button.js +45 -0
  80. package/src/lib/styles-asset-integration.js +210 -0
  81. package/src/lib/writedocs-temp-dir.js +93 -0
  82. package/src/pages/404.astro +62 -0
  83. package/src/pages/[...slug].astro +1270 -0
  84. package/src/pages/[...slug].md.ts +78 -0
  85. package/src/pages/llms-full.txt.ts +71 -0
  86. package/src/pages/llms.txt.ts +141 -0
  87. package/src/scripts/banner.ts +20 -0
  88. package/src/scripts/dropdowns.ts +61 -0
  89. package/src/scripts/image-zoom.ts +66 -0
  90. package/src/scripts/mobile-menu.ts +55 -0
  91. package/src/scripts/search.ts +155 -0
  92. package/src/scripts/sidebar-scroll.ts +65 -0
  93. package/src/scripts/theme-toggle.ts +35 -0
  94. package/src/scripts/topbar-offset.ts +141 -0
  95. package/src/styles/global.css +18 -0
@@ -0,0 +1,210 @@
1
+ // Lets a static asset referenced by a root-relative URL - a writedocs.json
2
+ // field (styles.favicon, styles.logo, footer.logo, styles.background.
3
+ // images.{light,dark}, seo.ogImage, styles.fonts.*.source - the exact set
4
+ // collectConfiguredAssetPaths() in lib/config.ts resolves) *or* a plain
5
+ // image written directly into a page's own MDX/Markdown body
6
+ // (`![alt](/images/foo.png)`, a raw `<img src="/images/foo.png">`, the
7
+ // `<Image src="...">` component, `<Card img="...">`, ...) - resolve from
8
+ // *anywhere* in the project, not just public/. "Any place to store
9
+ // assets" is the whole point: a site author shouldn't have to know Astro
10
+ // has a public/ convention at all, let alone reorganize their project
11
+ // around it, just to reference an image from a doc page.
12
+ //
13
+ // Deliberately NOT implemented as "copy everything into public/ (or a
14
+ // merged scratch dir) before Astro starts, then leave Astro's own
15
+ // publicDir alone": that would either (a) write into the user's own
16
+ // public/ folder as a side effect - a real file appearing in their
17
+ // project tree that they didn't create, which writedocsTempDir()'s own
18
+ // doc comment already argues against doing anywhere in this codebase, or
19
+ // (b) point publicDir at a one-shot copied scratch dir instead of the
20
+ // real public/, which would regress `writedocs dev`'s live file-reload
21
+ // for the *existing*, common case of a file genuinely living under
22
+ // public/ - Vite's own dev server watches and serves publicDir straight
23
+ // off disk per-request with no copy step involved at all today; a byte
24
+ // copy taken once at startup wouldn't pick up a live edit to that file
25
+ // without a full dev-server restart.
26
+ //
27
+ // Instead, this is a small Astro integration with two hooks, each
28
+ // resolving fresh rather than relying on any one-shot copy:
29
+ // - astro:server:setup (dev): adds Vite dev-server middleware that
30
+ // intercepts any request whose extension looks like a static asset
31
+ // (looksLikeAssetPath() below - images/fonts, the same set
32
+ // MIME_BY_EXTENSION already knows how to serve) and - only if it
33
+ // doesn't already exist under public/ (that stays Vite's own job,
34
+ // unshadowed) - streams it straight from wherever it lives in the
35
+ // project. Not scoped to writedocs.json's own configured fields specifically
36
+ // (an earlier version of this file was) - a page's own MDX can
37
+ // reference an image path this integration has no way to know about
38
+ // ahead of time short of parsing every page's content on every
39
+ // request, so instead of trying to enumerate what *should* be
40
+ // reachable, it just resolves whatever *is* actually requested,
41
+ // directly, the same way Vite's own publicDir serving already does.
42
+ // - astro:build:done: a one-time pass after Astro's own build finishes.
43
+ // First, the same writedocs.json-field-driven copy as before (still needed
44
+ // as its own pass - background images/font sources render into CSS
45
+ // `url(...)` inside a `<style>` block, and seo.ogImage into a `<meta
46
+ // content="...">` tag, neither of which the second pass below would
47
+ // ever see). Second, copyReferencedContentAssets() below scans every
48
+ // built .html page for `src="/..."` references (an `<img>`, mainly -
49
+ // see its own comment) not already present in the output, and resolves
50
+ // + copies each the same way - this is what actually covers a plain
51
+ // content image, since there's no fixed field name to read it from up
52
+ // front the way there is for writedocs.json's own fields. A real static
53
+ // build has no "later" for a live-reload concern to apply to, so a
54
+ // single pass of each here is sufficient.
55
+ import fs from 'node:fs';
56
+ import path from 'node:path';
57
+ import { fileURLToPath } from 'node:url';
58
+ import { loadDocsConfig, collectConfiguredAssetPaths } from './config.ts';
59
+
60
+ const MIME_BY_EXTENSION = {
61
+ '.svg': 'image/svg+xml',
62
+ '.png': 'image/png',
63
+ '.jpg': 'image/jpeg',
64
+ '.jpeg': 'image/jpeg',
65
+ '.gif': 'image/gif',
66
+ '.webp': 'image/webp',
67
+ '.avif': 'image/avif',
68
+ '.ico': 'image/x-icon',
69
+ '.woff': 'font/woff',
70
+ '.woff2': 'font/woff2',
71
+ '.ttf': 'font/ttf',
72
+ '.otf': 'font/otf',
73
+ };
74
+
75
+ function mimeFor(filePath) {
76
+ return MIME_BY_EXTENSION[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
77
+ }
78
+
79
+ /** Resolves a root-relative `urlPath` (e.g. "/images/hero.svg") against
80
+ * `contentDir` directly - not `<contentDir>/public` - the fallback
81
+ * location for one of the styles/footer/seo asset fields
82
+ * (collectConfiguredAssetPaths()) when it isn't sitting under public/.
83
+ * Segment-by-segment path-traversal guard (no
84
+ * `..`/`.` segments) since `urlPath` ultimately comes from an incoming
85
+ * request URL in the dev-server case, not just trusted writedocs.json
86
+ * content. Returns an absolute path, or null if nothing real is there. */
87
+ function resolveOutsidePublic(urlPath, contentDir) {
88
+ const segments = urlPath.replace(/^\/+/, '').split('/');
89
+ if (segments.some((segment) => segment === '..' || segment === '.' || segment === '')) return null;
90
+ const absolute = path.join(contentDir, ...segments);
91
+ try {
92
+ return fs.statSync(absolute).isFile() ? absolute : null;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ function publicPathFor(urlPath, contentDir) {
99
+ const segments = urlPath.replace(/^\/+/, '').split('/');
100
+ return path.join(contentDir, 'public', ...segments);
101
+ }
102
+
103
+ /** Root-relative, recognized-extension paths only - the same "does this
104
+ * look like a static asset at all" question mimeFor()'s own map already
105
+ * answers, reused here as the dev-middleware/build-scan gate instead of
106
+ * a fixed list of writedocs.json field names. Deliberately permissive: a page
107
+ * or config field asking for a path this returns true for gets a real
108
+ * attempt to resolve it from anywhere in the project; anything else
109
+ * (an actual API route, a `.html` page request, ...) is left alone,
110
+ * same as before this existed. */
111
+ function looksLikeAssetPath(urlPath) {
112
+ return Object.prototype.hasOwnProperty.call(MIME_BY_EXTENSION, path.extname(urlPath).toLowerCase());
113
+ }
114
+
115
+ /** Recursively lists every `.html` file under `dir`. Astro's own build
116
+ * output is already a flat-ish tree of one .html per route, so this is
117
+ * just a plain walk - no globbing dependency needed for something this
118
+ * small. */
119
+ function listHtmlFiles(dir) {
120
+ const results = [];
121
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
122
+ const full = path.join(dir, entry.name);
123
+ if (entry.isDirectory()) {
124
+ results.push(...listHtmlFiles(full));
125
+ } else if (entry.isFile() && entry.name.endsWith('.html')) {
126
+ results.push(full);
127
+ }
128
+ }
129
+ return results;
130
+ }
131
+
132
+ /** Scans every built page for root-relative, recognized-extension `src="/..."`
133
+ * references (`<img src="...">`, mainly - what `Image.astro`, `Frame.astro`,
134
+ * and a raw `![]()`/`<img>` in MDX all ultimately render down to; a
135
+ * `srcset="..."` attribute is checked too, since it's the same kind of
136
+ * reference and costs nothing extra to also cover) that aren't already
137
+ * present in the build output, and copies each one in from wherever it
138
+ * resolves in the project via resolveOutsidePublic() - the same
139
+ * fallback the dev middleware below already uses, just applied once,
140
+ * after the fact, to the rendered HTML instead of to live requests.
141
+ * This is what actually covers a plain content-body image: unlike a
142
+ * writedocs.json field, there's no fixed set of field names to read a
143
+ * content image's path from ahead of time, so instead this reads
144
+ * whatever `src` a page's own rendered markup ends up containing. */
145
+ function copyReferencedContentAssets(outDir, contentDir) {
146
+ const attrPattern = /\b(?:src|srcset)="([^"]+)"/g;
147
+ for (const htmlFile of listHtmlFiles(outDir)) {
148
+ const html = fs.readFileSync(htmlFile, 'utf8');
149
+ let match;
150
+ while ((match = attrPattern.exec(html))) {
151
+ // srcset can hold a comma-separated list of "url descriptor" pairs -
152
+ // split it out; a plain src is already just the one URL.
153
+ const candidates = match[1].split(',').map((part) => part.trim().split(/\s+/)[0]).filter(Boolean);
154
+ for (const urlPath of candidates) {
155
+ if (!urlPath.startsWith('/') || urlPath.startsWith('//')) continue; // only root-relative - not external, not protocol-relative
156
+ if (!looksLikeAssetPath(urlPath)) continue;
157
+ const segments = urlPath.replace(/^\/+/, '').split('/');
158
+ const outPath = path.join(outDir, ...segments);
159
+ if (fs.existsSync(outPath)) continue; // already present - via public/'s passthrough copy, or a previous iteration of this same loop
160
+ const resolved = resolveOutsidePublic(urlPath, contentDir);
161
+ if (!resolved) continue; // doesn't resolve to a real file anywhere in the project either - leave the broken reference as-is, same as today
162
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
163
+ fs.copyFileSync(resolved, outPath);
164
+ }
165
+ }
166
+ }
167
+ }
168
+
169
+ export function stylesAssetFallback(contentDir) {
170
+ return {
171
+ name: 'writedocs-styles-asset-fallback',
172
+ hooks: {
173
+ 'astro:server:setup': ({ server }) => {
174
+ server.middlewares.use((req, res, next) => {
175
+ if (!req.url) return next();
176
+ const urlPath = req.url.split('?')[0];
177
+ if (!looksLikeAssetPath(urlPath)) return next();
178
+ if (fs.existsSync(publicPathFor(urlPath, contentDir))) return next(); // real public/ file - Vite's own static serving already handles it, don't shadow
179
+ const resolved = resolveOutsidePublic(urlPath, contentDir);
180
+ if (!resolved) return next();
181
+ res.setHeader('Content-Type', mimeFor(resolved));
182
+ fs.createReadStream(resolved).pipe(res);
183
+ });
184
+ },
185
+ 'astro:build:done': async ({ dir }) => {
186
+ const outDir = fileURLToPath(dir);
187
+ const config = loadDocsConfig(contentDir);
188
+ // Pass 1: writedocs.json's own configured fields. Kept as its own,
189
+ // field-name-driven pass rather than folded into pass 2 below -
190
+ // background images/font sources render into CSS `url(...)`
191
+ // inside a `<style>` block, and seo.ogImage into a `<meta
192
+ // content="...">` tag, none of which pass 2's src="..."/
193
+ // srcset="..." attribute scan would ever see.
194
+ for (const urlPath of collectConfiguredAssetPaths(config)) {
195
+ const segments = urlPath.replace(/^\/+/, '').split('/');
196
+ const outPath = path.join(outDir, ...segments);
197
+ if (fs.existsSync(outPath)) continue; // already present via public/'s normal passthrough copy
198
+ const resolved = resolveOutsidePublic(urlPath, contentDir);
199
+ if (!resolved) continue; // not configured to a real file anywhere - same as today, just renders a broken <link>/<img>
200
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
201
+ fs.copyFileSync(resolved, outPath);
202
+ }
203
+ // Pass 2: arbitrary content-body assets - anything a page's own
204
+ // rendered HTML references by src/srcset that pass 1 didn't
205
+ // already account for.
206
+ copyReferencedContentAssets(outDir, contentDir);
207
+ },
208
+ },
209
+ };
210
+ }
@@ -0,0 +1,93 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+
5
+ /** Where writedocs' own generated/cached artifacts for a given content
6
+ * directory live: Astro's content-layer cache (`cacheDir`), the
7
+ * auto-generated OpenAPI stub pages the `generatedDocs` collection reads
8
+ * (see content.config.ts), and the resolved-operation JSON
9
+ * ApiPlayground.astro/ApiReferencePanel.astro read at render time (see
10
+ * lib/openapi-render.ts). All three used to live under
11
+ * `<contentDir>/.writedocs/` - moved out entirely (into the OS temp
12
+ * directory instead) so `writedocs dev`/`build` never writes anything
13
+ * into a person's own project folder for them to notice, gitignore, or
14
+ * accidentally commit.
15
+ *
16
+ * Namespaced by a short hash of contentDir's own resolved absolute path,
17
+ * not a fixed name, for two reasons: different content directories
18
+ * (this repo's own docs.json-examples/* fixtures, or two unrelated
19
+ * projects on the same machine using the same globally-installed
20
+ * writedocs) must never collide with each other on disk - see the
21
+ * "Content-layer cache collision" bug note in
22
+ * docs/dev/docs/architecture.mdx for what happens when they do - while
23
+ * repeated runs *of the same project* should keep reusing the same
24
+ * directory (Astro's content-layer cache is only useful if it survives
25
+ * across dev/build runs). The human-readable prefix is only there to
26
+ * make a real temp directory recognizable while debugging; nothing
27
+ * parses it back out.
28
+ *
29
+ * Deliberately a plain .js module, not .ts: src/cli/generate-api-pages.js
30
+ * runs via plain Node before Astro/Vite's TS-aware pipeline exists (see
31
+ * that file's own comment on why it otherwise avoids importing from
32
+ * lib/config.ts), so this is the one shared path-computation helper it
33
+ * can still import directly. Every other call site (astro.config.mjs,
34
+ * content.config.ts, src/pages/[...slug].astro, src/pages/[...slug].md.ts,
35
+ * lib/config.ts, lib/openapi-render.ts) imports this exact same file too
36
+ * - all of them need to agree on the identical path, since one writes
37
+ * what another reads. */
38
+ function namespaceLabel(contentDir) {
39
+ const resolved = path.resolve(contentDir);
40
+ const hash = crypto.createHash('sha1').update(resolved).digest('hex').slice(0, 12);
41
+ const label = path.basename(resolved) || 'root';
42
+ return `${label}-${hash}`;
43
+ }
44
+
45
+ export function writedocsTempDir(contentDir) {
46
+ return path.join(os.tmpdir(), 'writedocs', namespaceLabel(contentDir));
47
+ }
48
+
49
+ /** Where `astro build` actually writes its output during the build itself
50
+ * - deliberately *inside* the installed writedocs package (packageRoot),
51
+ * never contentDir/dist directly, and never OS temp either (unlike
52
+ * writedocsTempDir() above). build.js copies the finished result from
53
+ * here into the real `<contentDir>/dist` as an explicit final step once
54
+ * Astro's own build has fully finished - see that file's own comment for
55
+ * why a plain recursive copy, not Astro's default in-place output.
56
+ *
57
+ * The short version: Astro's static build stages its prerendered output
58
+ * in `<outDir>/.prerender/` and then does a plain `fs.rename()` into the
59
+ * real outDir (astro/dist/prerender/utils.js) - which throws `EXDEV:
60
+ * cross-device link not permitted` the moment outDir and that staging
61
+ * location land on different filesystems. Astro decides *where* to stage
62
+ * by checking whether outDir starts with `process.cwd()`
63
+ * (getOutDirWithinCwd) - pointing outDir at contentDir/dist directly (the
64
+ * obvious-looking fix) makes that check pass, but doesn't just move
65
+ * *where* staging happens, it moves *where Astro's own SSR/prerender
66
+ * bundle physically executes from* - and that bundle isn't fully
67
+ * self-contained: some of Astro's own dependencies (Tailwind's Vite
68
+ * plugin registering a loader hook that reaches for a small terminal-
69
+ * color-formatting package, observed directly while diagnosing this) do
70
+ * a bare-specifier `import` Node has to resolve the normal way - walking
71
+ * up from wherever that code physically executes looking for a
72
+ * `node_modules` containing it. contentDir has no `node_modules` at all
73
+ * by design (see docs/dev/docs/architecture.mdx) - a real consumer's
74
+ * project is just `writedocs.json` + a content folder - so once the SSR
75
+ * bundle executes from inside contentDir, *that* resolution fails
76
+ * instead, trading one crash for a less predictable one that depends on
77
+ * which of Astro's own runtime code paths a given site's content happens
78
+ * to trigger.
79
+ *
80
+ * Staying inside packageRoot keeps every one of those bare specifiers
81
+ * resolving exactly the way it already does today (this is where
82
+ * `node_modules` actually lives), while a plain `fs.cp()` - which copies
83
+ * across filesystem boundaries just fine, unlike `rename()` - handles the
84
+ * one genuinely cross-device step explicitly, once, after Astro is
85
+ * completely done needing any of its own dependencies to still resolve.
86
+ *
87
+ * Namespaced by the same contentDir-hash convention as writedocsTempDir()
88
+ * above, so two different content directories building against the same
89
+ * globally-installed writedocs (or two concurrent builds) never collide
90
+ * here either. */
91
+ export function writedocsBuildStagingDir(packageRoot, contentDir) {
92
+ return path.join(packageRoot, '.writedocs-build', namespaceLabel(contentDir));
93
+ }
@@ -0,0 +1,62 @@
1
+ ---
2
+ // Astro's own file-based convention: a static build (`output: 'static'`,
3
+ // this package's only supported mode - see astro.config.mjs) emits this
4
+ // file's output as a literal `404.html` at the site root, and most static
5
+ // hosts (Netlify, Vercel, GitHub Pages, Cloudflare Pages, ...) serve it
6
+ // automatically for any unmatched path. No params, no getStaticPaths -
7
+ // this is a single fixed route, same category as llms.txt.ts/
8
+ // llms-full.txt.ts (see their own comments on Astro auto-prerendering a
9
+ // non-dynamic page at its literal path).
10
+ import { loadDocsConfig } from '../lib/config';
11
+ import BaseLayout from '../layout/BaseLayout.astro';
12
+
13
+ const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
14
+ const config = loadDocsConfig(contentDir);
15
+
16
+ // writedocs.json's `notFound.{title,description}` (notFoundSchema in
17
+ // lib/config.ts) - both optional, with the hardcoded fallbacks below, so
18
+ // a site gets a reasonably branded 404 page without having to configure
19
+ // anything.
20
+ const title = config.notFound.title ?? 'Page not found';
21
+ const description = config.notFound.description ?? "The page you're looking for doesn't exist or has moved.";
22
+ ---
23
+ <BaseLayout config={config} title={title} description={description} path="/404/" seo={{ noindex: true }} mode="frame">
24
+ <div class="wd-404">
25
+ <p class="wd-404-eyebrow">404</p>
26
+ <h1>{title}</h1>
27
+ <p class="wd-404-description">{description}</p>
28
+ <a class="wd-404-home" href="/">&larr; Back to home</a>
29
+ </div>
30
+ </BaseLayout>
31
+
32
+ <style>
33
+ .wd-404 {
34
+ max-width: 560px;
35
+ margin: 0 auto;
36
+ padding: 5rem 1.5rem 6rem;
37
+ text-align: center;
38
+ }
39
+ .wd-404-eyebrow {
40
+ margin: 0 0 0.5rem;
41
+ font-size: 0.85rem;
42
+ font-weight: 600;
43
+ letter-spacing: 0.08em;
44
+ color: var(--wd-primary);
45
+ }
46
+ .wd-404 h1 {
47
+ margin: 0 0 0.75rem;
48
+ font-size: 1.75rem;
49
+ }
50
+ .wd-404-description {
51
+ margin: 0 0 2rem;
52
+ color: var(--wd-text-muted);
53
+ }
54
+ .wd-404-home {
55
+ color: var(--wd-primary);
56
+ text-decoration: none;
57
+ font-weight: 500;
58
+ }
59
+ .wd-404-home:hover {
60
+ text-decoration: underline;
61
+ }
62
+ </style>