@writedocs/generator 0.6.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 +12 -5
- package/package.json +90 -87
- package/src/cli/convert.js +44 -20
- package/src/cli/generate-api-pages.js +24 -2
- package/src/lib/content-check.js +41 -0
- 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/writedocs-legacy-convert.js +496 -0
- package/writedocs.schema.json +2291 -0
|
@@ -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,496 @@
|
|
|
1
|
+
// Converts the previous writedocs' config.json (the Docusaurus-based
|
|
2
|
+
// generator, https://docs.writedocs.io/schema.json) into a writedocs.json -
|
|
3
|
+
// `writedocs convert --writedocs` (src/cli/convert.js). Plain JavaScript, so
|
|
4
|
+
// the CLI can load it from an installed package.
|
|
5
|
+
//
|
|
6
|
+
// Unlike Mintlify's docs.json, config.json doesn't say where a page lives:
|
|
7
|
+
// pages are ids resolved against the docs/ folder the way the old
|
|
8
|
+
// generator's sidebar builder did (docusaurus-template/writedocs/
|
|
9
|
+
// sidebar.config.js) - `Guides/Setup/intro` is docs/Guides/Setup/intro.mdx
|
|
10
|
+
// (or .api.mdx / .info.mdx), a leading "docs/" and an extension are
|
|
11
|
+
// ignored, and a page whose file name starts with "_" was hidden. So the
|
|
12
|
+
// conversion reads the project folder, not just the file: it checks each
|
|
13
|
+
// page exists, reads its frontmatter for its old address, and finds the
|
|
14
|
+
// OpenAPI specs.
|
|
15
|
+
//
|
|
16
|
+
// Only config.json is converted - pages are left as they are.
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import matter from 'gray-matter';
|
|
20
|
+
import { iconExists } from './icons.js';
|
|
21
|
+
import { fileIdForPath } from './pages.js';
|
|
22
|
+
import { Notes, LANGUAGE_NAMES } from './mintlify-convert.js';
|
|
23
|
+
import { pageUrlResolver } from './link-check.js';
|
|
24
|
+
|
|
25
|
+
export function loadLegacyConfig(file) {
|
|
26
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const PAGE_ENDINGS = ['.api.mdx', '.info.mdx', '.mdx', '.md'];
|
|
30
|
+
// Keys config.json had that are about the old platform itself, not the site.
|
|
31
|
+
const PLATFORM_KEYS = new Set(['$schema', 'dashboard', 'draftStructures']);
|
|
32
|
+
const HANDLED_KEYS = new Set([
|
|
33
|
+
'websiteName', 'description', 'homepage', 'images', 'styles', 'colorMode', 'navbar', 'sidebars', 'apiFiles',
|
|
34
|
+
'apiOptions', 'translatedApiFiles', 'codeLanguages', 'changelog', 'externalLinks', 'integrations', 'footer',
|
|
35
|
+
'languages', 'hideWatermark', 'protected', 'closeSubpages',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const nonEmpty = (v) => typeof v === 'string' && v.trim() !== '';
|
|
39
|
+
const isUrl = (v) => /^[a-z][a-z0-9+.-]*:\/\//i.test(String(v));
|
|
40
|
+
const posix = (p) => p.split(path.sep).join('/');
|
|
41
|
+
|
|
42
|
+
/** An image path from config.json as a writedocs path: relative paths
|
|
43
|
+
* ("media/logo.png") were relative to the project root, so they get a
|
|
44
|
+
* leading slash; URLs stay as they are. */
|
|
45
|
+
function assetPath(value) {
|
|
46
|
+
if (!nonEmpty(value)) return undefined;
|
|
47
|
+
const v = value.trim();
|
|
48
|
+
return isUrl(v) || v.startsWith('/') ? v : `/${v.replace(/^\.\//, '')}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A Phosphor icon name (the old navbar's icon set, "BookOpen") as the
|
|
52
|
+
* Lucide name writedocs uses, when Lucide has one. */
|
|
53
|
+
function convertIcon(name) {
|
|
54
|
+
if (!nonEmpty(name)) return undefined;
|
|
55
|
+
const kebab = name.trim().replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/([A-Z])([A-Z][a-z])/g, '$1-$2').toLowerCase();
|
|
56
|
+
if (iconExists(kebab)) return kebab;
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The page id as the old generator cleaned it (cleanPath() in its
|
|
61
|
+
* sidebar.config.js). */
|
|
62
|
+
function cleanId(id) {
|
|
63
|
+
return String(id)
|
|
64
|
+
.trim()
|
|
65
|
+
.replace(/^\/?docs\//, '')
|
|
66
|
+
.replace(/^\//, '')
|
|
67
|
+
.replace(/(\.endpoint)?(\.(md|mdx))?$/, '');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function convertLegacyConfig(old, contentDir) {
|
|
71
|
+
const notes = new Notes();
|
|
72
|
+
const urlOf = await pageUrlResolver();
|
|
73
|
+
const config = {};
|
|
74
|
+
|
|
75
|
+
// The first language is the default one; config.json's pages are in it.
|
|
76
|
+
const languages = Array.isArray(old.languages) && old.languages.filter(nonEmpty).length ? old.languages.filter(nonEmpty) : ['en'];
|
|
77
|
+
|
|
78
|
+
// --- Pages ---------------------------------------------------------------
|
|
79
|
+
const pageCache = new Map();
|
|
80
|
+
/** { rel, id, data } for the file a page id names under `base` (docs/,
|
|
81
|
+
* or a language's folder), or null. */
|
|
82
|
+
function findPage(id, base = 'docs') {
|
|
83
|
+
const key = `${base}\n${id}`;
|
|
84
|
+
if (pageCache.has(key)) return pageCache.get(key);
|
|
85
|
+
let found = null;
|
|
86
|
+
const clean = cleanId(id);
|
|
87
|
+
for (const ending of PAGE_ENDINGS) {
|
|
88
|
+
const rel = `${base}/${clean}${ending}`;
|
|
89
|
+
const abs = path.join(contentDir, rel);
|
|
90
|
+
if (!fs.existsSync(abs)) continue;
|
|
91
|
+
let data = {};
|
|
92
|
+
try {
|
|
93
|
+
data = matter(fs.readFileSync(abs, 'utf-8'), {}).data ?? {};
|
|
94
|
+
} catch {}
|
|
95
|
+
found = { rel, id: fileIdForPath(rel), clean, data };
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
pageCache.set(key, found);
|
|
99
|
+
return found;
|
|
100
|
+
}
|
|
101
|
+
const hiddenPage = (id) => cleanId(id).split('/').pop().startsWith('_');
|
|
102
|
+
|
|
103
|
+
// --- OpenAPI specs ---------------------------------------------------------
|
|
104
|
+
// apiFiles: a spec path (relative to openAPI/) or URL, or { file,
|
|
105
|
+
// outputDir }. The old generator wrote a spec's pages to outputDir, by
|
|
106
|
+
// default docs/reference/<spec name> - which is where sidebar ids like
|
|
107
|
+
// "reference/pets/list-pets" pointed.
|
|
108
|
+
const specs = (Array.isArray(old.apiFiles) ? old.apiFiles : [])
|
|
109
|
+
.map((entry, i) => {
|
|
110
|
+
const file = typeof entry === 'string' ? entry : entry?.file;
|
|
111
|
+
if (!nonEmpty(file)) return null;
|
|
112
|
+
const name = path.parse(file.split('?')[0]).name;
|
|
113
|
+
const outputDir = (typeof entry === 'object' && nonEmpty(entry.outputDir) ? entry.outputDir : `docs/reference/${name.replace('_', '-')}`)
|
|
114
|
+
.replace(/^\.?\//, '')
|
|
115
|
+
.replace(/\/+$/, '');
|
|
116
|
+
const prefix = outputDir.replace(/^docs\//, '');
|
|
117
|
+
if (isUrl(file)) return { i, file, remote: true, prefix };
|
|
118
|
+
const candidates = [file.startsWith('openAPI/') ? file : `openAPI/${file}`, file];
|
|
119
|
+
const src = candidates.find((c) => fs.existsSync(path.join(contentDir, c)));
|
|
120
|
+
return { i, file, src: src ? posix(src) : null, prefix };
|
|
121
|
+
})
|
|
122
|
+
.filter(Boolean);
|
|
123
|
+
for (const spec of specs) {
|
|
124
|
+
if (spec.remote) {
|
|
125
|
+
notes.add(`spec-remote-${spec.i}`, ['apiFiles', spec.i], `The OpenAPI spec ${spec.file} is a URL - writedocs reads specs from the project.`, 'Download it into the project (openAPI/ is fine), and its API pages come back - see the note on the API pages below.');
|
|
126
|
+
} else if (!spec.src) {
|
|
127
|
+
notes.add(`spec-missing-${spec.i}`, ['apiFiles', spec.i], `The OpenAPI spec ${spec.file} isn't in the project (looked in openAPI/ and the project folder).`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const usedSpecs = new Set();
|
|
131
|
+
const apiPaths = new Set();
|
|
132
|
+
|
|
133
|
+
/** The spec a category of generated API pages came from: the one whose
|
|
134
|
+
* output folder the page ids are in, or the only spec there is. */
|
|
135
|
+
function specFor(ids) {
|
|
136
|
+
const byPrefix = specs.find((s) => ids.every((id) => cleanId(id).startsWith(`${s.prefix}/`)));
|
|
137
|
+
if (byPrefix) return { ...byPrefix, urlPrefix: byPrefix.prefix };
|
|
138
|
+
if (specs.length !== 1) return null;
|
|
139
|
+
// Matched by elimination: the pages' own folder is the better address.
|
|
140
|
+
const folders = ids.map((id) => cleanId(id).split('/').slice(0, -1).join('/'));
|
|
141
|
+
const common = folders.reduce((a, b) => {
|
|
142
|
+
const x = a.split('/');
|
|
143
|
+
const y = b.split('/');
|
|
144
|
+
let n = 0;
|
|
145
|
+
while (n < x.length && x[n] === y[n]) n += 1;
|
|
146
|
+
return x.slice(0, n).join('/');
|
|
147
|
+
});
|
|
148
|
+
return { ...specs[0], urlPrefix: common || specs[0].prefix };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- Sidebars ------------------------------------------------------------
|
|
152
|
+
const translations = readTranslations(contentDir);
|
|
153
|
+
const redirects = new Map(); // old URL -> new URL
|
|
154
|
+
const pageUrls = new Set();
|
|
155
|
+
const missing = []; // { id, trail }
|
|
156
|
+
|
|
157
|
+
/** Every page id items name - groups' own `page` included. */
|
|
158
|
+
const idsIn = (items) =>
|
|
159
|
+
(items ?? []).flatMap((item) => (typeof item === 'string' ? [item] : [item?.page, ...idsIn(item?.subpages)].filter(Boolean)));
|
|
160
|
+
/** The page ids in items that aren't a group's own `page`. */
|
|
161
|
+
const leafIds = (items) => (items ?? []).flatMap((item) => (typeof item === 'string' ? [item] : leafIds(item?.subpages)));
|
|
162
|
+
|
|
163
|
+
/** The URL path a spec's generated pages go under: its old output
|
|
164
|
+
* folder ("reference/pets"), or the folder its pages' ids were in.
|
|
165
|
+
* Each OpenAPI group needs its own. */
|
|
166
|
+
function apiPathFor(spec, ctx) {
|
|
167
|
+
const prefix = spec.urlPrefix || 'reference';
|
|
168
|
+
let base = `/${prefix}`;
|
|
169
|
+
for (let n = 2; apiPaths.has(`${ctx.lang}\n${base}`); n += 1) base = `/${prefix}-${n}`;
|
|
170
|
+
apiPaths.add(`${ctx.lang}\n${base}`);
|
|
171
|
+
return ctx.lang === ctx.defaultLang ? base : `/${ctx.lang}${base}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function convertItems(items, ctx, trail) {
|
|
175
|
+
const out = [];
|
|
176
|
+
(items ?? []).forEach((item, i) => {
|
|
177
|
+
if (typeof item === 'string') {
|
|
178
|
+
const page = resolve(item, ctx, [...trail, i]);
|
|
179
|
+
if (page) out.push(page);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (!item || typeof item !== 'object') return;
|
|
183
|
+
const group = { group: ctx.label(item.groupName ?? 'Untitled group') };
|
|
184
|
+
if (item.page && !hiddenPage(item.page)) {
|
|
185
|
+
const page = resolve(item.page, ctx, [...trail, i, 'page']);
|
|
186
|
+
if (page) group.page = page;
|
|
187
|
+
}
|
|
188
|
+
group.pages = convertItems(item.subpages, ctx, [...trail, i, 'subpages']);
|
|
189
|
+
if (group.page || group.pages.length) out.push(group);
|
|
190
|
+
});
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A page id as the page's writedocs id (in the current language, when a
|
|
195
|
+
* translation exists), recording its old address for a redirect. */
|
|
196
|
+
function resolve(id, ctx, trail) {
|
|
197
|
+
if (hiddenPage(id)) return null;
|
|
198
|
+
const page = findPage(id);
|
|
199
|
+
if (!page) {
|
|
200
|
+
if (ctx.lang === ctx.defaultLang) missing.push({ id, trail });
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
if (ctx.lang === ctx.defaultLang) recordAddress(page);
|
|
204
|
+
if (ctx.lang !== ctx.defaultLang) {
|
|
205
|
+
const translated = findPage(page.clean, `translations/${ctx.lang}`) ?? findPage(page.clean, `i18n/${ctx.lang}/docusaurus-plugin-content-docs/current`);
|
|
206
|
+
if (translated) return translated.id;
|
|
207
|
+
}
|
|
208
|
+
return page.id;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The address the old site served a page at: its frontmatter `slug`
|
|
212
|
+
* (absolute, or relative to its folder), or its id path - with a
|
|
213
|
+
* frontmatter `id` replacing the file name, and an index file standing
|
|
214
|
+
* for its folder (Docusaurus' rules). */
|
|
215
|
+
function recordAddress(page) {
|
|
216
|
+
const newUrl = urlOf(page.rel, page.data.slug);
|
|
217
|
+
pageUrls.add(newUrl);
|
|
218
|
+
const folder = page.clean.split('/').slice(0, -1);
|
|
219
|
+
let oldPath;
|
|
220
|
+
if (nonEmpty(page.data.slug)) {
|
|
221
|
+
oldPath = page.data.slug.startsWith('/') ? page.data.slug : `/${[...folder, page.data.slug].join('/')}`;
|
|
222
|
+
} else {
|
|
223
|
+
const last = nonEmpty(page.data.id) ? page.data.id : page.clean.split('/').pop().replace(/\.(api|info)$/, '');
|
|
224
|
+
oldPath = `/${(last.toLowerCase() === 'index' ? folder : [...folder, last]).join('/')}`;
|
|
225
|
+
}
|
|
226
|
+
const oldUrl = oldPath.replace(/\/+$/, '') || '/';
|
|
227
|
+
if (`${oldUrl}/`.replace(/\/\/$/, '/') === newUrl) return;
|
|
228
|
+
redirects.set(oldUrl, newUrl);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function convertCategories(categories, ctx, trail) {
|
|
232
|
+
const groups = [];
|
|
233
|
+
(categories ?? []).forEach((category, i) => {
|
|
234
|
+
const categoryTrail = [...trail, i];
|
|
235
|
+
// A category of generated API pages - its pages aren't files in the
|
|
236
|
+
// project, they came from a spec - becomes an OpenAPI group, once per
|
|
237
|
+
// spec. Groups in it can open on a hand-written page (a tag's intro);
|
|
238
|
+
// those are kept, above the generated ones.
|
|
239
|
+
const leaves = leafIds(category?.pages);
|
|
240
|
+
if (leaves.length && leaves.every((id) => !findPage(id))) {
|
|
241
|
+
const spec = specFor(leaves);
|
|
242
|
+
if (spec) {
|
|
243
|
+
const name = ctx.label(category?.categoryName ?? 'API Reference');
|
|
244
|
+
if (ctx.lang === ctx.defaultLang) {
|
|
245
|
+
notes.add(`api-${spec.i}`, categoryTrail, `These API pages were generated from ${spec.file}; they're an OpenAPI group now - a page for every operation in the spec, grouped by tag.`);
|
|
246
|
+
}
|
|
247
|
+
const intros = idsIn(category?.pages)
|
|
248
|
+
.filter((id) => !leaves.includes(id))
|
|
249
|
+
.map((id, n) => resolve(id, ctx, [...categoryTrail, 'pages', n]))
|
|
250
|
+
.filter(Boolean);
|
|
251
|
+
const key = `${ctx.lang}\n${spec.i}`;
|
|
252
|
+
const openapi = spec.src && !usedSpecs.has(key) ? { src: spec.src, path: apiPathFor(spec, ctx) } : null;
|
|
253
|
+
if (openapi) usedSpecs.add(key);
|
|
254
|
+
const inner = /^endpoints$/i.test(String(category?.categoryName ?? '').trim()) ? 'All endpoints' : 'Endpoints';
|
|
255
|
+
if (intros.length && openapi) groups.push({ group: name, pages: [...intros, { group: ctx.label(inner), openapi }] });
|
|
256
|
+
else if (intros.length) groups.push({ group: name, pages: intros });
|
|
257
|
+
else if (openapi) groups.push({ group: name, openapi });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const pages = convertItems(category?.pages, ctx, [...categoryTrail, 'pages']);
|
|
262
|
+
if (pages.length) groups.push({ group: ctx.label(category?.categoryName ?? 'Untitled'), pages });
|
|
263
|
+
});
|
|
264
|
+
return groups;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const sidebars = new Map((Array.isArray(old.sidebars) ? old.sidebars : []).map((s, i) => [s?.sidebarRef, { sidebar: s, i }]));
|
|
268
|
+
const usedSidebars = new Set();
|
|
269
|
+
|
|
270
|
+
/** A sidebar's content, as a container's children: `pages`, or
|
|
271
|
+
* `versions` when the sidebar has versions. */
|
|
272
|
+
function sidebarContent(ref, ctx, apiLabel) {
|
|
273
|
+
const entry = sidebars.get(ref);
|
|
274
|
+
if (!entry) return null;
|
|
275
|
+
usedSidebars.add(ref);
|
|
276
|
+
const { sidebar, i } = entry;
|
|
277
|
+
const sctx = { ...ctx, sidebarRef: ref, apiLabel, label: (text) => ctx.translate('sidebars', text, ref) };
|
|
278
|
+
if (Array.isArray(sidebar.versions) && sidebar.versions.length) {
|
|
279
|
+
const versions = sidebar.versions
|
|
280
|
+
.map((v, vi) => ({ version: String(v.version ?? `v${vi + 1}`), pages: convertCategories(v.categories, sctx, ['sidebars', i, 'versions', vi, 'categories']) }))
|
|
281
|
+
.filter((v) => v.pages.length);
|
|
282
|
+
return versions.length ? { versions } : null;
|
|
283
|
+
}
|
|
284
|
+
const pages = convertCategories(sidebar.categories, sctx, ['sidebars', i, 'categories']);
|
|
285
|
+
return pages.length ? { pages } : null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function buildNavigation(lang) {
|
|
289
|
+
const ctx = {
|
|
290
|
+
lang,
|
|
291
|
+
defaultLang: languages[0],
|
|
292
|
+
translate: (section, text, ref) => {
|
|
293
|
+
if (lang === languages[0] || !translations[lang]) return text;
|
|
294
|
+
const table = section === 'navbar' ? translations[lang].navbar : translations[lang].sidebars?.[ref];
|
|
295
|
+
return (table && nonEmpty(table[text]) && table[text]) || text;
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
const tabs = [];
|
|
299
|
+
const navbar = Array.isArray(old.navbar) ? old.navbar : [];
|
|
300
|
+
navbar.forEach((item, i) => {
|
|
301
|
+
if (!item || typeof item !== 'object') return;
|
|
302
|
+
const tab = { tab: ctx.translate('navbar', String(item.label ?? `Tab ${i + 1}`)) };
|
|
303
|
+
if (item.icon) {
|
|
304
|
+
const icon = convertIcon(item.icon);
|
|
305
|
+
if (icon) tab.icon = icon;
|
|
306
|
+
else if (lang === languages[0]) {
|
|
307
|
+
notes.add('icon', ['navbar', i, 'icon'], `Icon "${item.icon}" (Phosphor) has no match in writedocs' icon sets - left out.`, 'Add an `icon` to the tab: a Lucide name like "book-open" (lucide.dev), or "collection:name".');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (nonEmpty(item.sidebarRef)) {
|
|
311
|
+
const content = sidebarContent(item.sidebarRef, ctx, item.label);
|
|
312
|
+
if (content) tabs.push({ ...tab, ...content });
|
|
313
|
+
else if (lang === languages[0]) notes.add(`tab-empty-${i}`, ['navbar', i], `"${item.label}" points at sidebar "${item.sidebarRef}", which ${sidebars.has(item.sidebarRef) ? 'has no pages that exist' : "doesn't exist"} - left out.`);
|
|
314
|
+
} else if (Array.isArray(item.dropdown)) {
|
|
315
|
+
const dropdowns = [];
|
|
316
|
+
item.dropdown.forEach((d, di) => {
|
|
317
|
+
const dropdown = { dropdown: ctx.translate('navbar', String(d?.label ?? `Menu ${di + 1}`)) };
|
|
318
|
+
if (nonEmpty(d?.sidebarRef)) {
|
|
319
|
+
const content = sidebarContent(d.sidebarRef, ctx, d.label);
|
|
320
|
+
if (content) dropdowns.push({ ...dropdown, ...content });
|
|
321
|
+
else if (lang === languages[0]) notes.add(`dd-empty-${i}-${di}`, ['navbar', i, 'dropdown', di], `"${d.label}" points at sidebar "${d.sidebarRef}", which has no pages that exist - left out.`);
|
|
322
|
+
} else if (nonEmpty(d?.link)) dropdowns.push({ ...dropdown, href: d.link });
|
|
323
|
+
});
|
|
324
|
+
if (dropdowns.length) tabs.push({ ...tab, dropdowns });
|
|
325
|
+
if (lang === languages[0]) notes.add('dropdown', ['navbar', i, 'dropdown'], 'A navbar dropdown became a tab with a dropdown in its sidebar, one entry per sidebar it listed.');
|
|
326
|
+
} else if (nonEmpty(item.link)) {
|
|
327
|
+
tabs.push({ ...tab, href: item.link });
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
// No navbar (or nothing usable in it): the first sidebar is the site.
|
|
331
|
+
if (tabs.length === 0) {
|
|
332
|
+
const first = [...sidebars.keys()][0];
|
|
333
|
+
const content = first === undefined ? null : sidebarContent(first, ctx, undefined);
|
|
334
|
+
if (content?.pages) return content.pages;
|
|
335
|
+
return content ?? [];
|
|
336
|
+
}
|
|
337
|
+
// One tab showing a sidebar: a plain list of groups, no tab bar.
|
|
338
|
+
if (tabs.length === 1 && tabs[0].pages) return tabs[0].pages;
|
|
339
|
+
return { tabs };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// --- Site ------------------------------------------------------------------
|
|
343
|
+
config.name = nonEmpty(old.websiteName) ? old.websiteName : path.basename(contentDir);
|
|
344
|
+
if (!nonEmpty(old.websiteName)) notes.add('name', ['websiteName'], `No websiteName - the site is named "${config.name}", after its folder.`);
|
|
345
|
+
if (nonEmpty(old.description)) config.description = old.description;
|
|
346
|
+
|
|
347
|
+
const styles = {};
|
|
348
|
+
const images = old.images ?? {};
|
|
349
|
+
const oldStyles = old.styles ?? {};
|
|
350
|
+
const colors = {};
|
|
351
|
+
if (nonEmpty(oldStyles.mainColor)) colors.primary = oldStyles.mainColor;
|
|
352
|
+
if (nonEmpty(oldStyles.darkModeMainColor) && oldStyles.darkModeMainColor !== oldStyles.mainColor) colors.dark = { primary: oldStyles.darkModeMainColor };
|
|
353
|
+
if (Object.keys(colors).length) styles.colors = colors;
|
|
354
|
+
const logo = assetPath(images.logo);
|
|
355
|
+
const darkLogo = assetPath(images.darkLogo);
|
|
356
|
+
if (logo && darkLogo && darkLogo !== logo) styles.logo = { light: logo, dark: darkLogo };
|
|
357
|
+
else if (logo || darkLogo) styles.logo = logo ?? darkLogo;
|
|
358
|
+
if (assetPath(images.favicon)) styles.favicon = assetPath(images.favicon);
|
|
359
|
+
const navbar = {};
|
|
360
|
+
if (nonEmpty(oldStyles.navbarColor)) navbar.light = oldStyles.navbarColor;
|
|
361
|
+
if (nonEmpty(oldStyles.navbarDarkModeColor)) navbar.dark = oldStyles.navbarDarkModeColor;
|
|
362
|
+
if (Object.keys(navbar).length) styles.navbar = navbar;
|
|
363
|
+
else if (colors.primary) notes.add('navbar-color', ['styles', 'navbarColor'], 'Without navbarColor, the old navbar used mainColor. writedocs\' topbar matches the page background instead.', `To keep the colored bar, set "styles": { "navbar": { "light": "${colors.primary}" } }.`);
|
|
364
|
+
const background = {};
|
|
365
|
+
if (nonEmpty(oldStyles.backgroundDarkModeColor)) background.colors = { dark: oldStyles.backgroundDarkModeColor };
|
|
366
|
+
const bgLight = assetPath(images.background);
|
|
367
|
+
// darkBackground: unset means "same as background"; null turned it off.
|
|
368
|
+
const bgDark = images.darkBackground === null ? undefined : assetPath(images.darkBackground) ?? bgLight;
|
|
369
|
+
if (bgLight || bgDark) background.images = { ...(bgLight ? { light: bgLight } : {}), ...(bgDark ? { dark: bgDark } : {}) };
|
|
370
|
+
if (Object.keys(background).length) styles.background = background;
|
|
371
|
+
if (Object.keys(styles).length) config.styles = styles;
|
|
372
|
+
|
|
373
|
+
for (const key of ['logoSize', 'navbarMode']) {
|
|
374
|
+
if (oldStyles[key] !== undefined) notes.add(`style-${key}`, ['styles', key], `styles.${key} has no writedocs equivalent - dropped.`, key === 'logoSize' ? 'The logo is sized to the topbar\'s height.' : undefined);
|
|
375
|
+
}
|
|
376
|
+
if (oldStyles.pagination === false) notes.add('pagination', ['styles', 'pagination'], 'Previous/next links at the bottom of pages are always on in writedocs.', 'To hide them on a page, add `hideFooterPagination: true` to its frontmatter.');
|
|
377
|
+
if (old.colorMode && (old.colorMode.default === 'dark' || old.colorMode.switchOff)) {
|
|
378
|
+
notes.add('colorMode', ['colorMode'], 'writedocs follows the reader\'s system theme and always shows the light/dark toggle - colorMode has no equivalent.');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// --- Navigation ---------------------------------------------------------
|
|
382
|
+
const navigations = languages.map((lang) => buildNavigation(lang));
|
|
383
|
+
if (languages.length > 1) {
|
|
384
|
+
config.navigation = {
|
|
385
|
+
languages: languages.map((lang, i) => {
|
|
386
|
+
const nav = navigations[i];
|
|
387
|
+
const children = Array.isArray(nav) ? { pages: nav } : nav;
|
|
388
|
+
return { language: lang, ...(LANGUAGE_NAMES[lang] ? { label: LANGUAGE_NAMES[lang] } : {}), ...children };
|
|
389
|
+
}),
|
|
390
|
+
};
|
|
391
|
+
notes.add('languages', ['languages'], `Each language (${languages.join(', ')}) got its own navigation. A page with a translation in translations/<language>/ uses it; the others show the ${languages[0]} page.`);
|
|
392
|
+
} else {
|
|
393
|
+
config.navigation = navigations[0];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (missing.length) {
|
|
397
|
+
const message = `${missing.length === 1 ? 'A page' : `${missing.length} pages`} the sidebars list ${missing.length === 1 ? "isn't" : "aren't"} in docs/ - left out of the navigation: ${missing
|
|
398
|
+
.slice(0, 3)
|
|
399
|
+
.map((m) => `"${m.id}"`)
|
|
400
|
+
.join(', ')}${missing.length > 3 ? ', ...' : ''}.`;
|
|
401
|
+
const suggestion = "If they were generated from an OpenAPI spec that's a URL, download the spec first; otherwise add the pages, or fix their paths.";
|
|
402
|
+
for (const m of missing) notes.add('missing', m.trail, message, suggestion);
|
|
403
|
+
}
|
|
404
|
+
for (const [ref, { i }] of sidebars) {
|
|
405
|
+
if (!usedSidebars.has(ref)) notes.add('sidebar-unused', ['sidebars', i], 'Sidebars no navbar item points at were left out.');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// --- Homepage and redirects ----------------------------------------------
|
|
409
|
+
const redirectList = [];
|
|
410
|
+
if (nonEmpty(old.homepage)) {
|
|
411
|
+
const home = old.homepage.trim();
|
|
412
|
+
if (/\.html?$/i.test(home)) {
|
|
413
|
+
notes.add('homepage-html', ['homepage'], `The HTML homepage (${home}) isn't converted - "/" now opens the first page in the navigation.`, 'For a landing page, add index.mdx with `mode: custom` in its frontmatter, and rebuild the homepage there.');
|
|
414
|
+
} else {
|
|
415
|
+
const byUrl = redirects.get(`/${home.replace(/^\/+|\/+$/g, '')}`);
|
|
416
|
+
const byId = findPage(home);
|
|
417
|
+
const target = byUrl ?? (byId ? urlOf(byId.rel, byId.data.slug) : null) ?? [...pageUrls].find((u) => u === `/${home.replace(/^\/+|\/+$/g, '')}/`);
|
|
418
|
+
if (target && target !== '/') redirectList.push({ source: '/', destination: target });
|
|
419
|
+
else if (!target) notes.add('homepage', ['homepage'], `The homepage "${home}" isn't a page in the navigation - "/" opens the first page instead.`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
let unsafe = 0;
|
|
423
|
+
for (const [source, destination] of redirects) {
|
|
424
|
+
// A path a page is served at now can't also be a redirect.
|
|
425
|
+
if (pageUrls.has(`${source}/`) || source === '/') continue;
|
|
426
|
+
if (!/^[A-Za-z0-9\-._~/]+$/.test(source)) {
|
|
427
|
+
unsafe += 1;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
redirectList.push({ source, destination });
|
|
431
|
+
}
|
|
432
|
+
if (redirectList.length) config.redirects = redirectList;
|
|
433
|
+
if (redirects.size) {
|
|
434
|
+
notes.add(
|
|
435
|
+
'redirects',
|
|
436
|
+
['sidebars'],
|
|
437
|
+
`Pages are served at new addresses when they don't set \`slug\` in their frontmatter (writedocs puts the folder path in the address). ${redirectList.filter((r) => r.source !== '/').length} redirects from the old addresses were added.${unsafe ? ` ${unsafe} old addresses have spaces or other characters a redirect can't match - add a \`slug\` to those pages to keep their address.` : ''}`
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
if (apiPaths.size) notes.add('api-redirects', ['apiFiles'], 'API pages generated from a spec have new addresses; no redirects were added for them.');
|
|
441
|
+
|
|
442
|
+
// --- Links, integrations, the rest ---------------------------------------
|
|
443
|
+
if (Array.isArray(old.externalLinks) && old.externalLinks.length) {
|
|
444
|
+
const links = old.externalLinks.filter((l) => nonEmpty(l?.link)).map((l) => ({ label: String(l.name ?? l.link), href: l.link }));
|
|
445
|
+
if (links.length) config.topbar = { links };
|
|
446
|
+
if (old.externalLinks.some((l) => (l?.style ?? 'button') === 'button')) {
|
|
447
|
+
notes.add('button', ['externalLinks'], 'External links styled as buttons are plain topbar links in writedocs.');
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const integrations = {};
|
|
452
|
+
const oldIntegrations = old.integrations ?? {};
|
|
453
|
+
if (nonEmpty(oldIntegrations.gtag)) {
|
|
454
|
+
const tag = oldIntegrations.gtag.trim();
|
|
455
|
+
if (/^GTM-/i.test(tag)) integrations.googleTagManager = { containerId: tag };
|
|
456
|
+
else if (/^G-/i.test(tag)) integrations.ga4 = { measurementId: tag };
|
|
457
|
+
else notes.add('gtag', ['integrations', 'gtag'], `gtag "${tag}" isn't a GA4 ("G-...") or Google Tag Manager ("GTM-...") id - dropped.`);
|
|
458
|
+
}
|
|
459
|
+
if (nonEmpty(oldIntegrations.posthog?.api_key)) {
|
|
460
|
+
integrations.posthog = { apiKey: oldIntegrations.posthog.api_key, ...(nonEmpty(oldIntegrations.posthog.api_host) ? { apiHost: oldIntegrations.posthog.api_host } : {}) };
|
|
461
|
+
}
|
|
462
|
+
if (oldIntegrations.askAi && Object.keys(oldIntegrations.askAi).length) {
|
|
463
|
+
notes.add('askAi', ['integrations', 'askAi'], `askAi connected a support tool (${Object.keys(oldIntegrations.askAi).join(', ')}) - writedocs' Ask AI is DocsBot, configured differently.`, 'Set integrations.askAi.id to your DocsBot "teamId/botId", or add the support tool\'s widget with `scripts`.');
|
|
464
|
+
}
|
|
465
|
+
if (Object.keys(integrations).length) config.integrations = integrations;
|
|
466
|
+
|
|
467
|
+
if (images.metadata && assetPath(images.metadata)) config.seo = { ogImage: assetPath(images.metadata) };
|
|
468
|
+
|
|
469
|
+
if (old.changelog === true) notes.add('changelog', ['changelog'], 'The changelog tab (changelog/ folder) isn\'t converted.', 'Write it as a page with an <Update> entry per release, and add it to the navigation.');
|
|
470
|
+
if (old.footer?.copyright) notes.add('footer', ['footer', 'copyright'], 'writedocs\' footer has no copyright line - dropped.', 'Footer links go in `footer.columns`.');
|
|
471
|
+
if (old.apiOptions?.hideSendButton) notes.add('hideSendButton', ['apiOptions', 'hideSendButton'], 'The API playground always has its "Send" button in writedocs.');
|
|
472
|
+
if (Array.isArray(old.codeLanguages) && old.codeLanguages.length) notes.add('codeLanguages', ['codeLanguages'], 'The API playground\'s code languages aren\'t configurable in writedocs - it shows its own set.');
|
|
473
|
+
if (old.translatedApiFiles && Object.values(old.translatedApiFiles).some((v) => Array.isArray(v) && v.length)) {
|
|
474
|
+
notes.add('translatedApiFiles', ['translatedApiFiles'], 'Translated OpenAPI specs aren\'t converted - every language uses the default spec.');
|
|
475
|
+
}
|
|
476
|
+
if (old.protected) notes.add('protected', ['protected'], 'Password-protected docs aren\'t supported - the converted site is public.');
|
|
477
|
+
for (const key of Object.keys(old)) {
|
|
478
|
+
if (!HANDLED_KEYS.has(key) && !PLATFORM_KEYS.has(key)) notes.add(`unknown-${key}`, [key], `\`${key}\` isn't a config.json field writedocs knows - dropped.`);
|
|
479
|
+
}
|
|
480
|
+
if (fs.existsSync(path.join(contentDir, 'custom.css'))) {
|
|
481
|
+
notes.add('css', ['custom.css'], 'custom.css is loaded on every page by writedocs too, but its Docusaurus class names don\'t exist here.', 'Review it - rules for Docusaurus classes (.navbar, .menu, .theme-doc-...) do nothing now.');
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return { config, notes: notes.list() };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** translations.json: { <lang>: { navbar: { label: text }, sidebars: {
|
|
488
|
+
* <sidebarRef>: { name: text } } } } - the old generator's translated
|
|
489
|
+
* navbar labels and category/group names. */
|
|
490
|
+
function readTranslations(contentDir) {
|
|
491
|
+
try {
|
|
492
|
+
return JSON.parse(fs.readFileSync(path.join(contentDir, 'translations.json'), 'utf-8')) ?? {};
|
|
493
|
+
} catch {
|
|
494
|
+
return {};
|
|
495
|
+
}
|
|
496
|
+
}
|