@writedocs/generator 0.4.9 → 0.4.10

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 (63) hide show
  1. package/astro.config.mjs +39 -2
  2. package/bin/writedocs.js +23 -0
  3. package/package.json +1 -1
  4. package/src/cli/convert.js +82 -0
  5. package/src/cli/generate-api-pages.js +56 -3
  6. package/src/components/Accordion.astro +2 -1
  7. package/src/components/AccordionGroup.astro +4 -1
  8. package/src/components/ApiPlayground.astro +6 -2
  9. package/src/components/ApiReferencePanel.astro +6 -2
  10. package/src/components/Badge.astro +2 -0
  11. package/src/components/Callout.astro +2 -1
  12. package/src/components/Card.astro +2 -1
  13. package/src/components/CardGroup.astro +2 -1
  14. package/src/components/Check.astro +1 -1
  15. package/src/components/CodeBlock.astro +94 -0
  16. package/src/components/CodeGroup.astro +2 -1
  17. package/src/components/Color.astro +2 -1
  18. package/src/components/ColorItem.astro +2 -1
  19. package/src/components/ColorRow.astro +2 -1
  20. package/src/components/Column.astro +19 -0
  21. package/src/components/Columns.astro +1 -1
  22. package/src/components/Danger.astro +1 -1
  23. package/src/components/Expandable.astro +2 -1
  24. package/src/components/Frame.astro +2 -1
  25. package/src/components/GitHubRepo.astro +2 -1
  26. package/src/components/Hint.astro +2 -1
  27. package/src/components/Icon.astro +3 -2
  28. package/src/components/Image.astro +2 -1
  29. package/src/components/Info.astro +1 -1
  30. package/src/components/Note.astro +1 -1
  31. package/src/components/Panel.astro +2 -1
  32. package/src/components/Parameter.astro +2 -1
  33. package/src/components/Prompt.astro +2 -1
  34. package/src/components/RequestExample.astro +2 -1
  35. package/src/components/ResponseExample.astro +2 -1
  36. package/src/components/Searchbar.astro +2 -1
  37. package/src/components/Step.astro +2 -1
  38. package/src/components/Steps.astro +4 -1
  39. package/src/components/Tab.astro +2 -1
  40. package/src/components/Tabs.astro +2 -1
  41. package/src/components/Tile.astro +2 -1
  42. package/src/components/Tip.astro +1 -1
  43. package/src/components/TreeFile.astro +2 -1
  44. package/src/components/TreeFolder.astro +2 -1
  45. package/src/components/Update.astro +2 -1
  46. package/src/components/Video.astro +2 -1
  47. package/src/components/View.astro +2 -1
  48. package/src/components/Warning.astro +1 -1
  49. package/src/components/class-names.ts +8 -0
  50. package/src/components/index.ts +2 -0
  51. package/src/content.config.ts +23 -2
  52. package/src/lib/content-check.js +36 -6
  53. package/src/lib/mdx-auto-hydrate.js +12 -0
  54. package/src/lib/mdx-inject-builtins.js +15 -0
  55. package/src/lib/mdx-inline-react.js +202 -0
  56. package/src/lib/mdx-mintlify.js +65 -0
  57. package/src/lib/mdx-substitute-variables.js +17 -0
  58. package/src/lib/mdx-unknown-components.js +56 -3
  59. package/src/lib/mintlify-convert.js +599 -0
  60. package/src/lib/openapi-ref.js +44 -0
  61. package/src/lib/openapi-render.ts +10 -1
  62. package/src/lib/pages.js +89 -17
  63. package/src/pages/[...slug].astro +8 -2
