-
Notifications
You must be signed in to change notification settings - Fork 42
feat: add shadow-styles export for Shadow DOM integration #1027
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
makhnatkin
wants to merge
4
commits into
main
Choose a base branch
from
claude/nice-lederberg
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
cee414e
feat: export editor styles as string
makhnatkin 429c972
refactor: rename styles export to shadow-styles
makhnatkin 3ecf3c3
test(shadow-styles): enforce import list sync + soft-fail optional peers
makhnatkin f68a007
refactor(shadow-styles): address self-review feedback
makhnatkin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'}; | ||
| 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, '\\${')}\``; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)"; | ||
|
|
||
| // 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
|
||
| 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); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', | ||
| ]); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
toTemplateLiteralcan be removed by emitting a plain string literal viaJSON.stringify, which handles all escaping for you and is easier to reason about.Then you can delete
toTemplateLiteralentirely.2. Make codegen more readable
Instead of building
createStyleSheetas 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:
and reuse
CREATE_STYLESHEET_SNIPPETincreateShadowStylesModule.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:gulpfilesnippet:This keeps all functionality but reduces the cognitive load in the gulpfile and makes the CSS/peer logic easier to test and evolve independently.