From cbe73b18a43c5dee490427a83c4da007575fe1be Mon Sep 17 00:00:00 2001 From: James Loh Date: Mon, 17 Aug 2026 16:52:00 +1000 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20card=20assets=20bust?= =?UTF-8?q?ing=20caches=20on=20every=20Ghost=20restart=20(#29659)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref - `?v=` was `md5(Date.now())`, generated per process, so every restart and every instance behind a load balancer handed out a different URL for identical bytes — browser and CDN caches never stayed warm - the content-based hash already existed behind a flag but defaulted to off, so nobody was getting it; flipping the default is what actually delivers it - card assets couldn't be hashed at all, because they were minified lazily on first request — at render time the file usually didn't exist yet. Moving that to build time makes the bytes known up front, and drops a non-atomic write to a shared content volume that concurrent boots could tear - hashing content rather than version means a release that doesn't touch card CSS doesn't invalidate every site at once --- .gitignore | 1 + ghost/core/core/frontend/meta/asset-url.js | 29 +- .../assets-minification/card-assets.js | 167 ++++++++---- .../services/assets-minification/index.js | 17 +- .../frontend/web/routers/serve-public-file.js | 36 ++- ghost/core/core/shared/config/defaults.json | 2 +- ghost/core/package.json | 7 + ghost/core/scripts/build-card-assets.mjs | 60 +++++ .../test/e2e-frontend/default-routes.test.js | 38 +++ .../test/unit/frontend/helpers/asset.test.js | 4 +- .../test/unit/frontend/meta/asset-url.test.js | 27 +- .../frontend/services/card-assets.test.js | 253 ++++++++++++------ 12 files changed, 489 insertions(+), 152 deletions(-) create mode 100644 ghost/core/scripts/build-card-assets.mjs diff --git a/.gitignore b/.gitignore index 86dbe0262e7..ecc648125a0 100644 --- a/.gitignore +++ b/.gitignore @@ -131,6 +131,7 @@ test/functional/*.png /ghost/core/core/frontend/public/member-attribution.min.js /ghost/core/core/frontend/public/ghost-stats.min.js /ghost/core/core/frontend/public/private.min.js +/ghost/core/core/frontend/public/cards.manifest.json # Caddyfile - for local development with ssl + caddy Caddyfile !docker/dev-gateway/Caddyfile diff --git a/ghost/core/core/frontend/meta/asset-url.js b/ghost/core/core/frontend/meta/asset-url.js index ff0d552fc8b..52aeff07f9a 100644 --- a/ghost/core/core/frontend/meta/asset-url.js +++ b/ghost/core/core/frontend/meta/asset-url.js @@ -6,6 +6,7 @@ const urlUtils = require('../../shared/url-utils').default; const {SafeString} = require('../services/handlebars'); const assetHash = require('../services/asset-hash'); const themeEngine = require('../services/theme-engine'); +const {cardAssets} = require('../services/assets-minification'); /** * Serve either uploaded favicon or default @@ -121,9 +122,12 @@ function getAssetUrl(assetPath, hasMinFile) { return getFaviconUrl(); } - // Determine asset type - const isPublicAsset = assetPath.match(/^public\//); - const isThemeAsset = !isPublicAsset && !assetPath.match(/^asset/); + // Determine asset type. Anything that isn't a public asset is served out of the + // active theme's assets/ directory — but a caller may already have spelled that + // prefix themselves, in which case we must not add it a second time. + const isPublicAsset = !!assetPath.match(/^public\//); + const isThemeAsset = !isPublicAsset; + const hasAssetsPrefix = isThemeAsset && !!assetPath.match(/^asset/); // CASE: Build the output URL // If assetCdnUrl is configured, use it as the base (produces an absolute URL). @@ -134,7 +138,7 @@ function getAssetUrl(assetPath, hasMinFile) { : urlUtils.urlJoin(urlUtils.getSubdir(), '/'); // Optionally add /assets/ - if (isThemeAsset) { + if (isThemeAsset && !hasAssetsPrefix) { output = urlUtils.urlJoin(output, 'assets/'); } @@ -149,12 +153,19 @@ function getAssetUrl(assetPath, hasMinFile) { // Get the appropriate hash for this asset (ignore URL anchor) const hashPath = assetPath.includes('#') ? assetPath.slice(0, assetPath.indexOf('#')) : assetPath; let hash; - // Use file-based SHA256 hash if enabled via config (defaults to false for backwards compatibility) - if (config.get('caching:assets:contentBasedHash:enabled')) { + + // Card assets are assembled in memory, so their content hash is always + // available and always matches the bytes we serve — there's no file to miss + // and therefore no reason to gate this on contentBasedHash + const cardType = cardAssets.getCardType(hashPath); + if (cardType !== null) { + hash = cardAssets.getHash(cardType); + } else if (config.get('caching:assets:contentBasedHash:enabled')) { if (isThemeAsset) { - // For theme assets, use file-based SHA256 hash - hash = getThemeAssetHash(hashPath); - } else if (isPublicAsset) { + // Theme assets resolve relative to the theme's assets/ directory, so an + // explicitly-spelled prefix has to come back off before we look the file up + hash = getThemeAssetHash(hashPath.replace(/^assets\//, '')); + } else { // For public assets, use file-based SHA256 hash hash = getPublicAssetHash(hashPath); } diff --git a/ghost/core/core/frontend/services/assets-minification/card-assets.js b/ghost/core/core/frontend/services/assets-minification/card-assets.js index a5201d59ac9..5d834f1be1f 100644 --- a/ghost/core/core/frontend/services/assets-minification/card-assets.js +++ b/ghost/core/core/frontend/services/assets-minification/card-assets.js @@ -1,91 +1,166 @@ const debug = require('@tryghost/debug')('card-assets'); +const errors = require('@tryghost/errors'); const _ = require('lodash'); +const crypto = require('crypto'); +const fs = require('fs'); const path = require('path'); const config = require('../../../shared/config'); -const Minifier = require('./minifier'); -const AssetsMinificationBase = require('./assets-minification-base'); -module.exports = class CardAssets extends AssetsMinificationBase { +// Must match the asset-hash service so both flavours of ?v= look alike +const HASH_LENGTH = 16; + +// Each type maps card names to minified chunks; anything else still indexes +// and concatenates, serving garbage under a valid content hash +const isChunkMap = chunks => _.isPlainObject(chunks) && + Object.values(chunks).every(chunk => typeof chunk === 'string'); + +/** + * Card assets are built ahead of time by scripts/build-card-assets.mjs, which + * minifies every card's CSS/JS into a manifest. The only per-site variable is + * which cards the active theme asked for, so serving is a matter of picking + * chunks out of the manifest and concatenating them. + * + * Keeping this in memory (rather than minifying to content/public on first + * request, as Ghost used to) means the bundle — and the content hash we put in + * its ?v= — is identical for every process serving a given theme, so caches + * survive restarts and are shared across instances. + */ +module.exports = class CardAssets { constructor(options = {}) { - super(options); - - this.src = options.src || path.join(config.get('paths').assetSrc, 'cards'); - this.dest = options.dest || config.getContentPath('public'); - this.minifier = new Minifier({src: this.src, dest: this.dest}); + this.manifestPath = options.manifest || path.join(config.get('paths').publicFilePath, 'cards.manifest.json'); if ('config' in options) { this.config = options.config; } - this.files = []; + // Read eagerly so a broken build fails at boot rather than at render time + this.manifest = this.readManifest(); + this.bundles = new Map(); } /** - * @override + * @returns {{css?: Object, js?: Object}} */ - generateGlobs() { + readManifest() { + let reason; + + try { + const manifest = JSON.parse(fs.readFileSync(this.manifestPath, 'utf8')); + + // Well-formed JSON isn't necessarily a manifest + if (!_.isPlainObject(manifest)) { + reason = `Expected an object, got ${JSON.stringify(manifest).slice(0, 50)}`; + } else { + const present = ['css', 'js'].filter(type => type in manifest); + const badType = present.find(type => !isChunkMap(manifest[type])); + + if (!present.length) { + reason = 'Expected at least one of `css` or `js`'; + } else if (badType) { + reason = `Expected \`${badType}\` to map card names to minified chunks`; + } else { + return manifest; + } + } + } catch (err) { + reason = err.message; + } + + throw new errors.InternalServerError({ + message: `Could not use the card asset manifest at ${this.manifestPath}`, + context: reason, + help: 'Card assets ship with Ghost — reinstall, or run `pnpm build:assets` when running from source' + }); + } + + /** + * Resolve the theme's `card_assets` config against the cards this Ghost + * version actually ships + * + * @param {'css'|'js'} type + * @returns {string[]} card names, in bundle order + */ + getCardNames(type) { + const available = Object.keys(this.manifest[type] || {}); + // CASE: The theme has asked for all card assets to be included by default if (this.config === true) { - return { - 'cards.min.css': 'css/*.css', - 'cards.min.js': 'js/*.js' - }; + return available; } // CASE: the theme has declared an include directive, we should include exactly these assets // Include rules take precedence over exclude rules. if (_.has(this.config, 'include')) { - return { - 'cards.min.css': `css/@(${this.config.include.join('|')}).css`, - 'cards.min.js': `js/@(${this.config.include.join('|')}).js` - }; + return available.filter(name => this.config.include.includes(name)); } - // CASE: the theme has declared an exclude directive, we should include exactly these assets + // CASE: the theme has declared an exclude directive, we should include everything else if (_.has(this.config, 'exclude')) { - return { - 'cards.min.css': `css/!(${this.config.exclude.join('|')}).css`, - 'cards.min.js': `js/!(${this.config.exclude.join('|')}).js` - }; + return available.filter(name => !this.config.exclude.includes(name)); } // CASE: theme has asked that no assets be included // CASE: we didn't understand config, don't do anything - return {}; + return []; } - hasFile(type) { - if (this.files.length) { - return this.files.indexOf(`cards.min.${type}`) > -1; + /** + * @param {'css'|'js'} type + * @returns {{content: string, hash: string}|null} null when the theme wants no assets of this type + */ + getBundle(type) { + if (this.bundles.has(type)) { + return this.bundles.get(type); } - return Object.keys(this.generateGlobs()).indexOf(`cards.min.${type}`) > -1; + const chunks = this.manifest[type] || {}; + const names = this.getCardNames(type); + + debug('bundling', type, names); + + // JS chunks are terminated so a minifier that drops the trailing + // semicolon can't let one IIFE run into the next + const separator = type === 'js' ? ';\n' : '\n'; + const content = names.map(name => chunks[name]).join(separator); + + const bundle = content ? { + content, + hash: crypto.createHash('sha256').update(content).digest('base64url').substring(0, HASH_LENGTH) + } : null; + + this.bundles.set(type, bundle); + + return bundle; } - invalidate(cardAssetConfig) { - if (cardAssetConfig) { - this.config = cardAssetConfig; - } + hasFile(type) { + return !!this.getBundle(type); + } - return super.invalidate(); + /** + * @param {'css'|'js'} type + * @returns {string|null} content hash for use as a cache-busting key + */ + getHash(type) { + return this.getBundle(type)?.hash ?? null; } /** - * A theme can declare which cards it supports, and we'll do the rest - * - * @override + * @param {string} assetPath e.g. `public/cards.min.css` + * @returns {'css'|'js'|null} the card asset type this path refers to, if any */ - async load(cardAssetConfig) { - if (cardAssetConfig) { + getCardType(assetPath) { + return assetPath.match(/^public\/cards\.min\.(css|js)$/)?.[1] ?? null; + } + + /** + * @param {boolean|object} [cardAssetConfig] the active theme's `card_assets` config + */ + invalidate(cardAssetConfig) { + if (cardAssetConfig !== undefined) { this.config = cardAssetConfig; } - debug('loading with config', this.config); - - const globs = this.generateGlobs(); - - debug('globs', globs); - - this.files = await this.minify(globs) || []; + this.bundles.clear(); } }; diff --git a/ghost/core/core/frontend/services/assets-minification/index.js b/ghost/core/core/frontend/services/assets-minification/index.js index 2ef050302a0..7559053f0ed 100644 --- a/ghost/core/core/frontend/services/assets-minification/index.js +++ b/ghost/core/core/frontend/services/assets-minification/index.js @@ -1,9 +1,18 @@ const CardAssets = require('./card-assets'); const AdminAuthAssets = require('./admin-auth-assets'); -const cardAssets = new CardAssets(); -const adminAuthAssets = new AdminAuthAssets(); +let cardAssets; +let adminAuthAssets; + +// Instantiated lazily: the AdminAuthAssets constructor touches the filesystem, +// and consumers that only want cardAssets shouldn't trigger that module.exports = { - cardAssets, - adminAuthAssets + get cardAssets() { + cardAssets = cardAssets || new CardAssets(); + return cardAssets; + }, + get adminAuthAssets() { + adminAuthAssets = adminAuthAssets || new AdminAuthAssets(); + return adminAuthAssets; + } }; diff --git a/ghost/core/core/frontend/web/routers/serve-public-file.js b/ghost/core/core/frontend/web/routers/serve-public-file.js index 133bf6a8dd1..74ec3b94fe9 100644 --- a/ghost/core/core/frontend/web/routers/serve-public-file.js +++ b/ghost/core/core/frontend/web/routers/serve-public-file.js @@ -102,6 +102,36 @@ function createPublicFileMiddleware(location, file, mime, maxAge, options = {}) }; } +/** + * Card assets are assembled in memory from a build-time manifest, so there's no + * file on disk to read — see services/assets-minification/card-assets.js + * + * @param {'css'|'js'} type + * @param {string} mime + * @param {number} maxAge + */ +function createCardAssetMiddleware(type, mime, maxAge) { + return function serveCardAssetMiddleware(req, res, next) { + const bundle = cardAssets.getBundle(type); + + if (!bundle) { + return next(new errors.NotFoundError({ + message: tpl(messages.fileNotFound), + code: 'PUBLIC_FILE_NOT_FOUND', + property: `cards.min.${type}` + })); + } + + res.writeHead(200, { + 'Content-Type': mime, + 'Content-Length': Buffer.byteLength(bundle.content), + ETag: `"${bundle.hash}"`, + 'Cache-Control': `public, max-age=${maxAge}` + }); + res.end(bundle.content); + }; +} + // Handles requests to robots.txt and favicon.ico (and caches them) function servePublicFile(location, file, type, maxAge, options = {}) { const publicFileMiddleware = createPublicFileMiddleware(location, file, type, maxAge, options); @@ -127,9 +157,9 @@ function servePublicFiles(siteApp) { // Traffic analytics tracking script siteApp.get('/public/ghost-stats.min.js', createPublicFileMiddleware('static', 'public/ghost-stats.min.js', 'application/javascript', config.get('caching:publicAssets:maxAge'))); - // Card assets (built on the fly) - siteApp.get('/public/cards.min.css', cardAssets.serveMiddleware(), createPublicFileMiddleware('built', 'public/cards.min.css', 'text/css', config.get('caching:publicAssets:maxAge'))); - siteApp.get('/public/cards.min.js', cardAssets.serveMiddleware(), createPublicFileMiddleware('built', 'public/cards.min.js', 'application/javascript', config.get('caching:publicAssets:maxAge'))); + // Card assets (assembled in memory per active theme) + siteApp.get('/public/cards.min.css', createCardAssetMiddleware('css', 'text/css', config.get('caching:publicAssets:maxAge'))); + siteApp.get('/public/cards.min.js', createCardAssetMiddleware('js', 'application/javascript', config.get('caching:publicAssets:maxAge'))); // Comment counts siteApp.get('/public/comment-counts.min.js', createPublicFileMiddleware('static', 'public/comment-counts.min.js', 'application/javascript', config.get('caching:publicAssets:maxAge'))); diff --git a/ghost/core/core/shared/config/defaults.json b/ghost/core/core/shared/config/defaults.json index 6b6c69e3137..b9e22a739ec 100644 --- a/ghost/core/core/shared/config/defaults.json +++ b/ghost/core/core/shared/config/defaults.json @@ -235,7 +235,7 @@ }, "assets": { "contentBasedHash": { - "enabled": false, + "enabled": true, "maxSize": 100000 } } diff --git a/ghost/core/package.json b/ghost/core/package.json index d9f99058038..45d8d3da5b2 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -47,6 +47,7 @@ "dev": "nodemon index.js", "build:assets": "pnpm run '/^build:assets:/'", "build:assets:js": "node scripts/minify-assets.mjs", + "build:assets:cards": "node scripts/build-card-assets.mjs", "generate-golden-email": "node bin/generate-golden-email.js", "migrate:create": "node bin/create-migration.js", "build:assets:css": "postcss core/frontend/public/ghost.css --no-map --use cssnano -o core/frontend/public/ghost.min.css", @@ -344,21 +345,25 @@ }, "test:e2e": { "dependsOn": [ + "build:assets", "^build" ] }, "test:integration": { "dependsOn": [ + "build:assets", "^build" ] }, "test:legacy": { "dependsOn": [ + "build:assets", "^build" ] }, "test:ci:e2e": { "dependsOn": [ + "build:assets", "^build" ], "outputs": [ @@ -367,11 +372,13 @@ }, "test:ci:legacy": { "dependsOn": [ + "build:assets", "^build" ] }, "test:ci:integration": { "dependsOn": [ + "build:assets", "^build" ], "outputs": [ diff --git a/ghost/core/scripts/build-card-assets.mjs b/ghost/core/scripts/build-card-assets.mjs new file mode 100644 index 00000000000..437775196e1 --- /dev/null +++ b/ghost/core/scripts/build-card-assets.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node + +/** + * Builds the card asset manifest. + * + * Every card ships one optional CSS file and one optional JS file in + * core/frontend/src/cards. A theme picks a subset of them via its `card_assets` + * config, and Ghost serves that subset as /public/cards.min.{css,js}. + * + * The subset is the only variable — the card sources themselves are immutable + * for a given Ghost release. So we minify each card once here, at build time, + * and record the results in a manifest. At runtime Ghost just concatenates the + * chunks the active theme asked for, which makes the served bytes (and + * therefore the ?v= cache-busting hash) identical across every process and + * every boot. + */ + +import esbuild from 'esbuild'; +import path from 'path'; +import fs from 'fs'; +import logging from '@tryghost/logging'; + +const projectRoot = process.cwd().endsWith('ghost/core') || process.cwd().endsWith('ghost\\core') + ? process.cwd() + : path.join(process.cwd(), 'ghost', 'core'); + +const srcDir = path.join(projectRoot, 'core/frontend/src/cards'); +const destFile = path.join(projectRoot, 'core/frontend/public/cards.manifest.json'); + +const LOADERS = { + css: {loader: 'css'}, + js: {loader: 'js', target: ['es2020']} +}; + +async function buildType(type) { + const dir = path.join(srcDir, type); + const suffix = `.${type}`; + const files = fs.readdirSync(dir).filter(file => file.endsWith(suffix)).sort(); + + const chunks = {}; + for (const file of files) { + const contents = fs.readFileSync(path.join(dir, file), 'utf8'); + const {code} = await esbuild.transform(contents, {minify: true, ...LOADERS[type]}); + chunks[file.slice(0, -suffix.length)] = code; + } + + logging.debug(`✓ ${files.length} card ${type} files minified`); + + return chunks; +} + +const manifest = {}; +for (const type of Object.keys(LOADERS)) { + manifest[type] = await buildType(type); +} + +fs.mkdirSync(path.dirname(destFile), {recursive: true}); +fs.writeFileSync(destFile, JSON.stringify(manifest)); + +logging.debug(`Card asset manifest written to ${destFile}`); diff --git a/ghost/core/test/e2e-frontend/default-routes.test.js b/ghost/core/test/e2e-frontend/default-routes.test.js index d2f860dedaa..5082e0c858c 100644 --- a/ghost/core/test/e2e-frontend/default-routes.test.js +++ b/ghost/core/test/e2e-frontend/default-routes.test.js @@ -16,6 +16,7 @@ const testUtils = require('../utils'); const configUtils = require('../utils/config-utils'); const config = require('../../core/shared/config'); const settingsCache = require('../../core/shared/settings-cache'); +const {cardAssets} = require('../../core/frontend/services/assets-minification'); const origCache = _.cloneDeep(settingsCache); function assertCorrectFrontendHeaders(res) { @@ -392,6 +393,43 @@ describe('Default Frontend routing', function () { .expect(200) .expect(assertCorrectFrontendHeaders); }); + + it('should retrieve card assets', async function () { + const css = await request.get('/public/cards.min.css') + .expect('Cache-Control', testUtils.cacheRules.year) + .expect('Content-Type', 'text/css') + .expect(200) + .expect(assertCorrectFrontendHeaders); + + assert.match(css.text, /\.kg-/); + + await request.get('/public/cards.min.js') + .expect('Content-Type', 'application/javascript') + .expect(200); + }); + + it('should serve card assets under the same hash the page rendered', async function () { + const page = await request.get('/').expect(200); + const href = cheerio.load(page.text)('link[href*="cards.min.css"]').attr('href'); + + assertExists(href); + + const [, renderedHash] = href.match(/\?v=([^&#]+)/); + + // The hash is derived from the bundle contents, so it must survive a + // restart and match across processes — see card-assets.js + const asset = await request.get(`/public/cards.min.css?v=${renderedHash}`).expect(200); + assert.equal(asset.headers.etag, `"${renderedHash}"`); + }); + + it('should 404 card assets when the theme has asked for none', async function () { + // What a theme with `card_assets: false` leaves us with — there's no + // bundle to serve, so there's nothing at this URL + sinon.stub(cardAssets, 'getBundle').returns(null); + + await request.get('/public/cards.min.css').expect(404); + await request.get('/public/cards.min.js').expect(404); + }); }); describe('Site Map', function () { diff --git a/ghost/core/test/unit/frontend/helpers/asset.test.js b/ghost/core/test/unit/frontend/helpers/asset.test.js index 483cf0d22ed..5a58ad21b39 100644 --- a/ghost/core/test/unit/frontend/helpers/asset.test.js +++ b/ghost/core/test/unit/frontend/helpers/asset.test.js @@ -37,7 +37,7 @@ describe('{{asset}} helper', function () { it('handles ghost.css for default templates correctly', function () { rendered = asset('public/ghost.css'); assertExists(rendered); - assert.equal(String(rendered), '/public/ghost.css?v=abc'); + assert.match(String(rendered), /^\/public\/ghost\.css\?v=[A-Za-z0-9_-]{16}$/); }); it('handles custom favicon correctly', function () { @@ -106,7 +106,7 @@ describe('{{asset}} helper', function () { it('handles ghost.css for default templates correctly', function () { rendered = asset('public/ghost.css'); assertExists(rendered); - assert.equal(String(rendered), 'http://127.0.0.1/public/ghost.css?v=abc'); + assert.match(String(rendered), /^http:\/\/127\.0\.0\.1\/public\/ghost\.css\?v=[A-Za-z0-9_-]{16}$/); }); }); diff --git a/ghost/core/test/unit/frontend/meta/asset-url.test.js b/ghost/core/test/unit/frontend/meta/asset-url.test.js index 0a4112c7ef6..b9c92fe1b9f 100644 --- a/ghost/core/test/unit/frontend/meta/asset-url.test.js +++ b/ghost/core/test/unit/frontend/meta/asset-url.test.js @@ -31,8 +31,8 @@ describe('getAssetUrl', function () { it('should not add asset to url if ghost.css for default templates', function () { const testUrl = getAssetUrl('public/ghost.css'); - // Without caching:assets:contentBasedHash, uses global hash - assert.equal(testUrl, '/public/ghost.css?v=' + config.get('assetHash')); + // ghost.css ships in the static public path, so it gets a content-based hash + assert.match(testUrl, /^\/public\/ghost\.css\?v=[A-Za-z0-9_-]{16}$/); }); it('should not add asset to url has public in it', function () { @@ -41,6 +41,12 @@ describe('getAssetUrl', function () { assert.equal(testUrl, '/public/myfile.js?v=' + config.get('assetHash')); }); + it('should use the global hash when contentBasedHash is disabled', function () { + configUtils.set('caching:assets:contentBasedHash:enabled', false); + const testUrl = getAssetUrl('public/ghost.css'); + assert.equal(testUrl, '/public/ghost.css?v=' + config.get('assetHash')); + }); + it('should return hash before #', function () { const testUrl = getAssetUrl('myfile.svg#arrow-up'); assert.equal(testUrl, `/assets/myfile.svg?v=${config.get('assetHash')}#arrow-up`); @@ -122,8 +128,8 @@ describe('getAssetUrl', function () { it('should not add asset to url if ghost.css for default templates', function () { const testUrl = getAssetUrl('public/ghost.css'); - // Without caching:assets:contentBasedHash, uses global hash - assert.equal(testUrl, '/blog/public/ghost.css?v=' + config.get('assetHash')); + // ghost.css ships in the static public path, so it gets a content-based hash + assert.match(testUrl, /^\/blog\/public\/ghost\.css\?v=[A-Za-z0-9_-]{16}$/); }); it('should not add asset to url has public in it', function () { @@ -267,6 +273,19 @@ describe('getAssetUrl', function () { assert.equal(testUrl, `/assets/${testFile}?v=${expectedHash}`); }); + it('should use the same hash whether or not the caller spells the assets/ prefix', function () { + sinon.stub(themeEngine, 'getActive').returns({ + path: fixturesPath + }); + + const bare = getAssetUrl('built/screen.css'); + const prefixed = getAssetUrl('assets/built/screen.css'); + + // Same file, same URL, so the cache-busting key must match too + assert.equal(prefixed, bare); + assert.match(prefixed, /^\/assets\/built\/screen\.css\?v=[A-Za-z0-9_-]{16}$/); + }); + it('should fallback to global hash when theme asset file does not exist', function () { // Mock active theme sinon.stub(themeEngine, 'getActive').returns({ diff --git a/ghost/core/test/unit/frontend/services/card-assets.test.js b/ghost/core/test/unit/frontend/services/card-assets.test.js index 491d0d37526..66a0e23c079 100644 --- a/ghost/core/test/unit/frontend/services/card-assets.test.js +++ b/ghost/core/test/unit/frontend/services/card-assets.test.js @@ -1,6 +1,7 @@ const assert = require('node:assert/strict'); const path = require('path'); +const crypto = require('crypto'); const fs = require('fs').promises; const os = require('os'); @@ -8,138 +9,224 @@ const CardAssetService = require('../../../../core/frontend/services/assets-mini const themeDefaults = require('../../../../core/frontend/services/theme-engine/config/defaults.json'); +const MANIFEST = { + css: { + audio: '.audio{color:#fff}', + bookmark: '.bookmark{color:#000}', + gallery: '.gallery{color:red}' + }, + js: { + audio: 'a();', + gallery: 'g();' + } +}; + +const expectedHash = content => crypto.createHash('sha256').update(content).digest('base64url').substring(0, 16); + describe('Card Asset Service', function () { let testDir, - srcDir, - destDir; + manifestPath; + + const service = config => new CardAssetService(Object.assign({manifest: manifestPath}, config === undefined ? {} : {config})); beforeAll(async function () { testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ghost-tests-')); - srcDir = path.join(testDir, 'src'); - destDir = path.join(testDir, 'dest'); + manifestPath = path.join(testDir, 'cards.manifest.json'); - await fs.mkdir(srcDir); - await fs.mkdir(destDir); - await fs.mkdir(path.join(srcDir, 'css')); - await fs.mkdir(path.join(srcDir, 'js')); + await fs.writeFile(manifestPath, JSON.stringify(MANIFEST)); }); afterAll(async function () { await fs.rm(testDir, {recursive: true}); }); - it('can load nothing', async function () { - const cardAssets = new CardAssetService({ - src: srcDir, - dest: destDir + describe('Selecting cards from the manifest', function () { + it('CARD ASSET SERVICE DEFAULT CASE: do nothing', function () { + const cardAssets = service(); + + assert.deepEqual(cardAssets.getCardNames('css'), []); + assert.deepEqual(cardAssets.getCardNames('js'), []); }); - await cardAssets.load(); + it('GHOST DEFAULT CASE: include everything', function () { + const cardAssets = service(themeDefaults.card_assets); - assert.deepEqual(cardAssets.files, []); - }); + assert.deepEqual(cardAssets.getCardNames('css'), ['audio', 'bookmark', 'gallery']); + assert.deepEqual(cardAssets.getCardNames('js'), ['audio', 'gallery']); + }); - it('can load a single css file', async function () { - const cardAssets = new CardAssetService({ - src: srcDir, - dest: destDir + it('CASE: card_assets = true, all cards assets should be included', function () { + const cardAssets = service(true); + + assert.deepEqual(cardAssets.getCardNames('css'), ['audio', 'bookmark', 'gallery']); + assert.deepEqual(cardAssets.getCardNames('js'), ['audio', 'gallery']); }); - await fs.writeFile(path.join(srcDir, 'css', 'test.css'), '.test { color: #fff }'); + it('CASE: card_assets = false, no card assets should be included', function () { + const cardAssets = service(false); + + assert.deepEqual(cardAssets.getCardNames('css'), []); + assert.deepEqual(cardAssets.getCardNames('js'), []); + }); + + it('CASE: card_assets is an object with an exclude property', function () { + const cardAssets = service({exclude: ['bookmark']}); + + assert.deepEqual(cardAssets.getCardNames('css'), ['audio', 'gallery']); + assert.deepEqual(cardAssets.getCardNames('js'), ['audio', 'gallery']); + }); - await cardAssets.load(true); + it('CASE: card_assets is an object with an include property', function () { + const cardAssets = service({include: ['gallery']}); - assert.deepEqual(cardAssets.files, ['cards.min.css']); + assert.deepEqual(cardAssets.getCardNames('css'), ['gallery']); + assert.deepEqual(cardAssets.getCardNames('js'), ['gallery']); + }); + + it('CASE: card_assets has include and exclude, include should win', function () { + const cardAssets = service({include: ['gallery'], exclude: ['bookmark']}); + + assert.deepEqual(cardAssets.getCardNames('css'), ['gallery']); + assert.deepEqual(cardAssets.getCardNames('js'), ['gallery']); + }); + + it('ignores names that this Ghost version does not ship', function () { + const cardAssets = service({include: ['gallery', 'nope']}); + + assert.deepEqual(cardAssets.getCardNames('css'), ['gallery']); + }); }); - it('can correctly load nothing when config is false', async function () { - const cardAssets = new CardAssetService({ - src: srcDir, - dest: destDir + describe('Bundling', function () { + it('concatenates the selected chunks and hashes the result', function () { + const cardAssets = service({include: ['audio', 'gallery']}); + const expectedContent = `${MANIFEST.css.audio}\n${MANIFEST.css.gallery}`; + + assert.deepEqual(cardAssets.getBundle('css'), { + content: expectedContent, + hash: expectedHash(expectedContent) + }); + }); + + it('terminates js chunks so they cannot run into each other', function () { + const cardAssets = service(true); + + assert.equal(cardAssets.getBundle('js').content, `${MANIFEST.js.audio};\n${MANIFEST.js.gallery}`); + }); + + it('produces the same hash from a separate instance', function () { + assert.equal(service(true).getHash('css'), service(true).getHash('css')); + }); + + it('produces a different hash for a different selection', function () { + assert.notEqual(service(true).getHash('css'), service({exclude: ['bookmark']}).getHash('css')); + }); + + it('has no bundle, hash or file when nothing is selected', function () { + const cardAssets = service(false); + + assert.equal(cardAssets.getBundle('css'), null); + assert.equal(cardAssets.getHash('css'), null); + assert.equal(cardAssets.hasFile('css'), false); }); - await fs.writeFile(path.join(srcDir, 'css', 'test.css'), '.test { color: #fff }'); + it('has a file for each type that resolves to content', function () { + const cardAssets = service(true); + + assert.equal(cardAssets.hasFile('css'), true); + assert.equal(cardAssets.hasFile('js'), true); + }); - await cardAssets.load(false); + it('has no js file when the selection only matches css', function () { + const cardAssets = service({include: ['bookmark']}); - assert.deepEqual(cardAssets.files, []); + assert.equal(cardAssets.hasFile('css'), true); + assert.equal(cardAssets.hasFile('js'), false); + }); }); - describe('Generate the correct glob strings', function () { - it('CARD ASSET SERVICE DEFAULT CASE: do nothing', function () { - const cardAssets = new CardAssetService(); + describe('Invalidation', function () { + it('rebundles with the new config', function () { + const cardAssets = service(true); + const before = cardAssets.getHash('css'); - assert.deepEqual(cardAssets.generateGlobs(), {}); + cardAssets.invalidate({include: ['gallery']}); + + assert.deepEqual(cardAssets.getCardNames('css'), ['gallery']); + assert.notEqual(cardAssets.getHash('css'), before); }); - it('GHOST DEFAULT CASE: exclude bookmark and gallery', function () { - const cardAssets = new CardAssetService({ - config: themeDefaults.card_assets - }); + it('applies a falsy config', function () { + const cardAssets = service(true); - assert.deepEqual(cardAssets.generateGlobs(), { - 'cards.min.css': 'css/*.css', - 'cards.min.js': 'js/*.js' - }); + cardAssets.invalidate(false); + + assert.equal(cardAssets.hasFile('css'), false); }); - it('CASE: card_assets = true, all cards assets should be included', function () { - const cardAssets = new CardAssetService({ - config: true - }); + it('keeps the existing config when called without one', function () { + const cardAssets = service({include: ['gallery']}); - assert.deepEqual(cardAssets.generateGlobs(), { - 'cards.min.css': 'css/*.css', - 'cards.min.js': 'js/*.js' - }); + cardAssets.invalidate(); + + assert.deepEqual(cardAssets.getCardNames('css'), ['gallery']); }); + }); - it('CASE: card_assets = false, no card assets should be included', function () { - const cardAssets = new CardAssetService({ - config: false + describe('Without a usable manifest', function () { + const assertThrows = (manifestPathArg) => { + assert.throws(() => new CardAssetService({manifest: manifestPathArg, config: true}), { + errorType: 'InternalServerError', + message: /Could not use the card asset manifest/ }); + }; - assert.deepEqual(cardAssets.generateGlobs(), {}); + it('throws when the manifest is missing', function () { + assertThrows(path.join(testDir, 'does-not-exist.json')); }); - it('CASE: card_assets is an object with an exclude property, generate inverse match strings', function () { - const cardAssets = new CardAssetService({ - config: { - exclude: ['bookmarks'] - } - }); + it('throws when the manifest is truncated or corrupt', async function () { + const corruptPath = path.join(testDir, 'corrupt.json'); + await fs.writeFile(corruptPath, '{"css":{"audio":".audio{co'); - assert.deepEqual(cardAssets.generateGlobs(), { - 'cards.min.css': 'css/!(bookmarks).css', - 'cards.min.js': 'js/!(bookmarks).js' - }); + assertThrows(corruptPath); }); - it('CASE: card_assets is an object with an include property, generate match strings', function () { - const cardAssets = new CardAssetService({ - config: { - include: ['gallery'] - } - }); + it('throws when the manifest is empty', async function () { + const emptyPath = path.join(testDir, 'empty.json'); + await fs.writeFile(emptyPath, ''); - assert.deepEqual(cardAssets.generateGlobs(), { - 'cards.min.css': 'css/@(gallery).css', - 'cards.min.js': 'js/@(gallery).js' - }); + assertThrows(emptyPath); }); - it('CASE: card_assets has include and exclude, include should win', function () { - const cardAssets = new CardAssetService({ - config: { - include: ['gallery'], - exclude: ['bookmark'] - } + // Well-formed JSON that isn't a manifest + const unusable = { + 'is null': 'null', + 'is a number': '42', + 'is a string': '"cards"', + 'is an array': '[]', + 'has neither css nor js': '{}', + 'has a type that is not a chunk map': '{"css":"abc","js":{}}', + 'has a type holding a non-string chunk': '{"css":{"bookmark":123},"js":{}}' + }; + + Object.entries(unusable).forEach(([name, contents]) => { + it(`throws when the manifest ${name}`, async function () { + const unusablePath = path.join(testDir, `unusable-${name.replace(/\W+/g, '-')}.json`); + await fs.writeFile(unusablePath, contents); + + assertThrows(unusablePath); }); + }); - assert.deepEqual(cardAssets.generateGlobs(), { - 'cards.min.css': 'css/@(gallery).css', - 'cards.min.js': 'js/@(gallery).js' - }); + it('still serves a manifest that omits a type', async function () { + const cssOnlyPath = path.join(testDir, 'css-only.json'); + await fs.writeFile(cssOnlyPath, '{"css":{"bookmark":".bookmark{}"}}'); + + const cardAssets = new CardAssetService({manifest: cssOnlyPath, config: true}); + + assert.equal(cardAssets.hasFile('css'), true); + assert.equal(cardAssets.hasFile('js'), false); }); }); }); From db060f2d32a9846b2e08b8d8d2b4f4a70e45e11b Mon Sep 17 00:00:00 2001 From: Fabien O'Carroll Date: Mon, 27 Jul 2026 06:22:22 +0000 Subject: [PATCH 2/5] Added CodeRabbit review rules for TypeScript + Zod type safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ghost is moving from JavaScript to TypeScript, but TypeScript alone doesn't make the codebase type-safe: types disappear at runtime, and data arriving over HTTP, from config, the database, or third-party services can still be missing or malformed. We've adopted type safety as a desired property of Ghost — TypeScript as the language, Zod as the standard for validating data at runtime boundaries. A principle only sticks if it shows up in review, and humans are inconsistent at spotting an unvalidated JSON.parse or a stray `as` assertion buried in a large diff. These CodeRabbit rules make the automated reviewer carry that lens on every PR, so the question "where does this data become trusted?" gets asked even when no human reviewer thinks to ask it. Concretely: - TypeScript files are reviewed for unvalidated boundary data, for handwritten types duplicating what a Zod schema already describes (use z.infer), and for escape hatches like `any`, unchecked `as`, and @ts-nocheck. - New source files must be TypeScript, enforced by an error-mode pre-merge check. Places where plain JS is genuinely required — DB migrations, ember-admin, tooling — are exempt. - Substantially reworked JS files get a single optional nudge that they'd be cheap to convert while the context is fresh. Deliberately out of scope: internal function calls don't need runtime validation, and merely touching a JS file never requires converting it. The rules are meant to reinforce the direction, not to tax unrelated work. --- .coderabbit.yaml | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 94464dc19a8..eedd0cb367c 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -15,3 +15,51 @@ reviews: pre_merge_checks: docstrings: mode: "off" + custom_checks: + - name: "Type-safe boundaries" + mode: "warning" + instructions: | + Fail only if the PR: + - consumes boundary data (HTTP input, external API/SDK responses, env/config, + DB/filesystem reads, queue/webhook/event payloads) without validating it + first — Zod by default, another format only where an external contract + requires it; or + - introduces `any`, unchecked `as`, `@ts-nocheck`, or `@ts-ignore` to bypass + typing boundary data; or + - hand-writes a type duplicating a shape a Zod schema describes (use z.infer). + Never fail for: internal function/module calls (no runtime validation needed), + pre-existing JS files touched incidentally, tests, scripts, or config files. + - name: "New files are TypeScript" + mode: "error" + instructions: | + Fail if the PR adds a new .js/.jsx/.cjs/.mjs source file, unless it is: a DB + migration (ghost/core/core/server/data/migrations/), under apps/ember-admin/, + a tool/config file, under scripts/ or docker/, or generated/vendored code. + Modifying pre-existing JS files never fails this check. + path_instructions: + - path: "**/*.{ts,tsx,mts,cts}" + instructions: | + Review lens: "where does this data become trusted?" + - Boundary data (HTTP input, external API/SDK responses, env/config, + DB/filesystem reads, queue/webhook/event payloads) is `unknown` until + validated — Zod by default. + - Infer boundary types via z.infer/z.input; flag handwritten duplicates. + - Flag `any`, unchecked `as` on boundary data, `@ts-nocheck`, and unexplained + `@ts-ignore`/`@ts-expect-error`. + - Validated data stays trusted: don't request Zod on internal calls, and flag + redundant re-validation. + - ghost/core golden path: schema.ts owns Zod schemas + inferred types, with + codec/serializer modules at the edges (see core/server/services/gift-links). + - Looser typing in tests is fine unless it hides a real defect. + - path: "**/*.{js,jsx,cjs,mjs}" + instructions: | + New source files must be TypeScript: flag new JS files as a required change + unless exempt (DB migrations, apps/ember-admin/, tool/config files, scripts/, + docker/, generated code). + Never request conversion of pre-existing JS files. If the PR substantially + reworks one (rewritten logic or significant new functions — not renames or + small fixes), you may leave ONE optional, non-blocking note for the whole PR + that those files are cheap TS-conversion candidates; skip minor changes and + exempt areas. + If the PR adds or changes a runtime boundary (parsing HTTP input, JSON, config, + external responses), suggest validating it — ideally with TS + Zod. From be3a1520dc952f571b3e6489a172e4cb96949e52 Mon Sep 17 00:00:00 2001 From: Princi Vershwal Date: Mon, 17 Aug 2026 14:09:42 +0530 Subject: [PATCH 3/5] Fixed the boot benchmark failing on a missing card asset manifest (#30004) no ref - cbe73b1 moved card minification to build time and made cards.manifest.json a hard boot requirement, but job_perf-tests only ran build:tsc, so boot died with ENOENT and hyperfine exited 2 - the test jobs never hit this because their nx targets declare build:assets in dependsOn, and the production-image benchmark gets it from Dockerfile.production - this job boots via raw `node index.js`, so there is no target to inherit it from and it has to build the assets itself - the step sits outside the measured command, so the boot-time series stays comparable with its history --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5006aa5eff4..7df90c7bba3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -566,6 +566,9 @@ jobs: - name: Build TS code run: pnpm nx run-many -t build:tsc + - name: Build assets + run: pnpm --filter ghost run build:assets + - name: Run hyperfine on boot working-directory: ghost/core run: hyperfine --show-output --warmup 3 'GHOST_CI_SHUTDOWN_AFTER_BOOT=1 node index.js' --export-json boot-perf.json From d4611c6af58a91b23d67be8a785f539c348442f9 Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Mon, 17 Aug 2026 10:18:34 +0100 Subject: [PATCH 4/5] Improved CodeRabbit's Ghost review context (#30003) CodeRabbit had limited Ghost-specific context, making reviews more likely to miss established conventions or produce broad, generic feedback. Now our documentation is in the codebase, we were able to map canonical documentation to the code it governs and add focused review guidance for backend services, APIs, migrations, packages, Admin UI, public apps, E2E tests, and documentation. Prioritise concrete correctness, security, compatibility, and regression risks while avoiding feedback already covered by lint or CI. Explicitly enable relevant analysis tools, exclude generated output, and skip dependency-bot PRs. Keep general review feedback non-blocking while retaining the existing type-safety pre-merge checks. --- .coderabbit.yaml | 149 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 148 insertions(+), 1 deletion(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index eedd0cb367c..9f5c78e7340 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,7 +1,10 @@ # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json reviews: profile: quiet + request_changes_workflow: false review_details: true + review_status: true + review_progress: true high_level_summary: false collapse_walkthrough: false changed_files_summary: false @@ -9,9 +12,17 @@ reviews: estimate_code_review_effort: false poem: false auto_review: + enabled: true + drafts: false ignore_usernames: - - tryghost-renovate[bot] + - app/tryghost-renovate - dependabot[bot] + path_filters: + - "!**/dist/**" + - "!**/build/**" + - "!**/built/**" + - "!**/umd/**" + - "!**/coverage/**" pre_merge_checks: docstrings: mode: "off" @@ -37,6 +48,16 @@ reviews: a tool/config file, under scripts/ or docker/, or generated/vendored code. Modifying pre-existing JS files never fails this check. path_instructions: + - path: "**/*" + instructions: | + Prioritise concrete correctness, security, data-integrity, compatibility, + and regression risks. Explain the failure mode and point to the affected + code. Do not report formatting, naming, import ordering, type errors, or + other findings already owned by configured static tools or failing GitHub + checks. Do not request speculative abstractions, broad refactors, generic + documentation, or tests unrelated to changed behaviour. Treat nearby + AGENTS.md files and mapped codebase documentation as authoritative; do not + enforce proposals, plans, or historical guidance as current policy. - path: "**/*.{ts,tsx,mts,cts}" instructions: | Review lens: "where does this data become trusted?" @@ -63,3 +84,129 @@ reviews: exempt areas. If the PR adds or changes a runtime boundary (parsing HTTP input, JSON, config, external responses), suggest validating it — ideally with TS + Zod. + - path: "ghost/core/core/server/api/**" + instructions: | + Review API contract semantics: authentication and permissions, validation at + untrusted boundaries, writable-field allowlists, accidental response-data + exposure, stable error codes/statuses, pagination/filter consistency, cache + invalidation, and compatibility with existing clients. Require tests only for + changed behaviour or a credible regression path. Do not repeat endpoint + complexity, filenames, typing, or other ESLint/schema failures. + - path: "ghost/core/core/server/services/**" + instructions: | + Review new or changed service boundaries for explicit dependency ownership, + deterministic/idempotent initialisation, boot ordering, transaction and event + semantics, cache coherence, and restart/multi-instance safety. New standalone + services default to TypeScript; extending an existing JavaScript service is an + accepted exception. Do not enforce unapproved repository, ORM, or dependency- + injection proposals as current architecture. + - path: "ghost/core/core/server/data/{migrations,schema}/**" + instructions: | + Review migration safety beyond lint: schema and migration parity, existing-data + shape and volume, deploy/rollback compatibility, transaction and locking risk, + idempotency, export/integrity updates, and preservation of constraints/defaults. + Do not duplicate migration filename, loop, schema-field, or integrity-check CI. + - path: "packages/**" + instructions: | + Review package boundaries and production consumption: minimal explicit exports, + declared runtime dependencies, source-condition versus built-output parity, + copied runtime assets, ESM/NodeNext compatibility, and consumer-facing release + impact. Respect ghostPackage migration/exempt metadata and public/browser/test- + only exceptions. Do not repeat fields enforced by lint:packages or changeset CI. + - path: "apps/{admin,activitypub,admin-x-framework,shade}/**/*.{ts,tsx}" + instructions: | + Review Admin UI for existing Shade reuse, correct component layer, semantic + tokens, accessible interaction states, and whole-sentence translations. New UI + that depends on backend settings, endpoints, or config must feature-detect old + backend support and cover the not-yet-deployed backend case. Do not apply these + rules to independent public UMD apps. Do not repeat ESLint/Tailwind findings. + - path: "apps/{portal,comments-ui,signup-form,sodo-search,announcement-bar}/**/*.{js,jsx,ts,tsx}" + instructions: | + These are independent public UMD/CDN surfaces, not embedded Shade apps. Review + backwards-compatible browser behaviour, bundle/runtime assumptions, accessible + recovery states, namespace-correct whole-sentence translations, and safe + handling of server-provided data. Do not request Shade adoption or Admin-only + Tailwind conventions. + - path: "e2e/tests/**/*.ts" + instructions: | + Review semantic E2E quality that static checks miss: test the user-visible + integration at the lowest useful layer; prefer web-first assertions and + semantic locators; keep reusable interactions in page objects and assertions in + tests; avoid hard waits and networkidle; use factories and preserve isolation. + Per-file environment reuse is the default, so request per-test isolation only + for state-heavy cases that genuinely need it. A direct semantic locator is fine + for a small one-off assertion. Do not repeat Playwright ESLint or CI failures. + - path: "e2e/helpers/**/*.ts" + instructions: | + Review fixture/page-object lifecycle, concurrency, reset timing, reusable + readiness guards, and stable public locators. Page objects may use necessary + structural selectors for iframe/editor/theme internals but must not contain + business assertions. Preserve the documented per-file/per-test isolation model. + - path: "**/*{.,-}{test,spec}.{js,jsx,ts,tsx}" + instructions: | + Review whether tests prove changed behaviour, meaningful error/edge paths, and + externally observable contracts without coupling to implementation details. + Prefer the lowest useful test layer. Do not demand broad E2E coverage for + isolated logic or repeat test-run failures already visible in GitHub checks. + - path: "docs/**/*.md" + instructions: | + Check technical claims, paths, commands, and declared authority/status against + the current repository. Flag contradictions and stale instructions with a + concrete source of truth. Do not demand generic tutorial expansion or enforce + proposal language on production code. + tools: + eslint: + enabled: true + oxc: + enabled: true + stylelint: + enabled: true + emberTemplateLint: + enabled: true + actionlint: + enabled: true + zizmor: + enabled: true + yamllint: + enabled: true + shellcheck: + enabled: true + opengrep: + enabled: true + gitleaks: + enabled: true + trufflehog: + enabled: true + osvScanner: + enabled: true + github-checks: + enabled: true + +knowledge_base: + code_guidelines: + enabled: true + filePatterns: + - files: "docs/practices/api-design.md" + applyTo: "ghost/core/core/server/api/**,packages/admin-api-schema/**" + - files: "docs/practices/database-migrations.md" + applyTo: "ghost/core/core/server/data/migrations/**,ghost/core/core/server/data/schema/**" + - files: "docs/practices/error-handling.md" + applyTo: "ghost/core/core/server/**,apps/**/*.{js,jsx,ts,tsx}" + - files: "docs/practices/internationalization.md" + applyTo: "apps/**/*.{js,jsx,ts,tsx},packages/i18n/**" + - files: "docs/contributing/testing.md" + applyTo: "**/{test,tests}/**,**/*{.,-}{test,spec}.{js,jsx,ts,tsx}" + - files: "docs/codebase/monorepo-structure.md" + applyTo: "package.json,pnpm-workspace.yaml,nx.json,apps/**,packages/**,ghost/core/**,koenig/**" + - files: "docs/codebase/configuration.md" + applyTo: "ghost/core/core/shared/config/**,ghost/core/config*.json*" + - files: "docs/codebase/internal-caching.md" + applyTo: "ghost/core/core/server/adapters/cache/**,ghost/core/core/server/adapters/lib/redis/**,ghost/core/core/server/**/*cache*.{js,ts},ghost/core/core/shared/config/**,packages/adapters/cache-base/**" + - files: "docs/codebase/jobs.md" + applyTo: "ghost/core/core/server/services/**" + - files: "packages/README.md" + applyTo: "packages/**" + - files: "apps/shade/AGENTS.md" + applyTo: "apps/admin/**,apps/activitypub/**,apps/admin-x-framework/**,apps/shade/**" + - files: "e2e/README.md,e2e/AGENTS.md" + applyTo: "e2e/**" From c5f2fd9b64ec1a59712af1d249eea10bc1495e6d Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Mon, 17 Aug 2026 11:13:14 +0100 Subject: [PATCH 5/5] Added documentation link checks (#29999) Added fast, offline checks for broken repository-relative links, images, heading anchors, and objective Markdown syntax. Uses maintained remark and markdownlint tooling with a deliberately conservative rule set, avoiding subjective formatting noise while making documentation regressions fail through the existing lint:docs command. --- .markdownlint-cli2.jsonc | 16 + .../core/server/data/tinybird/ARCHITECTURE.md | 3 +- package.json | 7 +- .../admin-api-schema/src/schemas/README.md | 4 +- pnpm-lock.yaml | 575 +++++++++++++++++- pnpm-workspace.yaml | 3 + 6 files changed, 596 insertions(+), 12 deletions(-) create mode 100644 .markdownlint-cli2.jsonc diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 00000000000..9dbbb45b295 --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,16 @@ +{ + "config": { + "default": false, + "MD011": true, + "MD018": true, + "MD019": true, + "MD020": true, + "MD021": true, + "MD037": true, + "MD038": true, + "MD039": true, + "MD051": true, + "MD052": true, + "MD056": true + } +} diff --git a/ghost/core/core/server/data/tinybird/ARCHITECTURE.md b/ghost/core/core/server/data/tinybird/ARCHITECTURE.md index 2e515170668..917b9314081 100644 --- a/ghost/core/core/server/data/tinybird/ARCHITECTURE.md +++ b/ghost/core/core/server/data/tinybird/ARCHITECTURE.md @@ -10,8 +10,7 @@ to provide real-time analytics. 2. [MySQL Schema (Ghost Database)](#mysql-schema-ghost-database) 3. [Tinybird Event Schema](#tinybird-event-schema) 4. [Data Flow & Relationships](#data-flow--relationships) -5. [API Endpoints](#api-endpoints) -6. [Mock Data Considerations](#mock-data-considerations) +5. [Mock Data Considerations](#mock-data-considerations) ## Overview Ghost's traffic analytics system has two data sources: MySQL and Tinybird diff --git a/package.json b/package.json index cd2300d3d01..0b39f48b0d3 100644 --- a/package.json +++ b/package.json @@ -64,9 +64,11 @@ "knip:fix": "knip --fix --allow-remove-files=false", "lint": "pnpm nx run-many -t lint lint:boundaries && pnpm lint:packages && pnpm lint:docs", "lint:agent-skills": "node scripts/check-agent-skill-links.js", + "lint:doc-links": "git ls-files -z '*.md' ':(exclude,glob).changeset/**' ':(exclude,glob)**/fixture/**' ':(exclude,glob)**/fixtures/**' | xargs -0 remark --use remark-validate-links --frail --quiet", + "lint:markdown": "git ls-files -z '*.md' ':(exclude,glob).changeset/**' ':(exclude,glob)**/fixture/**' ':(exclude,glob)**/fixtures/**' | xargs -0 markdownlint-cli2 --config .markdownlint-cli2.jsonc", "lint:boundaries": "depcruise ghost/core/core apps --config .dependency-cruiser.cjs", "lint:packages": "node scripts/check-internal-packages.js", - "lint:docs": "pnpm lint:agent-skills", + "lint:docs": "pnpm lint:agent-skills && pnpm lint:markdown && pnpm lint:doc-links", "check": "pnpm lint && pnpm test", "test": "pnpm nx run-many -t test --exclude @tryghost/e2e --exclude ghost-admin", "test:unit": "pnpm nx run-many -t test:unit", @@ -95,7 +97,10 @@ "husky": "9.1.7", "knip": "catalog:", "lint-staged": "catalog:", + "markdownlint-cli2": "catalog:", "nx": "23.1.1", + "remark-cli": "catalog:", + "remark-validate-links": "catalog:", "rimraf": "6.1.3", "secretlint": "catalog:", "shell-quote": "catalog:", diff --git a/packages/admin-api-schema/src/schemas/README.md b/packages/admin-api-schema/src/schemas/README.md index f0a3766053c..445782641a7 100644 --- a/packages/admin-api-schema/src/schemas/README.md +++ b/packages/admin-api-schema/src/schemas/README.md @@ -1,8 +1,8 @@ # JSON Schema structuring convention When adding a new schema or definition following naming convention should be followed: -1. Name the file containing JSON definitions in the same way as resource is named in the API (aka "docName" in controller config). For example, for [tags](https://github.com/TryGhost/Ghost/blob/2a921b86598184fcd5a2d95fefae4283bba1042a/core/server/api/canary/tags.js#L9) the definitions file would be `tags.json` -2. Name the file containing JSON schema (which usually references "definitions") using following convention `{resourceName}-{methodName}.json`. For example, for [tags.edit](https://github.com/TryGhost/Ghost/blob/7252f03824fff38564e4fa759a181b6228e094b2/core/server/api/canary/tags.js#L89) method file would be named `tags-edit.json` +1. Name the file containing JSON definitions in the same way as resource is named in the API (aka "docName" in controller config). For example, for [tags](../../../../ghost/core/core/server/api/endpoints/tags.js#L13) the definitions file would be `tags.json` +2. Name the file containing JSON schema (which usually references "definitions") using following convention `{resourceName}-{methodName}.json`. For example, for [tags.edit](../../../../ghost/core/core/server/api/endpoints/tags.js#L97) method file would be named `tags-edit.json` 3. Import the schema and add it to the registry in `index.ts`. Add action schemas to `actionSchemaNames` so they are returned by `list()`. The need to have separate "definitions" and "schema" files comes from [schema reusability pattern](https://cswr.github.io/JsonSchema/spec/definitions_references/) that JSON schema allows for through $ref keyword. In some cases, the pattern doesn't quite work out because of limitations of the syntax (cannot override parts of referenced definition). Generally try to reuse as much schema as possible to avoid duplication unless it becomes painful to do so. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10808b4ed3e..154a1e5cad7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -354,6 +354,9 @@ catalogs: lucide-react: specifier: 1.23.0 version: 1.23.0 + markdownlint-cli2: + specifier: 0.23.2 + version: 0.23.2 mingo: specifier: 2.5.3 version: 2.5.3 @@ -387,6 +390,12 @@ catalogs: react-router: specifier: 7.18.1 version: 7.18.1 + remark-cli: + specifier: 12.0.1 + version: 12.0.1 + remark-validate-links: + specifier: 13.1.0 + version: 13.1.0 secretlint: specifier: 13.0.4 version: 13.0.4 @@ -570,12 +579,21 @@ importers: lint-staged: specifier: 'catalog:' version: 17.3.0 + markdownlint-cli2: + specifier: 'catalog:' + version: 0.23.2(supports-color@10.2.2) node: specifier: runtime:22.23.1 version: runtime:22.23.1 nx: specifier: 23.1.1 version: 23.1.1(@swc/core@1.15.43(@swc/helpers@0.5.23)) + remark-cli: + specifier: 'catalog:' + version: 12.0.1(bluebird@3.7.2)(supports-color@10.2.2) + remark-validate-links: + specifier: 'catalog:' + version: 13.1.0(bluebird@3.7.2)(supports-color@10.2.2) rimraf: specifier: 6.1.3 version: 6.1.3 @@ -6594,6 +6612,30 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@npmcli/config@8.3.4': + resolution: {integrity: sha512-01rtHedemDNhUXdicU7s+QYz/3JyV5Naj84cvdXGH4mgCdL+agmSYaLF4LUG4vMCLzhBO8YtS0gPpH1FGvbgAw==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@npmcli/git@5.0.8': + resolution: {integrity: sha512-liASfw5cqhjNW9UFd+ruwwdEf/lbOAQjLL2XY2dFW/bkJheXDYZgOyul/4gVvEV4BWkTXjYGmDqMw9uegdbJNQ==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@npmcli/map-workspaces@3.0.6': + resolution: {integrity: sha512-tkYs0OYnzQm6iIRdfy+LcLBjcKuQCeE5YLb8KnrIlutJfheNaPvPpgoFEyEFgbjzl5PLZ3IA/BWAwRU0eHuQDA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + '@npmcli/name-from-folder@2.0.0': + resolution: {integrity: sha512-pwK+BfEBZJbKdNYpHHRTNBwBoqrN/iIMO0AiGvYsp3Hoaq0WbgGSWQR6SCldZovoDpY3yje5lkFUe6gsDgJ2vg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + '@npmcli/package-json@5.2.1': + resolution: {integrity: sha512-f7zYC6kQautXHvNbLEWgD/uGu1+xCn9izgqBfgItWSx22U0ZDekxN08A1vM8cTxj/cRVe0Q94Ode+tdoYmIOOQ==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@npmcli/promise-spawn@7.0.2': + resolution: {integrity: sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ==} + engines: {node: ^16.14.0 || >=18.0.0} + '@number-flow/react@0.6.2': resolution: {integrity: sha512-WjZuV4aA+vhRCgCF+adGLgFVVAJ8vdvq6EchRT2FiBIgElTXtDOA3YgkcMr9UHpvpS9v4H70AB5wZv0D9jc3QA==} peerDependencies: @@ -9375,6 +9417,9 @@ packages: '@types/common-tags@1.8.4': resolution: {integrity: sha512-S+1hLDJPjWNDhcGxsxEbepzaxWqURP/o+3cP4aa2w7yBXgdcmKGQtZzP8JbyfOd0m+33nh+8+kvxYE2UJtBDkg==} + '@types/concat-stream@2.0.3': + resolution: {integrity: sha512-3qe4oQAPNwVNwK4C9c8u+VJqv9kez+2MR4qJpoPFfXtgxxif1QbFusvXzK0/Wra2VX07smostI2VMmJNSpZjuQ==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -9484,12 +9529,18 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/hosted-git-info@3.0.5': + resolution: {integrity: sha512-Dmngh7U003cOHPhKGyA7LWqrnvcTyILNgNPmNCxlx7j8MIi54iBliiT8XqVLIQ3GchoOjVAyBzNJVyuaJjqokg==} + '@types/http-cache-semantics@4.2.0': resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/is-empty@1.2.3': + resolution: {integrity: sha512-4J1l5d79hoIvsrKh5VUKVRA1aIdsOb10Hu5j3J2VfP/msDnfTdGPmNp2E1Wg+vs97Bktzo+MZePFFXSGoykYJw==} + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -9514,6 +9565,9 @@ packages: '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} @@ -9678,9 +9732,15 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/supports-color@8.1.3': + resolution: {integrity: sha512-Hy6UMpxhE3j1tLpl27exp1XqHD7n8chAiNPzWfz16LPZoMMoSc4dzLl6w9qijkEb/r5O1ozdu1CWGA2L83ZeZg==} + '@types/symlink-or-copy@1.2.2': resolution: {integrity: sha512-MQ1AnmTLOncwEf9IVU+B2e4Hchrku5N67NkgcAHW0p3sdzPe0FNMANxEm6OJUzPniEQGkeT3OROLlCwZJLWFZA==} + '@types/text-table@0.2.5': + resolution: {integrity: sha512-hcZhlNvMkQG/k1vcZ6yHOl6WAYftQ2MLfTHcYRZ2xYZFD8tGVnE3qFV0lj1smQeDSR7/yY0PyuUalauf33bJeA==} + '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} @@ -13012,6 +13072,9 @@ packages: duplexify@3.7.1: resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -13676,6 +13739,9 @@ packages: eol@0.9.1: resolution: {integrity: sha512-Ds/TEoZjwggRoz/Q2O7SE3i4Jm66mqTDfmdHdq/7DKVk3bro9Q8h6WdXKdPqFLMoqxrDK5SVRzHVPOS6uuGtrg==} + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + errno@0.1.8: resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} hasBin: true @@ -14732,6 +14798,9 @@ packages: github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + glob-parent@3.1.0: resolution: {integrity: sha512-E8Ak/2+dZY6fnzlR7+ueWvhsH1SjHr4jjss4YS/h4py44jY9MhK/VFdaZJAWDz6BbL21KeteKxFSFpq8OS5gVA==} @@ -14845,6 +14914,10 @@ packages: resolution: {integrity: sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + globby@16.2.2: + resolution: {integrity: sha512-NLvV9ubZ6NDsJaOpKPy3cQeJpKi9DcWiyCiFUpJPA0YihRqiE6RWaLUmgNNPr8MgPpLZjnBjSmou7uZBRJv9wA==} + engines: {node: '>=20'} + globby@16.2.3: resolution: {integrity: sha512-VZX7TV7jmd/pn71vdnLKtgwy1IWqc3KjI9x1/UtPkwoKk5fKrNLY30ltDe3cAM5xruIN7YuuaulFt133jRrKZg==} engines: {node: '>=20'} @@ -15047,6 +15120,10 @@ packages: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} + hosted-git-info@7.0.2: + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} + hosted-git-info@9.0.3: resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} engines: {node: ^20.17.0 || >=22.9.0} @@ -15252,6 +15329,10 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + ignore@6.0.2: + resolution: {integrity: sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A==} + engines: {node: '>= 4'} + ignore@7.0.5: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} @@ -15356,6 +15437,10 @@ packages: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ini@4.1.3: + resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + inline-source-map-comment@1.0.5: resolution: {integrity: sha512-a3/m6XgooVCXkZCduOb7pkuvUtNKt4DaqaggKKJrMQHQsqt6JcJXEreExeZiiK4vWL/cM/uF6+chH05pz2/TdQ==} hasBin: true @@ -15537,6 +15622,9 @@ packages: resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} engines: {node: '>= 0.4'} + is-empty@1.2.0: + resolution: {integrity: sha512-F2FnH/otLNJv0J6wc73A5Xo7oHLNnqplYqZhUu01tD54DIPvxIRSTSLkrUB/M0nHO4vo1O9PDfN4KoTxCzLh/w==} + is-extglob@1.0.0: resolution: {integrity: sha512-7Q+VbVafe6x2T+Tu6NcOf6sRklazEPmBoB3IWk3WdGZM2iGUwU/Oe3Wtq5lSEkDTTlpp8yx+5t4pzO/i9Ty1ww==} engines: {node: '>=0.10.0'} @@ -15820,6 +15908,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + isexe@4.0.0: resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} engines: {node: '>=20'} @@ -16128,6 +16220,10 @@ packages: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true + js-yaml@5.2.2: + resolution: {integrity: sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==} + hasBin: true + jsdom@16.7.0: resolution: {integrity: sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==} engines: {node: '>=10'} @@ -16176,6 +16272,10 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-parse-even-better-errors@3.0.2: + resolution: {integrity: sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -16219,6 +16319,10 @@ packages: jsonify@0.0.1: resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==} + jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + jsonrepair@3.14.0: resolution: {integrity: sha512-tWPGKMZf/8UPim+fcW2EfcQ/d/7aKUrP6IECz9G3Tu6Q5dX0orSleqJ9z6sSw7qrQkjF8/Edo4DvsWBZ8H+HNg==} hasBin: true @@ -16265,6 +16369,10 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + keygrip@1.1.0: resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} engines: {node: '>= 0.6'} @@ -16405,6 +16513,10 @@ packages: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} + levenshtein-edit-distance@1.0.0: + resolution: {integrity: sha512-gpgBvPn7IFIAL32f0o6Nsh2g+5uOvkt4eK9epTfgE4YVxBxwVhJ/p1888lMm/u8mXdu1ETLSi6zeEmkBI+0F3w==} + hasBin: true + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -16554,6 +16666,9 @@ packages: livereload-js@3.4.1: resolution: {integrity: sha512-5MP0uUeVCec89ZbNOT/i97Mc+q3SxXmiUGhRFOTmhrGPn//uWVQdCvcLJDy64MSBR5MidFdOR7B9viumoavy6g==} + load-plugin@6.0.3: + resolution: {integrity: sha512-kc0X2FEUZr145odl68frm+lMJuQ23+rTXYmR6TImqPtbpmXC4vVXbWKDQ9IzndA0HfyQamWfKLhzsqGSTxE63w==} + loader-runner@2.4.0: resolution: {integrity: sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==} engines: {node: '>=4.3.0 <5.0.0 || >=5.10'} @@ -16964,6 +17079,20 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + markdownlint-cli2-formatter-default@0.0.6: + resolution: {integrity: sha512-VVDGKsq9sgzu378swJ0fcHfSicUnMxnL8gnLm/Q4J/xsNJ4e5bA6lvAz7PCzIl0/No0lHyaWdqVD2jotxOSFMQ==} + peerDependencies: + markdownlint-cli2: '>=0.0.4' + + markdownlint-cli2@0.23.2: + resolution: {integrity: sha512-eUhcnkSpzURo/o4htSqc7LPDszgOOTknhU4eY/sPHvMCLxnTCYscv1gw1/js/idmaZPisv9ECVEIORcllqjTUw==} + engines: {node: '>=22'} + hasBin: true + + markdownlint@0.41.1: + resolution: {integrity: sha512-qHKeU2E1bdyNAT077go2FVTNXvYcktN5IHtF6XyeD1l0PClxzSp2tUApAV14ORI8DGX4H9bNKZEzelZp4qn8IA==} + engines: {node: '>=22'} + match-media@0.2.0: resolution: {integrity: sha512-EoLj8yVf95UNcZMbNJBlAA3E6XLzgMI6ZbnL90WnL2YCzOO4Nza1Ol6TH9ElbuUcDWxN7KxgVM2vYI6P5JqvKg==} @@ -17164,6 +17293,9 @@ packages: micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + micromark-extension-directive@4.0.0: + resolution: {integrity: sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==} + micromark-extension-gfm-autolink-literal@2.1.0: resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} @@ -17185,6 +17317,9 @@ packages: micromark-extension-gfm@3.0.0: resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + micromark-extension-mdx-expression@3.0.1: resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} @@ -17862,6 +17997,10 @@ packages: normalize-package-data@2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} + normalize-package-data@6.0.2: + resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} + engines: {node: ^16.14.0 || >=18.0.0} + normalize-package-data@8.0.0: resolution: {integrity: sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==} engines: {node: ^20.17.0 || >=22.9.0} @@ -17904,10 +18043,26 @@ packages: npm-git-info@1.0.3: resolution: {integrity: sha512-i5WBdj4F/ULl16z9ZhsJDMl1EQCMQhHZzBwNnKL2LOA+T8IHNeRkLCVz9uVV9SzUdGTbDq+1oXhIYMe+8148vw==} + npm-install-checks@6.3.0: + resolution: {integrity: sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + npm-normalize-package-bin@3.0.1: + resolution: {integrity: sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + npm-package-arg@11.0.3: + resolution: {integrity: sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==} + engines: {node: ^16.14.0 || >=18.0.0} + npm-package-arg@8.1.5: resolution: {integrity: sha512-LhgZrg0n0VgvzVdSm1oiZworPbTxYHUJCgtsJW8mGvlDpxTM1vSJc3m5QZeUkhAHIzbz3VCHd/R4osi1L1Tg/Q==} engines: {node: '>=10'} + npm-pick-manifest@9.1.0: + resolution: {integrity: sha512-nkc+3pIIhqHVQr085X9d2JzPzLyjzQS96zbruppqC9aZRm/x8xx6xhI98gHtsfELP2bE+loHq8ZaHFHhe+NauA==} + engines: {node: ^16.14.0 || >=18.0.0} + npm-run-path@2.0.2: resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==} engines: {node: '>=4'} @@ -18263,6 +18418,10 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-json@7.1.1: + resolution: {integrity: sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw==} + engines: {node: '>=16'} + parse-json@8.3.0: resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} engines: {node: '>=18'} @@ -19215,6 +19374,10 @@ packages: probe-image-size@7.3.0: resolution: {integrity: sha512-7CaDeBwiAbh6ohXsvLbAZhO7wzsZAmaevfxe39qvCwRh8LyaZfDlBGGLU1CCTgrTLtCOdwBBhjOrIHaIIimHfQ==} + proc-log@4.2.0: + resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + proc-log@5.0.0: resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -19256,6 +19419,10 @@ packages: resolution: {integrity: sha512-3npG2NGhTc8BWBolLLf8l/92OxMGaRLbqvIh9wjCHhDXNvk4zsxaTaCpiCunW09qWPrN2zeNSNwRLVBrQQtutA==} engines: {node: 10.* || >= 12.*} + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + promise.hash.helper@1.0.8: resolution: {integrity: sha512-KYcnXctWUWyVD3W3Ye0ZDuA1N8Szrh85cVCxpG6xYrOk/0CttRtYCmU30nWsUch0NuExQQ63QXvzRE6FLimZmg==} engines: {node: 10.* || >= 12.*} @@ -19280,6 +19447,9 @@ packages: property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + propose@0.0.5: + resolution: {integrity: sha512-Jary1vb+ap2DIwOGfyiadcK4x1Iu3pzpkDBy8tljFPmQvnc9ES3m1PMZOMiWOG50cfoAyYNtGeBzrp+Rlh4G9A==} + prosemirror-changeset@2.4.1: resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==} @@ -19645,6 +19815,10 @@ packages: read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + read-package-json-fast@3.0.2: + resolution: {integrity: sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + read-pkg-up@7.0.1: resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} engines: {node: '>=8'} @@ -19808,6 +19982,10 @@ packages: resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==} engines: {node: '>= 0.10'} + remark-cli@12.0.1: + resolution: {integrity: sha512-2NAEOACoTgo+e+YAaCTODqbrWyhMVmlUyjxNCkTrDRHHQvH6+NbrnqVvQaLH/Q8Ket3v90A43dgAJmXv8y5Tkw==} + hasBin: true + remark-footnotes@1.0.0: resolution: {integrity: sha512-X9Ncj4cj3/CIvLI2Z9IobHtVi8FVdUrdJkCNaL9kdX8ohfsi18DXHsCVd/A7ssARBdccdDb5ODnt62WuEWaM/g==} @@ -19832,9 +20010,15 @@ packages: remark-stringify@7.0.4: resolution: {integrity: sha512-qck+8NeA1D0utk1ttKcWAoHRrJxERYQzkHDyn+pF5Z4whX1ug98uCNPPSeFgLSaNERRxnD6oxIug6DzZQth6Pg==} + remark-validate-links@13.1.0: + resolution: {integrity: sha512-z+glZ4zoRyrWimQHtoqJEFJdPoIR1R1SDr/JoWjmS6EsYlyhxNuCHtIt165gmV7ltOSFJ+rGsipqRGfBPInd7A==} + remark@11.0.2: resolution: {integrity: sha512-bh+eJgn8wgmbHmIBOuwJFdTVRVpl3fcVP6HxmpPWO0ULGP9Qkh6INJh0N5Uy7GqlV7DQYGoqaKiEIpM5LLvJ8w==} + remark@15.0.1: + resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + remove-trailing-separator@1.1.0: resolution: {integrity: sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==} @@ -20414,6 +20598,10 @@ packages: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} + smol-toml@1.7.0: + resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} + engines: {node: '>= 18'} + smol-toml@1.7.1: resolution: {integrity: sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==} engines: {node: '>= 18'} @@ -20645,6 +20833,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@6.1.0: + resolution: {integrity: sha512-k01swCJAgQmuADB0YIc+7TuatfNvTBVOoaUWJjTB9R4VJzR5vNWzf5t42ESVZFPS8xTySF7CAdV4t/aaIm3UnQ==} + engines: {node: '>=16'} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -21323,6 +21515,10 @@ packages: resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} engines: {node: '>=8'} + type-fest@3.13.1: + resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==} + engines: {node: '>=14.16'} + type-fest@4.41.0: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} @@ -21502,6 +21698,12 @@ packages: resolution: {integrity: sha512-GIp57N6DVVJi8dpeIU6/leJGdv7W65ZSXFLFiNmxvexXkc0nXdqUvhA/qL9KqBKsILxMwg5MnmYNOIDJLb5JVA==} engines: {node: '>= 0.4.12'} + unified-args@11.0.1: + resolution: {integrity: sha512-WEQghE91+0s3xPVs0YW6a5zUduNLjmANswX7YbBfksHNDGMjHxaWCql4SR7c9q0yov/XiIEdk6r/LqfPjaYGcw==} + + unified-engine@11.2.2: + resolution: {integrity: sha512-15g/gWE7qQl9tQ3nAEbMd5h9HV1EACtFs6N9xaRBZICoCwnNGbal1kOs++ICf4aiTdItZxU2s/kYWhW7htlqJg==} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -21524,6 +21726,9 @@ packages: resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} engines: {node: '>=8'} + unist-util-inspect@8.1.0: + resolution: {integrity: sha512-mOlg8Mp33pR0eeFpo5d2902ojqFFOKMMG2hF8bmH7ZlhnmjFgh0NI3/ZDwdaBJNbvrS7LZFVrBVtIE9KZ9s7vQ==} + unist-util-is@3.0.0: resolution: {integrity: sha512-sVZZX3+kspVNmLWBPAB6r+7D9ZgAFPNWm66f7YNb420RlQSbn+n8rG8dGZSkrER7ZIXGQYNm5pqC3v3HopH24A==} @@ -21761,6 +21966,10 @@ packages: validate-npm-package-name@3.0.0: resolution: {integrity: sha512-M6w37eVCMMouJ9V/sdPGnC5H4uDr73/+xdq0FBLO3TFFX1+7wiUY6Es328NN+y43tmY+doUdN9g9J21vqB7iLw==} + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + validate-peer-dependencies@1.2.0: resolution: {integrity: sha512-nd2HUpKc6RWblPZQ2GDuI65sxJ2n/UqZwSBVtj64xlWjMx0m7ZB2m9b2JS3v1f+n9VWH/dd1CMhkHfP6pIdckA==} @@ -21803,6 +22012,15 @@ packages: vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + vfile-reporter@8.1.1: + resolution: {integrity: sha512-qxRZcnFSQt6pWKn3PAk81yLK2rO2i7CDXpy8v8ZquiEOMLSnPw6BMSi9Y1sUCwGGl7a9b3CJT1CKpnRF7pp66g==} + + vfile-sort@4.0.0: + resolution: {integrity: sha512-lffPI1JrbHDTToJwcq0rl6rBmkjQmMuXkAxsZPRS9DXbaJQvc642eCg6EGxcX2i1L+esbuhq+2l9tBll5v8AeQ==} + + vfile-statistics@3.0.0: + resolution: {integrity: sha512-/qlwqwWBWFOmpXujL/20P+Iuydil0rZZNglR+VNm6J0gpLHwuVM5s7g2TfVoswbXjZ4HuIhLMySEyIw5i7/D8w==} + vfile@4.2.1: resolution: {integrity: sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA==} @@ -22001,6 +22219,9 @@ packages: resolution: {integrity: sha512-SPRy/z6vC+Fb20XQDzagaSVVNzX77EcLLPnBJsqNy0CFQgBS6cexbYP62kzRSqNdyIDdRGc7SOCybRrpkf+Pmg==} engines: {node: '>= 20.*'} + walk-up-path@3.0.1: + resolution: {integrity: sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==} + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} @@ -22159,6 +22380,11 @@ packages: engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} hasBin: true + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + which@7.0.0: resolution: {integrity: sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} @@ -24584,7 +24810,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.3.0 + js-yaml: 4.3.1 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -25682,6 +25908,58 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@npmcli/config@8.3.4(bluebird@3.7.2)': + dependencies: + '@npmcli/map-workspaces': 3.0.6 + '@npmcli/package-json': 5.2.1(bluebird@3.7.2) + ci-info: 4.4.0 + ini: 4.1.3 + nopt: 7.2.1 + proc-log: 4.2.0 + semver: 7.8.5 + walk-up-path: 3.0.1 + transitivePeerDependencies: + - bluebird + + '@npmcli/git@5.0.8(bluebird@3.7.2)': + dependencies: + '@npmcli/promise-spawn': 7.0.2 + ini: 4.1.3 + lru-cache: 10.4.3 + npm-pick-manifest: 9.1.0 + proc-log: 4.2.0 + promise-inflight: 1.0.1(bluebird@3.7.2) + promise-retry: 2.0.1 + semver: 7.8.5 + which: 4.0.0 + transitivePeerDependencies: + - bluebird + + '@npmcli/map-workspaces@3.0.6': + dependencies: + '@npmcli/name-from-folder': 2.0.0 + glob: 10.5.0 + minimatch: 9.0.9 + read-package-json-fast: 3.0.2 + + '@npmcli/name-from-folder@2.0.0': {} + + '@npmcli/package-json@5.2.1(bluebird@3.7.2)': + dependencies: + '@npmcli/git': 5.0.8(bluebird@3.7.2) + glob: 10.5.0 + hosted-git-info: 7.0.2 + json-parse-even-better-errors: 3.0.2 + normalize-package-data: 6.0.2 + proc-log: 4.2.0 + semver: 7.8.5 + transitivePeerDependencies: + - bluebird + + '@npmcli/promise-spawn@7.0.2': + dependencies: + which: 4.0.0 + '@number-flow/react@0.6.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: esm-env: 1.2.2 @@ -28839,6 +29117,10 @@ snapshots: '@types/common-tags@1.8.4': {} + '@types/concat-stream@2.0.3': + dependencies: + '@types/node': 26.0.0 + '@types/connect@3.4.38': dependencies: '@types/node': 26.0.0 @@ -28975,10 +29257,14 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/hosted-git-info@3.0.5': {} + '@types/http-cache-semantics@4.2.0': {} '@types/http-errors@2.0.5': {} + '@types/is-empty@1.2.3': {} + '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -29009,6 +29295,8 @@ snapshots: '@types/ms': 2.1.0 '@types/node': 26.0.0 + '@types/katex@0.16.8': {} + '@types/keyv@3.1.4': dependencies: '@types/node': 26.0.0 @@ -29186,8 +29474,12 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.10 + '@types/supports-color@8.1.3': {} + '@types/symlink-or-copy@1.2.2': {} + '@types/text-table@0.2.5': {} + '@types/tough-cookie@4.0.5': {} '@types/trusted-types@2.0.7': @@ -30118,8 +30410,7 @@ snapshots: abbrev@1.1.1: {} - abbrev@2.0.0: - optional: true + abbrev@2.0.0: {} abbrev@5.0.0: {} @@ -30330,7 +30621,7 @@ snapshots: optional-require: 1.1.10 optionalDependencies: cson-parser: 4.0.9 - js-yaml: 4.3.0 + js-yaml: 4.3.1 aproba@1.2.0: {} @@ -33795,6 +34086,8 @@ snapshots: readable-stream: 2.3.8 stream-shift: 1.0.3 + eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -35334,6 +35627,8 @@ snapshots: eol@0.9.1: {} + err-code@2.0.3: {} + errno@0.1.8: dependencies: prr: 1.0.1 @@ -37058,6 +37353,8 @@ snapshots: github-from-package@0.0.0: optional: true + github-slugger@2.0.0: {} + glob-parent@3.1.0: dependencies: is-glob: 3.1.0 @@ -37223,6 +37520,15 @@ snapshots: merge2: 1.4.1 slash: 4.0.0 + globby@16.2.2: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + fast-glob: 3.3.3 + ignore: 7.0.6 + is-path-inside: 4.0.0 + slash: 5.1.0 + unicorn-magic: 0.4.0 + globby@16.2.3: dependencies: '@sindresorhus/merge-streams': 4.0.0 @@ -37528,6 +37834,10 @@ snapshots: dependencies: lru-cache: 6.0.0 + hosted-git-info@7.0.2: + dependencies: + lru-cache: 10.4.3 + hosted-git-info@9.0.3: dependencies: lru-cache: 11.5.2 @@ -37768,6 +38078,8 @@ snapshots: ignore@5.3.2: {} + ignore@6.0.2: {} + ignore@7.0.5: {} ignore@7.0.6: {} @@ -37852,6 +38164,8 @@ snapshots: ini@4.1.1: {} + ini@4.1.3: {} + inline-source-map-comment@1.0.5: dependencies: chalk: 1.1.3 @@ -38075,6 +38389,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-empty@1.2.0: {} + is-extglob@1.0.0: {} is-extglob@2.1.1: {} @@ -38301,6 +38617,8 @@ snapshots: isexe@2.0.0: {} + isexe@3.1.5: {} + isexe@4.0.0: {} iso-639-3@3.0.1: {} @@ -38912,6 +39230,10 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@5.2.2: + dependencies: + argparse: 2.0.1 + jsdom@16.7.0(supports-color@10.2.2): dependencies: abab: 2.0.6 @@ -38988,6 +39310,8 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-parse-even-better-errors@3.0.2: {} + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -39030,6 +39354,8 @@ snapshots: jsonify@0.0.1: {} + jsonpointer@5.0.1: {} + jsonrepair@3.14.0: {} jsonwebtoken@8.5.1: @@ -39122,6 +39448,10 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 + katex@0.16.47: + dependencies: + commander: 8.3.0 + keygrip@1.1.0: dependencies: tsscmp: 1.0.6 @@ -39285,6 +39615,8 @@ snapshots: leven@3.1.0: optional: true + levenshtein-edit-distance@1.0.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -39439,6 +39771,13 @@ snapshots: livereload-js@3.4.1: {} + load-plugin@6.0.3(bluebird@3.7.2): + dependencies: + '@npmcli/config': 8.3.4(bluebird@3.7.2) + import-meta-resolve: 4.2.0 + transitivePeerDependencies: + - bluebird + loader-runner@2.4.0: {} loader-runner@4.3.2: {} @@ -39838,6 +40177,38 @@ snapshots: markdown-table@3.0.4: {} + markdownlint-cli2-formatter-default@0.0.6(markdownlint-cli2@0.23.2(supports-color@10.2.2)): + dependencies: + markdownlint-cli2: 0.23.2(supports-color@10.2.2) + + markdownlint-cli2@0.23.2(supports-color@10.2.2): + dependencies: + globby: 16.2.2 + js-yaml: 5.2.2 + jsonc-parser: 3.3.1 + jsonpointer: 5.0.1 + markdown-it: 14.3.0 + markdownlint: 0.41.1(supports-color@10.2.2) + markdownlint-cli2-formatter-default: 0.0.6(markdownlint-cli2@0.23.2(supports-color@10.2.2)) + micromatch: 4.0.8 + smol-toml: 1.7.0 + transitivePeerDependencies: + - supports-color + + markdownlint@0.41.1(supports-color@10.2.2): + dependencies: + micromark: 4.0.2(supports-color@10.2.2) + micromark-core-commonmark: 2.0.3 + micromark-extension-directive: 4.0.0 + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-math: 3.1.0 + micromark-util-types: 2.0.2 + string-width: 8.2.1 + transitivePeerDependencies: + - supports-color + match-media@0.2.0: {} matcher-collection@1.1.2: @@ -40206,6 +40577,16 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-directive@4.0.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + parse-entities: 4.0.2 + micromark-extension-gfm-autolink-literal@2.1.0: dependencies: micromark-util-character: 2.1.1 @@ -40264,6 +40645,16 @@ snapshots: micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.8 + devlop: 1.1.0 + katex: 0.16.47 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + micromark-extension-mdx-expression@3.0.1: dependencies: '@types/estree': 1.0.9 @@ -40987,7 +41378,6 @@ snapshots: nopt@7.2.1: dependencies: abbrev: 2.0.0 - optional: true normalize-package-data@2.5.0: dependencies: @@ -40996,6 +41386,12 @@ snapshots: semver: 5.7.2 validate-npm-package-license: 3.0.4 + normalize-package-data@6.0.2: + dependencies: + hosted-git-info: 7.0.2 + semver: 7.8.5 + validate-npm-package-license: 3.0.4 + normalize-package-data@8.0.0: dependencies: hosted-git-info: 9.0.3 @@ -41026,12 +41422,32 @@ snapshots: npm-git-info@1.0.3: {} + npm-install-checks@6.3.0: + dependencies: + semver: 7.8.5 + + npm-normalize-package-bin@3.0.1: {} + + npm-package-arg@11.0.3: + dependencies: + hosted-git-info: 7.0.2 + proc-log: 4.2.0 + semver: 7.8.5 + validate-npm-package-name: 5.0.1 + npm-package-arg@8.1.5: dependencies: hosted-git-info: 4.1.0 semver: 7.8.5 validate-npm-package-name: 3.0.0 + npm-pick-manifest@9.1.0: + dependencies: + npm-install-checks: 6.3.0 + npm-normalize-package-bin: 3.0.1 + npm-package-arg: 11.0.3 + semver: 7.8.5 + npm-run-path@2.0.2: dependencies: path-key: 2.0.1 @@ -41622,6 +42038,14 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-json@7.1.1: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 3.0.2 + lines-and-columns: 2.0.3 + type-fest: 3.13.1 + parse-json@8.3.0: dependencies: '@babel/code-frame': 7.29.7 @@ -42603,6 +43027,8 @@ snapshots: transitivePeerDependencies: - supports-color + proc-log@4.2.0: {} + proc-log@5.0.0: {} proc-log@7.0.0: {} @@ -42632,6 +43058,11 @@ snapshots: promise-map-series@0.3.0: {} + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + promise.hash.helper@1.0.8: {} prompts@2.4.2: @@ -42657,6 +43088,10 @@ snapshots: property-information@7.2.0: {} + propose@0.0.5: + dependencies: + levenshtein-edit-distance: 1.0.0 + prosemirror-changeset@2.4.1: dependencies: prosemirror-transform: 1.12.0 @@ -43093,6 +43528,11 @@ snapshots: dependencies: pify: 2.3.0 + read-package-json-fast@3.0.2: + dependencies: + json-parse-even-better-errors: 3.0.2 + npm-normalize-package-bin: 3.0.1 + read-pkg-up@7.0.1: dependencies: find-up: 4.1.0 @@ -43330,6 +43770,16 @@ snapshots: relateurl@0.2.7: {} + remark-cli@12.0.1(bluebird@3.7.2)(supports-color@10.2.2): + dependencies: + import-meta-resolve: 4.2.0 + markdown-extensions: 2.0.0 + remark: 15.0.1(supports-color@10.2.2) + unified-args: 11.0.1(bluebird@3.7.2)(supports-color@10.2.2) + transitivePeerDependencies: + - bluebird + - supports-color + remark-footnotes@1.0.0: {} remark-gfm@4.0.1(supports-color@10.2.2): @@ -43408,12 +43858,38 @@ snapshots: unherit: 1.1.3 xtend: 4.0.2 + remark-validate-links@13.1.0(bluebird@3.7.2)(supports-color@10.2.2): + dependencies: + '@types/hosted-git-info': 3.0.5 + '@types/mdast': 4.0.4 + github-slugger: 2.0.0 + hosted-git-info: 7.0.2 + mdast-util-to-hast: 13.2.1 + mdast-util-to-string: 4.0.0 + propose: 0.0.5 + trough: 2.2.0 + unified-engine: 11.2.2(bluebird@3.7.2)(supports-color@10.2.2) + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - bluebird + - supports-color + remark@11.0.2: dependencies: remark-parse: 7.0.2 remark-stringify: 7.0.4 unified: 8.4.2 + remark@15.0.1(supports-color@10.2.2): + dependencies: + '@types/mdast': 4.0.4 + remark-parse: 11.0.0(supports-color@10.2.2) + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remove-trailing-separator@1.1.0: {} repeat-string@1.6.1: {} @@ -44123,6 +44599,8 @@ snapshots: smol-toml@1.6.1: {} + smol-toml@1.7.0: {} + smol-toml@1.7.1: {} snake-case@3.0.4: @@ -44422,6 +44900,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@6.1.0: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 10.6.0 + strip-ansi: 7.2.0 + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -45280,6 +45764,8 @@ snapshots: type-fest@0.8.1: {} + type-fest@3.13.1: {} + type-fest@4.41.0: {} type-fest@5.7.0: @@ -45469,6 +45955,48 @@ snapshots: unidecode@1.1.0: {} + unified-args@11.0.1(bluebird@3.7.2)(supports-color@10.2.2): + dependencies: + '@types/text-table': 0.2.5 + chalk: 5.6.2 + chokidar: 3.6.0 + comma-separated-tokens: 2.0.3 + json5: 2.2.3 + minimist: 1.2.8 + strip-ansi: 7.2.0 + text-table: 0.2.0 + unified-engine: 11.2.2(bluebird@3.7.2)(supports-color@10.2.2) + transitivePeerDependencies: + - bluebird + - supports-color + + unified-engine@11.2.2(bluebird@3.7.2)(supports-color@10.2.2): + dependencies: + '@types/concat-stream': 2.0.3 + '@types/debug': 4.1.13 + '@types/is-empty': 1.2.3 + '@types/node': 22.20.1 + '@types/unist': 3.0.3 + concat-stream: 2.0.0 + debug: 4.4.3(supports-color@10.2.2) + extend: 3.0.2 + glob: 10.5.0 + ignore: 6.0.2 + is-empty: 1.2.0 + is-plain-obj: 4.1.0 + load-plugin: 6.0.3(bluebird@3.7.2) + parse-json: 7.1.1 + trough: 2.2.0 + unist-util-inspect: 8.1.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + vfile-reporter: 8.1.1 + vfile-statistics: 3.0.0 + yaml: 2.9.0 + transitivePeerDependencies: + - bluebird + - supports-color + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -45504,6 +46032,10 @@ snapshots: dependencies: crypto-random-string: 2.0.0 + unist-util-inspect@8.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is@3.0.0: {} unist-util-is@4.1.0: {} @@ -45736,6 +46268,8 @@ snapshots: dependencies: builtins: 1.0.3 + validate-npm-package-name@5.0.1: {} + validate-peer-dependencies@1.2.0: dependencies: resolve-package-path: 3.1.0 @@ -45769,6 +46303,27 @@ snapshots: '@types/unist': 3.0.3 unist-util-stringify-position: 4.0.0 + vfile-reporter@8.1.1: + dependencies: + '@types/supports-color': 8.1.3 + string-width: 6.1.0 + supports-color: 10.2.2 + unist-util-stringify-position: 4.0.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + vfile-sort: 4.0.0 + vfile-statistics: 3.0.0 + + vfile-sort@4.0.0: + dependencies: + vfile: 6.0.3 + vfile-message: 4.0.3 + + vfile-statistics@3.0.0: + dependencies: + vfile: 6.0.3 + vfile-message: 4.0.3 + vfile@4.2.1: dependencies: '@types/unist': 2.0.11 @@ -46130,6 +46685,8 @@ snapshots: matcher-collection: 2.0.1 minimatch: 10.2.5 + walk-up-path@3.0.1: {} + walk-up-path@4.0.0: {} walker@1.0.8: @@ -46231,7 +46788,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.18.0 acorn-import-phases: 1.0.4(acorn@8.18.0) - browserslist: 4.28.4 + browserslist: 4.28.8 chrome-trace-event: 1.0.4 enhanced-resolve: 5.24.5 es-module-lexer: 2.1.0 @@ -46272,7 +46829,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.18.0 acorn-import-phases: 1.0.4(acorn@8.18.0) - browserslist: 4.28.4 + browserslist: 4.28.8 chrome-trace-event: 1.0.4 enhanced-resolve: 5.24.5 es-module-lexer: 2.1.0 @@ -46402,6 +46959,10 @@ snapshots: dependencies: isexe: 2.0.0 + which@4.0.0: + dependencies: + isexe: 3.1.5 + which@7.0.0: dependencies: isexe: 4.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 191f61edb85..19d13e8181d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -206,6 +206,9 @@ catalog: '@pnpm/releasing.versioning': 1100.2.4 '@pnpm/workspace.workspace-manifest-reader': 1100.1.5 execa: 10.0.1 + remark-cli: 12.0.1 + remark-validate-links: 13.1.0 + markdownlint-cli2: 0.23.2 catalogs: react17: