From cee414eb1aeae187e637940a73107bc08b44eec4 Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Tue, 5 May 2026 22:54:04 +0200 Subject: [PATCH 1/4] feat: export editor styles as string --- packages/editor/gulpfile.mjs | 60 ++++++++++++++++++- packages/editor/package.json | 10 ++++ .../tests/esbuild-test/esbuild-tester.js | 53 ++++++++++------ 3 files changed, 104 insertions(+), 19 deletions(-) diff --git a/packages/editor/gulpfile.mjs b/packages/editor/gulpfile.mjs index 5ae14b32e..4459dc136 100644 --- a/packages/editor/gulpfile.mjs +++ b/packages/editor/gulpfile.mjs @@ -1,15 +1,21 @@ -import {dirname, resolve} from 'node:path'; +import {readFileSync, readdirSync, writeFileSync} from 'node:fs'; +import {createRequire} from 'node:module'; +import {dirname, extname, resolve} from 'node:path'; import {fileURLToPath} from 'node:url'; -import {series, task} from '@markdown-editor/gulp-tasks'; +import {parallel, series, task} from '@markdown-editor/gulp-tasks'; import {registerBuildTasks} from '@markdown-editor/gulp-tasks/build'; import pkg from './package.json' with {type: 'json'}; const __dirname = dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); const BUILD_DIR = resolve('build'); const NODE_MODULES_DIR = resolve(__dirname, 'node_modules'); +const SOURCE_DIR = resolve('src'); +const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']); +const EXTERNAL_CSS_IMPORT_RE = /^\s*import\s+(?:.+?\s+from\s+)?['"]([^./'"][^'"]*\.css)['"];?/gm; registerBuildTasks({ version: pkg.version, @@ -17,4 +23,54 @@ registerBuildTasks({ nodeModulesDir: NODE_MODULES_DIR, }); +task('styles-string', (done) => { + const externalCss = collectExternalCss(); + const editorCss = readFileSync(resolve(BUILD_DIR, 'styles.css'), 'utf8'); + const styles = [externalCss, editorCss].filter(Boolean).join('\n'); + const content = toTemplateLiteral(styles); + + writeFileSync(resolve(BUILD_DIR, 'styles-string.mjs'), `export default ${content};\n`); + writeFileSync(resolve(BUILD_DIR, 'styles-string.cjs'), `module.exports = ${content};\n`); + writeFileSync( + resolve(BUILD_DIR, 'styles-string.d.ts'), + 'declare const styles: string;\nexport default styles;\n', + ); + done(); +}); + +task('build', series(parallel('ts', 'json', 'scss'), 'styles-string')); task('default', series('clean', 'build')); + +function collectExternalCss() { + const cssImports = new Set(); + + for (const sourceFile of getSourceFiles(SOURCE_DIR)) { + const sourceCode = readFileSync(sourceFile, 'utf8'); + + for (const match of sourceCode.matchAll(EXTERNAL_CSS_IMPORT_RE)) { + cssImports.add(match[1]); + } + } + + return Array.from(cssImports) + .map((cssImport) => readFileSync(require.resolve(cssImport), 'utf8')) + .join('\n'); +} + +function getSourceFiles(dir) { + return readdirSync(dir, {withFileTypes: true}) + .sort((left, right) => left.name.localeCompare(right.name)) + .flatMap((entry) => { + const entryPath = resolve(dir, entry.name); + + if (entry.isDirectory()) { + return getSourceFiles(entryPath); + } + + return SOURCE_EXTENSIONS.has(extname(entry.name)) ? [entryPath] : []; + }); +} + +function toTemplateLiteral(value) { + return `\`${value.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${')}\``; +} diff --git a/packages/editor/package.json b/packages/editor/package.json index 54469fa32..e32e9f949 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -152,6 +152,16 @@ "default": "./build/cjs/*" } }, + "./styles-string": { + "import": { + "types": "./build/styles-string.d.ts", + "default": "./build/styles-string.mjs" + }, + "require": { + "types": "./build/styles-string.d.ts", + "default": "./build/styles-string.cjs" + } + }, "./styles/*": "./build/esm/styles/*" }, "main": "build/cjs/index.js", diff --git a/packages/editor/tests/esbuild-test/esbuild-tester.js b/packages/editor/tests/esbuild-test/esbuild-tester.js index 3e1e9f447..cfd8b5ce1 100644 --- a/packages/editor/tests/esbuild-test/esbuild-tester.js +++ b/packages/editor/tests/esbuild-test/esbuild-tester.js @@ -7,6 +7,7 @@ const fs = require('node:fs'); const fsPromises = require('node:fs/promises'); const path = require('node:path'); +const {pathToFileURL} = require('node:url'); const esbuild = require('esbuild'); const {sassPlugin} = require('esbuild-sass-plugin'); @@ -38,20 +39,38 @@ const esbuildOptions = { alias: ['fs', 'path', 'stream'].reduce((acc, name) => ({...acc, [name]: paths.aliases}), {}), }; -esbuild - .build({...esbuildOptions, entryPoints: [paths.esbuildToTest]}) - .then(async () => { - const allExports = (await import(paths.compiledEsBuildToTest)).default; - // Make a file that exports everything from src - await fsPromises.writeFile(paths.tempTest, `import {${allExports}} from '../../src'`); - await esbuild.build({...esbuildOptions, entryPoints: [paths.tempTest]}); - }) - .finally(() => { - // Cleanup - if (fs.existsSync(paths.localBuild)) - fs.rmSync(paths.localBuild, { - force: true, - recursive: true, - }); - if (fs.existsSync(paths.tempTest)) fs.rmSync(paths.tempTest); - }); +run().finally(() => { + // Cleanup + if (fs.existsSync(paths.localBuild)) + fs.rmSync(paths.localBuild, { + force: true, + recursive: true, + }); + if (fs.existsSync(paths.tempTest)) fs.rmSync(paths.tempTest); +}); + +async function run() { + const stylesStringCjs = require(path.join(__dirname, '../../build/styles-string.cjs')); + + if (typeof stylesStringCjs !== 'string' || stylesStringCjs.length === 0) { + throw new Error('styles-string CJS export is invalid: expected non-empty string'); + } + + const {default: stylesStringMjs} = await import( + pathToFileURL(path.join(__dirname, '../../build/styles-string.mjs')).href + ); + + if (stylesStringMjs !== stylesStringCjs) { + throw new Error('styles-string ESM export is invalid: expected the same CSS as CJS'); + } + + console.info('styles-string smoke test: OK (length:', stylesStringCjs.length, ')'); + + await esbuild.build({...esbuildOptions, entryPoints: [paths.esbuildToTest]}); + + const allExports = (await import(paths.compiledEsBuildToTest)).default; + + // Make a file that exports everything from src + await fsPromises.writeFile(paths.tempTest, `import {${allExports}} from '../../src'`); + await esbuild.build({...esbuildOptions, entryPoints: [paths.tempTest]}); +} From 429c972cd897cb820ef5b411fb675d7ec84c0fc4 Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Wed, 6 May 2026 13:17:57 +0200 Subject: [PATCH 2/4] refactor: rename styles export to shadow-styles --- packages/editor/gulpfile.mjs | 94 +++++++++++-------- packages/editor/package.json | 10 +- .../tests/esbuild-test/esbuild-tester.js | 80 ++++++++++++---- 3 files changed, 124 insertions(+), 60 deletions(-) diff --git a/packages/editor/gulpfile.mjs b/packages/editor/gulpfile.mjs index 4459dc136..cd16b432a 100644 --- a/packages/editor/gulpfile.mjs +++ b/packages/editor/gulpfile.mjs @@ -1,6 +1,6 @@ -import {readFileSync, readdirSync, writeFileSync} from 'node:fs'; +import {readFileSync, writeFileSync} from 'node:fs'; import {createRequire} from 'node:module'; -import {dirname, extname, resolve} from 'node:path'; +import {dirname, resolve} from 'node:path'; import {fileURLToPath} from 'node:url'; import {parallel, series, task} from '@markdown-editor/gulp-tasks'; @@ -13,9 +13,16 @@ const require = createRequire(import.meta.url); const BUILD_DIR = resolve('build'); const NODE_MODULES_DIR = resolve(__dirname, 'node_modules'); -const SOURCE_DIR = resolve('src'); -const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']); -const EXTERNAL_CSS_IMPORT_RE = /^\s*import\s+(?:.+?\s+from\s+)?['"]([^./'"][^'"]*\.css)['"];?/gm; +// Keep this list aligned with non-relative CSS imports required by the default editor setup. +const SHADOW_STYLE_IMPORTS = Object.freeze([ + '@diplodoc/transform/dist/css/base.css', + '@diplodoc/transform/dist/css/_yfm-only.css', + '@diplodoc/cut-extension/runtime/styles.css', + '@diplodoc/file-extension/runtime/styles.css', + '@diplodoc/tabs-extension/runtime/styles.css', + '@diplodoc/quote-link-extension/runtime/styles.css', + '@diplodoc/folding-headings-extension/runtime/styles.css', +]); registerBuildTasks({ version: pkg.version, @@ -23,52 +30,61 @@ registerBuildTasks({ nodeModulesDir: NODE_MODULES_DIR, }); -task('styles-string', (done) => { - const externalCss = collectExternalCss(); +task('shadow-styles', (done) => { + const externalCss = readExternalShadowStyles(); const editorCss = readFileSync(resolve(BUILD_DIR, 'styles.css'), 'utf8'); const styles = [externalCss, editorCss].filter(Boolean).join('\n'); - const content = toTemplateLiteral(styles); + const moduleCode = createShadowStylesModule(styles); - writeFileSync(resolve(BUILD_DIR, 'styles-string.mjs'), `export default ${content};\n`); - writeFileSync(resolve(BUILD_DIR, 'styles-string.cjs'), `module.exports = ${content};\n`); + writeFileSync(resolve(BUILD_DIR, 'shadow-styles.mjs'), moduleCode.esm); + writeFileSync(resolve(BUILD_DIR, 'shadow-styles.cjs'), moduleCode.cjs); writeFileSync( - resolve(BUILD_DIR, 'styles-string.d.ts'), - 'declare const styles: string;\nexport default styles;\n', + resolve(BUILD_DIR, 'shadow-styles.d.ts'), + [ + 'export declare const cssText: string;', + 'export declare function createStyleSheet(): CSSStyleSheet;', + '', + ].join('\n'), ); done(); }); -task('build', series(parallel('ts', 'json', 'scss'), 'styles-string')); +task('build', series(parallel('ts', 'json', 'scss'), 'shadow-styles')); task('default', series('clean', 'build')); -function collectExternalCss() { - const cssImports = new Set(); - - for (const sourceFile of getSourceFiles(SOURCE_DIR)) { - const sourceCode = readFileSync(sourceFile, 'utf8'); - - for (const match of sourceCode.matchAll(EXTERNAL_CSS_IMPORT_RE)) { - cssImports.add(match[1]); - } - } - - return Array.from(cssImports) - .map((cssImport) => readFileSync(require.resolve(cssImport), 'utf8')) - .join('\n'); +function readExternalShadowStyles() { + return SHADOW_STYLE_IMPORTS.map((cssImport) => + readFileSync(require.resolve(cssImport), 'utf8'), + ).join('\n'); } -function getSourceFiles(dir) { - return readdirSync(dir, {withFileTypes: true}) - .sort((left, right) => left.name.localeCompare(right.name)) - .flatMap((entry) => { - const entryPath = resolve(dir, entry.name); - - if (entry.isDirectory()) { - return getSourceFiles(entryPath); - } - - return SOURCE_EXTENSIONS.has(extname(entry.name)) ? [entryPath] : []; - }); +function createShadowStylesModule(value) { + const cssText = toTemplateLiteral(value); + const createStyleSheetBody = [ + 'function createStyleSheet() {', + " if (typeof CSSStyleSheet === 'undefined') {", + " throw new Error('Constructable stylesheets are not available in this environment.');", + ' }', + ' const styleSheet = new CSSStyleSheet();', + ' styleSheet.replaceSync(cssText);', + ' return styleSheet;', + '}', + ].join('\n'); + + return { + esm: [`export const cssText = ${cssText};`, '', `export ${createStyleSheetBody}`, ''].join( + '\n', + ), + cjs: [ + `const cssText = ${cssText};`, + '', + createStyleSheetBody, + '', + 'exports.cssText = cssText;', + 'exports.createStyleSheet = createStyleSheet;', + '', + ].join('\n'), + }; } function toTemplateLiteral(value) { diff --git a/packages/editor/package.json b/packages/editor/package.json index e32e9f949..3f6184a71 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -152,14 +152,14 @@ "default": "./build/cjs/*" } }, - "./styles-string": { + "./shadow-styles": { "import": { - "types": "./build/styles-string.d.ts", - "default": "./build/styles-string.mjs" + "types": "./build/shadow-styles.d.ts", + "default": "./build/shadow-styles.mjs" }, "require": { - "types": "./build/styles-string.d.ts", - "default": "./build/styles-string.cjs" + "types": "./build/shadow-styles.d.ts", + "default": "./build/shadow-styles.cjs" } }, "./styles/*": "./build/esm/styles/*" diff --git a/packages/editor/tests/esbuild-test/esbuild-tester.js b/packages/editor/tests/esbuild-test/esbuild-tester.js index cfd8b5ce1..d847bddf3 100644 --- a/packages/editor/tests/esbuild-test/esbuild-tester.js +++ b/packages/editor/tests/esbuild-test/esbuild-tester.js @@ -50,27 +50,75 @@ run().finally(() => { }); async function run() { - const stylesStringCjs = require(path.join(__dirname, '../../build/styles-string.cjs')); + const OriginalCSSStyleSheet = globalThis.CSSStyleSheet; + const TestCSSStyleSheet = class CSSStyleSheet { + replaceSync(value) { + this.cssText = value; + } + }; - if (typeof stylesStringCjs !== 'string' || stylesStringCjs.length === 0) { - throw new Error('styles-string CJS export is invalid: expected non-empty string'); - } + try { + globalThis.CSSStyleSheet = TestCSSStyleSheet; - const {default: stylesStringMjs} = await import( - pathToFileURL(path.join(__dirname, '../../build/styles-string.mjs')).href - ); + const shadowStylesCjs = require(path.join(__dirname, '../../build/shadow-styles.cjs')); - if (stylesStringMjs !== stylesStringCjs) { - throw new Error('styles-string ESM export is invalid: expected the same CSS as CJS'); - } + if (typeof shadowStylesCjs.cssText !== 'string' || shadowStylesCjs.cssText.length === 0) { + throw new Error('shadow-styles CJS export is invalid: expected non-empty cssText'); + } + + if (typeof shadowStylesCjs.createStyleSheet !== 'function') { + throw new Error('shadow-styles CJS export is invalid: expected createStyleSheet()'); + } + + const shadowStylesMjs = await import( + pathToFileURL(path.join(__dirname, '../../build/shadow-styles.mjs')).href + ); + + if (shadowStylesMjs.cssText !== shadowStylesCjs.cssText) { + throw new Error( + 'shadow-styles ESM export is invalid: expected the same cssText as CJS', + ); + } - console.info('styles-string smoke test: OK (length:', stylesStringCjs.length, ')'); + if (typeof shadowStylesMjs.createStyleSheet !== 'function') { + throw new Error('shadow-styles ESM export is invalid: expected createStyleSheet()'); + } - await esbuild.build({...esbuildOptions, entryPoints: [paths.esbuildToTest]}); + const cjsStyleSheet = shadowStylesCjs.createStyleSheet(); + const mjsStyleSheet = shadowStylesMjs.createStyleSheet(); - const allExports = (await import(paths.compiledEsBuildToTest)).default; + if ( + !(cjsStyleSheet instanceof TestCSSStyleSheet) || + cjsStyleSheet.cssText !== shadowStylesCjs.cssText + ) { + throw new Error( + 'shadow-styles CJS helper is invalid: expected populated CSSStyleSheet', + ); + } - // Make a file that exports everything from src - await fsPromises.writeFile(paths.tempTest, `import {${allExports}} from '../../src'`); - await esbuild.build({...esbuildOptions, entryPoints: [paths.tempTest]}); + if ( + !(mjsStyleSheet instanceof TestCSSStyleSheet) || + mjsStyleSheet.cssText !== shadowStylesMjs.cssText + ) { + throw new Error( + 'shadow-styles ESM helper is invalid: expected populated CSSStyleSheet', + ); + } + + console.info('shadow-styles smoke test: OK (length:', shadowStylesCjs.cssText.length, ')'); + + await esbuild.build({...esbuildOptions, entryPoints: [paths.esbuildToTest]}); + + const allExports = (await import(paths.compiledEsBuildToTest)).default; + + // Make a file that exports everything from src + await fsPromises.writeFile(paths.tempTest, `import {${allExports}} from '../../src'`); + await esbuild.build({...esbuildOptions, entryPoints: [paths.tempTest]}); + } finally { + if (typeof OriginalCSSStyleSheet === 'undefined') { + delete globalThis.CSSStyleSheet; + } else { + globalThis.CSSStyleSheet = OriginalCSSStyleSheet; + } + } } From 3ecf3c3e1a08cd00123601dc74a40ecf3b5592a9 Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Wed, 6 May 2026 17:51:38 +0200 Subject: [PATCH 3/4] test(shadow-styles): enforce import list sync + soft-fail optional peers Add a regression script that diffs SHADOW_STYLE_IMPORTS against non-relative *.css imports under packages/editor/src/**, so a future extension addition cannot silently leave shadow-styles cssText stale behind a green CI. Wired as a separate ci:test:shadow-styles job mirroring the circular-deps check. Wrap require.resolve() for shadow-styles externals in a try/catch: optional peer packages (per peerDependenciesMeta) are skipped with a warning instead of crashing the build for consumers that don't install them. Required peers still throw. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 22 ++++++ package.json | 3 +- packages/editor/gulpfile.mjs | 34 +++++++-- packages/editor/package.json | 1 + .../scripts/check-shadow-styles-imports.js | 70 +++++++++++++++++++ 5 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 packages/editor/scripts/check-shadow-styles-imports.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 007d64a70..ce2c8d8e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,3 +77,25 @@ jobs: - name: Check circular dependencies run: pnpm ci:test:circular-deps + + check_shadow_styles_imports: + name: Check Shadow Styles Imports + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v5 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + cache: pnpm + + - name: Install dependencies + run: pnpm run ci:deps + + - name: Check shadow styles imports + run: pnpm ci:test:shadow-styles diff --git a/package.json b/package.json index 23bdd9c01..505857be4 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,12 @@ "ci:test:unit": "nx run-many -t test --verbose", "ci:test:esbuild": "nx run-many -t test:esbuild --verbose", "ci:test:circular-deps": "nx run-many -t test:circular-deps --verbose", + "ci:test:shadow-styles": "nx run-many -t test:shadow-styles --verbose", "start": "nx sb:start @markdown-editor/demo", "clean": "nx run-many -t clean", "build": "nx run-many -t build", "typecheck": "nx run-many -t typecheck", - "test": "nx run-many -t test,test:esbuild,test:circular-deps", + "test": "nx run-many -t test,test:esbuild,test:circular-deps,test:shadow-styles", "test:e2e": "nx playwright:docker @markdown-editor/demo", "test:e2e:report": "nx playwright:docker:report @markdown-editor/demo", "lint": "run-p -cs lint:*", diff --git a/packages/editor/gulpfile.mjs b/packages/editor/gulpfile.mjs index cd16b432a..6aa618ad5 100644 --- a/packages/editor/gulpfile.mjs +++ b/packages/editor/gulpfile.mjs @@ -14,7 +14,8 @@ const require = createRequire(import.meta.url); const BUILD_DIR = resolve('build'); const NODE_MODULES_DIR = resolve(__dirname, 'node_modules'); // Keep this list aligned with non-relative CSS imports required by the default editor setup. -const SHADOW_STYLE_IMPORTS = Object.freeze([ +// Drift is enforced by scripts/check-shadow-styles-imports.js. +export const SHADOW_STYLE_IMPORTS = Object.freeze([ '@diplodoc/transform/dist/css/base.css', '@diplodoc/transform/dist/css/_yfm-only.css', '@diplodoc/cut-extension/runtime/styles.css', @@ -24,6 +25,12 @@ const SHADOW_STYLE_IMPORTS = Object.freeze([ '@diplodoc/folding-headings-extension/runtime/styles.css', ]); +const OPTIONAL_PEERS = new Set( + Object.entries(pkg.peerDependenciesMeta ?? {}) + .filter(([, meta]) => meta?.optional) + .map(([name]) => name), +); + registerBuildTasks({ version: pkg.version, buildDir: BUILD_DIR, @@ -53,9 +60,28 @@ task('build', series(parallel('ts', 'json', 'scss'), 'shadow-styles')); task('default', series('clean', 'build')); function readExternalShadowStyles() { - return SHADOW_STYLE_IMPORTS.map((cssImport) => - readFileSync(require.resolve(cssImport), 'utf8'), - ).join('\n'); + return SHADOW_STYLE_IMPORTS.map((cssImport) => { + try { + return readFileSync(require.resolve(cssImport), 'utf8'); + } catch (err) { + if (err?.code === 'MODULE_NOT_FOUND' && isOptionalPeerImport(cssImport)) { + console.warn( + `[shadow-styles] Skipping optional peer CSS '${cssImport}' (package not installed).`, + ); + return ''; + } + throw err; + } + }) + .filter(Boolean) + .join('\n'); +} + +function isOptionalPeerImport(cssImport) { + const pkgName = cssImport.startsWith('@') + ? cssImport.split('/', 2).join('/') + : cssImport.split('/', 1)[0]; + return OPTIONAL_PEERS.has(pkgName); } function createShadowStylesModule(value) { diff --git a/packages/editor/package.json b/packages/editor/package.json index 3f6184a71..076cfa960 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -27,6 +27,7 @@ "test:watch": "jest --watchAll", "test:esbuild": "node tests/esbuild-test/esbuild-tester.js", "test:circular-deps": "node scripts/check-circular-deps.js 0", + "test:shadow-styles": "node scripts/check-shadow-styles-imports.js", "prepack": "cp ../../README.md ./README.md", "postpack": "rm -f ./README.md", "prepublishOnly": "pnpm run lint && pnpm run clean && pnpm run build" diff --git a/packages/editor/scripts/check-shadow-styles-imports.js b/packages/editor/scripts/check-shadow-styles-imports.js new file mode 100644 index 000000000..a725c9c71 --- /dev/null +++ b/packages/editor/scripts/check-shadow-styles-imports.js @@ -0,0 +1,70 @@ +/* eslint-disable no-console, no-undef */ +const fs = require('node:fs'); +const path = require('node:path'); +const {pathToFileURL} = require('node:url'); + +const SRC_DIR = path.resolve(__dirname, '..', 'src'); +const GULPFILE_URL = pathToFileURL(path.resolve(__dirname, '..', 'gulpfile.mjs')).href; + +// Bare `import 'pkg/path/file.css';` — non-relative, scoped or unscoped, ending in .css. +const CSS_IMPORT_RE = /(?:^|\s)import\s+['"]((?:@[^'"\s/]+\/)?[^'"\s.][^'"\s]*\.css)['"]/gm; + +const EXCLUDED_SCOPES = ['@gravity-ui/']; + +async function main() { + const {SHADOW_STYLE_IMPORTS} = await import(GULPFILE_URL); + if (!Array.isArray(SHADOW_STYLE_IMPORTS)) { + console.error('Failed to import SHADOW_STYLE_IMPORTS from gulpfile.mjs'); + process.exit(1); + } + + const declared = new Set(SHADOW_STYLE_IMPORTS); + const actual = collectCssImports(SRC_DIR); + + const missing = [...actual].filter((x) => !declared.has(x)).sort(); + const stale = [...declared].filter((x) => !actual.has(x)).sort(); + + if (missing.length || stale.length) { + console.error('Shadow styles imports drift detected:'); + if (missing.length) { + console.error(' Missing in SHADOW_STYLE_IMPORTS (found in src, not in list):'); + for (const item of missing) console.error(` + ${item}`); + } + if (stale.length) { + console.error(' Stale in SHADOW_STYLE_IMPORTS (in list, not used in src):'); + for (const item of stale) console.error(` - ${item}`); + } + console.error('Update SHADOW_STYLE_IMPORTS in packages/editor/gulpfile.mjs.'); + process.exit(1); + } + + console.log(`Shadow styles imports check passed (count: ${declared.size})`); + process.exit(0); +} + +function collectCssImports(dir) { + const result = new Set(); + walk(dir, (filePath) => { + if (!/\.(?:ts|tsx|js|jsx|mjs|cjs)$/.test(filePath)) return; + const content = fs.readFileSync(filePath, 'utf8'); + for (const match of content.matchAll(CSS_IMPORT_RE)) { + const spec = match[1]; + if (EXCLUDED_SCOPES.some((scope) => spec.startsWith(scope))) continue; + result.add(spec); + } + }); + return result; +} + +function walk(dir, visit) { + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full, visit); + else if (entry.isFile()) visit(full); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From f68a007c7ae53b16d02b59396eff2fe17116f1be Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Fri, 8 May 2026 18:36:54 +0200 Subject: [PATCH 4/4] refactor(shadow-styles): address self-review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decouple drift-check from gulpfile by extracting SHADOW_STYLE_IMPORTS into scripts/shadow-styles-imports.mjs, so the CI check no longer dynamically imports the gulpfile (which would also run registerBuildTasks side effects) just to read one constant. Broaden the drift-check regex to cover non-side-effect import forms (`import x from 'pkg/x.css'`, `import * as x from`, named imports, and dynamic `import('pkg/x.css')`), and strip block/line comments before matching so commented-out imports do not produce false positives. Split into two regexes (static vs dynamic) for readability. Add an `assertNoCssImportRules` guard in the `shadow-styles` gulp task: `CSSStyleSheet.replaceSync()` rejects `@import` rules at runtime, so fail the build instead of letting Shadow DOM consumers crash. The guard strips comments and string literals first to avoid false positives on `@import` substrings inside CSS values. Fold the standalone `Check Shadow Styles Imports` CI job into the existing `tests` job — saves a redundant pnpm install (~30-60s) per PR. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 25 ++-------- packages/editor/gulpfile.mjs | 28 ++++++----- .../scripts/check-shadow-styles-imports.js | 49 ++++++++++++++----- .../editor/scripts/shadow-styles-imports.mjs | 13 +++++ 4 files changed, 71 insertions(+), 44 deletions(-) create mode 100644 packages/editor/scripts/shadow-styles-imports.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce2c8d8e5..1dda928d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,9 @@ jobs: - name: ESBuild compatability run: pnpm ci:test:esbuild + - name: Check shadow styles imports + run: pnpm ci:test:shadow-styles + check_circular_deps: name: Check Circular Dependencies runs-on: ubuntu-latest @@ -77,25 +80,3 @@ jobs: - name: Check circular dependencies run: pnpm ci:test:circular-deps - - check_shadow_styles_imports: - name: Check Shadow Styles Imports - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup pnpm - uses: pnpm/action-setup@v5 - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: pnpm - - - name: Install dependencies - run: pnpm run ci:deps - - - name: Check shadow styles imports - run: pnpm ci:test:shadow-styles diff --git a/packages/editor/gulpfile.mjs b/packages/editor/gulpfile.mjs index 6aa618ad5..06fcaf25f 100644 --- a/packages/editor/gulpfile.mjs +++ b/packages/editor/gulpfile.mjs @@ -7,23 +7,13 @@ import {parallel, series, task} from '@markdown-editor/gulp-tasks'; import {registerBuildTasks} from '@markdown-editor/gulp-tasks/build'; import pkg from './package.json' with {type: 'json'}; +import {SHADOW_STYLE_IMPORTS} from './scripts/shadow-styles-imports.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); const BUILD_DIR = resolve('build'); const NODE_MODULES_DIR = resolve(__dirname, 'node_modules'); -// Keep this list aligned with non-relative CSS imports required by the default editor setup. -// Drift is enforced by scripts/check-shadow-styles-imports.js. -export const SHADOW_STYLE_IMPORTS = Object.freeze([ - '@diplodoc/transform/dist/css/base.css', - '@diplodoc/transform/dist/css/_yfm-only.css', - '@diplodoc/cut-extension/runtime/styles.css', - '@diplodoc/file-extension/runtime/styles.css', - '@diplodoc/tabs-extension/runtime/styles.css', - '@diplodoc/quote-link-extension/runtime/styles.css', - '@diplodoc/folding-headings-extension/runtime/styles.css', -]); const OPTIONAL_PEERS = new Set( Object.entries(pkg.peerDependenciesMeta ?? {}) @@ -41,6 +31,7 @@ task('shadow-styles', (done) => { const externalCss = readExternalShadowStyles(); const editorCss = readFileSync(resolve(BUILD_DIR, 'styles.css'), 'utf8'); const styles = [externalCss, editorCss].filter(Boolean).join('\n'); + assertNoCssImportRules(styles); const moduleCode = createShadowStylesModule(styles); writeFileSync(resolve(BUILD_DIR, 'shadow-styles.mjs'), moduleCode.esm); @@ -77,6 +68,21 @@ function readExternalShadowStyles() { .join('\n'); } +// `CSSStyleSheet.replaceSync()` rejects `@import` rules. If any inlined CSS ever +// adds one, fail the build instead of letting consumers crash at runtime. +function assertNoCssImportRules(css) { + const stripped = css + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(["'])(?:\\.|(?!\1).)*\1/g, ''); + if (/(?:^|[\s;}])@import\b/m.test(stripped)) { + throw new Error( + "[shadow-styles] '@import' rules are not allowed in cssText: " + + 'CSSStyleSheet.replaceSync() would reject them at runtime. ' + + 'Inline the imported file into SHADOW_STYLE_IMPORTS instead.', + ); + } +} + function isOptionalPeerImport(cssImport) { const pkgName = cssImport.startsWith('@') ? cssImport.split('/', 2).join('/') diff --git a/packages/editor/scripts/check-shadow-styles-imports.js b/packages/editor/scripts/check-shadow-styles-imports.js index a725c9c71..d28b5003c 100644 --- a/packages/editor/scripts/check-shadow-styles-imports.js +++ b/packages/editor/scripts/check-shadow-styles-imports.js @@ -4,17 +4,33 @@ const path = require('node:path'); const {pathToFileURL} = require('node:url'); const SRC_DIR = path.resolve(__dirname, '..', 'src'); -const GULPFILE_URL = pathToFileURL(path.resolve(__dirname, '..', 'gulpfile.mjs')).href; +const IMPORTS_MODULE_URL = pathToFileURL(path.resolve(__dirname, 'shadow-styles-imports.mjs')).href; -// Bare `import 'pkg/path/file.css';` — non-relative, scoped or unscoped, ending in .css. -const CSS_IMPORT_RE = /(?:^|\s)import\s+['"]((?:@[^'"\s/]+\/)?[^'"\s.][^'"\s]*\.css)['"]/gm; +// Capture group `(...\.css)` — non-relative specifier ending in .css: +// scoped (`@scope/pkg/...`) or unscoped (`pkg/...`). Anchored to start-of-line +// (with optional whitespace) so matches inside string literals can't pollute +// results. Two regexes — static and dynamic — for readability. +const CSS_PATH = "((?:@[^'\"\\s/]+/)?[^'\"\\s.][^'\"\\s]*\\.css)"; + +// import 'pkg/x.css'; +// import x from 'pkg/x.css'; +// import * as x from 'pkg/x.css'; +// import {x} from 'pkg/x.css'; +const STATIC_CSS_IMPORT_RE = new RegExp( + `^\\s*import\\s+(?:[\\w*\\s{},]+\\s+from\\s+)?['"]${CSS_PATH}['"]`, + 'gm', +); + +// import('pkg/x.css') +// await import('pkg/x.css') +const DYNAMIC_CSS_IMPORT_RE = new RegExp(`\\bimport\\s*\\(\\s*['"]${CSS_PATH}['"]`, 'g'); const EXCLUDED_SCOPES = ['@gravity-ui/']; async function main() { - const {SHADOW_STYLE_IMPORTS} = await import(GULPFILE_URL); + const {SHADOW_STYLE_IMPORTS} = await import(IMPORTS_MODULE_URL); if (!Array.isArray(SHADOW_STYLE_IMPORTS)) { - console.error('Failed to import SHADOW_STYLE_IMPORTS from gulpfile.mjs'); + console.error('Failed to import SHADOW_STYLE_IMPORTS from shadow-styles-imports.mjs'); process.exit(1); } @@ -34,7 +50,7 @@ async function main() { console.error(' Stale in SHADOW_STYLE_IMPORTS (in list, not used in src):'); for (const item of stale) console.error(` - ${item}`); } - console.error('Update SHADOW_STYLE_IMPORTS in packages/editor/gulpfile.mjs.'); + console.error('Update SHADOW_STYLE_IMPORTS in packages/editor/scripts/shadow-styles-imports.mjs.'); process.exit(1); } @@ -46,16 +62,27 @@ function collectCssImports(dir) { const result = new Set(); walk(dir, (filePath) => { if (!/\.(?:ts|tsx|js|jsx|mjs|cjs)$/.test(filePath)) return; - const content = fs.readFileSync(filePath, 'utf8'); - for (const match of content.matchAll(CSS_IMPORT_RE)) { - const spec = match[1]; - if (EXCLUDED_SCOPES.some((scope) => spec.startsWith(scope))) continue; - result.add(spec); + const content = stripComments(fs.readFileSync(filePath, 'utf8')); + for (const re of [STATIC_CSS_IMPORT_RE, DYNAMIC_CSS_IMPORT_RE]) { + for (const match of content.matchAll(re)) { + const spec = match[1]; + if (EXCLUDED_SCOPES.some((scope) => spec.startsWith(scope))) continue; + result.add(spec); + } } }); return result; } +// Strip block and line comments so commented-out imports don't show up as +// drift. Naive replace — acceptable because we never *add* matches by stripping, +// only remove potential matches. The only edge case is a regex literal like +// `/foo\/\/bar/` whose `//` would be mistaken for a line comment, but no +// `import 'pkg/x.css'` can live inside a regex literal anyway. +function stripComments(source) { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n\r]*/g, ''); +} + function walk(dir, visit) { for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { const full = path.join(dir, entry.name); diff --git a/packages/editor/scripts/shadow-styles-imports.mjs b/packages/editor/scripts/shadow-styles-imports.mjs new file mode 100644 index 000000000..cbbcb2863 --- /dev/null +++ b/packages/editor/scripts/shadow-styles-imports.mjs @@ -0,0 +1,13 @@ +// Single source of truth for non-relative CSS imports inlined into shadow-styles. +// Imported by both gulpfile.mjs (build task) and check-shadow-styles-imports.js +// (CI drift check). Keep this list aligned with imports under packages/editor/src. + +export const SHADOW_STYLE_IMPORTS = Object.freeze([ + '@diplodoc/transform/dist/css/base.css', + '@diplodoc/transform/dist/css/_yfm-only.css', + '@diplodoc/cut-extension/runtime/styles.css', + '@diplodoc/file-extension/runtime/styles.css', + '@diplodoc/tabs-extension/runtime/styles.css', + '@diplodoc/quote-link-extension/runtime/styles.css', + '@diplodoc/folding-headings-extension/runtime/styles.css', +]);