Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions .github/workflows/compare-builds.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
name: Compare Build Outputs

on:
workflow_run:
workflows: ['Generate Docs']
types: [completed]

permissions:
contents: read
actions: read
pull-requests: write

jobs:
get-comparators:
name: Get Comparators
runs-on: ubuntu-latest
if: github.event.workflow_run.event == 'pull_request'
outputs:
comparators: ${{ steps.get-comparators.outputs.comparators }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit

- name: Checkout Code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1

- name: List comparators
id: get-comparators
run: |
# List all .mjs files in scripts/compare-builds/ and remove the .mjs extension
COMPARATORS=$(ls scripts/compare-builds/*.mjs | xargs -n1 basename | sed 's/\.mjs$//' | jq -R -s -c 'split("\n")[:-1]')
echo "comparators=$COMPARATORS" >> $GITHUB_OUTPUT
compare:
name: Run ${{ matrix.comparator }} comparator
runs-on: ubuntu-latest
needs: get-comparators
strategy:
matrix:
comparator: ${{ fromJSON(needs.get-comparators.outputs.comparators) }}

steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit

- name: Checkout Code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1

- name: Download Output (HEAD)
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: ${{ matrix.comparator }}
path: out/head
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}

- name: Get Run ID from BASE
id: base-run
env:
WORKFLOW_ID: ${{ github.event.workflow_run.workflow_id }}
GH_TOKEN: ${{ github.token }}
run: |
ID=$(gh run list -c $GITHUB_SHA -w $WORKFLOW_ID -L 1 --json databaseId --jq ".[].databaseId")
echo "run_id=$ID" >> $GITHUB_OUTPUT
- name: Download Output (BASE)
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: ${{ matrix.comparator }}
path: out/base
run-id: ${{ steps.base-run.outputs.run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}

- name: Compare Bundle Size
id: compare
run: |
node scripts/compare-builds/${{ matrix.comparator }}.mjs > result.txt
if [ -s result.txt ]; then
echo "has_output=true" >> "$GITHUB_OUTPUT"
else
echo "has_output=false" >> "$GITHUB_OUTPUT"
fi
- name: Upload comparison artifact
if: steps.compare.outputs.has_output == 'true'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: ${{ matrix.comparator }}
path: result.txt

aggregate:
name: Aggregate Comparison Results
runs-on: ubuntu-latest
needs: compare
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit

- name: Download all comparison artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
path: results

- name: Combine results
id: combine
run: |
shopt -s nullglob
result_files=(results/*.txt)
if ((${#result_files[@]})); then
{
echo "combined<<EOF"
cat "${result_files[@]}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
fi
- name: Add Comment to PR
if: steps.combine.outputs.combined
uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1
with:
comment-tag: compared
message: ${{ steps.combine.outputs.combined }}
pr-number: ${{ github.event.workflow_run.pull_requests[0].number }}
2 changes: 1 addition & 1 deletion .github/workflows/generate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,5 +87,5 @@ jobs:
- name: Upload ${{ matrix.target }} artifacts
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: ${{ matrix.target }}-${{ github.run_id }}
name: ${{ matrix.target }}
path: out/${{ matrix.target }}
151 changes: 151 additions & 0 deletions scripts/compare-builds/web.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { stat, readdir } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const BASE = fileURLToPath(import.meta.resolve('../../out/base'));
const HEAD = fileURLToPath(import.meta.resolve('../../out/head'));
const UNITS = ['B', 'KB', 'MB', 'GB'];

/**
* Formats bytes into human-readable format
* @param {number} bytes - Number of bytes
* @returns {string} Formatted string (e.g., "1.50 KB")
*/
const formatBytes = bytes => {
if (!bytes) {
return '0 B';
}

const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${UNITS[i]}`;
};

/**
* Formats the difference between base and head sizes
* @param {number} base - Base file size in bytes
* @param {number} head - Head file size in bytes
* @returns {string} Formatted diff string (e.g., "+1.50 KB (+10.00%)")
*/
const formatDiff = (base, head) => {
const diff = head - base;
const sign = diff > 0 ? '+' : '';
const percent = base ? `${sign}${((diff / base) * 100).toFixed(2)}%` : 'N/A';
return `${sign}${formatBytes(diff)} (${percent})`;
};

/**
* Gets all files in a directory with their stats
* @param {string} dir - Directory path to search
* @returns {Promise<Map<string, number>>} Map of filename to size
*/
const getDirectoryStats = async dir => {
const files = await readdir(dir);
const entries = await Promise.all(
files.map(async file => [file, (await stat(path.join(dir, file))).size])
);
return new Map(entries);
};

/**
* Generates a table row for a file
* @param {string} file - Filename
* @param {number} baseSize - Base size in bytes
* @param {number} headSize - Head size in bytes
* @returns {string} Markdown table row
*/
const generateRow = (file, baseSize, headSize) => {
const baseCol = formatBytes(baseSize);
const headCol = formatBytes(headSize);
const diffCol = formatDiff(baseSize, headSize);

return `| \`${file}\` | ${baseCol} | ${headCol} | ${diffCol} |`;
};

/**
* Generates a markdown table
* @param {string[]} files - List of files
* @param {Map<string, number>} baseStats - Base stats map
* @param {Map<string, number>} headStats - Head stats map
* @returns {string} Markdown table
*/
const generateTable = (files, baseStats, headStats) => {
const header = '| File | Base | Head | Diff |\n|------|------|------|------|';
const rows = files.map(f =>
generateRow(f, baseStats.get(f), headStats.get(f))
);
return `${header}\n${rows.join('\n')}`;
};

/**
* Wraps content in a details/summary element
* @param {string} summary - Summary text
* @param {string} content - Content to wrap
* @returns {string} Markdown details element
*/
const details = (summary, content) =>
`<details>\n<summary>${summary}</summary>\n\n${content}\n\n</details>`;

const [baseStats, headStats] = await Promise.all(
[BASE, HEAD].map(getDirectoryStats)
);

const allFiles = Array.from(
new Set([...baseStats.keys(), ...headStats.keys()])
);

// Filter to only changed files (exist in both and have different sizes)
const changedFiles = allFiles.filter(
f =>
baseStats.has(f) &&
headStats.has(f) &&
baseStats.get(f) !== headStats.get(f)
);

if (changedFiles.length) {
// Separate HTML files and their matching JS files from other files
const pages = [];
const other = [];

// Get all HTML base names
const htmlBaseNames = new Set(
changedFiles
.filter(f => path.extname(f) === '.html')
.map(f => path.basename(f, '.html'))
);

for (const file of changedFiles) {
const ext = path.extname(file);
const basename = path.basename(file, ext);

// All HTML files go to pages
if (ext === '.html') {
pages.push(file);
}
// JS files go to pages only if they have a matching HTML file
else if (ext === '.js' && htmlBaseNames.has(basename)) {
pages.push(file);
}
// Everything else goes to other
else {
other.push(file);
}
}

pages.sort();
other.sort();

console.log('## Web Generator\n');

if (other.length) {
console.log(generateTable(other, baseStats, headStats));
}

if (pages.length) {
console.log(
details(
`Pages (${pages.filter(f => path.extname(f) === '.html').length})`,
generateTable(pages, baseStats, headStats)
)
);
}
}
4 changes: 2 additions & 2 deletions src/generators/web/constants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export const JSX_IMPORTS = {
* Specification rules for resource hints like prerendering and prefetching.
* @see https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API
*/
export const SPECULATION_RULES = {
export const SPECULATION_RULES = JSON.stringify({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export const SPECULATION_RULES = JSON.stringify({
export const SPECULATION_RULES_STRING = JSON.stringify({

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IDK why you made this unrelated change tho... Any reason why calling JSON.stringify right on definition? Not sure we do that with constants.

Copy link
Member Author

@avivkeller avivkeller Dec 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IDK why you made this unrelated change tho... Any reason why calling JSON.stringify right on definition? Not sure we do that with constants.

As I mentioned in the comments, I made this change to slightly reduce the build size, showcasing the comparison.

It's on the constant just so that the same stringify isn't called on every page.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I mentioned in the comments, I made this change to slightly reduce the build size, showcasing the comparison.

Hmm, how does this reduce build size? Hmmm

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Also apologies for not notidcing on the comments, I just quickly jumped to the PR)

// Eagerly prefetch all links that point to the API docs themselves
// in a moderate eagerness to improve resource loading
prefetch: [{ where: { href_matches: '/*' }, eagerness: 'eager' }],
Expand All @@ -94,4 +94,4 @@ export const SPECULATION_RULES = {
// These will be done in a moderate eagerness (hover, likely next navigation)
{ where: { selector_matches: '[rel~=prefetch]' }, eagerness: 'moderate' },
],
};
});
3 changes: 1 addition & 2 deletions src/generators/web/utils/processing.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,6 @@ export async function processJSXEntries(
]);

const titleSuffix = `Node.js v${version.version} Documentation`;
const speculationRulesString = JSON.stringify(SPECULATION_RULES, null, 2);

// Step 3: Create final HTML (could be parallelized in workers)
const results = entries.map(({ data: { api, heading } }) => {
Expand All @@ -118,7 +117,7 @@ export async function processJSXEntries(
.replace('{{dehydrated}}', serverBundle.get(fileName) ?? '')
.replace('{{importMap}}', clientBundle.importMap ?? '')
.replace('{{entrypoint}}', `./${fileName}?${randomUUID()}`)
.replace('{{speculationRules}}', speculationRulesString);
.replace('{{speculationRules}}', SPECULATION_RULES);

// Minify HTML (input must be a Buffer)
const finalHTMLBuffer = HTMLMinifier.minify(Buffer.from(renderedHtml), {});
Expand Down
Loading