Skip to content

feat: export CSS as string module for Shadow DOM injection#1027

Open
makhnatkin wants to merge 1 commit intomainfrom
claude/nice-lederberg
Open

feat: export CSS as string module for Shadow DOM injection#1027
makhnatkin wants to merge 1 commit intomainfrom
claude/nice-lederberg

Conversation

@makhnatkin
Copy link
Copy Markdown
Collaborator

@makhnatkin makhnatkin commented Mar 24, 2026

Summary

Adds `@gravity-ui/markdown-editor/styles-string` — a JS module that exports all editor styles as a string, enabling injection into Shadow DOM via `adoptedStyleSheets` or a `<style>` tag.

```ts
import editorStyles from '@gravity-ui/markdown-editor/styles-string';

// Option 1: Constructable Stylesheets (modern browsers)
const sheet = new CSSStyleSheet();
sheet.replaceSync(editorStyles);
shadowRoot.adoptedStyleSheets = [sheet];

// Option 2: <style> tag (broader compatibility)
const style = document.createElement('style');
style.textContent = editorStyles;
shadowRoot.appendChild(style);
```

What's included

The exported string contains:

  • All editor SCSS compiled from `src/**/*.scss`
  • External CSS from `@diplodoc/*` packages imported directly in TS source (YfmCut, YfmFile, YfmTabs, YfmConfigs, QuoteLink, FoldingHeading)

Note: `@gravity-ui/uikit` styles are not included — if your shadow root also uses UIKit components, you'll need to inject those separately.

Implementation details

  • Build: new `styles-string` gulp task runs after `scss`, reads `build/styles.css` + resolves external CSS via `require.resolve()`, writes `build/styles-string.mjs` (ESM) and `build/styles-string.cjs` (CJS)
  • `.mjs` extension is used for the ESM file so Node.js always treats it as ESM, regardless of the `build/` root having no `"type": "module"`
  • Smoke test added to `tests/esbuild-test/esbuild-tester.js` verifying both CJS and ESM files before the bundler compatibility test

Test plan

  • `pnpm run build` — `build/styles-string.mjs`, `.cjs`, `.d.ts` are generated
  • CJS: `typeof s === 'string' && s.length === 122371`
  • `pnpm run test:esbuild` → `styles-string smoke test: OK (length: 122371)`

Closes #1026

Hey @zeeeeby — is this what you had in mind? The exported string covers core editor styles plus the default YFM preset's external dependencies. Let me know if anything is missing from your specific setup (e.g. which extensions/preset you're using, whether you also need UIKit styles injected).

🤖 Generated with Claude Code

Summary by Sourcery

Export compiled editor CSS (including external style dependencies) as a string module and integrate it into the editor build and test pipeline.

New Features:

  • Expose a new styles-string entry point that exports all compiled editor styles as a single string for use in Shadow DOM or custom injection flows.

Enhancements:

  • Extend the editor build pipeline with a styles-string task that aggregates compiled SCSS and external CSS imports into ESM and CJS bundles with type definitions.

Build:

  • Update the gulp build tasks to run TypeScript, JSON, SCSS, and the new styles-string generation in parallel/sequence as part of the default build.

Tests:

  • Add a smoke test in the esbuild test harness to validate styles-string CJS/ESM outputs and ensure they remain compatible with the bundling process.

@makhnatkin makhnatkin requested a review from d3m1d0v as a code owner March 24, 2026 14:11
@gravity-ui
Copy link
Copy Markdown

gravity-ui Bot commented Mar 24, 2026

Storybook Deployed

@gravity-ui
Copy link
Copy Markdown

gravity-ui Bot commented Mar 24, 2026

🎭 Playwright Report

@makhnatkin makhnatkin marked this pull request as draft March 25, 2026 21:16
@makhnatkin makhnatkin force-pushed the claude/nice-lederberg branch from 8ca0e13 to cee414e Compare May 5, 2026 20:54
@makhnatkin makhnatkin marked this pull request as ready for review May 5, 2026 21:07
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented May 5, 2026

Reviewer's Guide

Adds a new build artifact that exports the editor’s full CSS (internal SCSS + external YFM-related CSS) as a string module (styles-string) in both ESM and CJS formats, wires it into the editor build pipeline, and adds a smoke test to ensure both module formats export identical non-empty CSS, then exposes it via a new package subpath export.

File-Level Changes

