diff --git a/.npmrc b/.npmrc index 116d16a..dee9221 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,3 @@ +registry=https://registry.npmjs.org/ ignore-workspace-root-check=true shamefully-hoist=true diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..fb6950b --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + "recommendations": ["dbaeumer.vscode-eslint"], + "unwantedRecommendations": ["esbenp.prettier-vscode"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index eb8204b..e1ad287 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,37 @@ { - "eslint.experimental.useFlatConfig": true + // Use ESLint for formatting (matches @antfu/eslint-config), not Prettier/TS formatter + "prettier.enable": false, + "editor.formatOnSave": false, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit", + "source.organizeImports": "never" + }, + // Flat config (eslint.config.js) + "eslint.useFlatConfig": true, + "eslint.experimental.useFlatConfig": true, + // Avoid duplicate squiggles; ESLint still auto-fixes style on save + "eslint.rules.customizations": [ + { "rule": "style/*", "severity": "off", "fixable": true }, + { "rule": "*-indent", "severity": "off", "fixable": true }, + { "rule": "*-spacing", "severity": "off", "fixable": true }, + { "rule": "*-spaces", "severity": "off", "fixable": true }, + { "rule": "*-order", "severity": "off", "fixable": true }, + { "rule": "*-dangle", "severity": "off", "fixable": true }, + { "rule": "*-newline", "severity": "off", "fixable": true }, + { "rule": "*quotes", "severity": "off", "fixable": true }, + { "rule": "*semi", "severity": "off", "fixable": true } + ], + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "vue", + "html", + "markdown", + "json", + "json5", + "jsonc", + "yaml" + ] } diff --git a/package.json b/package.json index 71bce1d..e5639ae 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,7 @@ }, "dependencies": { "mlly": "^1.7.4", + "typescript": "^5.8.3", "unplugin": "^2.3.4" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1c0929..c95a52c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: mlly: specifier: ^1.7.4 version: 1.7.4 + typescript: + specifier: ^5.8.3 + version: 5.8.3 unplugin: specifier: ^2.3.4 version: 2.3.4 @@ -72,9 +75,6 @@ importers: tsup: specifier: ^8.4.0 version: 8.4.0(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.4)(typescript@5.8.3)(yaml@2.8.0) - typescript: - specifier: ^5.8.3 - version: 5.8.3 vite: specifier: ^6.3.5 version: 6.3.5(@types/node@22.15.18)(jiti@2.4.2)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.0) diff --git a/src/build-events.ts b/src/build-events.ts new file mode 100644 index 0000000..8f8aea2 --- /dev/null +++ b/src/build-events.ts @@ -0,0 +1,9 @@ +import type { CompilerBuildResults } from '@stencil/core/internal' +import { EventEmitter } from 'node:events' + +export const stencilBuildEvents = new EventEmitter() + +export interface StencilBuildEvents { + buildFinished: (results: CompilerBuildResults) => void + buildError: (err: unknown) => void +} diff --git a/src/build-queue.ts b/src/build-queue.ts index ce2fd85..d7a224b 100644 --- a/src/build-queue.ts +++ b/src/build-queue.ts @@ -1,5 +1,7 @@ import type { Compiler } from '@stencil/core/compiler' +import type { CompilerBuildResults } from '@stencil/core/internal' import { EventEmitter } from 'node:events' +import { stencilBuildEvents } from './build-events.js' export class BuildQueue extends EventEmitter { #compiler: Compiler @@ -29,11 +31,13 @@ export class BuildQueue extends EventEmitter { async #runBuild() { this.#isBuilding = true this.emit('buildStart') + let results: CompilerBuildResults | undefined try { - await this.#compiler.build() + results = await this.#compiler.build() } catch (err) { this.emit('buildError', err) + stencilBuildEvents.emit('buildError', err) throw err } finally { @@ -42,8 +46,9 @@ export class BuildQueue extends EventEmitter { this.#pending = false await this.#runBuild() } - else { - this.emit('buildFinished') + else if (results) { + this.emit('buildFinished', results) + stencilBuildEvents.emit('buildFinished', results) } } } diff --git a/src/index.ts b/src/index.ts index 9cc252a..c32efa8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,8 @@ import type * as CoreCompiler from '@stencil/core/compiler' -import type { OutputTargetDistCustomElements } from '@stencil/core/internal' +import type { + Config, + OutputTargetDistCustomElements, +} from '@stencil/core/internal' import type { UnpluginFactory } from 'unplugin' import type { Options } from './types.js' @@ -13,16 +16,31 @@ import { findStaticImports, parseStaticImport } from 'mlly' import { createUnplugin } from 'unplugin' import { BuildQueue } from './build-queue' import { STENCIL_IMPORT } from './constants.js' -import { getRootDir, getStencilConfigFile, parseTagConfig, transformCompiledCode } from './utils.js' +import { + clearStyleDependencies, + rebuildStyleMap, + setGlobalStyleFromConfig, +} from './style-dependencies.js' +import { + getRootDir, + getStencilConfigFile, + parseTagConfig, + transformCompiledCode, +} from './utils.js' const DCE_OUTPUT_TARGET_NAME = 'dist-custom-elements' -export const unpluginFactory: UnpluginFactory = (options = {}) => { +export const unpluginFactory: UnpluginFactory = ( + options = {}, +) => { const nodeLogger = nodeApi.createNodeLogger() let distCustomElementsOptions: OutputTargetDistCustomElements | undefined let compiler: CoreCompiler.Compiler | undefined let buildQueue: BuildQueue | undefined - const isTest = process.env.NODE_ENV === 'test' || process.env.VITEST === 'true' + let stencilConfig: Config | undefined + let onBuildFinished: (() => void) | undefined + const isTest + = process.env.NODE_ENV === 'test' || process.env.VITEST === 'true' return { name: 'unplugin-stencil', @@ -34,7 +52,9 @@ export const unpluginFactory: UnpluginFactory = (options = const configPath = await getStencilConfigFile(options) const nodeSys = nodeApi.createNodeSys({ process, logger: nodeLogger }) nodeApi.setupNodeProcess({ process, logger: nodeLogger }) - const coreCompiler = await nodeSys.dynamicImport!(nodeSys.getCompilerExecutingPath()) as { loadConfig: typeof CoreCompiler.loadConfig } + const coreCompiler = (await nodeSys.dynamicImport!( + nodeSys.getCompilerExecutingPath(), + )) as { loadConfig: typeof CoreCompiler.loadConfig } const validated = await coreCompiler.loadConfig({ config: { rootDir: getRootDir(options), @@ -53,18 +73,39 @@ export const unpluginFactory: UnpluginFactory = (options = sys: nodeSys, }) - distCustomElementsOptions = validated.config.outputTargets.find(o => o.type === DCE_OUTPUT_TARGET_NAME) as OutputTargetDistCustomElements - if (!distCustomElementsOptions) - throw new Error(`Could not find "${DCE_OUTPUT_TARGET_NAME}" output target`) + distCustomElementsOptions = validated.config.outputTargets.find( + o => o.type === DCE_OUTPUT_TARGET_NAME, + ) as OutputTargetDistCustomElements + if (!distCustomElementsOptions) { + throw new Error( + `Could not find "${DCE_OUTPUT_TARGET_NAME}" output target`, + ) + } + + stencilConfig = validated.config + setGlobalStyleFromConfig(validated.config) compiler = await createCompiler(validated.config) buildQueue = new BuildQueue(compiler) + + onBuildFinished = () => { + if (compiler && stencilConfig) + void rebuildStyleMap(compiler.sys, stencilConfig) + } + buildQueue.on('buildFinished', onBuildFinished) }, async buildEnd() { + if (buildQueue && onBuildFinished) + buildQueue.off('buildFinished', onBuildFinished) + + clearStyleDependencies() + // Clean up compiler resources when build ends await compiler?.destroy() compiler = undefined buildQueue = undefined + stencilConfig = undefined + onBuildFinished = undefined // In test mode, force exit after a short delay to allow cleanup // This works around Stencil compiler not fully releasing file handles @@ -116,7 +157,9 @@ export const unpluginFactory: UnpluginFactory = (options = const imports = staticImports .filter(imp => imp.specifier === STENCIL_IMPORT) .map(imp => parseStaticImport(imp)) - const isStencilComponent = imports.some(imp => 'Component' in (imp.namedImports || {})) + const isStencilComponent = imports.some( + imp => 'Component' in (imp.namedImports || {}), + ) /** * don't compile the file if: @@ -126,11 +169,13 @@ export const unpluginFactory: UnpluginFactory = (options = * something with the setup failed and some of the primitives we need * to compile the file are missing */ - !compiler || !buildQueue + !compiler + || !buildQueue /** * the output directory is not set */ - || !distCustomElementsOptions || !distCustomElementsOptions.dir + || !distCustomElementsOptions + || !distCustomElementsOptions.dir /** * the file is not a Stencil component and not a CSS file */ @@ -140,7 +185,10 @@ export const unpluginFactory: UnpluginFactory = (options = } const componentTag = parseTagConfig(code) - const compilerFilePath = path.resolve(distCustomElementsOptions.dir, `${componentTag}.js`) + const compilerFilePath = path.resolve( + distCustomElementsOptions.dir, + `${componentTag}.js`, + ) const raw = await buildQueue.getLatestBuild(id, compilerFilePath) @@ -153,8 +201,14 @@ export const unpluginFactory: UnpluginFactory = (options = compilerFilePath, ) - const sourcemapFilePath = path.resolve(distCustomElementsOptions.dir, `${componentTag}.js.map`) - const rawSourcemap = await buildQueue.getLatestBuild(id, sourcemapFilePath) + const sourcemapFilePath = path.resolve( + distCustomElementsOptions.dir, + `${componentTag}.js.map`, + ) + const rawSourcemap = await buildQueue.getLatestBuild( + id, + sourcemapFilePath, + ) const sourcemapExists = await compiler.sys.access(sourcemapFilePath) const sourcemap = sourcemapExists @@ -172,4 +226,10 @@ export const unpluginFactory: UnpluginFactory = (options = export const unplugin = /* #__PURE__ */ createUnplugin(unpluginFactory) +export { stencilBuildEvents } from './build-events.js' +export { + type ComponentStyleDependencies, + getComponentStyleDependencies, +} from './style-dependencies.js' + export default unplugin diff --git a/src/style-dependencies.ts b/src/style-dependencies.ts new file mode 100644 index 0000000..13cdf69 --- /dev/null +++ b/src/style-dependencies.ts @@ -0,0 +1,252 @@ +import type { CompilerSystem, Config } from '@stencil/core/internal' +import path from 'node:path' +import process from 'node:process' +import ts from 'typescript' + +export interface ComponentStyleDependencies { + /** absolute .tsx path → absolute style file paths */ + byComponent: Map> + /** absolute style path → consuming .tsx paths */ + byStyle: Map> + /** resolved Config.globalStyle, if set */ + globalStyle: string | undefined +} + +let cached: ComponentStyleDependencies | null = null + +const EMPTY_DEPS: ComponentStyleDependencies = { + byComponent: new Map(), + byStyle: new Map(), + globalStyle: undefined, +} + +export function setGlobalStyleFromConfig(config: Config): void { + if (!cached) { + cached = { + byComponent: new Map(), + byStyle: new Map(), + globalStyle: resolveGlobalStyle(config), + } + } + else { + cached = { + ...cached, + globalStyle: resolveGlobalStyle(config), + } + } +} + +function resolveGlobalStyle(config: Config): string | undefined { + if (!config.globalStyle) + return undefined + return path.resolve(config.rootDir ?? process.cwd(), config.globalStyle) +} + +export function clearStyleDependencies(): void { + cached = null +} + +export function getComponentStyleDependencies(): ComponentStyleDependencies { + if (!cached) + return copyDeps(EMPTY_DEPS) + + return copyDeps(cached) +} + +function copyDeps( + deps: ComponentStyleDependencies, +): ComponentStyleDependencies { + const byComponent = new Map>() + for (const [component, styles] of deps.byComponent) + byComponent.set(component, new Set(styles)) + + const byStyle = new Map>() + for (const [style, components] of deps.byStyle) + byStyle.set(style, new Set(components)) + + return { + byComponent, + byStyle, + globalStyle: deps.globalStyle, + } +} + +export async function rebuildStyleMap( + sys: CompilerSystem, + config: Config, +): Promise { + const byComponent = new Map>() + const byStyle = new Map>() + const globalStyle = resolveGlobalStyle(config) + const scanRoot = path.resolve( + config.rootDir ?? process.cwd(), + config.srcDir ?? '.', + ) + + const tsxFiles = await collectTsxFiles(sys, scanRoot) + for (const filePath of tsxFiles) { + const content = await sys.readFile(filePath) + if (!content) + continue + + const stylePaths = extractStylePathsFromSource(filePath, content) + if (stylePaths.length === 0) + continue + + const normalizedComponent = path.resolve(filePath) + let componentStyles = byComponent.get(normalizedComponent) + if (!componentStyles) { + componentStyles = new Set() + byComponent.set(normalizedComponent, componentStyles) + } + + for (const stylePath of stylePaths) { + componentStyles.add(stylePath) + let consumers = byStyle.get(stylePath) + if (!consumers) { + consumers = new Set() + byStyle.set(stylePath, consumers) + } + consumers.add(normalizedComponent) + } + } + + cached = { byComponent, byStyle, globalStyle } +} + +async function collectTsxFiles( + sys: CompilerSystem, + dir: string, +): Promise { + const files: string[] = [] + let entries: string[] + try { + entries = await sys.readDir(dir) + } + catch { + return files + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry) + let stat: { isDirectory: boolean, isFile: boolean } | undefined + try { + stat = await sys.stat(fullPath) + } + catch { + continue + } + + if (stat?.isDirectory) { + files.push(...(await collectTsxFiles(sys, fullPath))) + } + else if (stat?.isFile && fullPath.endsWith('.tsx')) { + files.push(fullPath) + } + } + + return files +} + +export function extractStylePathsFromSource( + componentFilePath: string, + content: string, +): string[] { + const sourceFile = ts.createSourceFile( + componentFilePath, + content, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ) + + const stylePaths: string[] = [] + const componentDir = path.dirname(componentFilePath) + + function visit(node: ts.Node) { + if (ts.isClassDeclaration(node) && ts.canHaveDecorators(node)) { + const decorators = ts.getDecorators(node) + if (decorators) { + for (const decorator of decorators) { + collectStylesFromComponentDecorator( + decorator, + componentDir, + stylePaths, + ) + } + } + } + ts.forEachChild(node, visit) + } + + visit(sourceFile) + return stylePaths +} + +function collectStylesFromComponentDecorator( + decorator: ts.Decorator, + componentDir: string, + stylePaths: string[], +) { + const call = decorator.expression + if (!ts.isCallExpression(call) || call.arguments.length === 0) + return + + const arg = call.arguments[0] + if (!ts.isObjectLiteralExpression(arg)) + return + + const callee = call.expression + if (!ts.isIdentifier(callee) || callee.text !== 'Component') + return + + for (const prop of arg.properties) { + if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) + continue + + const name = prop.name.text + if (name === 'styleUrl') + collectStringLiterals(prop.initializer, componentDir, stylePaths) + else if (name === 'styleUrls') + collectStyleUrlsValue(prop.initializer, componentDir, stylePaths) + } +} + +function collectStyleUrlsValue( + node: ts.Expression, + componentDir: string, + stylePaths: string[], +) { + if (ts.isArrayLiteralExpression(node)) { + for (const el of node.elements) + collectStringLiterals(el, componentDir, stylePaths) + } + else if (ts.isObjectLiteralExpression(node)) { + for (const prop of node.properties) { + if (ts.isPropertyAssignment(prop)) + collectStyleUrlsValue(prop.initializer, componentDir, stylePaths) + else if (ts.isShorthandPropertyAssignment(prop)) + collectStringLiterals(prop.name, componentDir, stylePaths) + } + } + else { + collectStringLiterals(node, componentDir, stylePaths) + } +} + +function collectStringLiterals( + node: ts.Node, + componentDir: string, + stylePaths: string[], +) { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + const resolved = path.resolve(componentDir, node.text) + stylePaths.push(resolved) + return + } + + if (ts.isArrayLiteralExpression(node)) { + for (const el of node.elements) + collectStringLiterals(el, componentDir, stylePaths) + } +} diff --git a/test/build-events.test.ts b/test/build-events.test.ts new file mode 100644 index 0000000..858527a --- /dev/null +++ b/test/build-events.test.ts @@ -0,0 +1,28 @@ +import type { Compiler } from '@stencil/core/compiler' +import { describe, expect, it, vi } from 'vitest' +import { stencilBuildEvents } from '../src/build-events' +import { BuildQueue } from '../src/build-queue' + +describe('stencilBuildEvents', () => { + it('forwards buildFinished from BuildQueue', async () => { + const buildResults = { buildId: 1, hasError: false } as Awaited< + ReturnType + > + const compiler = { + build: vi.fn().mockResolvedValue(buildResults), + sys: { + stat: vi.fn().mockRejectedValue(new Error('missing')), + readFile: vi.fn(), + }, + } as unknown as Compiler + + const queue = new BuildQueue(compiler) + const onFinished = vi.fn() + stencilBuildEvents.on('buildFinished', onFinished) + + await queue.getLatestBuild('/src/cmp.tsx', '/dist/cmp.js') + + expect(onFinished).toHaveBeenCalledWith(buildResults) + stencilBuildEvents.off('buildFinished', onFinished) + }) +}) diff --git a/test/style-dependencies.test.ts b/test/style-dependencies.test.ts new file mode 100644 index 0000000..991dbf8 --- /dev/null +++ b/test/style-dependencies.test.ts @@ -0,0 +1,142 @@ +import type { CompilerSystem } from '@stencil/core/internal' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { + clearStyleDependencies, + extractStylePathsFromSource, + getComponentStyleDependencies, + rebuildStyleMap, + setGlobalStyleFromConfig, +} from '../src/style-dependencies' + +describe('extractStylePathsFromSource', () => { + const componentPath = '/project/src/components/my-cmp/my-cmp.tsx' + const componentDir = path.dirname(componentPath) + + it('extracts styleUrl', () => { + const code = ` +import { Component } from '@stencil/core' + +@Component({ + tag: 'my-cmp', + styleUrl: 'my-cmp.css', +}) +export class MyCmp {} +` + const paths = extractStylePathsFromSource(componentPath, code) + expect(paths).toEqual([path.resolve(componentDir, 'my-cmp.css')]) + }) + + it('extracts styleUrls array', () => { + const code = ` +import { Component } from '@stencil/core' + +@Component({ + tag: 'my-cmp', + styleUrls: ['a.css', 'b.css'], +}) +export class MyCmp {} +` + const paths = extractStylePathsFromSource(componentPath, code) + expect(paths).toEqual([ + path.resolve(componentDir, 'a.css'), + path.resolve(componentDir, 'b.css'), + ]) + }) + + it('extracts styleUrls mode object', () => { + const code = ` +import { Component } from '@stencil/core' + +@Component({ + tag: 'my-cmp', + styleUrls: { ios: 'ios.css', md: ['md.css'] }, +}) +export class MyCmp {} +` + const paths = extractStylePathsFromSource(componentPath, code) + expect(paths).toEqual([ + path.resolve(componentDir, 'ios.css'), + path.resolve(componentDir, 'md.css'), + ]) + }) + + it('returns empty when no @Component styles', () => { + const code = `export const x = 1` + expect(extractStylePathsFromSource(componentPath, code)).toEqual([]) + }) +}) + +describe('getComponentStyleDependencies', () => { + it('returns empty maps before any build', () => { + clearStyleDependencies() + const deps = getComponentStyleDependencies() + expect(deps.byComponent.size).toBe(0) + expect(deps.byStyle.size).toBe(0) + expect(deps.globalStyle).toBeUndefined() + }) + + it('returns a defensive copy', () => { + clearStyleDependencies() + setGlobalStyleFromConfig({ + rootDir: '/project', + globalStyle: 'src/global.css', + }) + const a = getComponentStyleDependencies() + const b = getComponentStyleDependencies() + expect(a).not.toBe(b) + expect(a.globalStyle).toBe(path.resolve('/project', 'src/global.css')) + a.byComponent.set('/x', new Set(['/y'])) + expect(b.byComponent.size).toBe(0) + }) +}) + +describe('rebuildStyleMap', () => { + it('builds byComponent and byStyle inverse map', async () => { + clearStyleDependencies() + + const rootDir = path.resolve('/project') + const srcDir = path.join(rootDir, 'src') + const cmpPath = path.join(srcDir, 'cmp.tsx') + const cssPath = path.join(srcDir, 'cmp.css') + + const files = new Map([ + [ + cmpPath, + ` +import { Component } from '@stencil/core' +@Component({ tag: 'cmp', styleUrl: 'cmp.css' }) +export class Cmp {} +`, + ], + ]) + + const sys = { + readDir: async (dir: string) => { + if (dir === srcDir) + return ['cmp.tsx'] + return [] + }, + stat: async (p: string) => ({ + isDirectory: p === srcDir, + isFile: p === cmpPath, + isSymbolicLink: false, + size: 0, + mtimeMs: 0, + ctimeMs: 0, + atimeMs: 0, + }), + readFile: async (p: string) => files.get(p) ?? '', + } as unknown as CompilerSystem + + await rebuildStyleMap(sys, { rootDir, srcDir }) + + const deps = getComponentStyleDependencies() + expect(deps.byComponent.get(path.resolve(cmpPath))).toEqual( + new Set([path.resolve(cssPath)]), + ) + expect(deps.byStyle.get(path.resolve(cssPath))).toEqual( + new Set([path.resolve(cmpPath)]), + ) + }) +})