This is an automated email from the ASF dual-hosted git repository.
morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-website.git
The following commit(s) were added to refs/heads/master by this push:
new 0d1f9e7894e5 [perf](ci) Reduce sequential sample build work (#4498)
0d1f9e7894e5 is described below
commit 0d1f9e7894e56c20377c199a25f50d6b55023112
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Sat Oct 24 16:42:45 2026 +0100
[perf](ci) Reduce sequential sample build work (#4498)
Shared-code PRs currently repeat translation parsing in five sequential
processes and match invalid links against tens of thousands of logical
routes. The measured CI Build took 38m32s. Keep one runner and the
existing locale/version coverage, while avoiding repeated parsing and
unnecessary article compilation.
- Share content-addressed structure/heading results within one
disposable sample build; always recompute translation decisions and
routes.
- Reuse rich documents across representative requirements, retain every
changed/uncertain import owner, and sample blogs before native aggregate
generation. Preserve lead/featured posts, complete metadata and native
link resolution. Blog body/state edits retain the complete blog build.
- Index fixed routes and cache native match results. Styles-only changes
skip rendered link checks explicitly; content/code/resource changes keep
checks.
- Select content/hosting fixtures by subsystem, extend the five-language
sampler fixture with omitted/edited broken blog cases, and enable
detailed native phase timings.
Validation: 275 tests passed, one existing opt-in test skipped; 22
workflow security tests passed. Five-language real-site
HTML/SEO/Chromium checks passed, as did nine complete production fixture
locale builds (162 pages). Sampling fixtures prove omitted broken bodies
do not compile and edited broken document/blog bodies fail. All original
mandatory import owners and representative feature requirements were
preserved.
On the same macOS/Node 26 machine, English improved from 244.06s to
67.65s (72.3% less); the optimized five-language sequential cold-cache
build took 180.56s. Selected docs decreased from 642 to 577, blogs from
1,030 to 25, and compiled MD/MDX modules from 1,752 to 682 per compiler.
These local measurements do not establish GitHub runner performance; CI
will measure that separately.
## Versions
No documentation/version content changed. Production builds, published
URLs and version/locale navigation remain unchanged.
## Languages
All five enabled locales remain covered. No translations changed.
## Docs Checklist
- [x] Checked by AI
- [x] Test Cases Built
- [x] Updated required version and language counterparts, or explained
why not
- [x] If only one language changed, confirmed whether source/translation
counterparts need sync
Co-authored-by: morningman <[email protected]>
---
.github/workflows/build-check.yml | 1 +
scripts/build-locales.cjs | 5 +-
scripts/i18n/CI_VALIDATION.md | 35 ++++++-
scripts/i18n/__tests__/build-cache.test.cjs | 48 +++++++++
scripts/i18n/__tests__/ci-blog-sample.test.cjs | 45 +++++++++
scripts/i18n/__tests__/ci-sampling.test.cjs | 51 +++++++++-
scripts/i18n/build-cache.cjs | 47 +++++++++
scripts/i18n/ci-blog-sample.cjs | 78 +++++++++++++++
scripts/i18n/ci-plan.cjs | 11 ++-
scripts/i18n/ci-sample.cjs | 130 ++++++++++++++++++-------
scripts/i18n/manual-plugins.cjs | 17 +++-
scripts/i18n/structure.cjs | 12 +++
scripts/i18n/verify-ci-sample.cjs | 1 +
scripts/i18n/verify-ci-sampling-fixture.cjs | 20 +++-
14 files changed, 450 insertions(+), 51 deletions(-)
diff --git a/.github/workflows/build-check.yml
b/.github/workflows/build-check.yml
index 823dca5d4793..f85b9caf8cf9 100644
--- a/.github/workflows/build-check.yml
+++ b/.github/workflows/build-check.yml
@@ -187,6 +187,7 @@ jobs:
NODE_OPTIONS: --max-old-space-size=8192
run: |
MODE="${{ steps.detect.outputs.validation_mode }}"
+ export DOCUSAURUS_PERF_LOGGER=true
if [ "$MODE" = "skip" ] || [ "$MODE" = "fixtures" ]; then
echo "No real-site build required: validation mode=$MODE"
exit 0
diff --git a/scripts/build-locales.cjs b/scripts/build-locales.cjs
index 5fb336e424a7..b6a9b477789b 100644
--- a/scripts/build-locales.cjs
+++ b/scripts/build-locales.cjs
@@ -45,7 +45,10 @@ async function buildLocales({siteDir = process.cwd(), outDir
= 'build', locales:
fs.mkdirSync(reportDir, {recursive:true});
const reportPath = path.join(reportDir, 'build-result.json');
const execute = run ?? ((args) => new Promise((resolve, reject) => {
- const child =
spawn(process.execPath,[path.join(__dirname,'build-locale.cjs'),
...args],{cwd:siteDir,stdio:'inherit'});
+ const child =
spawn(process.execPath,[path.join(__dirname,'build-locale.cjs'), ...args],{
+ cwd:siteDir,stdio:'inherit',env:{...process.env,
+ ...(samplePlan &&
{DORIS_I18N_STRUCTURE_CACHE:path.join(stage,'structure-cache')})},
+ });
console.log(JSON.stringify({event:'locale_process_start',
mode:args[0], locale:args[0] === 'plan' ? null : args[3], pid:child.pid}));
const stop = signal => child.kill(signal);
const term = () => stop('SIGTERM');
diff --git a/scripts/i18n/CI_VALIDATION.md b/scripts/i18n/CI_VALIDATION.md
index 33dd12eb2724..0da94df2ff70 100644
--- a/scripts/i18n/CI_VALIDATION.md
+++ b/scripts/i18n/CI_VALIDATION.md
@@ -10,7 +10,7 @@ committed lockfile after source planning. Source snapshot
checks remain enabled.
| --- | --- |
| README, planning or review notes only | Configuration, move/link and
workflow security checks; no dependency installation or site build |
| CI and tooling | Unit tests, five-language native compilation fixture and
publishing/Apache/Chromium fixture |
-| Shared UI, routing, SEO, localization or sidebars | Representative real
documents in five languages and every active version, plus native content and
publishing fixtures |
+| Shared UI, routing, SEO, localization or sidebars | Representative real
documents in five languages and every active version; native content fixtures
for content/locale implementations, publishing fixtures for delivery/host
implementations |
| Documentation or assets | Changed documents and import dependants, plus
representative real documents; English source changes select all five
languages, localized changes select English and the affected locales |
| Dependencies, active versions or unclassified inputs | Full five-language
publication build and verification |
| `ci/full` PR label or manual `validation=full` | Full five-language
publication build and verification |
@@ -26,17 +26,34 @@ The CI adapter first loads native Docusaurus metadata for
**all documents and
all active versions**, including IDs, front matter, routes, sidebars, source
and
translation state. Optional locales remain enabled during metadata collection.
It then removes unselected document modules from the generated route registry
-before Webpack compilation and static rendering. Non-document pages remain in
-the build. The production build does not load this adapter.
+before bundling and static rendering. Ordinary pages remain in the build.
+When no blog body or blog translation state changed, blog articles and their
+native lists/tags are sampled too. Blog metadata and logical link targets
remain
+complete. The production build does not load this adapter.
Every document plugin/version contributes its native main document and
available
-examples of MDX/imports, SQL, math and media/relative links. Real translated
bodies
+examples of MDX/imports, SQL, math and media/relative links. A greedy selection
+reuses rich documents across requirements while keeping each version's entry.
+Real translated bodies
and English fallbacks are sampled where available. Changed document bodies and
translation-state records are mandatory. Local MDX/JavaScript imports are
followed
transitively when an imported file changes; dynamic or unparseable imports
select
their owners conservatively. A selection above 500 documents fails with a
request
for `ci/full`, rather than silently dropping affected documents.
+The blog sample covers a recent article, lead/featured cards, MDX, code blocks
and available real/fallback
+bodies. Explicit changed imports and uncertain dependencies add their owners.
+Lists, tags and neighboring-post links are rebuilt from selected articles
before
+the native route generator runs, so an aggregate cannot import every omitted
body.
+Edited blog bodies/state retain the complete native blog build.
+
+Locale processes still run sequentially on one runner. During a sample build,
+content-addressed Markdown structures and aligned headings are shared through a
+fresh temporary directory owned by that build. Keys include all parser inputs;
+translation decisions and route/verification results are always computed anew.
+Only a bounded memory cache is retained, and it is released before bundling.
+The cache is removed with the build stage, so cold builds need no external
cache.
+
Links from rendered pages use the complete native route graph, generated
redirect
aliases and existing static files. Anchors on rendered destinations receive the
normal Docusaurus check. Anchors on omitted destinations cannot be verified and
@@ -44,6 +61,12 @@ are counted explicitly. Unselected document bodies are not
compiled, rendered or
checked for body syntax/anchors. Existing Docusaurus warning/error policies
still
apply; sample success is not full-site compilation success.
+Styles-only changes (`.css`, `.scss`, `.sass`) skip rendered link checking and
+report `linkCheckMode=skipped-style-only`. Content, code, routes, redirects and
+resource changes keep the normal path/anchor checks. The CI adapter indexes
fixed
+routes and reuses native matching results, including invalid paths, rather than
+scanning tens of thousands of logical routes for every repeated bad link.
+
Selected pages receive HTML/canonical checks, managed-translation SEO checks
and a
Chromium hydration smoke test per language. The separate publishing fixture
tests
resource delivery, redirects, language 404s and optimized artifacts on a small
site.
@@ -56,7 +79,7 @@ coverage remains in full validation and deployment.
languages and active versions.
- `.i18n-build/ci-sample/scopes/<locale>.json`: complete metadata count,
selected
document routes with reasons, actual client/server MDX modules compiled,
- metadata loading time and unverified anchor count.
+ metadata loading time, parser-cache counts, blog selection and unverified
anchor count.
- `.i18n-build/ci-sample/verification.json`: explicitly reports `mode=sample`,
`completeDocumentCompilation=false` and `publicationAllowed=false`.
- `.i18n-build/ci-sampling-fixture-proof.json`: proves an omitted broken import
@@ -90,3 +113,5 @@ node scripts/i18n/verify-ci-sample.cjs
The verifier needs Playwright/Chromium, installed like the workflow; use
`--no-browser` only for a local static check. The fixture can be run
independently
with `node scripts/i18n/verify-ci-sampling-fixture.cjs --browser`.
+The CI Build step enables `DOCUSAURUS_PERF_LOGGER=true` to separately log
native
+plugin loading, bundler configuration/compilation, SSG, post-build hooks and
links.
diff --git a/scripts/i18n/__tests__/build-cache.test.cjs
b/scripts/i18n/__tests__/build-cache.test.cjs
new file mode 100644
index 000000000000..555a14f74a6a
--- /dev/null
+++ b/scripts/i18n/__tests__/build-cache.test.cjs
@@ -0,0 +1,48 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const os = require('node:os');
+const {execFileSync} = require('node:child_process');
+const {createBuildCache} = require('../build-cache.cjs');
+
+function directory(t) {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'doris-parse-cache-'));
+ t.after(() => fs.rmSync(root, {recursive: true, force: true}));
+ return root;
+}
+test('locale processes reuse successful content results; changed inputs and
failures are recomputed', async t => {
+ const root = directory(t), first = createBuildCache(root);
+ let calls = 0;
+ const compute = async () => {calls++; return {heading: 'one'};};
+ await Promise.all([first.get('parse', ['one'], compute),
first.get('parse', ['one'], compute)]);
+ assert.equal(calls, 1);
+ const next = createBuildCache(root);
+ assert.deepEqual(await next.get('parse', ['one'], () => assert.fail('disk
reuse')), {heading: 'one'});
+ assert.equal(next.stats.diskHits, 1);
+ await next.get('parse', ['changed'], compute);
+ assert.equal(calls, 2);
+ await assert.rejects(next.get('parse', ['bad'], () => {throw new
Error('bad Markdown');}), /bad Markdown/);
+ await next.get('parse', ['bad'], compute);
+ assert.equal(calls, 3);
+});
+test('real cached parsing and heading alignment match fresh processes across
heading/format changes', t => {
+ const root = directory(t), modulePath = path.resolve(__dirname,
'../structure.cjs');
+ const inputs = [
+ ['# One\n\nSQL `x`', '## Uno', '.md', false],
+ ['# One {#stable}\n', '# Uno\n', '.md', false],
+ ['# Two {#changed}\n', '# Uno\n', '.md', true],
+ ['# Markdown\n', '# Markdown\n', '.mdx', false],
+ ];
+ const script = `const
{structure,alignHeadings}=require(${JSON.stringify(modulePath)});
+ (async()=>{const results=[];for(const [source,target,ext,keep] of
${JSON.stringify(inputs)}) {
+ const parsed=await structure(source,ext,keep);
+ try{results.push([parsed,await
alignHeadings(parsed,target,ext,keep)])}
+ catch(e){results.push([parsed,e.message])}
+
}console.log(JSON.stringify(results))})().catch(e=>{console.error(e);process.exitCode=1});`;
+ const run = cache => execFileSync(process.execPath, ['-e', script],
{encoding: 'utf8',
+ env: {...process.env, DORIS_I18N_STRUCTURE_CACHE: cache}}).trim();
+ const fresh = run('');
+ assert.equal(run(root), fresh);
+ assert.equal(run(root), fresh);
+});
diff --git a/scripts/i18n/__tests__/ci-blog-sample.test.cjs
b/scripts/i18n/__tests__/ci-blog-sample.test.cjs
new file mode 100644
index 000000000000..8ea212b81794
--- /dev/null
+++ b/scripts/i18n/__tests__/ci-blog-sample.test.cjs
@@ -0,0 +1,45 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const os = require('node:os');
+const sampler = require('../ci-blog-sample.cjs');
+const {DEFAULT_OPTIONS} =
require('@docusaurus/plugin-content-blog/lib/options.js');
+
+test('blog lists/tags contain representative bodies and all changed import
owners, while metadata stays complete', async t => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'doris-blog-sample-'));
+ t.after(() => fs.rmSync(root, {recursive: true, force: true}));
+ fs.mkdirSync(path.join(root, 'blog'));
+ fs.mkdirSync(path.join(root, 'src'));
+ fs.writeFileSync(path.join(root, 'src/Widget.tsx'), 'export default
function Widget(){return null;}');
+ const contents = {'new.md': '# New', 'rich.mdx': '# Rich\n\n```sql\nSELECT
1;\n```',
+ 'old.md': '# Old', 'owner.mdx': 'import Widget from
"@site/src/Widget.tsx";\n\n<Widget/>', 'other.md': '# Other'};
+ for (const [file, text] of Object.entries(contents))
fs.writeFileSync(path.join(root, 'blog', file), text);
+ const blogPosts = Object.keys(contents).map(file => ({id: file, metadata: {
+ source: '@site/blog/' + file, permalink: '/ja/blog/' + file, title:
file, tags: [], unlisted: false,
+ frontMatter: file === 'old.md' ? {order: 1, picked: true} : {},
+ }}));
+ const entries = blogPosts.map(post => ({siteLocale: 'ja', route:
post.metadata.permalink,
+ englishPath: 'blog/' + post.id, fallback: post.id !== 'rich.mdx',
indexable: post.id === 'rich.mdx'}));
+ const content = {blogPosts, contentIdentity: {entries}};
+ const context = {siteDir: root, baseUrl: '/ja/', i18n: {currentLocale:
'ja'}, siteConfig: {markdown: {format: 'detect'}}};
+ const options = {...DEFAULT_OPTIONS, id: 'default', postsPerPage: 'ALL'};
+ let restore = sampler.enable({mode: 'sample', files: []});
+ let selected = await sampler.sample(context, options, content);
+ assert.deepEqual(selected.blogPosts.map(post => post.id), ['new.md',
'rich.mdx', 'old.md']);
+ assert.deepEqual(selected.blogListPaginated.flatMap(page => page.items),
['new.md', 'rich.mdx', 'old.md']);
+ assert.equal(selected.contentIdentity.entries.length, 5);
+ assert.equal(content.blogPosts.length, 5);
+ restore();
+ restore = sampler.enable({mode: 'sample', files: ['src/Widget.tsx']});
+ selected = await sampler.sample(context, options, content);
+ assert(selected.blogPosts.some(post => post.id === 'owner.mdx'));
+ assert(!selected.blogPosts.some(post => post.id === 'other.md'));
+ restore();
+ for (const file of ['blog/owner.mdx',
'i18n/ja/docusaurus-plugin-content-blog/owner.mdx',
'translations/blog/state.json']) {
+ restore = sampler.enable({mode: 'sample', files: [file]});
+ assert.equal(await sampler.sample(context, options, content),
undefined);
+ restore();
+ }
+ assert.equal(sampler.enabled(), false);
+});
diff --git a/scripts/i18n/__tests__/ci-sampling.test.cjs
b/scripts/i18n/__tests__/ci-sampling.test.cjs
index a408de041676..eb0aaa01b3e7 100644
--- a/scripts/i18n/__tests__/ci-sampling.test.cjs
+++ b/scripts/i18n/__tests__/ci-sampling.test.cjs
@@ -4,7 +4,7 @@ const test = require('node:test'),
path = require('node:path'),
os = require('node:os');
const {policy} = require('../ci-plan.cjs');
-const {prune, dependencies, stripUnbuiltAnchors, selectDocs} =
require('../ci-sample.cjs');
+const {prune, dependencies, stripUnbuiltAnchors, selectDocs,
chooseRepresentatives, indexedRouteMatcher} = require('../ci-sample.cjs');
const {buildLocales} = require('../../build-locales.cjs');
function directory(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-sampling-'));
@@ -66,6 +66,55 @@ test('unselected MDX modules leave the compiler graph while
native layouts and n
assert.equal(output[1].component, 'Home');
assert.equal(routes[0].routes[0].routes.length, 2);
});
+test('one rich document covers several sample requirements without losing
entry or fallback coverage', t => {
+ const root = directory(t);
+ const source = {entry: '# Entry', sql: '```sql\nSELECT 1;\n```', math:
'$$x$$',
+ rich: 'import X from "x";\n\n```sql\nSELECT
1;\n```\n\n$$x$$\n\n'};
+ const rows = Object.entries(source).map(([docId, text]) => {
+ fs.writeFileSync(path.join(root, docId + '.mdx'), text);
+ return {pluginId: 'default', version: 'current', docId, englishPath:
docId + '.mdx', main: docId === 'entry',
+ decisions: [{locale: 'en', fallback: false, indexable: true},
+ {locale: 'ja', fallback: docId !== 'rich', indexable: docId
=== 'rich'}]};
+ });
+ const {selected} = chooseRepresentatives(rows, root);
+ assert.deepEqual([...selected.values()].map(({row}) => row.docId),
['entry', 'rich']);
+ const reasons = [...selected.values()].flatMap(item => item.reasons);
+ for (const reason of ['version/plugin entry', 'MDX/import', 'SQL', 'math',
'media/link', 'en real body', 'ja real body', 'ja fallback'])
+ assert(reasons.includes(reason), reason);
+});
+test('fixture selection follows the changed subsystem and preserves
sampler-specific verification', () => {
+ const css = policy(['src/scss/_variables.scss']);
+ assert.equal(css.mode, 'sample');
+ assert.equal(css.runContentFixture, false);
+ assert.equal(css.runPublishFixture, false);
+ assert.equal(css.checkRenderedLinks, false);
+ for (const file of ['docs/topic.mdx', 'src/utils/localizedRoute.ts',
'static/images/logo.svg', 'docusaurus.config.js'])
+ assert.equal(policy([file]).checkRenderedLinks, true, file);
+ assert.equal(policy(['src/utils/localizedRoute.ts']).runContentFixture,
true);
+ assert.equal(policy(['static/.htaccess']).runPublishFixture, true);
+ assert.equal(policy(['scripts/i18n/ci-sample.cjs',
'src/utils/localizedRoute.ts']).runSamplingFixture, true);
+});
+test('indexed link checking retains native fixed/dynamic route matching, case
and slash rules', () => {
+ const {matchRoutes} = require('react-router-config');
+ let scanned = 0;
+ const indexed = indexedRouteMatcher((routes, pathname) => {
+ scanned += routes.length;
+ return matchRoutes(routes, pathname);
+ });
+ const routes = [
+ ...Array.from({length: 2000}, (_, i) => ({path: '/docs/topic-' + i,
exact: true})),
+ {path: '/Strict/', exact: true, strict: true, sensitive: true},
+ {path: '/blog/:post', exact: true}, {path: '/中文', exact: true},
+ {path: '/nested', routes: [{path: '/nested/child', exact: true}]},
+ ];
+ for (const pathname of ['/docs/topic-1', '/DOCS/TOPIC-1/', '/Strict/',
'/strict/', '/Strict',
+ '/blog/post', '/blog/post/extra', '/中文', '/nested/child', '/absent'])
+ assert.equal(indexed(routes, pathname).length > 0, matchRoutes(routes,
pathname).length > 0, pathname);
+ const before = scanned;
+ indexed(routes, '/absent');
+ assert.equal(scanned, before, 'repeated invalid targets reuse the negative
result');
+ assert(scanned < 50, 'fixed routes are indexed instead of rescanned');
+});
test('dependency discovery follows MDX imports and their transitive code
imports, not fenced examples', async t => {
const root = directory(t);
fs.mkdirSync(path.join(root, 'docs'));
diff --git a/scripts/i18n/build-cache.cjs b/scripts/i18n/build-cache.cjs
new file mode 100644
index 000000000000..07b4f874d3a1
--- /dev/null
+++ b/scripts/i18n/build-cache.cjs
@@ -0,0 +1,47 @@
+// Disposable, content-addressed data shared by the sequential locale
processes.
+// The parent owns a fresh directory for one source snapshot; never cache
routes,
+// translation decisions or successful verification results here.
+const fs = require('node:fs');
+const path = require('node:path');
+const {createHash} = require('node:crypto');
+
+function createBuildCache(directory, {maxEntries = 256} = {}) {
+ const memory = new Map(), pending = new Map();
+ const stats = {computed: 0, memoryHits: 0, diskHits: 0};
+ const remember = (key, value) => {
+ memory.delete(key);
+ memory.set(key, value);
+ while (memory.size > maxEntries)
memory.delete(memory.keys().next().value);
+ return value;
+ };
+ async function get(namespace, inputs, compute) {
+ if (!directory) return compute();
+ const key = createHash('sha256').update(JSON.stringify([1, namespace,
inputs])).digest('hex');
+ if (memory.has(key)) {stats.memoryHits++; return remember(key,
memory.get(key));}
+ if (pending.has(key)) {stats.memoryHits++; return pending.get(key);}
+ const filename = path.join(directory, key.slice(0, 2), key + '.json');
+ try {
+ const stored = JSON.parse(fs.readFileSync(filename, 'utf8'));
+ if (stored.key === key && Object.hasOwn(stored, 'value')) {
+ stats.diskHits++;
+ return remember(key, stored.value);
+ }
+ } catch (error) {
+ if (!['ENOENT', 'ENOTDIR'].includes(error.code) && !(error
instanceof SyntaxError)) throw error;
+ }
+ const task = Promise.resolve().then(compute).then(value => {
+ stats.computed++;
+ fs.mkdirSync(path.dirname(filename), {recursive: true});
+ const temporary = filename + '.' + process.pid + '.tmp';
+ fs.writeFileSync(temporary, JSON.stringify({key, value}));
+ fs.renameSync(temporary, filename);
+ return remember(key, value);
+ }).finally(() => pending.delete(key));
+ pending.set(key, task);
+ return task;
+ }
+ return {get, stats, clear: () => memory.clear()};
+}
+
+const buildCache = createBuildCache(process.env.DORIS_I18N_STRUCTURE_CACHE);
+module.exports = {createBuildCache, buildCache};
diff --git a/scripts/i18n/ci-blog-sample.cjs b/scripts/i18n/ci-blog-sample.cjs
new file mode 100644
index 000000000000..2367e51c00f4
--- /dev/null
+++ b/scripts/i18n/ci-blog-sample.cjs
@@ -0,0 +1,78 @@
+// Only the disposable CI adapter enables this. Publication keeps the full
blog.
+const fs = require('node:fs');
+const path = require('node:path');
+let active;
+
+function enable(plan) {
+ // Content edits retain the existing complete blog build. This optimization
+ // is for PRs that change implementations rather than published content.
+ const contentChanged = plan.files.some(file =>
/^(blog\/|i18n\/[^/]+\/docusaurus-plugin-content-blog(?:\/|-[^/]+\/)|translations\/blog\/)/.test(file));
+ active = plan.mode === 'sample' && !contentChanged ? {plan, routes: [],
reports: []} : undefined;
+ return () => {active = undefined;};
+}
+const enabled = () => !!active;
+const fullRoutes = () => active?.routes ?? [];
+const reports = () => active?.reports ?? [];
+function collect(route) {active.routes.push(route);}
+
+async function sample(context, options, content) {
+ if (!active || !content.blogPosts.length) return;
+ const {plan} = active, selected = new Map();
+ const posts = content.blogPosts;
+ const entries = content.contentIdentity?.entries ?? [];
+ const lookup = new Map(entries.filter(e => e.siteLocale ===
context.i18n.currentLocale)
+ .map(e => [e.route.replace(/\/+$/, ''), e]));
+ const entry = post => lookup.get(post.metadata.permalink.replace(/\/+$/,
''));
+ const source = post => entry(post)?.englishPath ??
post.metadata.source.replace(/^@site\//, '');
+ const texts = new Map();
+ const text = post => {
+ const file = source(post);
+ if (!texts.has(file)) texts.set(file,
fs.readFileSync(path.resolve(context.siteDir, file), 'utf8'));
+ return texts.get(file);
+ };
+ const add = (post, reason) => {
+ if (!post) return;
+ if (!selected.has(post.id)) selected.set(post.id, {post, reasons: []});
+ selected.get(post.id).reasons.push(reason);
+ };
+ add(posts[0], 'latest post');
+ // The site's native list renders the order=1 article as its lead card.
+ add(posts.find(post => Number(post.metadata.frontMatter?.order) === 1),
'lead article');
+ add(posts.find(post => post.metadata.frontMatter?.picked &&
Number(post.metadata.frontMatter.order) !== 1), 'featured article');
+ add(posts.find(post => source(post).endsWith('.mdx')), 'MDX');
+ add(posts.find(post => /```(?:sql|javascript|js)\b/i.test(text(post))),
'code block');
+ add(posts.find(post => entry(post)?.indexable && !entry(post).fallback),
'real body');
+ add(posts.find(post => entry(post)?.fallback), 'English fallback');
+
+ // Explicitly imported components/assets still select all affected owners.
+ if (plan.files.some(file => /^(src|static)\//.test(file))) {
+ const {dependencies} = require('./ci-sample.cjs');
+ const changed = new Set(plan.files), cache = new Map();
+ for (const post of posts) {
+ const result = await dependencies(context.siteDir,
+ path.resolve(context.siteDir,
post.metadata.source.replace(/^@site\//, '')),
+ cache, new Set(), context.siteConfig.markdown.format);
+ if (result.uncertain || [...result.files].some(file =>
changed.has(path.relative(context.siteDir, file).split(path.sep).join('/'))))
+ add(post, result.uncertain ? 'uncertain import' : 'changed
import dependency');
+ }
+ }
+ const blogPosts = posts.filter(post => selected.has(post.id)).map(post =>
({...post,
+ metadata: {...post.metadata, prevItem: undefined, nextItem:
undefined}}));
+ const {paginateBlogPosts, getBlogTags, shouldBeListed} =
require('@docusaurus/plugin-content-blog/lib/blogUtils.js');
+ const {normalizeUrl} = require('@docusaurus/utils');
+ const listed = blogPosts.filter(shouldBeListed);
+ for (let i = 0; i < listed.length; i++) {
+ const neighbor = post => post && ({title: post.metadata.title,
permalink: post.metadata.permalink});
+ listed[i].metadata.prevItem = neighbor(listed[i - 1]);
+ listed[i].metadata.nextItem = neighbor(listed[i + 1]);
+ }
+ const pagination = {postsPerPageOption: options.postsPerPage,
pageBasePath: options.pageBasePath,
+ blogTitle: options.blogTitle, blogDescription:
options.blogDescription};
+ active.reports.push({pluginId: options.id, metadataPosts: posts.length,
renderedPosts: blogPosts.length,
+ selected: [...selected.values()].map(({post, reasons}) => ({source:
source(post), route: post.metadata.permalink, reasons}))});
+ return {...content, blogPosts,
+ blogListPaginated: paginateBlogPosts({...pagination, blogPosts: listed,
+ basePageUrl: normalizeUrl([context.baseUrl,
options.routeBasePath])}),
+ blogTags: getBlogTags({...pagination, blogPosts})};
+}
+module.exports = {enable, enabled, fullRoutes, reports, collect, sample};
diff --git a/scripts/i18n/ci-plan.cjs b/scripts/i18n/ci-plan.cjs
index 7684ee518ba1..13a4a447083a 100644
--- a/scripts/i18n/ci-plan.cjs
+++ b/scripts/i18n/ci-plan.cjs
@@ -5,7 +5,7 @@ const path = require('node:path');
const {execFileSync} = require('node:child_process');
const {createHash} = require('node:crypto');
const {locales, localeRegistry} = require('../../config/i18n');
-const {buildScope} = require('./build-scope.cjs');
+const {buildScope, validationScope} = require('./build-scope.cjs');
const {managedLocales} = require('./state.cjs');
const runtime =
/^(src\/|config\/|docusaurus\.config\.js$|sidebars[^/]*\.(ts|json)$|versioned_sidebars\/|scripts\/i18n\/(docs-plugin|status-plugin|state|structure|content-plugin|content-loader|source-groups|code-prose|protection|content-manifest|manual-state|manual-plugins|native-context|route-context|page-body|page-body-loader|sitemap)\.cjs$)/;
@@ -16,6 +16,7 @@ function policy(files, {forceFull = false, forceSample =
false} = {}) {
const risk = files.filter(file => highRisk.test(file));
const changed = files.some(file => content.test(file));
const shared = files.some(file => runtime.test(file));
+ const checks = validationScope(files);
const ci = files.some(file => /^(scripts\/|\.github\/)/.test(file));
const unknown = files.filter(
file =>
@@ -44,13 +45,15 @@ function policy(files, {forceFull = false, forceSample =
false} = {}) {
? unknown
: [mode],
runContentFixture:
- shared ||
+ (shared && checks.check_content_fixture) ||
files.some(file =>
/verify-content-fixture|__tests__\/fixtures\/content-manifest/.test(file),
),
- runSamplingFixture: mode === 'fixtures',
- runPublishFixture: mode === 'fixtures' || shared || mode === 'full',
+ runSamplingFixture: mode === 'fixtures' || files.some(file =>
+
/^scripts\/(build-locales?\.cjs$|i18n\/(ci-sample|ci-blog-sample|build-cache)\.cjs$)/.test(file)),
+ runPublishFixture: mode === 'fixtures' || checks.check_publish_fixture
|| mode === 'full',
publicationAllowed: mode === 'full',
+ checkRenderedLinks: !(files.length && files.every(file =>
/\.(css|scss|sass)$/.test(file))),
};
}
function plan({root, base, event = {}, forceFull = false, forceSample =
false}) {
diff --git a/scripts/i18n/ci-sample.cjs b/scripts/i18n/ci-sample.cjs
index a0710b4d7b10..b6d82d912ccd 100644
--- a/scripts/i18n/ci-sample.cjs
+++ b/scripts/i18n/ci-sample.cjs
@@ -106,35 +106,38 @@ function chooseRepresentatives(rows, root) {
groups.get(g).push(row);
}
for (const group of groups.values()) {
- add(group.find(r => r.main) ?? group[0], 'version/plugin entry');
- const texts = new Map();
- const text = r => {
- if (!texts.has(id(r)))
- texts.set(id(r), fs.readFileSync(path.resolve(root,
r.englishPath), 'utf8'));
- return texts.get(id(r));
- };
- for (const [feature, pattern] of [
+ const patterns = [
['MDX/import', /\bimport\s|<[A-Z][A-Za-z]+/],
['SQL', /```sql\b/i],
['math', /\$\$|\\\[/],
['media/link', /!\[|\]\(\.\.?\//],
- ])
- add(
- group.find(r => pattern.test(text(r))),
- feature,
- );
- const languages = new Set(group.flatMap(r => r.decisions.map(d =>
d.locale)));
- for (const locale of languages) {
- add(
- group.find(r =>
- r.decisions.some(d => d.locale === locale && !d.fallback
&& d.indexable),
- ),
- `${locale} real body`,
- );
- add(
- group.find(r => r.decisions.some(d => d.locale === locale &&
d.fallback)),
- `${locale} fallback`,
- );
+ ];
+ 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};
@@ -390,6 +393,34 @@ function stripUnbuiltAnchors(collected, known, rendered,
aliases) {
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,
@@ -410,6 +441,8 @@ async function install({root, outDir, locale, planFile}) {
)
.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,
@@ -488,29 +521,29 @@ async function install({root, outDir, locale, planFile}) {
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 = rows.find(r => key(r.route) === key(route.path));
+ const row = rowsByRoute.get(key(route.path));
if (row && chosen.has(key(row.route))) {
- const plugin = props.plugins.find(
- p =>
- p.name === 'docusaurus-plugin-content-docs' &&
- p.options.id === row.pluginId,
- );
- const doc = plugin.content.loadedVersions
- .find(v => v.versionName === row.version)
- .docs.find(d => d.id === row.docId);
+ 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 allPaths = getRoutesPaths(fullRoutes, props.baseUrl);
+ 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 = [
- ...fullRoutes,
+ ...completeRoutes,
...metadata
.filter(e => !pathSet.has(key(e.route)))
.map(e => ({path: e.route, exact: true, component:
'metadata-only'})),
@@ -613,7 +646,12 @@ async function install({root, outDir, locale, planFile}) {
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) {
@@ -665,13 +703,30 @@ async function install({root, outDir, locale, planFile}) {
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();
- return originalLinks({...params, collectedLinks: sanitized.collected,
routes: fullRoutes});
+ 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;
@@ -685,4 +740,5 @@ module.exports = {
prune,
dependencies,
stripUnbuiltAnchors,
+ indexedRouteMatcher,
};
diff --git a/scripts/i18n/manual-plugins.cjs b/scripts/i18n/manual-plugins.cjs
index 27d080fa8f72..8414829e2018 100644
--- a/scripts/i18n/manual-plugins.cjs
+++ b/scripts/i18n/manual-plugins.cjs
@@ -9,6 +9,7 @@ const {localizedContext} = require('./native-context.cjs');
const fs = require('node:fs');
const {withContentRoutes} = require('./route-context.cjs');
const {validateMessages} = require('./page-body.cjs');
+const blogSample = require('./ci-blog-sample.cjs');
function wrap(kind, native) {
async function adapter(context, options) {
@@ -21,7 +22,9 @@ function wrap(kind, native) {
return relative(base, source);
}
function nativeOptions(ctx) {
- if (kind !== 'blog' || ctx.i18n.currentLocale === 'en') return
options;
+ const base = kind === 'blog' && blogSample.enabled()
+ ? {...options, feedOptions: {...options.feedOptions, type:
null}} : options;
+ if (kind !== 'blog' || ctx.i18n.currentLocale === 'en') return
base;
const locale = ctx.i18n.currentLocale;
return {...options, feedOptions: {...options.feedOptions, type:
null}, async processBlogPosts(args) {
const processed = await options.processBlogPosts?.(args);
@@ -46,6 +49,18 @@ function wrap(kind, native) {
fs.writeFileSync(englishI18n, JSON.stringify({...context.i18n,
currentLocale: context.i18n.defaultLocale}));
}
return withContentRoutes({...plugin,
+ async contentLoaded(args) {
+ const selected = kind === 'blog' && await
blogSample.sample(context, options, args.content);
+ if (!selected) return plugin.contentLoaded(args);
+ // Populate native metadata and Markdown-link/asset resolvers
+ // for every post, but register only sampled native routes.
+ await plugin.contentLoaded({...args, actions:
{...args.actions, addRoute: blogSample.collect}});
+ Object.assign(args.content, selected);
+ const {createAllRoutes} =
require('@docusaurus/plugin-content-blog/lib/routes.js');
+ const pluginDir = path.join(context.generatedFilesDir,
'docusaurus-plugin-content-blog');
+ return createAllRoutes({baseUrl: context.baseUrl, content:
args.content, actions: args.actions, options,
+ aliasedSource: source => '~blog/' +
path.relative(pluginDir, source).split(path.sep).join('/')});
+ },
...(kind === 'pages' && {configureWebpack(...args) {
const result = plugin.configureWebpack?.(...args) ?? {};
return {...result, module: {...result.module, rules:
[...(result.module?.rules ?? []), {
diff --git a/scripts/i18n/structure.cjs b/scripts/i18n/structure.cjs
index b43aedbd7fcf..0823933cdb4f 100644
--- a/scripts/i18n/structure.cjs
+++ b/scripts/i18n/structure.cjs
@@ -4,6 +4,7 @@ const headingsPlugin =
require('@docusaurus/mdx-loader/lib/remark/headings').def
const {escapeMarkdownHeadingIds} = require('@docusaurus/utils');
const {getFormat} = require('@docusaurus/mdx-loader/lib/format.js');
const {normalizedNumbers} = require('./protection.cjs');
+const {buildCache} = require('./build-cache.cjs');
function withoutPositions(value) {
if (Array.isArray(value)) return value.map(withoutPositions);
@@ -14,6 +15,11 @@ function withoutPositions(value) {
}
async function structure(raw, extension, anchorsMaintainCase = false) {
+ return buildCache.get('structure', [raw, extension, anchorsMaintainCase],
+ () => parseStructure(raw, extension, anchorsMaintainCase));
+}
+
+async function parseStructure(raw, extension, anchorsMaintainCase) {
if (/^\.tsx?$/.test(extension)) {
const {parseScript} = require('./code-prose.cjs');
const file = parseScript(raw);
@@ -102,6 +108,12 @@ async function structure(raw, extension,
anchorsMaintainCase = false) {
}
async function alignHeadings(sourceStructure, target, extension, maintainCase
= false) {
+ const headings = sourceStructure.headings.map(({depth, id}) => [depth,
id]);
+ return buildCache.get('aligned-headings', [headings, target, extension,
maintainCase],
+ () => alignTargetHeadings(sourceStructure, target, extension,
maintainCase));
+}
+
+async function alignTargetHeadings(sourceStructure, target, extension,
maintainCase) {
const parsed = await structure(target, extension, maintainCase);
if (parsed.headings.length !== sourceStructure.headings.length
|| parsed.headings.some((heading, i) => heading.depth !==
sourceStructure.headings[i].depth)) {
diff --git a/scripts/i18n/verify-ci-sample.cjs
b/scripts/i18n/verify-ci-sample.cjs
index f989aeaa12c0..ee63924782a7 100644
--- a/scripts/i18n/verify-ci-sample.cjs
+++ b/scripts/i18n/verify-ci-sample.cjs
@@ -45,6 +45,7 @@ async function verify({root = process.cwd(), browser = true})
{
compiledRoutes: scope.compiledRoutes.length,
compiledMdxModules: new
Set(Object.values(scope.compiledMdxModules).flat()).size,
anchorChecksSkipped: scope.anchorChecksSkipped,
+ linkCheckMode: scope.linkCheckMode,
});
}
if (browser) {
diff --git a/scripts/i18n/verify-ci-sampling-fixture.cjs
b/scripts/i18n/verify-ci-sampling-fixture.cjs
index 2839cc23ab04..f68fcff7d456 100644
--- a/scripts/i18n/verify-ci-sampling-fixture.cjs
+++ b/scripts/i18n/verify-ci-sampling-fixture.cjs
@@ -2,14 +2,16 @@ const fs = require('node:fs'),
path = require('node:path'),
assert = require('node:assert/strict');
const {execFileSync, spawnSync} = require('node:child_process');
-const {createFixture} = require('./__tests__/fixtures/multi-locale.cjs');
+const {createContentFixture} =
require('./__tests__/fixtures/content-manifest.cjs');
const {plan} = require('./ci-plan.cjs');
const {verify} = require('./verify-ci-sample.cjs');
const root = path.resolve(__dirname, '../..');
async function main() {
const site = path.join(root, '.i18n-build/ci-sampling-fixture');
fs.rmSync(site, {recursive: true, force: true});
- const {write} = await createFixture(site);
+ const {write} = await createContentFixture(site);
+ write('blog/2026-02-01-rich.mdx', '---\ntitle: Rich blog
sample\n---\n\n<span>Rich blog body</span>\n\n```sql\nSELECT
1;\n```\n\n[Omitted blog](./2025-01-01-omitted.mdx#unverified)\n');
+ write('blog/2025-01-01-omitted.mdx', '---\ntitle: Omitted
blog\n---\n\nimport Missing from "./missing-blog-component";\n\n<Missing/>\n');
write(
'docs/02-rich.mdx',
'---\ntitle: Rich MDX\n---\n\nimport Widget from
"@site/src/components/FixtureRich";\n\n<Widget />\n\n$$ x + 1 $$\n',
@@ -68,6 +70,10 @@ async function main() {
assert(!scope.selected.some(r => r.docId === 'zz-unselected'));
assert(scope.compiledMdxModules.client.length > 0);
assert(!scope.compiledMdxModules.client.includes('docs/zz-unselected.mdx'));
+ assert(scope.blogSampling.some(report => report.renderedPosts <
report.metadataPosts));
+
assert(scope.compiledMdxModules.client.includes('blog/2026-02-01-rich.mdx'));
+
assert(!scope.compiledMdxModules.client.includes('blog/2025-01-01-omitted.mdx'),
+ 'Blog aggregates must not re-import omitted bodies');
}
await verify({root: site, browser: process.argv.includes('--browser')});
assert(
@@ -85,9 +91,11 @@ async function main() {
skippedAnchors: s.anchorChecksSkipped,
})),
excludedBrokenModuleWasNotCompiled: true,
+ excludedBrokenBlogWasNotCompiled: true,
};
// A real edit makes that same module mandatory. Its missing import must
now
// fail compilation instead of being silently dropped by the sampler.
+ const originalDocument = fs.readFileSync(path.join(site,
'docs/zz-unselected.mdx'), 'utf8');
fs.appendFileSync(path.join(site, 'docs/zz-unselected.mdx'),
'\nChanged.\n');
commit();
const changed = plan({root: site, base, forceSample: true});
@@ -104,6 +112,14 @@ async function main() {
'The changed document must reach the compiler',
);
proof.changedBrokenModuleFailed = true;
+ fs.writeFileSync(path.join(site, 'docs/zz-unselected.mdx'),
originalDocument);
+ fs.appendFileSync(path.join(site, 'blog/2025-01-01-omitted.mdx'),
'\nChanged blog.\n');
+ commit();
+ fs.writeFileSync(file, JSON.stringify(plan({root: site, base, forceSample:
true}), null, 2));
+ const blogFailure = run('pipe');
+ assert.notEqual(blogFailure.status, 0, 'Edited blog must receive native
compilation');
+ assert.match(blogFailure.stdout + blogFailure.stderr,
/missing-blog-component/);
+ proof.changedBrokenBlogFailed = true;
fs.writeFileSync(
path.join(root, '.i18n-build/ci-sampling-fixture-proof.json'),
JSON.stringify(proof, null, 2) + '\n',
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]