Change Details Files
Introduce styles-string gulp task to generate stringified CSS bundle and integrate it into the build pipeline.
  • Add filesystem and module utilities (fs, module, extname) and constants for source traversal and CSS import detection.
  • Define styles-string gulp task that reads compiled styles.css and external CSS, concatenates them, escapes them into a JS template literal, and writes styles-string.mjs, styles-string.cjs, and styles-string.d.ts into the build directory.
  • Update the build gulp task to run ts, json, and scss in parallel and then run styles-string.
  • Implement helper functions to recursively collect TS/JS source files, detect external CSS imports via regex, resolve them with require.resolve, and serialize CSS into a safe template literal.
packages/editor/gulpfile.mjs
Add smoke tests verifying the CJS and ESM styles-string outputs and keep esbuild compatibility tests intact.
  • Refactor esbuild test harness into an async run function with a shared finally cleanup block for temporary build artifacts.
  • Require the generated CJS styles-string module and assert it exports a non-empty string.
  • Dynamically import the ESM styles-string module via pathToFileURL and assert its default export matches the CJS string exactly.
  • Log a concise success message for the styles-string smoke test before proceeding with existing esbuild-based tests that compile and re-bundle all editor exports.
packages/editor/tests/esbuild-test/esbuild-tester.js
Expose the new styles-string artifact via a package subpath export for both ESM and CJS consumers.
  • Add a "./styles-string" subpath in exports mapping to styles-string.mjs/styles-string.cjs with associated .d.ts typings for import and require.
  • Leave existing exports (default, styles/*) untouched to avoid breaking current consumers.
packages/editor/package.json

Assessment against linked issues

Issue Objective Addressed Explanation
#1026 Provide an API to obtain all markdown editor styles as a single string that can be injected into a Shadow DOM.
#1026 Integrate the styles-string export into the build and package exports so it is reliably generated and consumable by users.

Possibly linked issues

  • Shadow dom support #1026: PR adds a styles-string export giving all editor CSS as a string for Shadow DOM injection, matching issue request.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 1 issue, and left some high level feedback:

  • The EXTERNAL_CSS_IMPORT_RE approach hard-codes a particular import shape (single-line, static, bare module, .css suffix) and may miss or mis-detect imports as code evolves; consider either broadening the regex to handle multiline/named-only imports or switching to a simple AST-based scan so TS/JS formatting changes don’t silently break CSS collection.
  • In collectExternalCss, require.resolve(cssImport) will throw if a referenced CSS module is missing; if this is expected to be robust in different consumer setups, you might want to add a clearer error message or guard around unresolved imports so build failures are easier to diagnose.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `EXTERNAL_CSS_IMPORT_RE` approach hard-codes a particular import shape (single-line, static, bare module, `.css` suffix) and may miss or mis-detect imports as code evolves; consider either broadening the regex to handle multiline/named-only imports or switching to a simple AST-based scan so TS/JS formatting changes don’t silently break CSS collection.
- In `collectExternalCss`, `require.resolve(cssImport)` will throw if a referenced CSS module is missing; if this is expected to be robust in different consumer setups, you might want to add a clearer error message or guard around unresolved imports so build failures are easier to diagnose.

## Individual Comments

### Comment 1
<location path="packages/editor/gulpfile.mjs" line_range="26" />
<code_context>
     nodeModulesDir: NODE_MODULES_DIR,
 });

+task('styles-string', (done) => {
+    const externalCss = collectExternalCss();
+    const editorCss = readFileSync(resolve(BUILD_DIR, 'styles.css'), 'utf8');
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the CSS aggregation and helper logic into a separate module so the gulpfile remains a small, declarative task wiring file.

You can keep the new functionality but move the “mini build system” out of the gulpfile so the gulpfile stays small and easy to scan.

### 1. Extract helpers into a separate module

Create a dedicated helper file (e.g. `build/styles-string.mjs`) and move the regex, FS traversal, and template-escaping there:

```js
// build/styles-string.mjs
import {readFileSync, readdirSync} from 'node:fs';
import {createRequire} from 'node:module';
import {extname, resolve} from 'node:path';

const require = createRequire(import.meta.url);
const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
const EXTERNAL_CSS_IMPORT_RE =
    /^\s*import\s+(?:.+?\s+from\s+)?['"]([^./'"][^'"]*\.css)['"];?/gm;

export function createStylesString({buildDir, sourceDir}) {
    const externalCss = collectExternalCss(sourceDir);
    const editorCss = readFileSync(resolve(buildDir, 'styles.css'), 'utf8');
    const styles = [externalCss, editorCss].filter(Boolean).join('\n');
    return toTemplateLiteral(styles);
}

function collectExternalCss(sourceDir) {
    const cssImports = new Set();

    for (const sourceFile of getSourceFiles(sourceDir)) {
        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})
        // drop sort() if deterministic order is not required
        .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, '\\${')}\``;
}
```

### 2. Keep the gulpfile focused on task wiring

Then the gulpfile only wires tasks together and delegates the logic:

```js
// gulpfile.mjs
import {writeFileSync} from 'node:fs';
import {dirname, resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
import {parallel, series, task} from '@markdown-editor/gulp-tasks';
import {registerBuildTasks} from '@markdown-editor/gulp-tasks/build';
import {createStylesString} from './build/styles-string.mjs';

import pkg from './package.json' with {type: 'json'};

const __dirname = dirname(fileURLToPath(import.meta.url));
const BUILD_DIR = resolve('build');
const SOURCE_DIR = resolve('src');

registerBuildTasks({
    version: pkg.version,
    buildDir: BUILD_DIR,
    nodeModulesDir: resolve(__dirname, 'node_modules'),
});

task('styles-string', (done) => {
    const content = createStylesString({buildDir: BUILD_DIR, sourceDir: SOURCE_DIR});

    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'));
```

This keeps all behavior intact but:

- The gulpfile returns to being a small, declarative task definition.
- The complex parts (regex parsing, traversal, escaping) live in an isolated module that you can unit test independently.
- Future readers only need to dive into `build/styles-string.mjs` when they care about the details of CSS aggregation.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

nodeModulesDir: NODE_MODULES_DIR,
});

task('styles-string', (done) => {
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 extracting the CSS aggregation and helper logic into a separate module so the gulpfile remains a small, declarative task wiring file.

You can keep the new functionality but move the “mini build system” out of the gulpfile so the gulpfile stays small and easy to scan.

1. Extract helpers into a separate module

Create a dedicated helper file (e.g. build/styles-string.mjs) and move the regex, FS traversal, and template-escaping there:

// build/styles-string.mjs
import {readFileSync, readdirSync} from 'node:fs';
import {createRequire} from 'node:module';
import {extname, resolve} from 'node:path';

const require = createRequire(import.meta.url);
const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
const EXTERNAL_CSS_IMPORT_RE =
    /^\s*import\s+(?:.+?\s+from\s+)?['"]([^./'"][^'"]*\.css)['"];?/gm;

export function createStylesString({buildDir, sourceDir}) {
    const externalCss = collectExternalCss(sourceDir);
    const editorCss = readFileSync(resolve(buildDir, 'styles.css'), 'utf8');
    const styles = [externalCss, editorCss].filter(Boolean).join('\n');
    return toTemplateLiteral(styles);
}

function collectExternalCss(sourceDir) {
    const cssImports = new Set();

    for (const sourceFile of getSourceFiles(sourceDir)) {
        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})
        // drop sort() if deterministic order is not required
        .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, '\\${')}\``;
}

2. Keep the gulpfile focused on task wiring

Then the gulpfile only wires tasks together and delegates the logic:

// gulpfile.mjs
import {writeFileSync} from 'node:fs';
import {dirname, resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
import {parallel, series, task} from '@markdown-editor/gulp-tasks';
import {registerBuildTasks} from '@markdown-editor/gulp-tasks/build';
import {createStylesString} from './build/styles-string.mjs';

import pkg from './package.json' with {type: 'json'};

const __dirname = dirname(fileURLToPath(import.meta.url));
const BUILD_DIR = resolve('build');
const SOURCE_DIR = resolve('src');

registerBuildTasks({
    version: pkg.version,
    buildDir: BUILD_DIR,
    nodeModulesDir: resolve(__dirname, 'node_modules'),
});

task('styles-string', (done) => {
    const content = createStylesString({buildDir: BUILD_DIR, sourceDir: SOURCE_DIR});

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

This keeps all behavior intact but:

  • The gulpfile returns to being a small, declarative task definition.
  • The complex parts (regex parsing, traversal, escaping) live in an isolated module that you can unit test independently.
  • Future readers only need to dive into build/styles-string.mjs when they care about the details of CSS aggregation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Shadow dom support

1 participant