| // Disposable-process adapter for Docusaurus 3.6.3. Production builds never load | |
| // this module. Native metadata stays complete; only selected routes enter the | |
| // bundler and SSG. Unrendered public routes remain valid *logical* link targets. | |
| const fs = require('node:fs'); | |
| const path = require('node:path'); | |
| const assert = require('node:assert/strict'); | |
| const {execFileSync} = require('node:child_process'); | |
| const key = value => decodeURI(value).replace(/\/+$/, '') || '/'; | |
| const id = row => JSON.stringify([row.pluginId, row.version, row.docId]); | |
| const sourceFile = (root, file) => path.resolve(root, file.replace(/^@site\//, '')); | |
| const gitPath = (root, file) => | |
| path | |
| .relative(root, file) | |
| .split(path.sep) | |
| .join('/') | |
| .replace(/^\.i18n-build\/([^/]+)\//, 'i18n/$1/'); | |
| const sha = root => | |
| execFileSync('git', ['rev-parse', 'HEAD'], {cwd: root, encoding: 'utf8'}).trim(); | |
| function flatten(routes) { | |
| return routes.flatMap(route => | |
| route.routes ? flatten(route.routes) : route.path === '*' ? [] : [route], | |
| ); | |
| } | |
| function rowsFor(props) { | |
| const rows = []; | |
| const global = JSON.parse( | |
| fs.readFileSync(path.join(props.generatedFilesDir, 'globalData.json'), 'utf8'), | |
| ); | |
| for (const plugin of props.plugins.filter(p => p.name === 'docusaurus-plugin-content-docs')) { | |
| const entries = plugin.content.contentIdentity?.entries ?? []; | |
| const lookup = new Map( | |
| entries.map(e => [JSON.stringify([e.siteLocale, e.version, e.docId]), e]), | |
| ); | |
| const grouped = new Map(); | |
| for (const entry of entries) { | |
| const k = JSON.stringify([entry.version, entry.docId]); | |
| if (!grouped.has(k)) grouped.set(k, []); | |
| grouped.get(k).push(entry); | |
| } | |
| for (const version of plugin.content.loadedVersions) | |
| for (const doc of version.docs) { | |
| const entry = lookup.get( | |
| JSON.stringify([props.i18n.currentLocale, version.versionName, doc.id]), | |
| ); | |
| const english = lookup.get(JSON.stringify(['en', version.versionName, doc.id])); | |
| const englishPath = | |
| english?.englishPath ?? | |
| entry?.englishPath ?? | |
| gitPath(props.siteDir, sourceFile(props.siteDir, doc.source)); | |
| const filename = path.relative( | |
| version.contentPath, | |
| path.resolve(props.siteDir, englishPath), | |
| ); | |
| const aliases = new Set([ | |
| englishPath, | |
| gitPath(props.siteDir, sourceFile(props.siteDir, doc.source)), | |
| ]); | |
| for (const item of grouped.get(JSON.stringify([version.versionName, doc.id])) ?? []) | |
| for (const file of [item.englishPath, item.sourcePath, item.targetPath].filter( | |
| Boolean, | |
| )) | |
| aliases.add(file); | |
| if (!filename.startsWith('..')) | |
| for (const locale of props.i18n.locales) | |
| aliases.add( | |
| `i18n/${locale}/docusaurus-plugin-content-docs${plugin.options.id === 'default' ? '' : '-' + plugin.options.id}/${version.versionName === 'current' ? 'current' : 'version-' + version.versionName}/${filename.split(path.sep).join('/')}`, | |
| ); | |
| rows.push({ | |
| pluginId: plugin.options.id, | |
| version: version.versionName, | |
| docId: doc.id, | |
| route: doc.permalink, | |
| englishPath, | |
| source: doc.source, | |
| aliases: [...aliases], | |
| main: | |
| doc.id === | |
| global['docusaurus-plugin-content-docs']?.[ | |
| plugin.options.id | |
| ]?.versions.find(v => v.name === version.versionName)?.mainDocId, | |
| decisions: ( | |
| grouped.get(JSON.stringify([version.versionName, doc.id])) ?? [] | |
| ).map(e => ({ | |
| locale: e.siteLocale, | |
| fallback: e.fallback, | |
| indexable: e.indexable, | |
| state: e.state, | |
| })), | |
| }); | |
| } | |
| } | |
| return rows.sort((a, b) => id(a).localeCompare(id(b))); | |
| } | |
| function chooseRepresentatives(rows, root) { | |
| const selected = new Map(); | |
| const add = (row, reason) => { | |
| if (!row) return; | |
| const k = id(row); | |
| if (!selected.has(k)) selected.set(k, {row, reasons: []}); | |
| selected.get(k).reasons.push(reason); | |
| }; | |
| const groups = new Map(); | |
| for (const row of rows) { | |
| const g = JSON.stringify([row.pluginId, row.version]); | |
| if (!groups.has(g)) groups.set(g, []); | |
| groups.get(g).push(row); | |
| } | |
| for (const group of groups.values()) { | |
| const patterns = [ | |
| ['MDX/import', /\bimport\s|<[A-Z][A-Za-z]+/], | |
| ['SQL', /```sql\b/i], | |
| ['math', /\$\$|\\\[/], | |
| ['media/link', /!\[|\]\(\.\.?\//], | |
| ]; | |
| const coverage = new Map(group.map(row => { | |
| const text = fs.readFileSync(path.resolve(root, row.englishPath), 'utf8'); | |
| const features = patterns.filter(([, pattern]) => pattern.test(text)).map(([feature]) => feature); | |
| for (const decision of row.decisions) { | |
| if (decision.fallback) features.push(`${decision.locale} fallback`); | |
| else if (decision.indexable) features.push(`${decision.locale} real body`); | |
| } | |
| return [row, new Set(features)]; | |
| })); | |
| const uncovered = new Set([...coverage.values()].flatMap(features => [...features])); | |
| const take = row => { | |
| for (const feature of coverage.get(row)) if (uncovered.delete(feature)) add(row, feature); | |
| }; | |
| const main = group.find(row => row.main) ?? group[0]; | |
| add(main, 'version/plugin entry'); | |
| take(main); | |
| // One rich document can cover several requirements. Preserve every | |
| // existing feature/locale requirement without adding a separate first | |
| // match for each of them. Changed/import-dependent owners are added later. | |
| while (uncovered.size) { | |
| let best, score = 0; | |
| for (const row of group) { | |
| const count = [...coverage.get(row)].filter(feature => uncovered.has(feature)).length; | |
| if (count > score) {best = row; score = count;} | |
| } | |
| take(best); | |
| } | |
| } | |
| return {selected, add}; | |
| } | |
| async function dependencies( | |
| root, | |
| file, | |
| cache = new Map(), | |
| seen = new Set(), | |
| markdownConfigFormat = 'mdx', | |
| ) { | |
| if (seen.has(file)) return {files: seen, uncertain: false}; | |
| seen.add(file); | |
| if (!/\.(mdx?|[cm]?[jt]sx?)$/.test(file)) return {files: seen, uncertain: false}; | |
| let imports, | |
| uncertain = false; | |
| if (cache.has(file)) ({imports, uncertain} = cache.get(file)); | |
| else { | |
| const source = fs.readFileSync(file, 'utf8'); | |
| imports = []; | |
| // A lexical hint only: actual imports are read from AST, so fenced code | |
| // examples cannot accidentally turn the entire site into dependencies. | |
| if (/\b(import|export|require)\b/.test(source)) { | |
| const ts = require('typescript'); | |
| const blocks = []; | |
| if (/\.mdx?$/.test(file)) { | |
| try { | |
| const matter = require('gray-matter'); | |
| const parsed = matter(source); | |
| const {getFormat} = require('@docusaurus/mdx-loader/lib/format.js'); | |
| const format = getFormat({ | |
| filePath: file, | |
| frontMatterFormat: parsed.data.format, | |
| markdownConfigFormat, | |
| }); | |
| if (format === 'mdx') { | |
| const {createProcessor} = await import('@mdx-js/mdx'); | |
| const tree = createProcessor({format}).parse(parsed.content); | |
| const walk = n => { | |
| if (n.type === 'mdxjsEsm') blocks.push(n.value); | |
| for (const child of n.children ?? []) walk(child); | |
| }; | |
| walk(tree); | |
| } | |
| } catch { | |
| uncertain = true; | |
| } | |
| } else blocks.push(source); | |
| for (const block of blocks) { | |
| const ast = ts.createSourceFile( | |
| file, | |
| block, | |
| ts.ScriptTarget.Latest, | |
| true, | |
| /\.[cm]?ts$/.test(file) ? ts.ScriptKind.TS : ts.ScriptKind.TSX, | |
| ); | |
| if (ast.parseDiagnostics.length) uncertain = true; | |
| const walk = node => { | |
| if ( | |
| (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && | |
| node.moduleSpecifier && | |
| ts.isStringLiteral(node.moduleSpecifier) | |
| ) | |
| imports.push(node.moduleSpecifier.text); | |
| if ( | |
| ts.isCallExpression(node) && | |
| (node.expression.kind === ts.SyntaxKind.ImportKeyword || | |
| node.expression.getText(ast) === 'require') | |
| ) { | |
| if (node.arguments[0] && ts.isStringLiteral(node.arguments[0])) | |
| imports.push(node.arguments[0].text); | |
| else uncertain = true; | |
| } | |
| ts.forEachChild(node, walk); | |
| }; | |
| walk(ast); | |
| } | |
| } | |
| cache.set(file, {imports, uncertain}); | |
| } | |
| for (const name of imports) { | |
| const base = name.startsWith('@site/') | |
| ? path.join(root, name.slice(6)) | |
| : name.startsWith('.') | |
| ? path.resolve(path.dirname(file), name) | |
| : undefined; | |
| if (!base) continue; | |
| const child = [ | |
| '', | |
| '.js', | |
| '.jsx', | |
| '.mjs', | |
| '.cjs', | |
| '.ts', | |
| '.tsx', | |
| '.mts', | |
| '.cts', | |
| '.mdx', | |
| '.md', | |
| '.json', | |
| '/index.js', | |
| '/index.jsx', | |
| '/index.ts', | |
| '/index.tsx', | |
| ] | |
| .map(ext => base + ext) | |
| .find(p => fs.existsSync(p) && fs.statSync(p).isFile()); | |
| if (child) { | |
| const result = await dependencies(root, child, cache, seen, markdownConfigFormat); | |
| uncertain ||= result.uncertain; | |
| } else uncertain = true; // Deleted imports must still select their owners. | |
| } | |
| return {files: seen, uncertain}; | |
| } | |
| function stateIdentities(root, files, base) { | |
| const changed = new Set(); | |
| for (const file of files.filter( | |
| f => /^translations\/state\/[^/]+\//.test(f) && f.endsWith('.json'), | |
| )) { | |
| const parse = text => { | |
| const data = JSON.parse(text); | |
| return data.pages ?? (data.page ? [data.page] : data.docId ? [data] : []); | |
| }; | |
| let before = []; | |
| try { | |
| before = parse( | |
| execFileSync('git', ['show', `${base}:${file}`], { | |
| cwd: root, | |
| encoding: 'utf8', | |
| stdio: ['ignore', 'pipe', 'ignore'], | |
| maxBuffer: 16 * 1024 * 1024, | |
| }), | |
| ); | |
| } catch {} | |
| const after = fs.existsSync(path.join(root, file)) | |
| ? parse(fs.readFileSync(path.join(root, file), 'utf8')) | |
| : []; | |
| const a = new Map(before.map(r => [id(r), JSON.stringify(r)])), | |
| b = new Map(after.map(r => [id(r), JSON.stringify(r)])); | |
| for (const k of new Set([...a.keys(), ...b.keys()])) | |
| if (a.get(k) !== b.get(k)) changed.add(k); | |
| } | |
| return changed; | |
| } | |
| async function selectDocs(props, plan) { | |
| const rows = rowsFor(props), | |
| {selected, add} = chooseRepresentatives(rows, props.siteDir); | |
| const changed = new Set(plan.files), | |
| changedStates = stateIdentities(props.siteDir, plan.files, plan.baseSha); | |
| for (const row of rows) | |
| if (row.aliases.some(f => changed.has(f)) || changedStates.has(id(row))) { | |
| add(row, 'changed document/state'); | |
| // A state transition can replace the body in a generated locale | |
| // view even when its source file was not edited. Importers of that | |
| // document must be compiled too, including importers of doc routes. | |
| changed.add(gitPath(props.siteDir, sourceFile(props.siteDir, row.source))); | |
| } | |
| const dependenciesChanged = [...changed].filter(f => | |
| /^(docs\/|versioned_docs\/|community\/|course\/|releasenotes\/|i18n\/|src\/|static\/)/.test( | |
| f, | |
| ), | |
| ); | |
| if (dependenciesChanged.length) { | |
| const cache = new Map(); | |
| for (const row of rows) { | |
| const result = await dependencies( | |
| props.siteDir, | |
| sourceFile(props.siteDir, row.source), | |
| cache, | |
| new Set(), | |
| props.siteConfig?.markdown?.format ?? 'mdx', | |
| ); | |
| if ( | |
| result.uncertain || | |
| [...result.files].some(file => changed.has(gitPath(props.siteDir, file))) | |
| ) | |
| add( | |
| row, | |
| result.uncertain | |
| ? 'conservative dynamic/unparsed import' | |
| : 'changed import dependency', | |
| ); | |
| } | |
| } | |
| if (selected.size > 500) { | |
| const directory = path.join(props.siteDir, '.i18n-build/ci-sample/scopes'); | |
| fs.mkdirSync(directory, {recursive: true}); | |
| fs.writeFileSync( | |
| path.join(directory, props.i18n.currentLocale + '.selection.json'), | |
| JSON.stringify( | |
| { | |
| status: 'selection-exceeded', | |
| sourceSha: plan.sourceSha, | |
| metadataDocs: rows.length, | |
| selected: [...selected.values()], | |
| }, | |
| null, | |
| 2, | |
| ) + '\n', | |
| ); | |
| throw new Error( | |
| `Sample expands to ${selected.size} documents; use the ci/full label or manual full validation instead of truncating affected documents`, | |
| ); | |
| } | |
| return {rows, selected}; | |
| } | |
| function prune(routes, docPaths, chosen, versions, inDocs = false) { | |
| const categories = new Set(); | |
| function walk(route, parentDocs) { | |
| const docs = parentDocs || route.plugin?.name === 'docusaurus-plugin-content-docs'; | |
| if (route.routes) { | |
| const children = route.routes.map(child => walk(child, docs)).filter(Boolean); | |
| return children.length ? {...route, routes: children} : undefined; | |
| } | |
| if (!docs) return route; | |
| if (docPaths.has(key(route.path))) return chosen.has(key(route.path)) ? route : undefined; | |
| if (/Redirect/.test(route.component)) return route; | |
| const group = | |
| versions.find(v => key(route.path).startsWith(key(v.path) + '/'))?.key ?? 'root'; | |
| if (!categories.has(group)) { | |
| categories.add(group); | |
| return route; | |
| } | |
| return undefined; | |
| } | |
| return routes.map(route => walk(route, inDocs)).filter(Boolean); | |
| } | |
| function stripUnbuiltAnchors(collected, known, rendered, aliases) { | |
| let skipped = 0, | |
| metadataValidated = 0; | |
| const result = {}; | |
| for (const [from, data] of Object.entries(collected)) | |
| result[from] = { | |
| ...data, | |
| links: data.links.flatMap(link => { | |
| const url = new URL(link, 'https://ci.invalid' + from); | |
| const alias = aliases.get(key(url.pathname)); | |
| const target = alias ? new URL(alias, 'https://ci.invalid') : url; | |
| if (alias && !target.hash) target.hash = url.hash; | |
| const targetKey = key(target.pathname); | |
| if (url.hash && known.has(targetKey) && !rendered.has(targetKey)) { | |
| skipped++; | |
| metadataValidated++; | |
| return []; | |
| } | |
| if (!url.hash && known.has(targetKey)) { | |
| metadataValidated++; | |
| return []; | |
| } | |
| return [alias ? target.pathname + target.search + target.hash : link]; | |
| }), | |
| }; | |
| return {collected: result, skipped, metadataValidated}; | |
| } | |
| // Docusaurus repeatedly scans every logical route for unknown paths. Index | |
| // fixed ASCII exact routes, then let its original matcher decide candidates | |
| // with the original strict/sensitive flags. Patterns keep the native fallback. | |
| function indexedRouteMatcher(original) { | |
| const indexes = new WeakMap(); | |
| const normalize = value => value.replace(/\/+$/, '').toLowerCase() || '/'; | |
| return (routes, pathname) => { | |
| let index = indexes.get(routes); | |
| if (!index) { | |
| index = {literal: new Map(), other: [], results: new Map()}; | |
| for (const route of routes) { | |
| if (route.exact && !route.routes && typeof route.path === 'string' && /^[\w/%.~-]+$/.test(route.path)) { | |
| const k = normalize(route.path); | |
| if (!index.literal.has(k)) index.literal.set(k, []); | |
| index.literal.get(k).push(route); | |
| } else index.other.push(route); | |
| } | |
| indexes.set(routes, index); | |
| } | |
| if (!index.results.has(pathname)) { | |
| const candidates = index.literal.get(normalize(pathname)) ?? []; | |
| const result = original(candidates, pathname); | |
| index.results.set(pathname, result.length ? result : original(index.other, pathname)); | |
| } | |
| return index.results.get(pathname); | |
| }; | |
| } | |
| async function install({root, outDir, locale, planFile}) { | |
| assert.equal( | |
| require('@docusaurus/core/package.json').version, | |
| '3.6.3', | |
| 'Revalidate the CI-only Docusaurus adapter', | |
| ); | |
| const plan = JSON.parse(fs.readFileSync(planFile)); | |
| assert.equal(plan.mode, 'sample'); | |
| assert.equal(plan.sourceSha, sha(root), 'Sample plan is stale'); | |
| assert(plan.locales.includes(locale), 'Locale is outside the requested sample'); | |
| const diff = require('node:crypto') | |
| .createHash('sha256') | |
| .update( | |
| execFileSync('git', ['diff', 'HEAD', '--binary'], { | |
| cwd: root, | |
| maxBuffer: 16 * 1024 * 1024, | |
| }), | |
| ) | |
| .digest('hex'); | |
| assert.equal(plan.trackedDiffHash, diff, 'Source changed after sample planning'); | |
| const blogSample = require('./ci-blog-sample.cjs'); | |
| const restoreBlogSample = blogSample.enable(plan); | |
| const siteModule = require('@docusaurus/core/lib/server/site.js'); | |
| const linksModule = require('@docusaurus/core/lib/server/brokenLinks.js'); | |
| const originalSite = siteModule.loadSite, | |
| originalLinks = linksModule.handleBrokenLinks; | |
| const updates = require('@docusaurus/utils/lib/lastUpdateUtils.js'), | |
| readUpdate = updates.readLastUpdateData; | |
| let deferredDates = 0; | |
| // Timestamps are irrelevant to the full ID/route/sidebar census. Resolve | |
| // their real source data only for documents that will actually be rendered. | |
| updates.readLastUpdateData = async () => { | |
| deferredDates++; | |
| return {}; | |
| }; | |
| let scope, | |
| fullRoutes, | |
| aliases = new Map(), | |
| known, | |
| rendered; | |
| const scopeDir = path.join(root, '.i18n-build/ci-sample/scopes'); | |
| fs.mkdirSync(scopeDir, {recursive: true}); | |
| const save = () => | |
| fs.writeFileSync( | |
| path.join(scopeDir, locale + '.json'), | |
| JSON.stringify(scope, null, 2) + '\n', | |
| ); | |
| siteModule.loadSite = async args => { | |
| const started = Date.now(); | |
| const site = await originalSite(args), | |
| props = site.props; | |
| fullRoutes = props.routes; | |
| const {rows, selected} = await selectDocs(props, plan); | |
| const docPaths = new Set(rows.map(r => key(r.route))), | |
| chosen = new Set([...selected.values()].map(x => key(x.row.route))); | |
| const versions = props.plugins | |
| .filter(p => p.name === 'docusaurus-plugin-content-docs') | |
| .flatMap(p => | |
| p.content.loadedVersions.map(v => ({ | |
| path: v.path, | |
| key: p.options.id + ':' + v.versionName, | |
| })), | |
| ) | |
| .sort((a, b) => b.path.length - a.path.length); | |
| for (const plugin of props.plugins.filter(p => p.name === 'docusaurus-plugin-content-docs')) | |
| for (const version of plugin.content.loadedVersions) | |
| for (const doc of version.docs.filter(d => chosen.has(key(d.permalink)))) { | |
| const state = doc.translation; | |
| const file = state | |
| ? path.resolve( | |
| root, | |
| state.contentLocale === 'en' ? state.englishPath : state.sourcePath, | |
| ) | |
| : sourceFile(root, doc.source); | |
| const utils = require('@docusaurus/utils'); | |
| const parsed = await props.siteConfig.markdown.parseFrontMatter({ | |
| filePath: file, | |
| fileContent: fs.readFileSync(file, 'utf8'), | |
| defaultParseFrontMatter: utils.DEFAULT_PARSE_FRONT_MATTER, | |
| }); | |
| Object.assign( | |
| doc, | |
| await readUpdate( | |
| file, | |
| state?.useTarget | |
| ? {...plugin.options, showLastUpdateTime: true} | |
| : plugin.options, | |
| parsed.frontMatter.last_update, | |
| ), | |
| ); | |
| fs.writeFileSync( | |
| path.join( | |
| props.generatedFilesDir, | |
| 'docusaurus-plugin-content-docs', | |
| plugin.options.id, | |
| utils.docuHash(doc.source) + '.json', | |
| ), | |
| JSON.stringify(doc, null, 2), | |
| ); | |
| } | |
| const rowsByRoute = new Map(rows.map(row => [key(row.route), row])); | |
| const docsByIdentity = new Map(props.plugins | |
| .filter(p => p.name === 'docusaurus-plugin-content-docs') | |
| .flatMap(p => p.content.loadedVersions.flatMap(v => v.docs | |
| .map(doc => [JSON.stringify([p.options.id, v.versionName, doc.id]), doc])))); | |
| for (const route of flatten(fullRoutes)) { | |
| const row = rowsByRoute.get(key(route.path)); | |
| if (row && chosen.has(key(row.route))) { | |
| const doc = docsByIdentity.get(id(row)); | |
| route.metadata = {...route.metadata, lastUpdatedAt: doc.lastUpdatedAt}; | |
| } | |
| } | |
| const pruned = prune(fullRoutes, docPaths, chosen, versions); | |
| const {getRoutesPaths} = require('@docusaurus/core/lib/server/routes.js'); | |
| const originalPaths = new Set(getRoutesPaths(fullRoutes, props.baseUrl).map(key)); | |
| const completeRoutes = [...fullRoutes, ...blogSample.fullRoutes().filter(route => !originalPaths.has(key(route.path)))]; | |
| const allPaths = getRoutesPaths(completeRoutes, props.baseUrl); | |
| known = new Set(allPaths.map(key)); | |
| const metadata = props.plugins.flatMap(p => p.content?.contentIdentity?.entries ?? []); | |
| for (const entry of metadata) known.add(key(entry.route)); | |
| const pathSet = new Set(allPaths.map(key)); | |
| const logical = [ | |
| ...completeRoutes, | |
| ...metadata | |
| .filter(e => !pathSet.has(key(e.route))) | |
| .map(e => ({path: e.route, exact: true, component: 'metadata-only'})), | |
| ]; | |
| for (const plugin of props.plugins.filter( | |
| p => p.name === 'docusaurus-plugin-client-redirects', | |
| )) { | |
| const context = { | |
| relativeRoutesPaths: allPaths.map( | |
| p => '/' + p.slice(props.baseUrl.length).replace(/^\//, ''), | |
| ), | |
| baseUrl: props.baseUrl, | |
| outDir: props.outDir, | |
| options: plugin.options, | |
| siteConfig: props.siteConfig, | |
| }; | |
| const redirects = | |
| require('@docusaurus/plugin-client-redirects/lib/collectRedirects').default( | |
| context, | |
| props.siteConfig.trailingSlash, | |
| ); | |
| const absolute = value => | |
| props.baseUrl.replace(/\/$/, '') + '/' + value.replace(/^\//, ''); | |
| for (const redirect of redirects) { | |
| const from = absolute(redirect.from), | |
| to = redirect.to.startsWith('/') ? absolute(redirect.to) : redirect.to; | |
| known.add(key(from)); | |
| aliases.set(key(from), to); | |
| logical.push({path: from, exact: true, component: 'redirect-metadata'}); | |
| } | |
| plugin.postBuild = async () => { | |
| const kept = redirects.filter( | |
| r => | |
| !r.to.startsWith('/') || rendered.has(key(absolute(r.to).split(/[?#]/)[0])), | |
| ); | |
| const write = require('@docusaurus/plugin-client-redirects/lib/writeRedirectFiles'); | |
| await write.default( | |
| write.toRedirectFiles(kept, context, props.siteConfig.trailingSlash), | |
| ); | |
| }; | |
| } | |
| // Static files are real public targets too. Do not invent routes for | |
| // missing resources merely to silence partial-build link checks. | |
| const walkStatic = (directory, prefix = '') => { | |
| if (!fs.existsSync(directory)) return; | |
| for (const entry of fs.readdirSync(directory, {withFileTypes: true})) { | |
| if (entry.name.startsWith('.')) continue; | |
| const relative = prefix + entry.name, | |
| file = path.join(directory, entry.name); | |
| if (entry.isDirectory()) walkStatic(file, relative + '/'); | |
| else { | |
| known.add(key('/' + relative)); | |
| known.add(key(props.baseUrl + relative)); | |
| } | |
| } | |
| }; | |
| for (const directory of props.siteConfig.staticDirectories) | |
| walkStatic(path.resolve(root, directory)); | |
| fullRoutes = logical; | |
| props.routes = pruned; | |
| props.routesPaths = getRoutesPaths(pruned, props.baseUrl); | |
| rendered = new Set(props.routesPaths.map(key)); | |
| // Native search must index compiled bodies only, never metadata-only routes. | |
| for (const plugin of props.plugins.filter( | |
| p => p.name === 'docusaurus-plugin-content-docs', | |
| )) { | |
| for (const version of plugin.content.loadedVersions) | |
| version.docs = version.docs.filter(d => chosen.has(key(d.permalink))); | |
| if (plugin.content.translationManifest) | |
| plugin.content.translationManifest.pages = | |
| plugin.content.translationManifest.pages.filter(p => | |
| rendered.has(key(p.permalink)), | |
| ); | |
| } | |
| const manifest = path.join(root, '.i18n-build/manifests', locale + '.json'); | |
| if (fs.existsSync(manifest)) { | |
| const data = JSON.parse(fs.readFileSync(manifest)); | |
| data.scope = {mode: 'sample', nativePages: data.pages.length}; | |
| data.pages = data.pages.filter(p => rendered.has(key(p.permalink))); | |
| fs.writeFileSync(manifest, JSON.stringify(data, null, 2) + '\n'); | |
| } | |
| await require('@docusaurus/core/lib/server/codegen/codegenRoutes').generateRouteFiles({ | |
| generatedFilesDir: props.generatedFilesDir, | |
| routes: pruned, | |
| baseUrl: props.baseUrl, | |
| }); | |
| scope = { | |
| schemaVersion: 1, | |
| mode: 'sample', | |
| status: 'planned', | |
| publicationAllowed: false, | |
| sourceSha: plan.sourceSha, | |
| locale, | |
| metadataDocs: rows.length, | |
| renderedDocs: selected.size, | |
| metadataLoadMs: Date.now() - started, | |
| deferredTimestampReads: deferredDates, | |
| metadataRoutes: known.size, | |
| compiledRoutes: props.routesPaths, | |
| selected: [...selected.values()].map(({row, reasons}) => ({...row, reasons})), | |
| knownRoutes: [...known], | |
| anchorChecksSkipped: 0, | |
| structureCache: {...require('./build-cache.cjs').buildCache.stats}, | |
| blogSampling: blogSample.reports(), | |
| linkCheckMode: plan.checkRenderedLinks === false ? 'skipped-style-only' : 'indexed', | |
| }; | |
| // Parser results stay on disk for the next locale, not in the bundler heap. | |
| require('./build-cache.cjs').buildCache.clear(); | |
| props.plugins.push({ | |
| name: 'ci-sample-provenance', | |
| configureWebpack(_config, isServer) { | |
| return { | |
| plugins: [ | |
| { | |
| apply(compiler) { | |
| compiler.hooks.done.tap('CICompiledModules', stats => { | |
| const files = new Set(), | |
| seen = new Set(); | |
| const visit = module => { | |
| if (seen.has(module)) return; | |
| seen.add(module); | |
| const resource = module.resource?.split('?')[0]; | |
| if ( | |
| resource?.startsWith(root + path.sep) && | |
| /\.mdx?$/i.test(resource) | |
| ) | |
| files.add(gitPath(root, resource)); | |
| for (const child of module.modules ?? []) visit(child); | |
| }; | |
| for (const module of stats.compilation.modules) visit(module); | |
| scope.compiledMdxModules ??= {}; | |
| scope.compiledMdxModules[isServer ? 'server' : 'client'] = [ | |
| ...files, | |
| ].sort(); | |
| save(); | |
| }); | |
| }, | |
| }, | |
| ], | |
| }; | |
| }, | |
| async postBuild() { | |
| scope.status = 'compiled'; | |
| save(); | |
| fs.writeFileSync( | |
| path.join(props.outDir, '.ci-validation.json'), | |
| JSON.stringify({ | |
| schemaVersion: 1, | |
| mode: 'sample', | |
| publicationAllowed: false, | |
| sourceSha: plan.sourceSha, | |
| }) + '\n', | |
| ); | |
| }, | |
| }); | |
| save(); | |
| return site; | |
| }; | |
| linksModule.handleBrokenLinks = async params => { | |
| if (plan.checkRenderedLinks === false) { | |
| assert(plan.files.length && plan.files.every(file => /\.(css|scss|sass)$/.test(file)), | |
| 'Only styles-only changes may skip rendered link checks'); | |
| scope.linkCheckMs = 0; | |
| save(); | |
| return; | |
| } | |
| const started = Date.now(); | |
| const sanitized = stripUnbuiltAnchors(params.collectedLinks, known, rendered, aliases); | |
| scope.anchorChecksSkipped = sanitized.skipped; | |
| scope.linksValidatedFromMetadata = sanitized.metadataValidated; | |
| save(); | |
| const router = require('react-router-config'), matchRoutes = router.matchRoutes; | |
| router.matchRoutes = indexedRouteMatcher(matchRoutes); | |
| try { | |
| return await originalLinks({...params, collectedLinks: sanitized.collected, routes: fullRoutes}); | |
| } finally { | |
| router.matchRoutes = matchRoutes; | |
| scope.linkCheckMs = Date.now() - started; | |
| save(); | |
| } | |
| }; | |
| return () => { | |
| restoreBlogSample(); | |
| siteModule.loadSite = originalSite; | |
| linksModule.handleBrokenLinks = originalLinks; | |
| updates.readLastUpdateData = readUpdate; | |
| }; | |
| } | |
| module.exports = { | |
| install, | |
| selectDocs, | |
| chooseRepresentatives, | |
| rowsFor, | |
| prune, | |
| dependencies, | |
| stripUnbuiltAnchors, | |
| indexedRouteMatcher, | |
| }; |