|
| 1 | +import type { UserConfig } from 'unocss' |
| 2 | +import { Buffer } from 'node:buffer' |
| 3 | +import fs from 'node:fs/promises' |
| 4 | +import { createRequire } from 'node:module' |
| 5 | +import { join } from 'node:path' |
| 6 | +import { transform } from 'lightningcss' |
| 7 | +import MagicString from 'magic-string' |
| 8 | +import { glob } from 'tinyglobby' |
| 9 | +import { createGenerator } from 'unocss' |
| 10 | +import { namespaceShadowCssVars, rewireBakedPrimaryColors, shadowSurfaceSafelist } from './uno.config' |
| 11 | + |
| 12 | +// Story-only utility classes must not leak into a shipped shadow-root |
| 13 | +// stylesheet. |
| 14 | +const IGNORE = ['**/*.stories.*', '**/__tests__/**'] |
| 15 | + |
| 16 | +export interface BuildShadowCssOptions { |
| 17 | + /** |
| 18 | + * Absolute path of the package's UnoCSS-scanned source directory. The |
| 19 | + * compiled stylesheet is written to `<srcDir>/.generated/css.ts`. |
| 20 | + */ |
| 21 | + srcDir: string |
| 22 | + /** Glob patterns (relative to `srcDir`) UnoCSS extracts classes from. */ |
| 23 | + globs: string[] |
| 24 | + /** The package's own `uno.config` default export. */ |
| 25 | + config: UserConfig<any> |
| 26 | + /** |
| 27 | + * Absolute path to the primary-ramp override stylesheet, appended AFTER |
| 28 | + * the UnoCSS output so its `:host`/`:root, :host` block wins over Wind's |
| 29 | + * own primary declarations (see each package's `primary-ramp.css`). |
| 30 | + */ |
| 31 | + primaryRampPath: string |
| 32 | + /** |
| 33 | + * Absolute path to a hand-authored stylesheet run through the generator's |
| 34 | + * configured transformers (directives, variant groups) and merged in |
| 35 | + * right after the CSS reset. Omit for a package with no hand-written |
| 36 | + * styles. |
| 37 | + */ |
| 38 | + userStylePath?: string |
| 39 | + /** |
| 40 | + * Prefix Wind's `--un-*` custom properties are renamed to (see |
| 41 | + * `namespaceShadowCssVars`) — unique per shadow-root surface so two |
| 42 | + * shadow trees on the same host page never collide. |
| 43 | + */ |
| 44 | + varPrefix: string |
| 45 | +} |
| 46 | + |
| 47 | +export interface BuildShadowCssResult { |
| 48 | + /** Number of source files scanned for class extraction. */ |
| 49 | + sourceCount: number |
| 50 | + /** The compiled, minified shadow-root stylesheet. */ |
| 51 | + css: string |
| 52 | +} |
| 53 | + |
| 54 | +// Compile a shadow-root surface's UnoCSS output ahead of time into a plain |
| 55 | +// string module (`<srcDir>/.generated/css.ts`) that the surface adopts into |
| 56 | +// its shadow root — fully styled inside any host page without a global |
| 57 | +// stylesheet, and immune to the host page's own styles leaking in. Shared by |
| 58 | +// `@devframes/hub-ui`'s dock and `@devframes/json-render-ui`'s renderer |
| 59 | +// module: same pipeline, same two shadow-root gotchas (see the root |
| 60 | +// AGENTS.md "Design system" section), different source globs. Writes the |
| 61 | +// generated file itself; returns stats so each caller (a `scripts/` entry, |
| 62 | +// exempt from the `no-console` lint rule) prints its own summary line. |
| 63 | +export async function buildShadowCss(options: BuildShadowCssOptions): Promise<BuildShadowCssResult> { |
| 64 | + const { srcDir, globs, config, primaryRampPath, userStylePath, varPrefix } = options |
| 65 | + const generatedCss = join(srcDir, '.generated/css.ts') |
| 66 | + |
| 67 | + const require = createRequire(import.meta.url) |
| 68 | + const reset = await fs.readFile(require.resolve('@unocss/reset/tailwind.css'), 'utf-8') |
| 69 | + const files = await glob(globs, { |
| 70 | + cwd: srcDir, |
| 71 | + absolute: true, |
| 72 | + ignore: IGNORE, |
| 73 | + }) |
| 74 | + |
| 75 | + // Shadow-root surfaces reuse `@antfu/design`'s Vue components (buttons, |
| 76 | + // badges, …) directly. UnoCSS ignores `node_modules` by default, so their |
| 77 | + // semantic shortcut classes (`btn-primary`, `btn-action`, `badge-*`, …) |
| 78 | + // would be absent from the shadow-root stylesheet — scan the design |
| 79 | + // package's component sources too so those classes ship in the injected |
| 80 | + // CSS. |
| 81 | + const designComponentsDir = join(require.resolve('@antfu/design/package.json'), '..', 'components') |
| 82 | + const designFiles = await glob('**/*.vue', { |
| 83 | + cwd: designComponentsDir, |
| 84 | + absolute: true, |
| 85 | + ignore: IGNORE, |
| 86 | + }) |
| 87 | + |
| 88 | + const generator = await createGenerator(config) |
| 89 | + |
| 90 | + const tokens = new Set<string>() |
| 91 | + for (const file of [...files, ...designFiles]) { |
| 92 | + const content = await fs.readFile(file, 'utf-8') |
| 93 | + await generator.applyExtractors(content, file, tokens) |
| 94 | + } |
| 95 | + |
| 96 | + // The hand-written stylesheet (if any) may use `--at-apply` — run it |
| 97 | + // through the configured transformers (directives, variant groups) before |
| 98 | + // merging. |
| 99 | + const userStyle = userStylePath |
| 100 | + ? new MagicString(await fs.readFile(userStylePath, 'utf-8').catch(() => '')) |
| 101 | + : undefined |
| 102 | + if (userStyle) { |
| 103 | + for (const transformer of generator.config.transformers ?? []) { |
| 104 | + await transformer.transform(userStyle, userStylePath!, { uno: generator } as any) |
| 105 | + } |
| 106 | + } |
| 107 | + |
| 108 | + const primaryRamp = await fs.readFile(primaryRampPath, 'utf-8') |
| 109 | + const unoResult = await generator.generate(tokens) |
| 110 | + // Wind3 drops a *plain* semantic shortcut (`.bg-base` / `.color-base`) from |
| 111 | + // the main pass when the same shortcut also appears variant-prefixed in the |
| 112 | + // sources (e.g. `@antfu/design`'s Tabs emits `data-[state=active]:bg-base`) — |
| 113 | + // a shortcut+variant interaction. Generate the shadow-surface tokens in a |
| 114 | + // dedicated pass so their plain (and `.dark`) rules are always present. |
| 115 | + const surfaces = await generator.generate(shadowSurfaceSafelist.join(' ')) |
| 116 | + // Wind3 bakes the `primary` theme color to literal `rgb()` triplets at |
| 117 | + // generate-time — rewire them to read the live `--colors-primary-*` |
| 118 | + // variables `primary-ramp.css` derives from `--devframe-primary`, so a |
| 119 | + // rebrand actually retints `text-primary`/`bg-primary`/`btn-primary`/… |
| 120 | + // (see `rewireBakedPrimaryColors`'s own comment). |
| 121 | + const primaryTheme = (generator.config.theme as { colors?: Record<string, Record<string, string>> }).colors?.primary ?? {} |
| 122 | + const unoCss = rewireBakedPrimaryColors(unoResult.css, primaryTheme) |
| 123 | + const surfacesCss = rewireBakedPrimaryColors(surfaces.css, primaryTheme) |
| 124 | + // Namespace Wind's `--un-*` vars so this shadow-root stylesheet is immune |
| 125 | + // to a host page's Wind4 `@property` registrations (see |
| 126 | + // `namespaceShadowCssVars`). |
| 127 | + let css = [ |
| 128 | + reset, |
| 129 | + userStyle?.toString(), |
| 130 | + unoCss, |
| 131 | + surfacesCss, |
| 132 | + primaryRamp, |
| 133 | + ].filter((part): part is string => part !== undefined).join('\n') |
| 134 | + |
| 135 | + css = namespaceShadowCssVars(css, varPrefix) |
| 136 | + css = transform({ |
| 137 | + filename: 'hub-ui.css', |
| 138 | + code: Buffer.from(css), |
| 139 | + minify: true, |
| 140 | + }).code.toString() |
| 141 | + |
| 142 | + await fs.mkdir(join(srcDir, '.generated'), { recursive: true }) |
| 143 | + await fs.writeFile(generatedCss, [ |
| 144 | + `/* eslint-disable eslint-comments/no-unlimited-disable */`, |
| 145 | + `/* eslint-disable */`, |
| 146 | + `export default ${JSON.stringify(String(css))}`, |
| 147 | + '', |
| 148 | + ].join('\n')) |
| 149 | + |
| 150 | + return { sourceCount: files.length, css } |
| 151 | +} |
0 commit comments