@writedocs/generator 0.5.0 → 0.7.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/bin/writedocs.js +42 -5
- package/package.json +90 -87
- package/src/cli/convert.js +44 -20
- package/src/cli/generate-api-pages.js +25 -58
- package/src/lib/a11y-check.js +291 -0
- package/src/lib/content-check.js +172 -2
- package/src/lib/json-schema-descriptions.js +204 -0
- package/src/lib/json-schema.js +91 -0
- package/src/lib/link-check.js +23 -11
- package/src/lib/mintlify-convert.js +2 -2
- package/src/lib/openapi-spec.js +73 -0
- package/src/lib/writedocs-legacy-convert.js +496 -0
- package/writedocs.schema.json +2291 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// The editor help text for writedocs.schema.json (see lib/json-schema.js):
|
|
2
|
+
// one entry per field of writedocs.json, keyed by its path - `a.b.c`, with
|
|
3
|
+
// `[]` for an array's items. Fields inside the navigation's recursive types
|
|
4
|
+
// are keyed by the type's name instead (`Tab.icon`, `NavigationItem.group`).
|
|
5
|
+
//
|
|
6
|
+
// scripts/json-schema.test.js fails when a field has no entry here, or an
|
|
7
|
+
// entry names a field that doesn't exist - so a new field in
|
|
8
|
+
// config-schema.ts needs its description added here too.
|
|
9
|
+
|
|
10
|
+
const CHILDREN = {
|
|
11
|
+
pages: 'The pages and groups in this section of the sidebar.',
|
|
12
|
+
tabs: 'Tabs inside this one.',
|
|
13
|
+
versions: 'Versions inside this one - a version picker in the sidebar.',
|
|
14
|
+
languages: 'Languages inside this one - a language picker.',
|
|
15
|
+
dropdowns: 'Dropdowns inside this one - a menu of sections in the sidebar.',
|
|
16
|
+
products: 'Products inside this one - a product picker.',
|
|
17
|
+
href: 'Makes this a link to another address instead of a section with pages of its own.',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const CONTAINERS = {
|
|
21
|
+
Tab: {
|
|
22
|
+
tab: 'The tab\'s name, shown in the topbar.',
|
|
23
|
+
icon: 'Icon next to the name: a Lucide name ("book-open"), "collection:name" ("mdi:server"), an emoji, or an image path.',
|
|
24
|
+
},
|
|
25
|
+
Version: {
|
|
26
|
+
version: 'The version\'s name, like "v2".',
|
|
27
|
+
label: 'Text shown in the version picker instead of `version`.',
|
|
28
|
+
tag: 'A short badge next to the version, like "Latest" or "Deprecated".',
|
|
29
|
+
default: 'Open this version first. Without it, the first version is the default.',
|
|
30
|
+
},
|
|
31
|
+
Language: {
|
|
32
|
+
language: 'The language code, like "en" or "pt-BR".',
|
|
33
|
+
label: 'Text shown in the language picker instead of `language`, like "Português".',
|
|
34
|
+
},
|
|
35
|
+
Dropdown: {
|
|
36
|
+
dropdown: 'The dropdown\'s name.',
|
|
37
|
+
icon: 'Icon next to the name: a Lucide name, "collection:name", an emoji, or an image path.',
|
|
38
|
+
},
|
|
39
|
+
Product: {
|
|
40
|
+
product: 'The product\'s name, shown in the product picker.',
|
|
41
|
+
icon: 'Icon next to the name: a Lucide name, "collection:name", an emoji, or an image path.',
|
|
42
|
+
description: 'One line under the name in the product picker.',
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const containerEntries = Object.entries(CONTAINERS).flatMap(([type, fields]) => [
|
|
47
|
+
...Object.entries(fields).map(([field, text]) => [`${type}.${field}`, text]),
|
|
48
|
+
...Object.entries(CHILDREN).map(([field, text]) => [`${type}.${field}`, text]),
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const logo = (where) => ({
|
|
52
|
+
[where]: 'The logo: one image path for both color modes, or `{ light, dark, label }` with an image per mode.',
|
|
53
|
+
[`${where}.light`]: 'Logo image shown in light mode.',
|
|
54
|
+
[`${where}.dark`]: 'Logo image shown in dark mode.',
|
|
55
|
+
[`${where}.label`]: 'Text shown next to the logo. No text is shown by default - a logo is usually already a wordmark.',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const font = (where, what) => ({
|
|
59
|
+
[`${where}.family`]: `Font family for ${what}. A Google Fonts name loads automatically; add \`source\` for a font file instead.`,
|
|
60
|
+
[`${where}.weight`]: `Font weight for ${what}, like 400 or 700.`,
|
|
61
|
+
[`${where}.source`]: 'Path or URL of a font file to load, instead of Google Fonts.',
|
|
62
|
+
[`${where}.format`]: 'Format of the `source` font file.',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const link = (where) => ({
|
|
66
|
+
[`${where}.label`]: 'Link text. A link needs a label, an icon, or both.',
|
|
67
|
+
[`${where}.href`]: 'Where the link goes - a site path like "/docs/intro/" or a full URL.',
|
|
68
|
+
[`${where}.icon`]: 'Icon shown with the link: a Lucide name, "collection:name", an emoji, or an image path.',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const script = (where) => ({
|
|
72
|
+
[`${where}.src`]: 'URL of the script to load. Use either `src` or `content`, not both.',
|
|
73
|
+
[`${where}.content`]: 'The script\'s code, inline. Use either `src` or `content`, not both.',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export const ROOT_DESCRIPTION = 'Configuration of a writedocs site: its name, look, navigation and integrations.';
|
|
77
|
+
|
|
78
|
+
export const TYPE_DESCRIPTIONS = {
|
|
79
|
+
NavigationItem: 'A page (its path from the project folder, without the extension), a group of pages, or a link.',
|
|
80
|
+
Tab: 'A tab in the topbar, with its own sidebar.',
|
|
81
|
+
Version: 'A version of the docs, chosen from a version picker.',
|
|
82
|
+
Language: 'A language of the docs, chosen from a language picker.',
|
|
83
|
+
Dropdown: 'A section chosen from a dropdown menu.',
|
|
84
|
+
Product: 'A product, chosen from a product picker.',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const DESCRIPTIONS = {
|
|
88
|
+
$schema: 'The JSON Schema this file follows - for editor autocompletion and checks.',
|
|
89
|
+
name: 'Required. The site\'s name - shown in the browser tab ("Page title · name") and as the topbar text when there\'s no logo.',
|
|
90
|
+
description: 'Description used for pages that don\'t set their own `description` in frontmatter.',
|
|
91
|
+
|
|
92
|
+
styles: 'Colors, logo, favicon, fonts, code block themes, navbar and background.',
|
|
93
|
+
'styles.colors': 'The site\'s colors.',
|
|
94
|
+
'styles.colors.primary': 'Main color: links, buttons, the active page in the sidebar. Default "#6366f1".',
|
|
95
|
+
'styles.colors.text': 'Body text color in light mode. Default "#0f172a".',
|
|
96
|
+
'styles.colors.dark': 'Dark-mode colors. Anything left out uses the light-mode value.',
|
|
97
|
+
'styles.colors.dark.primary': 'Main color in dark mode. Pick a lighter one if `primary` is dark - links use it on a dark background.',
|
|
98
|
+
'styles.colors.dark.text': 'Body text color in dark mode. Default "#e2e8f0".',
|
|
99
|
+
...logo('styles.logo'),
|
|
100
|
+
'styles.favicon': 'Favicon image path.',
|
|
101
|
+
'styles.fonts': 'Fonts. Default: Inter. `heading` and `body` override just that part; anything they leave out comes from the top-level fields.',
|
|
102
|
+
...font('styles.fonts', 'all text'),
|
|
103
|
+
'styles.fonts.heading': 'Font for headings only.',
|
|
104
|
+
...font('styles.fonts.heading', 'headings'),
|
|
105
|
+
'styles.fonts.body': 'Font for body text only.',
|
|
106
|
+
...font('styles.fonts.body', 'body text'),
|
|
107
|
+
'styles.codeblocks': 'Syntax-highlighting themes for code blocks and the API playground.',
|
|
108
|
+
'styles.codeblocks.light': 'Shiki theme name for light mode (https://shiki.style/themes). Default "github-light".',
|
|
109
|
+
'styles.codeblocks.dark': 'Shiki theme name for dark mode. Default "github-dark".',
|
|
110
|
+
'styles.codeblocks.langAlias': 'Extra language names for code blocks, mapped to a language Shiki knows - like { "curl": "bash" }.',
|
|
111
|
+
'styles.navbar': 'The topbar\'s own background. Without it, the topbar matches the page background. Its text turns black or white automatically.',
|
|
112
|
+
'styles.navbar.light': 'Topbar in light mode: a background color, or `{ background, accent }`.',
|
|
113
|
+
'styles.navbar.light.background': 'Topbar background color in light mode.',
|
|
114
|
+
'styles.navbar.light.accent': 'Color of the active tab and hover states in the topbar, in place of `styles.colors.primary`.',
|
|
115
|
+
'styles.navbar.dark': 'Topbar in dark mode: a background color, or `{ background, accent }`.',
|
|
116
|
+
'styles.navbar.dark.background': 'Topbar background color in dark mode.',
|
|
117
|
+
'styles.navbar.dark.accent': 'Color of the active tab and hover states in the topbar, in dark mode.',
|
|
118
|
+
'styles.background': 'Background color of the site, and an optional background image.',
|
|
119
|
+
'styles.background.colors': 'Background colors.',
|
|
120
|
+
'styles.background.colors.light': 'Background color in light mode. Default "#ffffff".',
|
|
121
|
+
'styles.background.colors.dark': 'Background color in dark mode. Default "#0b1120".',
|
|
122
|
+
'styles.background.images': 'Background images, shown behind the content and sidebar.',
|
|
123
|
+
'styles.background.images.light': 'Background image in light mode.',
|
|
124
|
+
'styles.background.images.dark': 'Background image in dark mode.',
|
|
125
|
+
|
|
126
|
+
navigation:
|
|
127
|
+
'Required. The site\'s structure: a list of pages and groups, or an object with exactly one of `tabs`, `versions`, `languages`, `dropdowns` or `products`. Those can nest inside each other.',
|
|
128
|
+
'navigation.global': 'Dropdowns shown on every page, whatever section is open.',
|
|
129
|
+
'navigation.global.dropdowns': 'Dropdowns shown on every page.',
|
|
130
|
+
'navigation.tabs': 'Tabs in the topbar, each with its own sidebar.',
|
|
131
|
+
'navigation.versions': 'Versions of the docs, with a version picker.',
|
|
132
|
+
'navigation.languages': 'Languages of the docs, with a language picker.',
|
|
133
|
+
'navigation.dropdowns': 'Sections chosen from a dropdown menu.',
|
|
134
|
+
'navigation.products': 'Products, with a product picker.',
|
|
135
|
+
|
|
136
|
+
'NavigationItem.group': 'The group\'s name in the sidebar.',
|
|
137
|
+
'NavigationItem.page': 'A page the group\'s name links to.',
|
|
138
|
+
'NavigationItem.pages': 'The pages and groups inside this group.',
|
|
139
|
+
'NavigationItem.openapi': 'Generate this group\'s pages from an OpenAPI spec - a page per operation, grouped by tag.',
|
|
140
|
+
'NavigationItem.openapi.src': 'Path of the OpenAPI spec file, relative to writedocs.json.',
|
|
141
|
+
'NavigationItem.openapi.path': 'URL path the generated pages go under, like "/api". Each OpenAPI group needs its own.',
|
|
142
|
+
'NavigationItem.label': 'Link text in the sidebar.',
|
|
143
|
+
'NavigationItem.href': 'Where the link goes.',
|
|
144
|
+
...Object.fromEntries(containerEntries),
|
|
145
|
+
|
|
146
|
+
socials: 'Icon links in the footer: platform -> URL, like { "github": "https://github.com/acme" }. The key is also the icon.',
|
|
147
|
+
topbar: 'The topbar.',
|
|
148
|
+
'topbar.links': 'Links at the right of the topbar.',
|
|
149
|
+
...link('topbar.links[]'),
|
|
150
|
+
footer: 'The footer: columns of links, and the `socials` icons.',
|
|
151
|
+
'footer.columns': 'Columns of links.',
|
|
152
|
+
'footer.columns[].title': 'The column\'s heading. Optional.',
|
|
153
|
+
'footer.columns[].links': 'The links in the column.',
|
|
154
|
+
...link('footer.columns[].links[]'),
|
|
155
|
+
...logo('footer.logo'),
|
|
156
|
+
|
|
157
|
+
api: 'The API playground.',
|
|
158
|
+
'api.proxy': 'Send "Try it" requests through writedocs\' proxy, for APIs that don\'t allow calls from other sites (CORS). Default true.',
|
|
159
|
+
domain: 'The site\'s address, like "docs.example.com". Turns on sitemap.xml and absolute URLs in link previews.',
|
|
160
|
+
|
|
161
|
+
seo: 'Default metadata for every page. A page\'s own `seo` frontmatter overrides it field by field.',
|
|
162
|
+
'seo.ogImage': 'Image shown in link previews - a path or a full URL.',
|
|
163
|
+
'seo.ogType': 'The og:type of pages. Default "website".',
|
|
164
|
+
'seo.twitterCard': 'X/Twitter card style. Default "summary_large_image" with an `ogImage`, "summary" without.',
|
|
165
|
+
'seo.keywords': 'Keywords for the <meta name="keywords"> tag.',
|
|
166
|
+
'seo.noindex': 'Ask search engines not to index pages, and leave them out of sitemap.xml.',
|
|
167
|
+
contextMenu: 'Adds a "Copy page" menu to pages - copy as Markdown, or open the page in an AI assistant - and a Markdown copy of each page at its address + ".md".',
|
|
168
|
+
'contextMenu.openIn': 'Which AI assistants the menu offers. Default: all three.',
|
|
169
|
+
redirects: 'Redirects from old addresses. Each matches one exact path.',
|
|
170
|
+
'redirects[].source': 'The old path, like "/old-page".',
|
|
171
|
+
'redirects[].destination': 'Where to send it, like "/docs/new-page/".',
|
|
172
|
+
variables: 'Values pages can insert with [[name]], like { "productName": "Acme" }.',
|
|
173
|
+
banner: 'A banner above the topbar on every page.',
|
|
174
|
+
'banner.content': 'The banner\'s text. Markdown links work.',
|
|
175
|
+
'banner.dismissible': 'Let readers close it. Default false.',
|
|
176
|
+
'banner.type': 'Its color: "info", "warning" or "critical". Default "info".',
|
|
177
|
+
notFound: 'The 404 page.',
|
|
178
|
+
'notFound.title': 'The 404 page\'s title.',
|
|
179
|
+
'notFound.description': 'Text under the title on the 404 page.',
|
|
180
|
+
scripts: 'Scripts added to every page - for tools `integrations` doesn\'t cover.',
|
|
181
|
+
'scripts.head': 'Scripts added at the end of <head>.',
|
|
182
|
+
...script('scripts.head[]'),
|
|
183
|
+
'scripts.body': 'Scripts added at the end of <body>.',
|
|
184
|
+
...script('scripts.body[]'),
|
|
185
|
+
|
|
186
|
+
integrations: 'Analytics and chat tools.',
|
|
187
|
+
'integrations.ga4': 'Google Analytics 4.',
|
|
188
|
+
'integrations.ga4.measurementId': 'Measurement ID, like "G-XXXXXXXXXX".',
|
|
189
|
+
'integrations.googleTagManager': 'Google Tag Manager.',
|
|
190
|
+
'integrations.googleTagManager.containerId': 'Container ID, like "GTM-XXXXXXX".',
|
|
191
|
+
'integrations.plausible': 'Plausible Analytics.',
|
|
192
|
+
'integrations.plausible.domain': 'The site\'s domain, as registered in Plausible.',
|
|
193
|
+
'integrations.plausible.src': 'Script URL, for a self-hosted Plausible. Default "https://plausible.io/js/script.js".',
|
|
194
|
+
'integrations.fathom': 'Fathom Analytics.',
|
|
195
|
+
'integrations.fathom.siteId': 'Fathom\'s site ID, like "ABCDEFGH".',
|
|
196
|
+
'integrations.posthog': 'PostHog.',
|
|
197
|
+
'integrations.posthog.apiKey': 'Project API key, starting with "phc_".',
|
|
198
|
+
'integrations.posthog.apiHost': 'API host. Default "https://us.i.posthog.com" - use yours for EU cloud or self-hosting.',
|
|
199
|
+
'integrations.umami': 'Umami Analytics.',
|
|
200
|
+
'integrations.umami.websiteId': 'Umami\'s website ID.',
|
|
201
|
+
'integrations.umami.src': 'Script URL. Default "https://cloud.umami.is/script.js" - use yours when self-hosting.',
|
|
202
|
+
'integrations.askAi': 'AI chat over the docs, with DocsBot.',
|
|
203
|
+
'integrations.askAi.id': 'DocsBot\'s "teamId/botId". The WRITEDOCS_ASK_AI_ID environment variable overrides it.',
|
|
204
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// writedocs.schema.json - the JSON Schema of writedocs.json, for editors
|
|
2
|
+
// (autocompletion, hover help, red squiggles). Generated from the Zod
|
|
3
|
+
// schema itself (config-schema.ts, through its generated .js) rather than
|
|
4
|
+
// written by hand, so it accepts exactly what `writedocs validate` and the
|
|
5
|
+
// build accept. The help text comes from json-schema-descriptions.js.
|
|
6
|
+
//
|
|
7
|
+
// scripts/build-json-schema.mjs writes it to the package root (run by
|
|
8
|
+
// `prepare`); scripts/json-schema.test.js checks it against real configs.
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { docsConfigSchema } from './config-schema.js';
|
|
11
|
+
import { DESCRIPTIONS, TYPE_DESCRIPTIONS, ROOT_DESCRIPTION } from './json-schema-descriptions.js';
|
|
12
|
+
|
|
13
|
+
// Zod names the navigation's recursive types __schema0, __schema1, ... -
|
|
14
|
+
// given their real names here, recognized by the field that identifies
|
|
15
|
+
// each one.
|
|
16
|
+
const TYPE_BY_KEY = { tab: 'Tab', version: 'Version', language: 'Language', dropdown: 'Dropdown', product: 'Product' };
|
|
17
|
+
|
|
18
|
+
function typeNameFor(definition) {
|
|
19
|
+
const variants = definition.anyOf ?? [definition];
|
|
20
|
+
if (variants.some((v) => v.type === 'string')) return 'NavigationItem';
|
|
21
|
+
const keys = Object.keys(variants[0]?.properties ?? {});
|
|
22
|
+
const key = keys.find((k) => TYPE_BY_KEY[k]);
|
|
23
|
+
return key ? TYPE_BY_KEY[key] : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function renameDefinitions(schema) {
|
|
27
|
+
const renames = {};
|
|
28
|
+
for (const [name, definition] of Object.entries(schema.definitions ?? {})) {
|
|
29
|
+
const readable = typeNameFor(definition);
|
|
30
|
+
if (readable) renames[name] = readable;
|
|
31
|
+
}
|
|
32
|
+
const text = JSON.stringify(schema).replace(/"#\/definitions\/([^"]+)"/g, (m, name) => `"#/definitions/${renames[name] ?? name}"`);
|
|
33
|
+
const renamed = JSON.parse(text);
|
|
34
|
+
renamed.definitions = Object.fromEntries(Object.entries(renamed.definitions ?? {}).map(([name, d]) => [renames[name] ?? name, d]));
|
|
35
|
+
return renamed;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Calls `visit(propertySchema, path)` for every property, through unions
|
|
39
|
+
* and array items (not through $refs - definitions are walked on their own
|
|
40
|
+
* with the type name as the path's start). */
|
|
41
|
+
function eachProperty(node, path, visit) {
|
|
42
|
+
if (!node || typeof node !== 'object') return;
|
|
43
|
+
for (const [key, child] of Object.entries(node.properties ?? {})) {
|
|
44
|
+
const childPath = path ? `${path}.${key}` : key;
|
|
45
|
+
visit(child, childPath);
|
|
46
|
+
eachProperty(child, childPath, visit);
|
|
47
|
+
}
|
|
48
|
+
if (node.items) eachProperty(node.items, `${path}[]`, visit);
|
|
49
|
+
for (const key of ['anyOf', 'oneOf', 'allOf']) for (const variant of node[key] ?? []) eachProperty(variant, path, visit);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function describe(target, text) {
|
|
53
|
+
target.description = text;
|
|
54
|
+
// VS Code renders this one, with `code` formatting.
|
|
55
|
+
target.markdownDescription = text;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** { schema, undocumented, unused } - `undocumented`: field paths with no
|
|
59
|
+
* description; `unused`: descriptions for paths that don't exist. */
|
|
60
|
+
export function buildJsonSchema() {
|
|
61
|
+
let schema = z.toJSONSchema(docsConfigSchema, { io: 'input', target: 'draft-7', unrepresentable: 'any' });
|
|
62
|
+
schema = renameDefinitions(schema);
|
|
63
|
+
|
|
64
|
+
// `$schema` is the one extra key writedocs.json accepts (see
|
|
65
|
+
// ROOT_ALLOWED_EXTRA_KEYS); every other unknown key is a typo the build
|
|
66
|
+
// would ignore, so the editor flags it.
|
|
67
|
+
schema.properties = { $schema: { type: 'string' }, ...schema.properties };
|
|
68
|
+
schema.additionalProperties = false;
|
|
69
|
+
|
|
70
|
+
const seen = new Set();
|
|
71
|
+
const undocumented = [];
|
|
72
|
+
const apply = (node, path) => {
|
|
73
|
+
seen.add(path);
|
|
74
|
+
if (DESCRIPTIONS[path]) describe(node, DESCRIPTIONS[path]);
|
|
75
|
+
else if (!undocumented.includes(path)) undocumented.push(path);
|
|
76
|
+
};
|
|
77
|
+
eachProperty(schema, '', apply);
|
|
78
|
+
for (const [name, definition] of Object.entries(schema.definitions ?? {})) {
|
|
79
|
+
if (TYPE_DESCRIPTIONS[name]) describe(definition, TYPE_DESCRIPTIONS[name]);
|
|
80
|
+
eachProperty(definition, name, apply);
|
|
81
|
+
}
|
|
82
|
+
const unused = Object.keys(DESCRIPTIONS).filter((path) => !seen.has(path));
|
|
83
|
+
|
|
84
|
+
const ordered = {
|
|
85
|
+
$schema: schema.$schema,
|
|
86
|
+
title: 'writedocs.json',
|
|
87
|
+
description: ROOT_DESCRIPTION,
|
|
88
|
+
...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== '$schema')),
|
|
89
|
+
};
|
|
90
|
+
return { schema: ordered, undocumented, unused };
|
|
91
|
+
}
|
package/src/lib/link-check.js
CHANGED
|
@@ -76,6 +76,27 @@ function normalizeEntryId(id) {
|
|
|
76
76
|
}
|
|
77
77
|
const urlForId = (id) => (id === 'index' ? '/' : `/${id}/`);
|
|
78
78
|
|
|
79
|
+
/** (rel, frontmatterSlug) => the URL the build serves that page at: its
|
|
80
|
+
* frontmatter `slug`, or its file path with each segment through
|
|
81
|
+
* github-slugger (see this file's header). Also used by
|
|
82
|
+
* lib/writedocs-legacy-convert.js, for redirects from old addresses. */
|
|
83
|
+
export async function pageUrlResolver() {
|
|
84
|
+
const { slug } = await loadSlugger();
|
|
85
|
+
return (rel, frontmatterSlug) =>
|
|
86
|
+
urlForId(
|
|
87
|
+
frontmatterSlug
|
|
88
|
+
? normalizeEntryId(frontmatterSlug)
|
|
89
|
+
: normalizeEntryId(
|
|
90
|
+
rel
|
|
91
|
+
.replace(/\.mdx?$/i, '')
|
|
92
|
+
.split('/')
|
|
93
|
+
.map((segment) => slug(segment))
|
|
94
|
+
.join('/')
|
|
95
|
+
.replace(/\/index$/, '')
|
|
96
|
+
)
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
79
100
|
function textOf(node) {
|
|
80
101
|
if (typeof node.value === 'string' && (node.type === 'text' || node.type === 'inlineCode')) return node.value;
|
|
81
102
|
return (node.children ?? []).map(textOf).join('');
|
|
@@ -246,6 +267,7 @@ function configLinks(config) {
|
|
|
246
267
|
*/
|
|
247
268
|
export async function checkLinks(contentDir, configText) {
|
|
248
269
|
const { slug, default: Slugger } = await loadSlugger();
|
|
270
|
+
const urlOf = await pageUrlResolver();
|
|
249
271
|
const config = JSON.parse(configText);
|
|
250
272
|
|
|
251
273
|
// --- What the site has -------------------------------------------------
|
|
@@ -260,17 +282,7 @@ export async function checkLinks(contentDir, configText) {
|
|
|
260
282
|
for (const rel of pageFiles) {
|
|
261
283
|
const abs = path.join(contentDir, rel);
|
|
262
284
|
const scan = await scanOnce(abs);
|
|
263
|
-
const
|
|
264
|
-
? normalizeEntryId(scan.data.slug)
|
|
265
|
-
: normalizeEntryId(
|
|
266
|
-
rel
|
|
267
|
-
.replace(/\.mdx?$/i, '')
|
|
268
|
-
.split('/')
|
|
269
|
-
.map((segment) => slug(segment))
|
|
270
|
-
.join('/')
|
|
271
|
-
.replace(/\/index$/, '')
|
|
272
|
-
);
|
|
273
|
-
const url = urlForId(id);
|
|
285
|
+
const url = urlOf(rel, scan?.data?.slug);
|
|
274
286
|
pages.set(url, { rel, abs, scan });
|
|
275
287
|
urlOfFile.set(rel, url);
|
|
276
288
|
}
|
|
@@ -57,7 +57,7 @@ function pathString(trail) {
|
|
|
57
57
|
return trail.reduce((s, part) => (typeof part === 'number' ? `${s}[${part}]` : s ? `${s}.${part}` : part), '');
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
class Notes {
|
|
60
|
+
export class Notes {
|
|
61
61
|
constructor() {
|
|
62
62
|
this.byKey = new Map();
|
|
63
63
|
}
|
|
@@ -83,7 +83,7 @@ const ENDPOINT_RE = /^(?:(\S+?\.(?:json|ya?ml))\s+)?(GET|POST|PUT|PATCH|DELETE|H
|
|
|
83
83
|
// item that isn't in that item type's own list below is reported.
|
|
84
84
|
const STRUCTURAL = ['pages', 'groups', 'tabs', 'anchors', 'dropdowns', 'products', 'versions', 'languages', 'menu', 'href', 'openapi', 'hidden'];
|
|
85
85
|
|
|
86
|
-
const LANGUAGE_NAMES = {
|
|
86
|
+
export const LANGUAGE_NAMES = {
|
|
87
87
|
ar: 'العربية', ca: 'Català', cn: '简体中文', 'zh-Hant': '繁體中文', cs: 'Čeština', da: 'Dansk', de: 'Deutsch',
|
|
88
88
|
en: 'English', es: 'Español', fi: 'Suomi', fr: 'Français', 'fr-CA': 'Français (Canada)', he: 'עברית',
|
|
89
89
|
hi: 'हिन्दी', hu: 'Magyar', id: 'Bahasa Indonesia', it: 'Italiano', ja: '日本語', 'ja-JP': '日本語', jp: '日本語',
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// OpenAPI specs as writedocs uses them - which ones writedocs.json's
|
|
2
|
+
// navigation points at, and which operations a spec defines. Shared by
|
|
3
|
+
// generate-api-pages.js (which turns them into pages before every dev/build)
|
|
4
|
+
// and lib/content-check.js (`writedocs validate`), so both find the same
|
|
5
|
+
// specs and the same operations. Plain JavaScript: both run under plain Node.
|
|
6
|
+
|
|
7
|
+
export const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
|
|
8
|
+
|
|
9
|
+
/** "METHOD /path" for every operation in a (dereferenced) spec - the key a
|
|
10
|
+
* page's `openapi:` frontmatter names an operation by (see
|
|
11
|
+
* openApiOperationKey() in lib/openapi-ref.js). */
|
|
12
|
+
export function operationKeys(spec) {
|
|
13
|
+
const keys = new Set();
|
|
14
|
+
for (const [urlPath, pathItem] of Object.entries(spec?.paths ?? {})) {
|
|
15
|
+
for (const method of HTTP_METHODS) if (pathItem?.[method]) keys.add(`${method.toUpperCase()} ${urlPath}`);
|
|
16
|
+
}
|
|
17
|
+
return keys;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Walks writedocs.json's raw `navigation` tree (any shape - a bare array, or
|
|
21
|
+
* an object choosing tabs/versions/languages/dropdowns/products, plus
|
|
22
|
+
* `global.dropdowns`) looking for group nodes shaped like
|
|
23
|
+
* `{ group, openapi: { src, path } }`, however deeply nested inside
|
|
24
|
+
* hand-authored groups or containers. Runs directly against the raw
|
|
25
|
+
* JSON (before writedocs.json's own zod validation even happens - this CLI
|
|
26
|
+
* step runs first, see generateApiPages() below), so it deliberately
|
|
27
|
+
* doesn't import anything from lib/config.ts and just duck-types each
|
|
28
|
+
* node the same way lib/config.ts's own walkSections()/
|
|
29
|
+
* expandOpenApiInContainer() do. */
|
|
30
|
+
export function collectOpenApiGroups(navigation) {
|
|
31
|
+
const found = [];
|
|
32
|
+
|
|
33
|
+
function fromPagesItem(item) {
|
|
34
|
+
if (!item || typeof item !== 'object') return; // plain page-slug string - not a group
|
|
35
|
+
if (item.group && item.openapi) {
|
|
36
|
+
found.push(item);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (Array.isArray(item.pages)) {
|
|
40
|
+
for (const child of item.pages) fromPagesItem(child);
|
|
41
|
+
}
|
|
42
|
+
// otherwise a { label, href } link leaf - nothing to collect
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function fromContainer(node) {
|
|
46
|
+
if (!node || typeof node !== 'object') return;
|
|
47
|
+
if (Array.isArray(node.pages)) {
|
|
48
|
+
for (const item of node.pages) fromPagesItem(item);
|
|
49
|
+
} else if (Array.isArray(node.tabs)) {
|
|
50
|
+
for (const t of node.tabs) fromContainer(t);
|
|
51
|
+
} else if (Array.isArray(node.versions)) {
|
|
52
|
+
for (const v of node.versions) fromContainer(v);
|
|
53
|
+
} else if (Array.isArray(node.languages)) {
|
|
54
|
+
for (const l of node.languages) fromContainer(l);
|
|
55
|
+
} else if (Array.isArray(node.dropdowns)) {
|
|
56
|
+
for (const d of node.dropdowns) fromContainer(d);
|
|
57
|
+
} else if (Array.isArray(node.products)) {
|
|
58
|
+
for (const p of node.products) fromContainer(p);
|
|
59
|
+
}
|
|
60
|
+
// otherwise a bare { href } container - nothing to collect
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (Array.isArray(navigation)) {
|
|
64
|
+
for (const item of navigation) fromPagesItem(item);
|
|
65
|
+
} else if (navigation && typeof navigation === 'object') {
|
|
66
|
+
if (Array.isArray(navigation.global?.dropdowns)) {
|
|
67
|
+
for (const d of navigation.global.dropdowns) fromContainer(d);
|
|
68
|
+
}
|
|
69
|
+
fromContainer(navigation);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return found;
|
|
73
|
+
}
|