@@ -0,0 +1,599 @@
1
+ // Converts a Mintlify docs.json into a writedocs.json - `writedocs convert
2
+ // --mintlify` (src/cli/convert.js). Plain JavaScript so the CLI can load it
3
+ // from an installed package (see lib/icons.js's comment).
4
+ //
5
+ // convertMintlifyConfig() is a pure function: Mintlify config in, writedocs
6
+ // config plus a list of notes out. A note is anything that couldn't be
7
+ // carried over as-is - dropped, approximated, or needing a manual step - so
8
+ // the author gets one complete list instead of discovering each difference
9
+ // on their own. loadMintlifyConfig() reads docs.json from disk and resolves
10
+ // its `$ref`s first.
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { iconExists } from './icons.js';
14
+
15
+ // ---------------------------------------------------------------------
16
+ // Reading docs.json
17
+ // ---------------------------------------------------------------------
18
+
19
+ /** Reads a Mintlify docs.json and resolves every `$ref` in it, the way
20
+ * Mintlify does: a `$ref` is a path to another JSON file, relative to the
21
+ * file it's in; an object reference is merged under any sibling keys (the
22
+ * siblings win); a non-object reference replaces the whole value. */
23
+ export function loadMintlifyConfig(file) {
24
+ const root = path.dirname(path.resolve(file));
25
+ const seen = new Set();
26
+ function readJson(p) {
27
+ const abs = path.resolve(p);
28
+ if (seen.has(abs)) throw new Error(`Circular $ref: ${path.relative(root, abs)}`);
29
+ seen.add(abs);
30
+ try {
31
+ return resolve(JSON.parse(fs.readFileSync(abs, 'utf-8')), path.dirname(abs));
32
+ } finally {
33
+ seen.delete(abs);
34
+ }
35
+ }
36
+ function resolve(value, dir) {
37
+ if (Array.isArray(value)) return value.map((v) => resolve(v, dir));
38
+ if (!value || typeof value !== 'object') return value;
39
+ if (typeof value.$ref === 'string') {
40
+ const target = readJson(path.join(dir, value.$ref));
41
+ const { $ref, ...siblings } = value;
42
+ if (target && typeof target === 'object' && !Array.isArray(target)) {
43
+ return { ...target, ...resolve(siblings, dir) };
44
+ }
45
+ return target;
46
+ }
47
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, resolve(v, dir)]));
48
+ }
49
+ return readJson(file);
50
+ }
51
+
52
+ // ---------------------------------------------------------------------
53
+ // Notes
54
+ // ---------------------------------------------------------------------
55
+
56
+ function pathString(trail) {
57
+ return trail.reduce((s, part) => (typeof part === 'number' ? `${s}[${part}]` : s ? `${s}.${part}` : part), '');
58
+ }
59
+
60
+ class Notes {
61
+ constructor() {
62
+ this.byKey = new Map();
63
+ }
64
+ /** `key` groups repeats of the same kind of note into one entry. */
65
+ add(key, trail, message, suggestion) {
66
+ const where = pathString(trail);
67
+ const existing = this.byKey.get(key);
68
+ if (existing) existing.where.push(where);
69
+ else this.byKey.set(key, { where: [where], message, suggestion });
70
+ }
71
+ list() {
72
+ return [...this.byKey.values()];
73
+ }
74
+ }
75
+
76
+ // ---------------------------------------------------------------------
77
+ // Navigation
78
+ // ---------------------------------------------------------------------
79
+
80
+ const ENDPOINT_RE = /^(?:(\S+?\.(?:json|ya?ml))\s+)?(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE|WEBHOOK)\s+\S+/i;
81
+
82
+ // Structural keys, handled for every navigation item - anything else on an
83
+ // item that isn't in that item type's own list below is reported.
84
+ const STRUCTURAL = ['pages', 'groups', 'tabs', 'anchors', 'dropdowns', 'products', 'versions', 'languages', 'menu', 'href', 'openapi', 'hidden'];
85
+
86
+ const LANGUAGE_NAMES = {
87
+ ar: 'العربية', ca: 'Català', cn: '简体中文', 'zh-Hant': '繁體中文', cs: 'Čeština', da: 'Dansk', de: 'Deutsch',
88
+ en: 'English', es: 'Español', fi: 'Suomi', fr: 'Français', 'fr-CA': 'Français (Canada)', he: 'עברית',
89
+ hi: 'हिन्दी', hu: 'Magyar', id: 'Bahasa Indonesia', it: 'Italiano', ja: '日本語', 'ja-JP': '日本語', jp: '日本語',
90
+ ko: '한국어', lv: 'Latviešu', nl: 'Nederlands', no: 'Norsk', pl: 'Polski', pt: 'Português', 'pt-BR': 'Português (Brasil)',
91
+ ro: 'Română', ru: 'Русский', sv: 'Svenska', tr: 'Türkçe', uk: 'Українська', uz: 'Oʻzbek', vi: 'Tiếng Việt', zh: '中文',
92
+ };
93
+
94
+ function slugify(value) {
95
+ return String(value)
96
+ .toLowerCase()
97
+ .normalize('NFKD')
98
+ .replace(/[̀-ͯ]/g, '')
99
+ .replace(/[^a-z0-9]+/g, '-')
100
+ .replace(/^-+|-+$/g, '') || 'api';
101
+ }
102
+
103
+ /** A Mintlify page reference as a writedocs page id: no leading slash, no
104
+ * extension, and a folder's index page by the folder's path ("cli/index"
105
+ * -> "cli") - which is how writedocs ids index pages (fileIdForPath() in
106
+ * lib/pages.js). */
107
+ function pagePath(ref) {
108
+ return String(ref).trim().replace(/^\.?\//, '').replace(/\.mdx?$/i, '').replace(/\/index$/, '');
109
+ }
110
+
111
+ class NavConverter {
112
+ constructor(notes, defaultSpec) {
113
+ this.notes = notes;
114
+ this.defaultSpec = defaultSpec;
115
+ this.usedApiPaths = new Set();
116
+ }
117
+
118
+ /** An OpenAPI reference as writedocs can use it: a local file path, or
119
+ * null (with a note) when it's a URL or missing. */
120
+ spec(value, trail) {
121
+ let src = value;
122
+ if (Array.isArray(src)) {
123
+ if (src.length > 1) {
124
+ this.notes.add('openapi-multi', trail, 'More than one OpenAPI spec on one navigation item - only the first is used.', 'Split the item so each spec has its own group.');
125
+ }
126
+ src = src[0];
127
+ }
128
+ if (src && typeof src === 'object') src = src.source;
129
+ if (typeof src !== 'string' || !src.trim()) return null;
130
+ if (/^https?:\/\//i.test(src)) {
131
+ this.notes.add('openapi-url', trail, `OpenAPI spec is a URL (${src}) - writedocs reads specs from files in the project.`, 'Download the spec into the project, then add an openapi group for it: { "group": "...", "openapi": { "src": "spec.json", "path": "/api" } }.');
132
+ return null;
133
+ }
134
+ return src.trim().replace(/^\.?\//, '');
135
+ }
136
+
137
+ apiPath(name) {
138
+ const base = `/${slugify(name)}`;
139
+ let candidate = base;
140
+ for (let n = 2; this.usedApiPaths.has(candidate); n++) candidate = `${base}-${n}`;
141
+ this.usedApiPaths.add(candidate);
142
+ return candidate;
143
+ }
144
+
145
+ openapiGroup(name, src) {
146
+ return { group: name, openapi: { src, path: this.apiPath(name) } };
147
+ }
148
+
149
+ reportExtraKeys(item, own, kind, trail) {
150
+ for (const key of Object.keys(item)) {
151
+ if (own.includes(key) || STRUCTURAL.includes(key)) continue;
152
+ this.notes.add(`nav-key:${kind}:${key}`, [...trail, key], `\`${key}\` on ${kind}s has no writedocs equivalent - dropped.`);
153
+ }
154
+ }
155
+
156
+ /** A Mintlify navigation node's children, as writedocs children
157
+ * ({ pages } | { tabs } | ... | { href }), or null if it has none. */
158
+ children(node, trail, spec) {
159
+ const ownSpec = node.openapi !== undefined ? this.spec(node.openapi, [...trail, 'openapi']) : spec;
160
+ if (node.pages || node.groups) {
161
+ const pages = [
162
+ ...this.items(node.pages ?? [], [...trail, 'pages'], ownSpec),
163
+ ...this.items(node.groups ?? [], [...trail, 'groups'], ownSpec),
164
+ ];
165
+ return { pages };
166
+ }
167
+ if (node.tabs) return this.list('tabs', node.tabs, [...trail, 'tabs'], ownSpec, (t, tr) => this.container(t, tr, ownSpec, 'tab', 'tab', ['tab', 'icon']));
168
+ if (node.anchors) {
169
+ this.notes.add('anchors', [...trail, 'anchors'], "Anchors became tabs - writedocs has no sidebar anchors.", 'For a plain external link, a global dropdown or a topbar link may fit better.');
170
+ return this.list('tabs', node.anchors, [...trail, 'anchors'], ownSpec, (a, tr) => this.container({ ...a, tab: a.anchor, anchor: undefined }, tr, ownSpec, 'tab', 'anchor', ['tab', 'anchor', 'icon']));
171
+ }
172
+ if (node.menu) {
173
+ this.notes.add('menu', [...trail, 'menu'], "A tab's or product's menu became a list of dropdowns inside it.");
174
+ return this.list('dropdowns', node.menu, [...trail, 'menu'], ownSpec, (m, tr) => this.container({ ...m, dropdown: m.item, item: undefined }, tr, ownSpec, 'dropdown', 'menu item', ['dropdown', 'item', 'icon']));
175
+ }
176
+ if (node.dropdowns) return this.list('dropdowns', node.dropdowns, [...trail, 'dropdowns'], ownSpec, (d, tr) => this.container(d, tr, ownSpec, 'dropdown', 'dropdown', ['dropdown', 'icon']));
177
+ if (node.products) return this.list('products', node.products, [...trail, 'products'], ownSpec, (p, tr) => this.container(p, tr, ownSpec, 'product', 'product', ['product', 'icon', 'description']));
178
+ if (node.versions) return this.list('versions', node.versions, [...trail, 'versions'], ownSpec, (v, tr) => this.container(v, tr, ownSpec, 'version', 'version', ['version', 'tag', 'default']));
179
+ if (node.languages) return this.list('languages', node.languages, [...trail, 'languages'], ownSpec, (l, tr) => this.language(l, tr, ownSpec));
180
+ if (typeof node.href === 'string') return { href: node.href };
181
+ // An item with only an OpenAPI spec: Mintlify generates every endpoint.
182
+ if (node.openapi !== undefined && ownSpec) {
183
+ const label = node.tab ?? node.anchor ?? node.dropdown ?? node.product ?? node.item ?? node.version ?? node.language ?? 'API reference';
184
+ return { pages: [this.openapiGroup(label, ownSpec)] };
185
+ }
186
+ return null;
187
+ }
188
+
189
+ list(key, items, trail, spec, convert) {
190
+ const converted = items.map((item, i) => convert(item, [...trail, i])).filter(Boolean);
191
+ return converted.length ? { [key]: converted } : null;
192
+ }
193
+
194
+ /** A tab/anchor/dropdown/menu item/product/version. */
195
+ container(item, trail, spec, nameKey, kind, own) {
196
+ const name = item[nameKey];
197
+ if (item.hidden) {
198
+ this.notes.add(`hidden:${kind}`, trail, `Hidden ${kind}s were left out of the navigation - writedocs has no hidden ${kind}s.`, "Their pages still build and are reachable by URL, like a page that isn't in the navigation.");
199
+ return null;
200
+ }
201
+ this.reportExtraKeys(item, own, kind, trail);
202
+ const children = this.children(item, trail, spec);
203
+ if (!children) {
204
+ this.notes.add(`empty:${kind}`, trail, `A ${kind} with no pages, groups or link was left out.`);
205
+ return null;
206
+ }
207
+ const out = { [nameKey]: name };
208
+ if (item.icon && typeof item.icon === 'string') out.icon = item.icon;
209
+ if (nameKey === 'product' && item.description) out.description = item.description;
210
+ if (nameKey === 'version') {
211
+ if (item.tag) out.tag = item.tag;
212
+ if (item.default) out.default = true;
213
+ }
214
+ return { ...out, ...children };
215
+ }
216
+
217
+ language(item, trail, spec) {
218
+ if (item.hidden) {
219
+ this.notes.add('hidden:language', trail, 'Hidden languages were left out of the navigation.', 'Their pages still build and are reachable by URL.');
220
+ return null;
221
+ }
222
+ for (const key of ['banner', 'footer', 'navbar']) {
223
+ if (item[key]) {
224
+ this.notes.add(`language:${key}`, [...trail, key], `A per-language \`${key}\` isn't supported - writedocs has one ${key} for the whole site.`, `Only the top-level \`${key}\` was converted.`);
225
+ }
226
+ }
227
+ this.reportExtraKeys(item, ['language', 'default', 'banner', 'footer', 'navbar'], 'language', trail);
228
+ const children = this.children(item, trail, spec);
229
+ if (!children) {
230
+ this.notes.add('empty:language', trail, 'A language with no pages was left out.');
231
+ return null;
232
+ }
233
+ const out = { language: item.language };
234
+ if (LANGUAGE_NAMES[item.language]) out.label = LANGUAGE_NAMES[item.language];
235
+ return { ...out, ...children };
236
+ }
237
+
238
+ /** A `pages`/`groups` array: page paths and groups. */
239
+ items(list, trail, spec) {
240
+ const out = [];
241
+ list.forEach((item, i) => {
242
+ const tr = [...trail, i];
243
+ if (typeof item === 'string') {
244
+ if (ENDPOINT_RE.test(item.trim())) {
245
+ this.notes.add('endpoint-outside-group', tr, 'An API endpoint listed outside a group was left out.', 'Put the endpoints in a group with an `openapi` spec - writedocs generates a page for every endpoint in it.');
246
+ return;
247
+ }
248
+ out.push(pagePath(item));
249
+ } else if (item && typeof item === 'object' && typeof item.group === 'string') {
250
+ const group = this.group(item, tr, spec);
251
+ if (group) out.push(group);
252
+ } else if (item && typeof item === 'object' && typeof item.href === 'string') {
253
+ out.push({ label: item.label ?? item.title ?? item.href, href: item.href });
254
+ } else {
255
+ this.notes.add('unknown-page-entry', tr, 'A navigation entry that is neither a page path nor a group was left out.');
256
+ }
257
+ });
258
+ return out;
259
+ }
260
+
261
+ group(g, trail, spec) {
262
+ if (g.hidden) {
263
+ this.notes.add('hidden:group', trail, 'Hidden groups were left out of the navigation.', 'Their pages still build and are reachable by URL.');
264
+ return null;
265
+ }
266
+ this.reportExtraKeys(g, ['group', 'root', 'pages'], 'group', trail);
267
+ const ownSpec = g.openapi !== undefined ? this.spec(g.openapi, [...trail, 'openapi']) : spec;
268
+ const pages = [];
269
+ let endpoints = 0;
270
+ (g.pages ?? []).forEach((item, i) => {
271
+ if (typeof item === 'string' && ENDPOINT_RE.test(item.trim())) {
272
+ endpoints++;
273
+ return;
274
+ }
275
+ pages.push(...this.items([item], [...trail, 'pages', i], ownSpec));
276
+ });
277
+ const wantsApi = endpoints > 0 || (g.openapi !== undefined && (g.pages ?? []).length === 0);
278
+ if (wantsApi) {
279
+ if (ownSpec) {
280
+ if (endpoints > 0) {
281
+ this.notes.add('endpoint-selection', [...trail, 'pages'], 'Groups that listed individual API endpoints now generate a page for every endpoint in their OpenAPI spec - writedocs has no per-endpoint selection.', 'To show only some endpoints, split the spec, or write those pages by hand with `openapi: "METHOD /path"` frontmatter.');
282
+ }
283
+ if (pages.length === 0 && !g.root) return this.openapiGroup(g.group, ownSpec);
284
+ pages.push(this.openapiGroup('Endpoints', ownSpec));
285
+ } else if (endpoints > 0) {
286
+ this.notes.add('endpoint-no-spec', [...trail, 'pages'], 'API endpoints listed without a usable OpenAPI spec were left out.', 'Add an `openapi` group with a spec file in the project.');
287
+ }
288
+ }
289
+ if (pages.length === 0 && !g.root) {
290
+ this.notes.add('empty:group', trail, 'A group with no pages was left out.');
291
+ return null;
292
+ }
293
+ const out = { group: g.group };
294
+ if (g.root) out.page = pagePath(g.root);
295
+ out.pages = pages;
296
+ return out;
297
+ }
298
+ }
299
+
300
+ function convertNavigation(nav, notes, topbarLinks, defaultSpec) {
301
+ const trail = ['navigation'];
302
+ const conv = new NavConverter(notes, defaultSpec);
303
+ if (Array.isArray(nav)) {
304
+ notes.add('legacy-navigation', trail, 'docs.json navigation is an array - that is the older mint.json shape.', 'Run `npx mint upgrade` in the Mintlify project first, then convert again.');
305
+ return { navigation: [], api: conv };
306
+ }
307
+ const children = nav ? conv.children(nav, trail, defaultSpec) : null;
308
+ let navigation = children ?? { pages: [] };
309
+ if (!children) notes.add('empty-navigation', trail, 'No navigation could be converted.');
310
+
311
+ // `global` links: writedocs' global dropdowns are its equivalent of
312
+ // Mintlify's global anchors (config-schema.ts), but they only exist
313
+ // alongside a root division, not a plain page list - there they become
314
+ // topbar links instead.
315
+ const globalLinks = [];
316
+ const g = nav?.global ?? {};
317
+ for (const [key, nameKey] of [['anchors', 'anchor'], ['tabs', 'tab'], ['dropdowns', 'dropdown'], ['products', 'product']]) {
318
+ (g[key] ?? []).forEach((item, i) => {
319
+ if (item.hidden) return;
320
+ if (typeof item.href !== 'string') {
321
+ notes.add('global-no-href', ['navigation', 'global', key, i], 'Global navigation items without an `href` were left out.');
322
+ return;
323
+ }
324
+ globalLinks.push({ label: item[nameKey], href: item.href, icon: item.icon });
325
+ });
326
+ }
327
+ for (const key of ['languages', 'versions']) {
328
+ if (g[key]?.length) notes.add(`global-${key}`, ['navigation', 'global', key], `Global ${key} (links to other sites) aren't supported - left out.`);
329
+ }
330
+ if (children?.pages) {
331
+ navigation = children.pages;
332
+ for (const link of globalLinks) topbarLinks.push(link.icon ? link : { label: link.label, href: link.href });
333
+ if (globalLinks.length) {
334
+ notes.add('global-to-topbar', ['navigation', 'global'], 'Global anchors became topbar links - writedocs only has global dropdowns alongside tabs, versions, languages, dropdowns or products.');
335
+ }
336
+ } else if (globalLinks.length) {
337
+ navigation = {
338
+ global: { dropdowns: globalLinks.map((l) => ({ dropdown: l.label, ...(l.icon ? { icon: l.icon } : {}), href: l.href })) },
339
+ ...navigation,
340
+ };
341
+ }
342
+ return { navigation, api: conv };
343
+ }
344
+
345
+ // ---------------------------------------------------------------------
346
+ // Everything else
347
+ // ---------------------------------------------------------------------
348
+
349
+ const SOCIAL_ICONS = { x: 'x-twitter', website: 'globe' };
350
+
351
+ function lightDark(value) {
352
+ if (typeof value === 'string') return { light: value, dark: value };
353
+ if (value && typeof value === 'object') return { light: value.light, dark: value.dark };
354
+ return null;
355
+ }
356
+
357
+ /**
358
+ * Mintlify docs.json (with `$ref`s already resolved) -> { config, notes }.
359
+ * `config` is a writedocs.json object; `notes` is a list of
360
+ * { where: string[], message, suggestion? }.
361
+ */
362
+ export function convertMintlifyConfig(docs) {
363
+ const notes = new Notes();
364
+ const out = { name: docs.name ?? 'Documentation' };
365
+ if (!docs.name) notes.add('name', ['name'], 'docs.json has no `name` - used "Documentation".');
366
+ if (docs.description) out.description = docs.description;
367
+
368
+ // styles
369
+ const styles = {};
370
+ const colors = {};
371
+ if (docs.colors?.primary) colors.primary = docs.colors.primary;
372
+ if (docs.colors?.light) colors.dark = { primary: docs.colors.light };
373
+ if (docs.colors?.dark) notes.add('colors.dark', ['colors', 'dark'], '`colors.dark` (buttons and hover states) has no writedocs equivalent - dropped.', 'writedocs uses `colors.primary` for those.');
374
+ if (Object.keys(colors).length) styles.colors = colors;
375
+ if (typeof docs.logo === 'string') styles.logo = docs.logo;
376
+ else if (docs.logo && typeof docs.logo === 'object') {
377
+ styles.logo = {};
378
+ if (docs.logo.light) styles.logo.light = docs.logo.light;
379
+ if (docs.logo.dark) styles.logo.dark = docs.logo.dark;
380
+ if (docs.logo.href) notes.add('logo.href', ['logo', 'href'], '`logo.href` isn\'t supported - the logo always links to the home page.');
381
+ }
382
+ if (typeof docs.favicon === 'string') styles.favicon = docs.favicon;
383
+ else if (docs.favicon?.light) {
384
+ styles.favicon = docs.favicon.light;
385
+ if (docs.favicon.dark) notes.add('favicon.dark', ['favicon', 'dark'], 'writedocs has one favicon - used `favicon.light`, dropped `favicon.dark`.');
386
+ }
387
+ if (docs.fonts && typeof docs.fonts === 'object') {
388
+ const font = (f) => {
389
+ const o = { family: f.family };
390
+ for (const k of ['weight', 'source', 'format']) if (f[k] !== undefined) o[k] = f[k];
391
+ return o;
392
+ };
393
+ if (docs.fonts.family) {
394
+ styles.fonts = font(docs.fonts);
395
+ if (docs.fonts.heading?.family) styles.fonts.heading = font(docs.fonts.heading);
396
+ if (docs.fonts.body?.family) styles.fonts.body = font(docs.fonts.body);
397
+ } else if (docs.fonts.body?.family || docs.fonts.heading?.family) {
398
+ styles.fonts = font(docs.fonts.body ?? docs.fonts.heading);
399
+ if (docs.fonts.heading?.family && docs.fonts.body?.family) styles.fonts.heading = font(docs.fonts.heading);
400
+ }
401
+ }
402
+ const cb = docs.styling?.codeblocks;
403
+ if (cb !== undefined && cb !== 'system') {
404
+ const theme = typeof cb === 'string' ? cb : cb?.theme;
405
+ if (theme === 'dark') styles.codeblocks = { light: 'github-dark', dark: 'github-dark' };
406
+ else if (typeof theme === 'string') styles.codeblocks = { light: theme, dark: theme };
407
+ else if (theme && typeof theme === 'object') styles.codeblocks = { light: theme.light, dark: theme.dark };
408
+ if (cb?.languages) notes.add('codeblock-languages', ['styling', 'codeblocks', 'languages'], 'Custom code block languages aren\'t supported - dropped.');
409
+ }
410
+ const bg = docs.background;
411
+ if (bg) {
412
+ const background = {};
413
+ const c = lightDark(bg.color);
414
+ if (c) background.colors = c;
415
+ const img = lightDark(bg.image);
416
+ if (img) background.images = img;
417
+ if (Object.keys(background).length) styles.background = background;
418
+ if (bg.decoration) notes.add('background.decoration', ['background', 'decoration'], `\`background.decoration\` ("${bg.decoration}") isn't supported - dropped.`);
419
+ }
420
+ if (Object.keys(styles).length) out.styles = styles;
421
+
422
+ // navigation (also collects topbar links from `global`)
423
+ const topbarLinks = [];
424
+ const defaultSpec = docs.api?.openapi;
425
+ const navNotesSpec = defaultSpec !== undefined ? new NavConverter(notes).spec(defaultSpec, ['api', 'openapi']) : null;
426
+ const { navigation } = convertNavigation(docs.navigation, notes, topbarLinks, navNotesSpec);
427
+ out.navigation = navigation;
428
+
429
+ // topbar
430
+ const navbar = docs.navbar ?? {};
431
+ const linkFor = (l) => {
432
+ if (l.type === 'github' || l.type === 'discord') {
433
+ return { label: l.label ?? (l.type === 'github' ? 'GitHub' : 'Discord'), href: l.href, icon: l.type };
434
+ }
435
+ const link = { label: l.label, href: l.href };
436
+ if (typeof l.icon === 'string') link.icon = l.icon;
437
+ return link;
438
+ };
439
+ for (const l of navbar.links ?? []) if (l?.href && (l.label || l.type || l.icon)) topbarLinks.push(linkFor(l));
440
+ if (navbar.primary?.href) {
441
+ topbarLinks.push(linkFor(navbar.primary));
442
+ notes.add('navbar.primary', ['navbar', 'primary'], 'The navbar\'s primary button became a regular topbar link - writedocs has no call-to-action button style.');
443
+ }
444
+ if (topbarLinks.length) out.topbar = { links: topbarLinks };
445
+
446
+ // footer + socials
447
+ const socials = docs.footer?.socials;
448
+ if (socials && typeof socials === 'object') {
449
+ out.socials = {};
450
+ for (const [key, url] of Object.entries(socials)) {
451
+ const icon = iconExists(key) && !SOCIAL_ICONS[key] ? key : SOCIAL_ICONS[key] ?? key;
452
+ if (!iconExists(icon)) notes.add(`social:${key}`, ['footer', 'socials', key], `No icon for the "${key}" social link - it shows without one.`);
453
+ out.socials[icon] = url;
454
+ }
455
+ }
456
+ if (Array.isArray(docs.footer?.links) && docs.footer.links.length) {
457
+ out.footer = {
458
+ columns: docs.footer.links.map((col) => ({
459
+ ...(col.header ? { title: col.header } : {}),
460
+ links: (col.items ?? []).filter((i) => i?.href && i.label).map((i) => ({ label: i.label, href: i.href })),
461
+ })),
462
+ };
463
+ }
464
+
465
+ // banner
466
+ if (docs.banner?.content) {
467
+ out.banner = { content: docs.banner.content };
468
+ if (docs.banner.dismissible) out.banner.dismissible = true;
469
+ if (['info', 'warning', 'critical'].includes(docs.banner.type)) out.banner.type = docs.banner.type;
470
+ if (docs.banner.color) notes.add('banner.color', ['banner', 'color'], '`banner.color` isn\'t supported - the banner uses its `type`\'s color.');
471
+ }
472
+
473
+ // 404
474
+ const e404 = docs.errors?.['404'];
475
+ if (e404?.title || e404?.description) {
476
+ out.notFound = {};
477
+ if (e404.title) out.notFound.title = e404.title;
478
+ if (e404.description) out.notFound.description = e404.description;
479
+ }
480
+ if (e404 && e404.redirect !== false) {
481
+ notes.add('404-redirect', ['errors', '404', 'redirect'], 'Mintlify redirects a missing page to the home page; writedocs shows a 404 page instead.');
482
+ }
483
+
484
+ // redirects
485
+ if (Array.isArray(docs.redirects) && docs.redirects.length) {
486
+ // Mintlify redirects can match a path pattern (`/old/:slug`, `/old/*`);
487
+ // writedocs redirects are exact paths - a pattern would become a
488
+ // literal page path (and `:` can't even be a folder name on Windows).
489
+ const isPattern = (p) => /(^|\/):[A-Za-z_]|\*/.test(p);
490
+ const patterns = docs.redirects.filter((r) => r?.source && r?.destination && (isPattern(r.source) || isPattern(r.destination)));
491
+ out.redirects = docs.redirects
492
+ .filter((r) => r?.source && r?.destination && !patterns.includes(r))
493
+ .map((r) => ({ source: r.source, destination: r.destination }));
494
+ if (out.redirects.length === 0) delete out.redirects;
495
+ if (patterns.length) {
496
+ const examples = patterns.slice(0, 2).map((r) => `${r.source} -> ${r.destination}`).join('; ');
497
+ notes.add('redirect-patterns', ['redirects'], `${patterns.length} redirect${patterns.length === 1 ? '' : 's'} with a path parameter or wildcard (e.g. ${examples}) ${patterns.length === 1 ? 'was' : 'were'} left out - writedocs redirects match one exact path.`, 'Add an exact redirect for each old page that still gets traffic.');
498
+ }
499
+ if (docs.redirects.some((r) => r?.permanent === false)) {
500
+ notes.add('redirect-permanent', ['redirects'], '`permanent: false` on redirects has no effect - every writedocs redirect is the same kind.');
501
+ }
502
+ }
503
+
504
+ // variables
505
+ if (docs.variables && typeof docs.variables === 'object' && Object.keys(docs.variables).length) {
506
+ out.variables = { ...docs.variables };
507
+ const hyphenated = Object.keys(docs.variables).filter((k) => k.includes('-'));
508
+ if (hyphenated.length) {
509
+ notes.add('variables-hyphen', ['variables'], `Variables with a hyphen (${hyphenated.join(', ')}) can't be written as {{name}} in writedocs pages.`, 'Write them as [[name]] in your pages instead.');
510
+ }
511
+ }
512
+
513
+ // SEO
514
+ const meta = docs.seo?.metatags ?? {};
515
+ const seo = {};
516
+ const ignoredMeta = [];
517
+ for (const [k, v] of Object.entries(meta)) {
518
+ if (k === 'og:image') seo.ogImage = v;
519
+ else if (k === 'og:type') seo.ogType = v;
520
+ else if (k === 'twitter:card' && ['summary', 'summary_large_image'].includes(v)) seo.twitterCard = v;
521
+ else if (k === 'keywords') seo.keywords = String(v).split(',').map((s) => s.trim()).filter(Boolean);
522
+ else if (k === 'robots' && /noindex/i.test(String(v))) seo.noindex = true;
523
+ else ignoredMeta.push(k);
524
+ }
525
+ if (Object.keys(seo).length) out.seo = seo;
526
+ if (ignoredMeta.length) {
527
+ notes.add('metatags', ['seo', 'metatags'], `These meta tags aren't supported and were dropped: ${ignoredMeta.join(', ')}.`, 'writedocs sets canonical, og:title/description/url and twitter tags itself; a verification tag can go in a custom head script.');
528
+ }
529
+ if (docs.seo?.indexing === 'all') notes.add('seo.indexing', ['seo', 'indexing'], '`seo.indexing: "all"` has no equivalent - writedocs indexes every page that isn\'t marked noindex.');
530
+
531
+ // context menu
532
+ const options = docs.contextual?.options;
533
+ if (Array.isArray(options) && options.length) {
534
+ const openIn = options.filter((o) => ['chatgpt', 'claude', 'perplexity'].includes(o));
535
+ out.contextMenu = { openIn };
536
+ const other = options.filter((o) => typeof o !== 'string' || !['copy', 'view', 'chatgpt', 'claude', 'perplexity'].includes(o));
537
+ if (other.length) {
538
+ notes.add('contextual', ['contextual', 'options'], `Context menu options writedocs doesn't have were dropped: ${other.map((o) => (typeof o === 'string' ? o : o.title ?? 'custom')).join(', ')}.`, 'writedocs\' page menu always has copy and view-as-Markdown, plus open in ChatGPT, Claude and Perplexity.');
539
+ }
540
+ }
541
+
542
+ // API playground
543
+ if (docs.api?.playground?.proxy === false) out.api = { proxy: false };
544
+ if (docs.api?.playground?.display && docs.api.playground.display !== 'interactive') {
545
+ notes.add('api.playground.display', ['api', 'playground', 'display'], `\`api.playground.display: "${docs.api.playground.display}"\` isn't supported - the playground is always interactive.`);
546
+ }
547
+ for (const key of ['params', 'url', 'examples', 'mdx', 'asyncapi']) {
548
+ if (docs.api?.[key] !== undefined) notes.add(`api.${key}`, ['api', key], `\`api.${key}\` has no writedocs equivalent - dropped.`);
549
+ }
550
+
551
+ // integrations
552
+ const integrations = {};
553
+ const ig = docs.integrations ?? {};
554
+ if (ig.ga4?.measurementId) integrations.ga4 = { measurementId: ig.ga4.measurementId };
555
+ if (ig.gtm?.tagId) integrations.googleTagManager = { containerId: ig.gtm.tagId };
556
+ if (ig.plausible?.domain) {
557
+ integrations.plausible = { domain: ig.plausible.domain };
558
+ if (ig.plausible.server) integrations.plausible.src = `${ig.plausible.server.replace(/\/+$/, '')}/js/script.js`;
559
+ }
560
+ if (ig.fathom?.siteId) integrations.fathom = { siteId: ig.fathom.siteId };
561
+ if (ig.posthog?.apiKey) {
562
+ integrations.posthog = { apiKey: ig.posthog.apiKey };
563
+ if (ig.posthog.apiHost) integrations.posthog.apiHost = ig.posthog.apiHost;
564
+ }
565
+ if (Object.keys(integrations).length) out.integrations = integrations;
566
+ const otherIntegrations = Object.keys(ig).filter((k) => !['ga4', 'gtm', 'plausible', 'fathom', 'posthog', 'telemetry', 'cookies'].includes(k));
567
+ if (otherIntegrations.length) {
568
+ notes.add('integrations', ['integrations'], `These integrations have no writedocs equivalent and were dropped: ${otherIntegrations.join(', ')}.`, 'Most can be added as a custom script in writedocs.json\'s `scripts.head`.');
569
+ }
570
+
571
+ // Settings with no equivalent at all
572
+ const unsupported = {
573
+ appearance: 'Light/dark mode defaults (`appearance`) aren\'t configurable - readers choose with the theme toggle.',
574
+ interaction: '`interaction` settings aren\'t supported.',
575
+ thumbnails: 'Social thumbnails (`thumbnails`) aren\'t generated - set `seo.ogImage` for a social preview image.',
576
+ metadata: '`metadata` (last-modified timestamps) isn\'t supported.',
577
+ markdown: '`markdown` export settings aren\'t supported.',
578
+ search: '`search` settings aren\'t supported.',
579
+ };
580
+ for (const [key, message] of Object.entries(unsupported)) if (docs[key] !== undefined) notes.add(key, [key], message);
581
+ if (docs.styling?.eyebrows) notes.add('styling.eyebrows', ['styling', 'eyebrows'], '`styling.eyebrows` isn\'t supported - writedocs shows breadcrumbs.');
582
+ if (docs.icons?.library === 'tabler') {
583
+ notes.add('icons.library', ['icons', 'library'], 'Icon names are looked up in Lucide, then Font Awesome - not Tabler.', 'Tabler names that don\'t exist there can be written as "tabler:name".');
584
+ }
585
+
586
+ return { config: out, notes: notes.list() };
587
+ }
588
+
589
+ /** Formats notes for the terminal - same layout as `writedocs validate`. */
590
+ export function formatNotes(notes) {
591
+ return notes
592
+ .map((n) => {
593
+ const where = n.where.length > 3 ? `${n.where.slice(0, 3).join(', ')} and ${n.where.length - 3} more` : n.where.join(', ');
594
+ const lines = [` ${where}`, ` ${n.message}`];
595
+ if (n.suggestion) lines.push(` ${n.suggestion}`);
596
+ return lines.join('\n');
597
+ })
598
+ .join('\n\n');
599
+ }
@@ -0,0 +1,44 @@
1
+ // A page's `openapi` frontmatter, in either form writedocs accepts:
2
+ //
3
+ // openapi: "GET /pets/{petId}" - an operation in one of
4
+ // writedocs.json's openapi
5
+ // groups
6
+ // openapi: "/admin-openapi.json POST /v1/jobs" - Mintlify's form: the spec
7
+ // file first (relative to
8
+ // the content directory)
9
+ //
10
+ // Everything that reads the field - the API playground, the sidebar's method
11
+ // badge, hand-written-override matching, and generate-api-pages.js, which
12
+ // processes every spec a page names this way - goes through here, so the two
13
+ // forms mean the same thing everywhere. Plain JavaScript: generate-api-pages
14
+ // runs under plain Node.
15
+
16
+ const METHOD = /^(get|post|put|patch|delete|head|options|trace|webhook)$/i;
17
+
18
+ /** { spec, method, path } - `spec` is null for the short form, and has no
19
+ * leading slash otherwise. Null when the value isn't an operation at all. */
20
+ export function parseOpenApiRef(value) {
21
+ if (typeof value !== 'string') return null;
22
+ const parts = value.trim().split(/\s+/);
23
+ if (parts.length === 2 && METHOD.test(parts[0])) {
24
+ return { spec: null, method: parts[0].toUpperCase(), path: parts[1] };
25
+ }
26
+ if (parts.length === 3 && METHOD.test(parts[1])) {
27
+ return { spec: parts[0].replace(/^\.?\//, ''), method: parts[1].toUpperCase(), path: parts[2] };
28
+ }
29
+ return null;
30
+ }
31
+
32
+ /** The folder name (under writedocsTempDir()/openapi/_pages/) holding the
33
+ * operations of a spec that pages name in their frontmatter - shared by
34
+ * generate-api-pages.js, which writes it, and findOperationFile(), which
35
+ * reads it. */
36
+ export function specDirName(spec) {
37
+ return String(spec).replace(/^\.?\//, '').replace(/[^a-zA-Z0-9]+/g, '_');
38
+ }
39
+
40
+ /** "METHOD /path" - the key an operation is known by, whichever spec it's in. */
41
+ export function openApiOperationKey(value) {
42
+ const ref = parseOpenApiRef(value);
43
+ return ref ? `${ref.method} ${ref.path}` : null;
44
+ }