@writedocs/generator 0.4.8 → 0.4.9

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/astro.config.mjs CHANGED
@@ -27,6 +27,7 @@ import { remarkInjectBuiltinComponents } from './src/lib/mdx-inject-builtins.js'
27
27
  import { remarkTitleAnchorIds } from './src/lib/mdx-title-anchor-ids.js';
28
28
  import { remarkSubstituteVariables } from './src/lib/mdx-substitute-variables.js';
29
29
  import { remarkMintlifyTreeLists, remarkMintlifyPromptText } from './src/lib/mdx-mintlify.js';
30
+ import { remarkUnknownComponentFallback } from './src/lib/mdx-unknown-components.js';
30
31
  import { writedocsTempDir, writedocsBuildStagingDir } from './src/lib/writedocs-temp-dir.js';
31
32
  import { stylesAssetFallback } from './src/lib/styles-asset-integration.js';
32
33
  import {
@@ -370,6 +371,11 @@ export default defineConfig({
370
371
  // the tree when it looks for components to import.
371
372
  remarkMintlifyTreeLists,
372
373
  remarkMintlifyPromptText,
374
+ // An unknown component becomes a fragment (its children still
375
+ // render) with a warning, instead of failing the whole build - see
376
+ // lib/mdx-unknown-components.js. After remarkMintlifyTreeLists, so
377
+ // the <Tree.Folder>/<Tree.File> elements it adds count as known.
378
+ remarkUnknownComponentFallback,
373
379
  remarkInjectBuiltinComponents,
374
380
  // Runs after remarkInjectBuiltinComponents (order doesn't actually
375
381
  // matter between them - that plugin only ever prepends an import
package/bin/writedocs.js CHANGED
@@ -1,139 +1,167 @@
1
- #!/usr/bin/env node
2
- import { Command } from 'commander';
3
- import fs from 'node:fs';
4
- import path from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
- import dotenv from 'dotenv';
7
- import { runDev } from '../src/cli/dev.js';
8
- import { runBuild } from '../src/cli/build.js';
9
- import { runInit } from '../src/cli/init.js';
10
- import { requireBuildKey } from '../src/cli/build-auth.js';
11
- // O MESMO modulo que o build usa (via loadDocsConfig, que reexporta daqui) e
12
- // que a plataforma importa por `@writedocs/generator/config-schema` - e o que
13
- // faz os tres reportarem os mesmos problemas com as mesmas palavras, em vez de
14
- // cada um manter a sua copia do schema.
15
- //
16
- // `.js`, e nao o `.ts` que e a fonte de verdade: este arquivo e carregado por
17
- // node puro, sem o transform do Astro/Vite, e o Node RECUSA type stripping para
18
- // qualquer arquivo sob node_modules - por desenho, em qualquer versao
19
- // (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING). Ou seja: importar o `.ts` aqui
20
- // funciona a partir do checkout e falha 100% das vezes para quem instala o
21
- // pacote, que foi exatamente o que quebrou o `validate` no 0.2.0. O `.js` e
22
- // gerado do `.ts` pelo script `prepare` (package.json), entao ele existe tanto
23
- // no checkout quanto dentro do tarball, e o consumidor nao roda build nenhum.
24
- // Ver a mesma regra ja escrita em src/cli/write-redirects-file.js.
25
- import {
26
- validateDocsConfig,
27
- formatValidationIssuesDetailed,
28
- unknownRootKeyIssues,
29
- } from '../src/lib/config-schema.js';
30
-
31
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
32
- const packageRoot = path.resolve(__dirname, '..');
33
- // Read rather than hardcode - a literal version string here silently drifts
34
- // from package.json's own "version" the moment either one is bumped without
35
- // the other (exactly what `npm version <bump>` does: it only touches
36
- // package.json). `writedocs --version` should always reflect what actually
37
- // got published, not whatever this string happened to say at the time this
38
- // line was last hand-edited.
39
- const { version } = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf-8'));
40
-
41
- const program = new Command();
42
- program
43
- .name('writedocs')
44
- .description('Static site generator for writedocs.json + MDX')
45
- .version(version);
46
-
47
- program
48
- .command('dev')
49
- .description('Start a local dev server with hot reload')
50
- .argument('[dir]', 'content directory (contains writedocs.json and docs/)', '.')
51
- .option('-p, --port <port>', 'port to run on')
52
- .action(async (dir, opts) => {
53
- await runDev({
54
- contentDir: path.resolve(process.cwd(), dir),
55
- packageRoot,
56
- port: opts.port,
57
- });
58
- });
59
-
60
- program
61
- // { hidden: true } keeps `build` out of `writedocs --help` - it's not a
62
- // command ordinary users are meant to discover or run themselves. See
63
- // src/cli/build-auth.js for the second layer: even someone who knows the
64
- // command exists still can't run it without the right key.
65
- .command('build', { hidden: true })
66
- .description('Build a static site into <dir>/dist')
67
- .argument('[dir]', 'content directory (contains writedocs.json and docs/)', '.')
68
- .option('-k, --key <key>', 'build authorization key (or set WRITEDOCS_API_KEY)')
69
- .action(async (dir, opts) => {
70
- const contentDir = path.resolve(process.cwd(), dir);
71
- // Project-scoped, not global: a .env sitting next to this project's own
72
- // writedocs.json (WRITEDOCS_KEY_SERVER_URL, WRITEDOCS_API_KEY) is picked
73
- // up automatically, so build doesn't need those exported by hand every
74
- // session. dotenv never overwrites a var already set in the real
75
- // environment - an explicit `export`/CI secret still wins over the file.
76
- dotenv.config({ path: path.join(contentDir, '.env'), quiet: true });
77
- await requireBuildKey(opts.key || process.env.WRITEDOCS_API_KEY);
78
- await runBuild({ contentDir, packageRoot });
79
- });
80
-
81
- program
82
- // Visivel, ao contrario do `build`: validar a propria configuracao e
83
- // exatamente o tipo de coisa que um usuario deve poder rodar sozinho, no CI
84
- // inclusive. Sem chave, sem dotenv, sem rede - le um arquivo e sai 0 ou 1.
85
- .command('validate')
86
- .description('Validate writedocs.json against the schema')
87
- .argument('[dir]', 'content directory (contains writedocs.json)', '.')
88
- .action(async (dir) => {
89
- const contentDir = path.resolve(process.cwd(), dir);
90
- const configPath = path.join(contentDir, 'writedocs.json');
91
- if (!fs.existsSync(configPath)) {
92
- // Mesma mensagem que loadDocsConfig() ja da pro mesmo caso.
93
- console.error(
94
- `[writedocs] writedocs.json not found at ${configPath}. Run "writedocs init" to scaffold one.`
95
- );
96
- process.exit(1);
97
- }
98
-
99
- const rawText = fs.readFileSync(configPath, 'utf-8');
100
- const result = validateDocsConfig(rawText);
101
- // Chave desconhecida na raiz e AVISO, nunca erro: a raiz nao e `.strict()`
102
- // de proposito (tornar strict quebraria configs existentes), entao o Zod
103
- // descarta a chave em silencio. Sem JSON parseavel nao ha chave pra avaliar.
104
- const warnings = result.kind === 'invalid_json' ? [] : unknownRootKeyIssues(rawText);
105
- const nomeArquivo = path.basename(configPath);
106
-
107
- if (!result.ok) {
108
- // A versao longa (linha, frase humana, sugestao) em vez da curta que o
109
- // `writedocs build` imprime: aqui o usuario pediu explicitamente um
110
- // diagnostico, e tem a tela inteira pra ele.
111
- console.error(`[writedocs] ${configPath} failed validation:\n`);
112
- console.error(formatValidationIssuesDetailed(result.issues, { fileName: nomeArquivo }));
113
- console.error('');
114
- }
115
-
116
- if (warnings.length > 0) {
117
- console.error(`[writedocs] ${warnings.length} warning${warnings.length === 1 ? '' : 's'}:\n`);
118
- console.error(formatValidationIssuesDetailed(warnings, { fileName: nomeArquivo }));
119
- console.error('');
120
- }
121
-
122
- if (!result.ok) process.exit(1);
123
- // Aviso nao invalida: um config so com avisos continua valido, e sai 0 -
124
- // o mesmo criterio que a plataforma usa pra marcar o projeto como `valid`.
125
- console.log(`[writedocs] ${configPath} is valid.`);
126
- });
127
-
128
- program
129
- .command('init')
130
- .description('Scaffold a writedocs.json and starter docs/ folder')
131
- .argument('[dir]', 'directory to scaffold into', '.')
132
- .action(async (dir) => {
133
- await runInit({ targetDir: path.resolve(process.cwd(), dir) });
134
- });
135
-
136
- program.parseAsync(process.argv).catch((err) => {
137
- console.error(`[writedocs] ${err.message}`);
138
- process.exit(1);
139
- });
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import dotenv from 'dotenv';
7
+ import { runDev } from '../src/cli/dev.js';
8
+ import { runBuild } from '../src/cli/build.js';
9
+ import { runInit } from '../src/cli/init.js';
10
+ import { requireBuildKey } from '../src/cli/build-auth.js';
11
+ // O MESMO modulo que o build usa (via loadDocsConfig, que reexporta daqui) e
12
+ // que a plataforma importa por `@writedocs/generator/config-schema` - e o que
13
+ // faz os tres reportarem os mesmos problemas com as mesmas palavras, em vez de
14
+ // cada um manter a sua copia do schema.
15
+ //
16
+ // `.js`, e nao o `.ts` que e a fonte de verdade: este arquivo e carregado por
17
+ // node puro, sem o transform do Astro/Vite, e o Node RECUSA type stripping para
18
+ // qualquer arquivo sob node_modules - por desenho, em qualquer versao
19
+ // (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING). Ou seja: importar o `.ts` aqui
20
+ // funciona a partir do checkout e falha 100% das vezes para quem instala o
21
+ // pacote, que foi exatamente o que quebrou o `validate` no 0.2.0. O `.js` e
22
+ // gerado do `.ts` pelo script `prepare` (package.json), entao ele existe tanto
23
+ // no checkout quanto dentro do tarball, e o consumidor nao roda build nenhum.
24
+ // Ver a mesma regra ja escrita em src/cli/write-redirects-file.js.
25
+ import {
26
+ validateDocsConfig,
27
+ formatValidationIssuesDetailed,
28
+ unknownRootKeyIssues,
29
+ } from '../src/lib/config-schema.js';
30
+
31
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
32
+ const packageRoot = path.resolve(__dirname, '..');
33
+ // Read rather than hardcode - a literal version string here silently drifts
34
+ // from package.json's own "version" the moment either one is bumped without
35
+ // the other (exactly what `npm version <bump>` does: it only touches
36
+ // package.json). `writedocs --version` should always reflect what actually
37
+ // got published, not whatever this string happened to say at the time this
38
+ // line was last hand-edited.
39
+ const { version } = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf-8'));
40
+
41
+ const program = new Command();
42
+ program
43
+ .name('writedocs')
44
+ .description('Static site generator for writedocs.json + MDX')
45
+ .version(version);
46
+
47
+ program
48
+ .command('dev')
49
+ .description('Start a local dev server with hot reload')
50
+ .argument('[dir]', 'content directory (contains writedocs.json and docs/)', '.')
51
+ .option('-p, --port <port>', 'port to run on')
52
+ .action(async (dir, opts) => {
53
+ await runDev({
54
+ contentDir: path.resolve(process.cwd(), dir),
55
+ packageRoot,
56
+ port: opts.port,
57
+ });
58
+ });
59
+
60
+ program
61
+ // { hidden: true } keeps `build` out of `writedocs --help` - it's not a
62
+ // command ordinary users are meant to discover or run themselves. See
63
+ // src/cli/build-auth.js for the second layer: even someone who knows the
64
+ // command exists still can't run it without the right key.
65
+ .command('build', { hidden: true })
66
+ .description('Build a static site into <dir>/dist')
67
+ .argument('[dir]', 'content directory (contains writedocs.json and docs/)', '.')
68
+ .option('-k, --key <key>', 'build authorization key (or set WRITEDOCS_API_KEY)')
69
+ .action(async (dir, opts) => {
70
+ const contentDir = path.resolve(process.cwd(), dir);
71
+ // Project-scoped, not global: a .env sitting next to this project's own
72
+ // writedocs.json (WRITEDOCS_KEY_SERVER_URL, WRITEDOCS_API_KEY) is picked
73
+ // up automatically, so build doesn't need those exported by hand every
74
+ // session. dotenv never overwrites a var already set in the real
75
+ // environment - an explicit `export`/CI secret still wins over the file.
76
+ dotenv.config({ path: path.join(contentDir, '.env'), quiet: true });
77
+ await requireBuildKey(opts.key || process.env.WRITEDOCS_API_KEY);
78
+ await runBuild({ contentDir, packageRoot });
79
+ });
80
+
81
+ program
82
+ // Visivel, ao contrario do `build`: validar a propria configuracao e
83
+ // exatamente o tipo de coisa que um usuario deve poder rodar sozinho, no CI
84
+ // inclusive. Sem chave, sem dotenv, sem rede - le um arquivo e sai 0 ou 1.
85
+ .command('validate')
86
+ .description('Validate writedocs.json and every page - frontmatter, MDX, navigation, components, icons')
87
+ .argument('[dir]', 'content directory (contains writedocs.json)', '.')
88
+ .option('--config-only', 'check writedocs.json only, not the pages')
89
+ .action(async (dir, options) => {
90
+ const contentDir = path.resolve(process.cwd(), dir);
91
+ const configPath = path.join(contentDir, 'writedocs.json');
92
+ if (!fs.existsSync(configPath)) {
93
+ // Mesma mensagem que loadDocsConfig() ja da pro mesmo caso.
94
+ console.error(
95
+ `[writedocs] writedocs.json not found at ${configPath}. Run "writedocs init" to scaffold one.`
96
+ );
97
+ process.exit(1);
98
+ }
99
+
100
+ const rawText = fs.readFileSync(configPath, 'utf-8');
101
+ const result = validateDocsConfig(rawText);
102
+ // Chave desconhecida na raiz e AVISO, nunca erro: a raiz nao e `.strict()`
103
+ // de proposito (tornar strict quebraria configs existentes), entao o Zod
104
+ // descarta a chave em silencio. Sem JSON parseavel nao ha chave pra avaliar.
105
+ const warnings = result.kind === 'invalid_json' ? [] : unknownRootKeyIssues(rawText);
106
+ const nomeArquivo = path.basename(configPath);
107
+
108
+ if (!result.ok) {
109
+ // A versao longa (linha, frase humana, sugestao) em vez da curta que o
110
+ // `writedocs build` imprime: aqui o usuario pediu explicitamente um
111
+ // diagnostico, e tem a tela inteira pra ele.
112
+ console.error(`[writedocs] ${configPath} failed validation:\n`);
113
+ console.error(formatValidationIssuesDetailed(result.issues, { fileName: nomeArquivo }));
114
+ console.error('');
115
+ }
116
+
117
+ if (warnings.length > 0) {
118
+ console.error(`[writedocs] ${warnings.length} warning${warnings.length === 1 ? '' : 's'}:\n`);
119
+ console.error(formatValidationIssuesDetailed(warnings, { fileName: nomeArquivo }));
120
+ console.error('');
121
+ }
122
+
123
+ // The pages: everything that would fail the build (errors) or build
124
+ // differently than written (warnings), in one pass - see
125
+ // src/lib/content-check.js. Runs even when writedocs.json itself is
126
+ // invalid, so one run reports both; only the navigation/icon checks
127
+ // need writedocs.json to at least be valid JSON.
128
+ let content = null;
129
+ if (!options.configOnly) {
130
+ const { checkContent, formatContentIssues } = await import('../src/lib/content-check.js');
131
+ content = await checkContent(contentDir, result.kind === 'invalid_json' ? null : rawText);
132
+ if (content.errors.length > 0) {
133
+ console.error(`[writedocs] ${content.errors.length} error${content.errors.length === 1 ? '' : 's'} in the pages:\n`);
134
+ console.error(formatContentIssues(content.errors));
135
+ console.error('');
136
+ }
137
+ if (content.warnings.length > 0) {
138
+ console.error(`[writedocs] ${content.warnings.length} warning${content.warnings.length === 1 ? '' : 's'} in the pages:\n`);
139
+ console.error(formatContentIssues(content.warnings));
140
+ console.error('');
141
+ }
142
+ }
143
+
144
+ if (!result.ok || (content && content.errors.length > 0)) process.exit(1);
145
+ // Aviso nao invalida: um config so com avisos continua valido, e sai 0 -
146
+ // o mesmo criterio que a plataforma usa pra marcar o projeto como `valid`.
147
+ console.log(`[writedocs] ${configPath} is valid.`);
148
+ if (content) {
149
+ console.log(
150
+ `[writedocs] Checked ${content.pages} page${content.pages === 1 ? '' : 's'}: no errors` +
151
+ (content.warnings.length ? `, ${content.warnings.length} warning${content.warnings.length === 1 ? '' : 's'} above.` : '.')
152
+ );
153
+ }
154
+ });
155
+
156
+ program
157
+ .command('init')
158
+ .description('Scaffold a writedocs.json and starter docs/ folder')
159
+ .argument('[dir]', 'directory to scaffold into', '.')
160
+ .action(async (dir) => {
161
+ await runInit({ targetDir: path.resolve(process.cwd(), dir) });
162
+ });
163
+
164
+ program.parseAsync(process.argv).catch((err) => {
165
+ console.error(`[writedocs] ${err.message}`);
166
+ process.exit(1);
167
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writedocs/generator",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
4
4
  "description": "Static site generator for docs — a writedocs.json + MDX folder in, a static site out.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -59,6 +59,7 @@
59
59
  "@iconify-json/ri": "^1.2.10",
60
60
  "@iconify-json/simple-icons": "^1.2.89",
61
61
  "@iconify-json/tabler": "^1.2.35",
62
+ "@mdx-js/mdx": "^3.1.1",
62
63
  "@shikijs/transformers": "^4.3.1",
63
64
  "@tailwindcss/vite": "^4.3.2",
64
65
  "astro": "7.2.9",
@@ -7,7 +7,7 @@
7
7
  // and Card.astro (the two places writedocs.json icon strings get rendered)
8
8
  // don't each need their own copy of the resolveIcon() call + branch.
9
9
  import { Icon } from 'astro-icon/components';
10
- import { resolveIcon } from '../lib/config';
10
+ import { resolveIcon, iconExists } from '../lib/config';
11
11
 
12
12
  interface Props {
13
13
  icon?: string;
@@ -21,7 +21,19 @@ interface Props {
21
21
  style?: string;
22
22
  }
23
23
  const { icon, class: className = 'wd-icon', style } = Astro.props as Props;
24
- const resolved = icon ? resolveIcon(icon) : null;
24
+ // An icon name no installed collection has renders nothing, with a warning
25
+ // (once per name - a sidebar icon renders on every page), instead of
26
+ // failing the whole build the way astro-icon does on a missing name.
27
+ // `writedocs validate` lists the same names ahead of time.
28
+ let resolved = icon ? resolveIcon(icon) : null;
29
+ if (icon && resolved?.kind === 'iconify' && !iconExists(icon)) {
30
+ const warned: Set<string> = ((globalThis as any).__wdWarnedIcons ??= new Set());
31
+ if (!warned.has(icon)) {
32
+ warned.add(icon);
33
+ console.warn(`[writedocs] unknown icon "${icon}" - no installed icon set has it, so it's left out.`);
34
+ }
35
+ resolved = null;
36
+ }
25
37
  ---
26
38
  {resolved && (
27
39
  resolved.kind === 'iconify' ? (
@@ -1,11 +1,10 @@
1
1
  import { defineCollection } from 'astro:content';
2
2
  import { glob } from 'astro/loaders';
3
3
  import type { Loader } from 'astro/loaders';
4
- import { z } from 'astro/zod';
5
4
  import fs from 'node:fs';
6
5
  import path from 'node:path';
7
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
8
- import { seoFieldsSchema, findAllPages } from './lib/config';
7
+ import { pageFrontmatterSchema, findAllPages } from './lib/config';
9
8
  import { writedocsTempDir } from './lib/writedocs-temp-dir.js';
10
9
 
11
10
  const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
@@ -32,131 +31,11 @@ const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
32
31
  const generatedDocsDir = path.join(writedocsTempDir(contentDir), 'generated-docs');
33
32
  const generatedDocsBase = pathToFileURL(generatedDocsDir + path.sep);
34
33
 
35
- const MINTLIFY_MODE_ALIASES: Record<string, string> = { center: 'frame', assistant: 'default' };
36
-
37
- const docsSchema = z.object({
38
- title: z.string(),
39
- description: z.string().optional(),
40
- // Overrides the URL this page is served at, independent of where the
41
- // file actually lives - writedocs.json's `pages` arrays always keep
42
- // referencing the file's own path regardless. This is Astro's own
43
- // glob()-loader convention (a `slug` frontmatter field becomes
44
- // `entry.id` verbatim - see generateIdDefault in
45
- // astro/dist/content/loaders/glob.js), not writedocs-specific
46
- // behavior; declaring it here just brings it into the schema (and
47
- // its docs) rather than leaving it an undocumented Astro feature.
48
- // See fileIdForEntry() in lib/config.ts for how routes/links still
49
- // resolve a page by its file id once this diverges from `entry.id`.
50
- // Leading/trailing slashes are fine either way ("/", "/guides/x",
51
- // "guides/x" and "guides/x/" all mean the same thing) - see
52
- // normalizeEntryId() in lib/config.ts, which is what actually
53
- // strips them before this value is ever used as a route or href.
54
- slug: z.string().optional(),
55
- // Marks this page as an OpenAPI operation reference: "METHOD /path"
56
- // matching an operation in the owning group's OpenAPI spec, e.g.
57
- // "GET /pets/{petId}" - [...slug].astro renders <ApiPlayground /> for
58
- // any page with this field set, below the page's own MDX body (if
59
- // any). Sites don't write this by hand for most pages; it's either
60
- // set on a generated stub in the `generatedDocs` collection below (see
61
- // generate-api-pages.js) or hand-authored to "eject" one specific
62
- // operation into a real file (in `docs`) with custom prose - either
63
- // way, the value is always exactly the same "METHOD /path" key
64
- // generate-api-pages.js uses to look up the operation's full resolved
65
- // schema/examples at render time.
66
- openapi: z.string().optional(),
67
- // Controls how much of the site's own chrome (topbar, sidebar, table of
68
- // contents) wraps this page - see BaseLayout.astro/[...slug].astro for
69
- // what each value actually removes:
70
- // default - the normal three-column reading layout (all chrome).
71
- // wide - drops the table of contents; the article itself also
72
- // renders wider, for content that wants the extra room
73
- // (wide tables, side-by-side images).
74
- // frame - drops the sidebar and table of contents, but keeps the
75
- // topbar and the article's own normal presentation
76
- // ("frame" as in: still inside the site's outer frame).
77
- // custom - drops the sidebar and table of contents, the
78
- // auto-rendered <h1>, and prev/next nav, and skips the
79
- // article's own prose width/padding too - a blank canvas
80
- // for a hand-built landing/home page made entirely of
81
- // components - but keeps the topbar, so the page still
82
- // has site branding/nav/search/theme-toggle available.
83
- // blank - everything 'custom' drops, plus the topbar too: no site
84
- // chrome at all, just <slot />. For a page that wants to
85
- // look nothing like the rest of the site (an auth screen,
86
- // a print-style page).
87
- //
88
- // Two Mintlify values are accepted so a migrated page doesn't fail the
89
- // build (see MINTLIFY_MODE_ALIASES below): `center` is the same layout
90
- // as our `frame`, and `assistant` (a full-page Mintlify AI chat, which
91
- // writedocs doesn't have) falls back to `default`. Mintlify's own
92
- // `frame` means something else (a canvas that keeps the sidebar) - it's
93
- // left as our `frame`; see docs/dev/docs/mintlify-compat.mdx.
94
- mode: z
95
- .preprocess(
96
- (value) => (typeof value === 'string' && value in MINTLIFY_MODE_ALIASES ? MINTLIFY_MODE_ALIASES[value] : value),
97
- z.enum(['default', 'wide', 'frame', 'custom', 'blank']),
98
- )
99
- .default('default'),
100
- // Per-page meta tag overrides - same shape as writedocs.json's top-level
101
- // `seo` (see seoFieldsSchema in lib/config.ts, the single source of
102
- // truth for this shape). A page only needs to set the specific fields
103
- // it wants to override; mergeSeo() falls back to the site-wide default
104
- // for anything left unset. See BaseLayout.astro for where this and the
105
- // site-wide seo actually get merged and rendered.
106
- seo: seoFieldsSchema.optional(),
107
- // Mintlify's spelling of `seo.noindex` - a top-level `noindex: true`.
108
- // Folded into `seo` by the transform below, so everything downstream
109
- // (the robots meta tag, sitemap.xml, llms.txt) keeps reading
110
- // `seo.noindex` only.
111
- noindex: z.boolean().optional(),
112
- // Mintlify's short navigation label - used for the sidebar and the
113
- // topbar dropdown menus in place of `title` ([...slug].astro's
114
- // navTitleForSlug). The page's own <h1> and prev/next links keep `title`.
115
- sidebarTitle: z.string().optional(),
116
- // Mintlify's spellings of `seo.keywords`, `seo.ogImage`, `seo.ogType`
117
- // and `seo.twitterCard`, folded into `seo` below like `noindex`.
118
- keywords: z.array(z.string()).optional(),
119
- 'og:image': z.string().optional(),
120
- 'og:type': z.string().optional(),
121
- 'twitter:card': z.enum(['summary', 'summary_large_image']).optional(),
122
- // Mintlify's sidebar/page-chrome fields - see NavTree.astro and
123
- // [...slug].astro for where each is used:
124
- // icon - shown before the page's label in the sidebar.
125
- // tag - a short label after it (e.g. "NEW").
126
- // deprecated - a "Deprecated" label in the sidebar and next
127
- // to the page's <h1>.
128
- // hidden - left out of the sidebar, dropdowns and
129
- // prev/next, but still built and reachable by
130
- // URL. Also noindexed, as on Mintlify.
131
- // url - an external link: the page's sidebar entry
132
- // links straight to it, and the page's own URL
133
- // redirects there.
134
- // hideFooterPagination - no prev/next links on this page.
135
- // hideApiMarker - no HTTP method badge on this page's sidebar
136
- // entry.
137
- icon: z.string().optional(),
138
- tag: z.string().optional(),
139
- deprecated: z.boolean().optional(),
140
- hidden: z.boolean().optional(),
141
- url: z.string().optional(),
142
- hideFooterPagination: z.boolean().optional(),
143
- hideApiMarker: z.boolean().optional(),
144
- }).transform(
145
- ({ noindex, keywords, 'og:image': ogImage, 'og:type': ogType, 'twitter:card': twitterCard, ...data }) => {
146
- // Top-level Mintlify keys fill in `seo` only where the page's own `seo`
147
- // doesn't already set that field - an explicit `seo` value always wins.
148
- // A `hidden` page is noindexed unless it says otherwise.
149
- const fromMintlify = { noindex: noindex ?? (data.hidden ? true : undefined), keywords, ogImage, ogType, twitterCard };
150
- const seo = { ...data.seo };
151
- let changed = false;
152
- for (const [key, value] of Object.entries(fromMintlify)) {
153
- if (value === undefined || seo[key as keyof typeof seo] !== undefined) continue;
154
- (seo as Record<string, unknown>)[key] = value;
155
- changed = true;
156
- }
157
- return changed ? { ...data, seo } : data;
158
- },
159
- );
34
+ // Page frontmatter - defined in lib/config-schema.ts as
35
+ // pageFrontmatterSchema, next to writedocs.json's own schema, so
36
+ // `writedocs validate` checks every page against exactly the schema the
37
+ // build uses (that file compiles to the plain JS validate loads).
38
+ const docsSchema = pageFrontmatterSchema;
160
39
 
161
40
  /** Wraps another loader so it's skipped entirely - no filesystem scan, no
162
41
  * "directory doesn't exist"/"no files found" warning - when its own base