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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
106 changes: 105 additions & 1 deletion packages/editor/gulpfile.mjs
Original file line number Diff line number Diff line change
@@ -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'};

Check failure on line 9 in packages/editor/gulpfile.mjs

View workflow job for this annotation

GitHub Actions / Verify Files

There should be at least one empty line between import groups
import {SHADOW_STYLE_IMPORTS} from './scripts/shadow-styles-imports.mjs';

Check failure on line 10 in packages/editor/gulpfile.mjs

View workflow job for this annotation

GitHub Actions / Verify Files

`./scripts/shadow-styles-imports.mjs` import should occur before import of `./package.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 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

Check failure on line 74 in packages/editor/gulpfile.mjs

View workflow job for this annotation

GitHub Actions / Verify Files

Replace `⏎········.replace(/\/\*[\s\S]*?\*\//g,·'')⏎········` with `.replace(/\/\*[\s\S]*?\*\//g,·'')`
.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) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (complexity): Consider simplifying the shadow-styles generation by using JSON.stringify-based string literals, a shared multi-line snippet for createStyleSheet, and extracting optional-peer/CSS logic into a helper module to keep the gulpfile focused on task wiring.

You can keep the current behavior but reduce complexity in a few focused spots:

1. Drop custom template-literal escaping

toTemplateLiteral can be removed by emitting a plain string literal via JSON.stringify, which handles all escaping for you and is easier to reason about.

function createShadowStylesModule(value) {
    const cssJson = JSON.stringify(value);

    const createStyleSheetFn = `
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;
}
`;

    return {
        esm: [
            `export const cssText = ${cssJson};`,
            createStyleSheetFn,
            'export {createStyleSheet};',
            '',
        ].join('\n'),
        cjs: [
            `const cssText = ${cssJson};`,
            createStyleSheetFn,
            'exports.cssText = cssText;',
            'exports.createStyleSheet = createStyleSheet;',
            '',
        ].join('\n'),
    };
}

Then you can delete toTemplateLiteral entirely.

2. Make codegen more readable

Instead of building createStyleSheet as an array of lines and injecting it twice, use a single multi-line string (as above). This keeps the logic readable and easy to edit while still generating both ESM and CJS variants.

If you want to simplify further, you can factor the common snippet out:

const CREATE_STYLESHEET_SNIPPET = `
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;
}
`;

and reuse CREATE_STYLESHEET_SNIPPET in createShadowStylesModule.

3. Move optional-peer logic into a helper module

To keep the gulpfile focused on task wiring, push the peer-dependency parsing into a small helper. Behavior stays the same but the gulpfile reads more linearly.

scripts/shadow-styles-helpers.js:

import {readFileSync} from 'node:fs';
import {createRequire} from 'node:module';
import pkg from '../package.json' with {type: 'json'};

const require = createRequire(import.meta.url);

const OPTIONAL_PEERS = new Set(
    Object.entries(pkg.peerDependenciesMeta ?? {})
        .filter(([, meta]) => meta?.optional)
        .map(([name]) => name),
);

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',
]);

export 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');
}

function isOptionalPeerImport(cssImport) {
    const pkgName = cssImport.startsWith('@')
        ? cssImport.split('/', 2).join('/')
        : cssImport.split('/', 1)[0];
    return OPTIONAL_PEERS.has(pkgName);
}

gulpfile snippet:

import {readExternalShadowStyles, SHADOW_STYLE_IMPORTS} from './scripts/shadow-styles-helpers.js';

// ... use readExternalShadowStyles() in the 'shadow-styles' task as you do now

This keeps all functionality but reduces the cognitive load in the gulpfile and makes the CSS/peer logic easier to test and evolve independently.

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, '\\${')}\``;
}
11 changes: 11 additions & 0 deletions packages/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
97 changes: 97 additions & 0 deletions packages/editor/scripts/check-shadow-styles-imports.js
Original file line number Diff line number Diff line change
@@ -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)";

Check failure on line 13 in packages/editor/scripts/check-shadow-styles-imports.js

View workflow job for this annotation

GitHub Actions / Verify Files

Replace `"((?:@[^'\"\\s/]+/)?[^'\"\\s.][^'\"\\s]*\\.css)"` with `'((?:@[^\'"\\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.');

Check failure on line 53 in packages/editor/scripts/check-shadow-styles-imports.js

View workflow job for this annotation

GitHub Actions / Verify Files

Replace `'Update·SHADOW_STYLE_IMPORTS·in·packages/editor/scripts/shadow-styles-imports.mjs.'` with `⏎············'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);
});
13 changes: 13 additions & 0 deletions packages/editor/scripts/shadow-styles-imports.mjs
Original file line number Diff line number Diff line change
@@ -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',
]);
93 changes: 80 additions & 13 deletions packages/editor/tests/esbuild-test/esbuild-tester.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;
}
}
}
Loading