diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 007d64a70..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 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 5ae14b32e..06fcaf25f 100644 --- a/packages/editor/gulpfile.mjs +++ b/packages/editor/gulpfile.mjs @@ -1,20 +1,124 @@ +import {readFileSync, writeFileSync} from 'node:fs'; +import {createRequire} from 'node:module'; import {dirname, 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'}; +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'); +const OPTIONAL_PEERS = new Set( + Object.entries(pkg.peerDependenciesMeta ?? {}) + .filter(([, meta]) => meta?.optional) + .map(([name]) => name), +); + registerBuildTasks({ version: pkg.version, buildDir: BUILD_DIR, nodeModulesDir: NODE_MODULES_DIR, }); +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); + writeFileSync(resolve(BUILD_DIR, 'shadow-styles.cjs'), moduleCode.cjs); + writeFileSync( + 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'), 'shadow-styles')); task('default', series('clean', 'build')); + +function readExternalShadowStyles() { + 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'); +} + +// `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('/') + : cssImport.split('/', 1)[0]; + return OPTIONAL_PEERS.has(pkgName); +} + +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) { + return `\`${value.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${')}\``; +} diff --git a/packages/editor/package.json b/packages/editor/package.json index 54469fa32..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" @@ -152,6 +153,16 @@ "default": "./build/cjs/*" } }, + "./shadow-styles": { + "import": { + "types": "./build/shadow-styles.d.ts", + "default": "./build/shadow-styles.mjs" + }, + "require": { + "types": "./build/shadow-styles.d.ts", + "default": "./build/shadow-styles.cjs" + } + }, "./styles/*": "./build/esm/styles/*" }, "main": "build/cjs/index.js", 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..d28b5003c --- /dev/null +++ b/packages/editor/scripts/check-shadow-styles-imports.js @@ -0,0 +1,97 @@ +/* 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 IMPORTS_MODULE_URL = pathToFileURL(path.resolve(__dirname, 'shadow-styles-imports.mjs')).href; + +// 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(IMPORTS_MODULE_URL); + if (!Array.isArray(SHADOW_STYLE_IMPORTS)) { + console.error('Failed to import SHADOW_STYLE_IMPORTS from shadow-styles-imports.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/scripts/shadow-styles-imports.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 = 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); + if (entry.isDirectory()) walk(full, visit); + else if (entry.isFile()) visit(full); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); 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', +]); diff --git a/packages/editor/tests/esbuild-test/esbuild-tester.js b/packages/editor/tests/esbuild-test/esbuild-tester.js index 3e1e9f447..d847bddf3 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,86 @@ const esbuildOptions = { alias: ['fs', 'path', 'stream'].reduce((acc, name) => ({...acc, [name]: paths.aliases}), {}), }; -esbuild - .build({...esbuildOptions, entryPoints: [paths.esbuildToTest]}) - .then(async () => { +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 OriginalCSSStyleSheet = globalThis.CSSStyleSheet; + const TestCSSStyleSheet = class CSSStyleSheet { + replaceSync(value) { + this.cssText = value; + } + }; + + try { + globalThis.CSSStyleSheet = TestCSSStyleSheet; + + const shadowStylesCjs = require(path.join(__dirname, '../../build/shadow-styles.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', + ); + } + + if (typeof shadowStylesMjs.createStyleSheet !== 'function') { + throw new Error('shadow-styles ESM export is invalid: expected createStyleSheet()'); + } + + const cjsStyleSheet = shadowStylesCjs.createStyleSheet(); + const mjsStyleSheet = shadowStylesMjs.createStyleSheet(); + + if ( + !(cjsStyleSheet instanceof TestCSSStyleSheet) || + cjsStyleSheet.cssText !== shadowStylesCjs.cssText + ) { + throw new Error( + 'shadow-styles CJS helper is invalid: expected populated CSSStyleSheet', + ); + } + + 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(() => { - // Cleanup - if (fs.existsSync(paths.localBuild)) - fs.rmSync(paths.localBuild, { - force: true, - recursive: true, - }); - if (fs.existsSync(paths.tempTest)) fs.rmSync(paths.tempTest); - }); + } finally { + if (typeof OriginalCSSStyleSheet === 'undefined') { + delete globalThis.CSSStyleSheet; + } else { + globalThis.CSSStyleSheet = OriginalCSSStyleSheet; + } + } +}