diff --git a/napi/angular-compiler/test/decorator-fields.test.ts b/napi/angular-compiler/test/decorator-fields.test.ts index efa0ba655..816d14c9a 100644 --- a/napi/angular-compiler/test/decorator-fields.test.ts +++ b/napi/angular-compiler/test/decorator-fields.test.ts @@ -3,9 +3,13 @@ import { describe, expect, it } from 'vitest' import { emptyDelimitedRange, locateComponentDecorators, + locateStyleFieldsFor, + locateStyleUrlFor, + locateStyleUrlsFor, locateStylesFieldFor, locateTemplateStringFor, locateTemplateUrlFor, + readStringLiterals, } from '../vite-plugin/utils/decorator-fields.js' describe('decorator-fields utils', () => { @@ -55,6 +59,79 @@ describe('decorator-fields utils', () => { expect(locateComponentDecorators(src)).toEqual([]) }) + it('ignores a commented-out decorator that follows the real one', () => { + // The phantom sits between the real decorator and the class. Pairing + // the class with it would read the stale metadata, not merely miss it. + const src = [ + `@Component({ selector: 'x', styleUrls: ['./real.css'] })`, + `// @Component({ styleUrl: './old.css' })`, + `export class FooComponent {}`, + ].join('\n') + const out = locateComponentDecorators(src) + expect(out).toHaveLength(1) + expect(out[0].className).toBe('FooComponent') + expect(readStringLiterals(src, locateStyleUrlsFor(src, 'FooComponent')!).literals).toEqual([ + './real.css', + ]) + }) + + it('ignores a decorator inside a block comment that follows the real one', () => { + const src = [ + `@Component({ selector: 'x', styleUrls: ['./real.css'] })`, + `/* @Component({ styleUrl: './old.css' }) */`, + `export class FooComponent {}`, + ].join('\n') + expect(readStringLiterals(src, locateStyleUrlsFor(src, 'FooComponent')!).literals).toEqual([ + './real.css', + ]) + }) + + it('ignores a commented-out decorator that precedes the real one', () => { + const src = [ + `// @Component({ styleUrl: './old.css' })`, + `@Component({ selector: 'x', styleUrls: ['./real.css'] })`, + `export class FooComponent {}`, + ].join('\n') + const out = locateComponentDecorators(src) + expect(out).toHaveLength(1) + expect(readStringLiterals(src, locateStyleUrlsFor(src, 'FooComponent')!).literals).toEqual([ + './real.css', + ]) + }) + + it('ignores a decorator written inside a string literal', () => { + const src = [ + `const doc = 'see @Component({ styleUrl: "./str.css" }) for details';`, + `@Component({ selector: 'x', styleUrls: ['./real.css'] })`, + `export class FooComponent {}`, + ].join('\n') + const out = locateComponentDecorators(src) + expect(out).toHaveLength(1) + expect(readStringLiterals(src, locateStyleUrlsFor(src, 'FooComponent')!).literals).toEqual([ + './real.css', + ]) + }) + + it('pairs each class with its own decorator when a phantom sits between them', () => { + const src = [ + `@Component({ selector: 'a', styleUrls: ['./a.css'] })`, + `// @Component({ styleUrl: './fake.css' })`, + `export class AComponent {}`, + `@Component({ selector: 'b', styleUrls: ['./b.css'] })`, + `export class BComponent {}`, + ].join('\n') + expect(locateComponentDecorators(src).map((d) => d.className)).toEqual([ + 'AComponent', + 'BComponent', + ]) + expect(readStringLiterals(src, locateStyleUrlsFor(src, 'AComponent')!).literals).toEqual([ + './a.css', + ]) + expect(readStringLiterals(src, locateStyleUrlsFor(src, 'BComponent')!).literals).toEqual([ + './b.css', + ]) + }) + it('returns a single entry for a single-component file', () => { const src = `@Component({ selector: 'x' })\nexport class FooComponent {}` const out = locateComponentDecorators(src) @@ -304,13 +381,802 @@ describe('decorator-fields utils', () => { }) }) + describe('locateStyleUrlsFor / locateStyleUrlFor', () => { + const multi = ` + @Component({ selector: 'a', styleUrls: ['./first.css'] }) + export class FirstComponent {} + @Component({ selector: 'b', styleUrls: ['./second.css', './extra.css'] }) + export class SecondComponent {} + ` + + it('returns null when className matches no decorator', () => { + expect(locateStyleUrlsFor(multi, 'Nope')).toBeNull() + expect(locateStyleUrlFor(multi, 'Nope')).toBeNull() + }) + + it('returns each component its own styleUrls range in a multi-component file', () => { + const first = locateStyleUrlsFor(multi, 'FirstComponent')! + const second = locateStyleUrlsFor(multi, 'SecondComponent')! + expect(multi.slice(first[0], first[1] + 1)).toBe(`['./first.css']`) + expect(multi.slice(second[0], second[1] + 1)).toBe(`['./second.css', './extra.css']`) + }) + + it('locates the singular `styleUrl:` string form', () => { + const src = `@Component({ styleUrl: './solo.css' })\nexport class Foo {}` + const range = locateStyleUrlFor(src, 'Foo')! + expect(src.slice(range[0], range[1] + 1)).toBe(`'./solo.css'`) + }) + + // The four cross-match guards: `styleUrl`, `styleUrls` and `styles` are + // distinct fields and must never resolve to one another. + it('does not match `styleUrls:` when looking for the singular `styleUrl:`', () => { + const src = `@Component({ styleUrls: ['./a.css'] })\nexport class Foo {}` + expect(locateStyleUrlFor(src, 'Foo')).toBeNull() + }) + + it('does not match the singular `styleUrl:` when looking for `styleUrls:`', () => { + const src = `@Component({ styleUrl: './a.css' })\nexport class Foo {}` + expect(locateStyleUrlsFor(src, 'Foo')).toBeNull() + }) + + it('does not match an inline `styles:` field as either url field', () => { + const src = `@Component({ styles: ['.a { color: red }'] })\nexport class Foo {}` + expect(locateStyleUrlsFor(src, 'Foo')).toBeNull() + expect(locateStyleUrlFor(src, 'Foo')).toBeNull() + }) + + it('does not match either url field as the inline `styles:` field', () => { + const urls = `@Component({ styleUrls: ['./a.css'] })\nexport class Foo {}` + const url = `@Component({ styleUrl: './a.css' })\nexport class Foo {}` + expect(locateStylesFieldFor(urls, 'Foo')).toBeNull() + expect(locateStylesFieldFor(url, 'Foo')).toBeNull() + }) + + it('finds styleUrls when the decorator also has an inline styles field', () => { + const src = `@Component({ styles: ['.x{}'], styleUrls: ['./real.css'] })\nexport class Foo {}` + const range = locateStyleUrlsFor(src, 'Foo')! + expect(src.slice(range[0], range[1] + 1)).toBe(`['./real.css']`) + }) + }) + + // The endpoint needs three answers, not two: a class with no styles must be + // served none, while a class whose styles cannot be read must fall back to + // the file-level list. Only a null return distinguishes them. + describe('locateStyleFieldsFor', () => { + // Read the literals out of a located field, for the tests that care + // about the value rather than the classification. + const literalsIn = (src: string, field: { kind: string; range?: [number, number] }) => { + expect(field.kind).toBe('literal') + return readStringLiterals(src, (field as { range: [number, number] }).range).literals + } + + it('returns null when className matches no decorator', () => { + const src = `@Component({ styleUrls: ['./a.css'] })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Nope')).toBeNull() + }) + + it('reports both fields absent for a class that declares no styles', () => { + const src = `@Component({ template: '

' })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')).toEqual({ + urls: { kind: 'absent' }, + inline: { kind: 'absent' }, + }) + }) + + it('reports a field whose value has no literal shape as unreadable', () => { + const src = `@Component({ styleUrls: STYLE_URLS })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'unreadable' }) + }) + + it('reports a singular `styleUrl` identifier value as unreadable, not absent', () => { + // The regression this guards: reporting it absent tells the caller + // "this class has no styles", which strips the component's CSS. + const src = `@Component({ styleUrl: STYLE_URL })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'unreadable' }) + }) + + it('reports inline and url ranges together when a class declares both', () => { + const src = `@Component({ styles: ['.x{}'], styleUrls: ['./real.css'] })\nexport class Foo {}` + const fields = locateStyleFieldsFor(src, 'Foo')! + expect(literalsIn(src, fields.inline)).toEqual(['.x{}']) + expect(literalsIn(src, fields.urls)).toEqual(['./real.css']) + }) + + it('falls back to the singular `styleUrl` for the urls member', () => { + const src = `@Component({ styleUrl: './solo.css' })\nexport class Foo {}` + const fields = locateStyleFieldsFor(src, 'Foo')! + expect(literalsIn(src, fields.urls)).toEqual(['./solo.css']) + expect(fields.inline).toEqual({ kind: 'absent' }) + }) + + it('reports the urls member unreadable when only the singular form is unreadable', () => { + const src = `@Component({ styleUrl: STYLE_URL, styles: ['.x{}'] })\nexport class Foo {}` + const fields = locateStyleFieldsFor(src, 'Foo')! + expect(fields.urls).toEqual({ kind: 'unreadable' }) + expect(literalsIn(src, fields.inline)).toEqual(['.x{}']) + }) + + it('resolves each class separately in a multi-component file', () => { + const src = ` + @Component({ selector: 'a', styleUrls: ['./a.css'] }) + export class StyledComponent {} + @Component({ selector: 'b', template: '

' }) + export class BareComponent {} + ` + const styled = locateStyleFieldsFor(src, 'StyledComponent')! + expect(literalsIn(src, styled.urls)).toEqual(['./a.css']) + expect(locateStyleFieldsFor(src, 'BareComponent')).toEqual({ + urls: { kind: 'absent' }, + inline: { kind: 'absent' }, + }) + }) + + // Quoted keys are valid TS and the Rust extractor resolves them + // (verified: `'styleUrls'` and `"styleUrls"` both report their URL). + // Reading them as absent tells the caller the class declares no + // styles, which strips the component's CSS. + it('reads a single-quoted key the same as the bare form', () => { + const src = `@Component({ 'styleUrls': ['./a.css'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + + it('reads a double-quoted key the same as the bare form', () => { + const src = `@Component({ "styleUrls": ['./a.css'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + + it('reads a quoted singular `styleUrl` key', () => { + const src = `@Component({ 'styleUrl': './solo.css' })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./solo.css']) + }) + + it('reads a quoted inline `styles` key', () => { + const src = `@Component({ 'styles': ['.x{}'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.inline)).toEqual(['.x{}']) + }) + + it('keeps the cross-match guards for quoted keys', () => { + const urls = `@Component({ 'styleUrls': ['./a.css'] })\nexport class Foo {}` + expect(locateStyleFieldsFor(urls, 'Foo')!.inline).toEqual({ kind: 'absent' }) + const inline = `@Component({ 'styles': ['.x{}'] })\nexport class Foo {}` + expect(locateStyleFieldsFor(inline, 'Foo')!.urls).toEqual({ kind: 'absent' }) + }) + + // A computed key hides the field name from this scan, but the Rust + // extractor resolves it (verified: `[K]: ['./computed.css']` reports + // the URL). "Absent" would be a lie, so the whole classification is + // unknown and the caller falls back. + it('reports both fields unreadable when a computed key is present', () => { + const src = `@Component({ [K]: ['./a.css'] })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')).toEqual({ + urls: { kind: 'unreadable' }, + inline: { kind: 'unreadable' }, + }) + }) + + it('reports both fields unreadable for a quoted key whose escape is malformed', () => { + // A decodable escape is resolved instead — see the `escaped keys` + // block. Only an escape this scan cannot decode leaves the field name + // unknown, and then "absent" would be a guess rather than an answer. + const src = `@Component({ 'style\\u00ZZrls': ['./a.css'] })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')).toEqual({ + urls: { kind: 'unreadable' }, + inline: { kind: 'unreadable' }, + }) + }) + + it('does not promote a readable field to unreadable because of a computed key', () => { + // The visible field is still exactly what it says; only the fields + // this scan cannot see are unknown. + const src = `@Component({ [K]: 1, styleUrls: ['./a.css'] })\nexport class Foo {}` + const fields = locateStyleFieldsFor(src, 'Foo')! + expect(literalsIn(src, fields.urls)).toEqual(['./a.css']) + expect(fields.inline).toEqual({ kind: 'unreadable' }) + }) + + // A spread is the one unreadable form the Rust extractor ALSO drops + // (verified: `...BASE` reports no styleUrls). Both sides see nothing, + // so "absent" matches what the compiled component gets; falling back + // would hand this class its siblings' CSS. + it('leaves fields absent for a spread, which the compiler also drops', () => { + const src = `@Component({ ...BASE })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')).toEqual({ + urls: { kind: 'absent' }, + inline: { kind: 'absent' }, + }) + }) + + // Shorthand style fields. Measured against the Rust extractor, which + // resolves a same-file string constant behind the singular `styleUrl` + // but drops the array-valued forms — so the two need opposite answers, + // for the same reason the spread above stays absent: match what the + // compiled component actually ends up with. + it('reports a shorthand singular `styleUrl` as unreadable, not absent', () => { + const src = `@Component({ template: '

', styleUrl })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'unreadable' }) + }) + + it('leaves a shorthand `styleUrls` absent, which the compiler also drops', () => { + const src = `@Component({ template: '

', styleUrls })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'absent' }) + }) + + it('leaves a shorthand inline `styles` absent, which the compiler also drops', () => { + const src = `@Component({ template: '

', styles })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.inline).toEqual({ kind: 'absent' }) + }) + + it('does not let an unrelated shorthand degrade a readable field', () => { + const src = `@Component({ selector, styleUrls: ['./a.css'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + + // The trap in accepting a bare key: an identifier used as a VALUE is not + // a shorthand property, and reading it as one would fall back on a field + // that is right there and readable. + it('does not mistake a style-named identifier used as a value for a shorthand', () => { + const src = `@Component({ selector: styleUrl, styleUrls: ['./a.css'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + + it('does not let an unrelated method degrade a readable field', () => { + const src = `@Component({ foo() { return 1 }, styleUrls: ['./a.css'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + + // A method or accessor named like a style field is not a style field: + // the compiler reads no styles from it either. + it('leaves a method named like a style field absent', () => { + const src = `@Component({ styleUrls() { return ['./a.css'] } })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'absent' }) + }) + + it('leaves a getter named like a style field absent', () => { + const src = `@Component({ get styleUrl() { return './a.css' } })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'absent' }) + }) + + it('reads the real field past a setter named like a style field', () => { + const src = `@Component({ set styleUrl(v) {}, styleUrls: ['./a.css'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + + it('does not read a style field nested in a deeper object', () => { + const src = `@Component({ data: { styleUrls: ['./deep.css'] } })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'absent' }) + }) + + it('reads a field followed by a trailing comma', () => { + const src = `@Component({ styleUrls: ['./a.css'], })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + + it('reads a shorthand style field that closes the object', () => { + // No trailing comma — the key is bounded by `}` rather than `,`. + const src = `@Component({ styleUrl })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'unreadable' }) + }) + + // A value that merely STARTS with a literal does not denote it. Every + // form below was measured against the Rust extractor: it reports no + // styleUrls and compiles no styles for any of them. Returning the + // leading literal would hand the endpoint a stylesheet the component + // never had, and — being a confident answer — would skip the fallback + // that exists for exactly this case. + describe('a literal that is only the start of a larger expression', () => { + const urls = (field: string) => + locateStyleFieldsFor( + `const SUFFIX = '.s.css';\nconst MORE: string[] = ['./m.css'];\n@Component({ template: '

', ${field} })\nexport class Foo {}`, + 'Foo', + )!.urls + const inline = (field: string) => + locateStyleFieldsFor( + `const EXTRA = '.b{}';\n@Component({ template: '

', ${field} })\nexport class Foo {}`, + 'Foo', + )!.inline + + it('rejects a singular `styleUrl` concatenated with an identifier', () => { + expect(urls(`styleUrl: './a.css' + SUFFIX`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects a singular `styleUrl` concatenated with another literal', () => { + expect(urls(`styleUrl: './a.css' + './b.css'`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects a `styleUrls` array concatenated with an identifier', () => { + expect(urls(`styleUrls: './a.css' + SUFFIX`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects an inline `styles` value concatenated with an identifier', () => { + expect(inline(`styles: '.a{}' + EXTRA`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects a method call on a style literal', () => { + expect(urls(`styleUrl: './a.css'.replace('a', 'b')`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects a method call on a `styleUrls` array literal', () => { + expect(urls(`styleUrls: ['./a.css'].concat(MORE)`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects a method call on an inline `styles` array literal', () => { + expect(inline(`styles: ['.a{}'].concat(MORE)`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects a TypeScript `as` assertion after a style literal', () => { + expect(urls(`styleUrl: './a.css' as string`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects a TypeScript `as const` assertion after a `styleUrls` array', () => { + expect(urls(`styleUrls: ['./a.css'] as const`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects a TypeScript `as` assertion after an inline `styles` array', () => { + expect(inline(`styles: ['.a{}'] as string[]`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects a non-null assertion after a style literal', () => { + expect(urls(`styleUrl: './a.css'!`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects a `satisfies` clause after a style literal', () => { + expect(urls(`styleUrl: './a.css' satisfies string`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects a trailing expression hidden behind a comment', () => { + expect(urls(`styleUrl: './a.css' /* why */ + SUFFIX`)).toEqual({ kind: 'unreadable' }) + }) + + // The other side of the rule: a value the property really does end at + // stays readable, whether a comma, the object's brace or a comment + // closes it out. + it.each([ + [`styleUrl: './a.css'`, `the object's closing brace`], + [`styleUrl: './a.css',`, 'a trailing comma'], + [`styleUrl: './a.css' /* why */`, 'a block comment then the brace'], + [`styleUrl: './a.css' // why\n`, 'a line comment then the brace'], + [`styleUrls: ['./a.css']`, 'an array at the closing brace'], + [`styleUrls: ['./a.css'],`, 'an array with a trailing comma'], + ])('still reads %j, ended by %s', (field) => { + const src = `@Component({ template: '

', ${field} })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + }) + + // Escaped identifier keys. `style\u0055rls` IS `styleUrls` to the + // TypeScript parser, and the Rust extractor resolves it (verified: it + // reports `./x.css`). Reading it as absent told the caller the class + // declares no styles, which strips the component's CSS. Decoding gives + // an exact per-class answer, where falling back would serve the + // file-level union — every sibling's CSS along with this class's own. + describe('escaped keys', () => { + it('decodes a \\uHHHH escape in a bare key', () => { + const src = `@Component({ style\\u0055rls: ['./a.css'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + + it('decodes a \\u{…} escape in a bare key', () => { + const src = `@Component({ style\\u{55}rls: ['./a.css'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + + it('decodes an escape at the first character of a bare key', () => { + const src = `@Component({ \\u0073tyleUrls: ['./a.css'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + + it('decodes an escaped singular `styleUrl` key', () => { + const src = `@Component({ style\\u0055rl: './solo.css' })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./solo.css']) + }) + + it('decodes an escaped inline `styles` key', () => { + const src = `@Component({ style\\u0073: ['.x{}'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.inline)).toEqual(['.x{}']) + }) + + it('decodes a quoted key carrying an escape', () => { + // A quoted key is a string literal, so it decodes by string rules. + const src = `@Component({ 'style\\u0055rls': ['./a.css'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + + it('treats an escaped shorthand singular `styleUrl` like the plain one', () => { + const src = `@Component({ template: '

', style\\u0055rl })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'unreadable' }) + }) + + it('leaves an escaped shorthand `styleUrls` absent, as the compiler drops it', () => { + const src = `@Component({ template: '

', style\\u0055rls })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'absent' }) + }) + + it('keeps the cross-match guards for decoded keys', () => { + const urls = `@Component({ style\\u0055rls: ['./a.css'] })\nexport class Foo {}` + expect(locateStyleFieldsFor(urls, 'Foo')!.inline).toEqual({ kind: 'absent' }) + const singular = `@Component({ style\\u0055rl: './a.css' })\nexport class Foo {}` + expect(locateStyleFieldsFor(singular, 'Foo')!.inline).toEqual({ kind: 'absent' }) + const inline = `@Component({ style\\u0073: ['.x{}'] })\nexport class Foo {}` + expect(locateStyleFieldsFor(inline, 'Foo')!.urls).toEqual({ kind: 'absent' }) + }) + + it('does not match a decoded key that names something else', () => { + const src = `@Component({ style\\u0055rlsExtra: ['./a.css'] })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'absent' }) + }) + + it('does not match a lookalike built from a non-ASCII letter', () => { + // Cyrillic \u0435 in place of `e` — a different identifier, and the + // compiler reports no styleUrls for it either. + const src = `@Component({ styl\u0435Urls: ['./a.css'] })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'absent' }) + }) + + it('reports a malformed escape in a key as unreadable, not absent', () => { + const src = `@Component({ style\\u00ZZrls: ['./a.css'] })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')).toEqual({ + urls: { kind: 'unreadable' }, + inline: { kind: 'unreadable' }, + }) + }) + + it('reports a \\xHH escape in a key as unreadable — illegal in an identifier', () => { + const src = `@Component({ style\\x55rls: ['./a.css'] })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')).toEqual({ + urls: { kind: 'unreadable' }, + inline: { kind: 'unreadable' }, + }) + }) + + it('does not let an escaped unrelated key degrade a readable field', () => { + const src = `@Component({ sel\\u0065ctor: 'a', styleUrls: ['./a.css'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + }) + }) + // ----------------------------------------------------------------- // Comment-aware scanning. Without this, the walker treats `'` in a // `// don't ...` line comment as opening a string literal that never // closes (real field missed), and a `// styles: [...]` line comment // or `/* styles: [...] */` block comment as a real field (wrong // range returned). + // + // `complete` reports whether every element was read. A caller acting + // on a partial list silently drops stylesheets. // ----------------------------------------------------------------- + describe('readStringLiterals', () => { + // Read the value of `styles:` from a one-component source, so these + // exercise the same path the extractors use. + const readOf = (value: string) => { + const src = `@Component({ styles: ${value} })\nclass Foo {}` + return readStringLiterals(src, locateStylesFieldFor(src, 'Foo')!) + } + const literalsOf = (value: string): string[] => readOf(value).literals + + it('reads every entry of an array in order', () => { + expect(readOf(`['./a.css', './b.css']`)).toEqual({ + literals: ['./a.css', './b.css'], + complete: true, + }) + }) + + it('reads a single-entry array', () => { + expect(literalsOf(`['./a.css']`)).toEqual(['./a.css']) + }) + + it('reads a bare string value as one entry', () => { + expect(readOf(`'./a.css'`)).toEqual({ literals: ['./a.css'], complete: true }) + }) + + it('reads mixed quote styles, including template literals', () => { + expect(literalsOf('[\'./a.css\', "./b.css", `./c.css`]')).toEqual([ + './a.css', + './b.css', + './c.css', + ]) + }) + + it('reports an empty array as complete, not unknown', () => { + // `styles: []` is a definitive answer: this class has no styles. + expect(readOf(`[]`)).toEqual({ literals: [], complete: true }) + }) + + it('decodes hex and unicode escapes to the value they denote', () => { + // The Rust extractor reports the cooked value; a raw read would name a + // path that does not exist. + expect(literalsOf(String.raw`['.\x2fa.css', '\u{2e}/b.css']`)).toEqual(['./a.css', './b.css']) + }) + + it('decodes the standard single-character escapes', () => { + expect(literalsOf(String.raw`['a\nb\tc\rd\be\ff\vg']`)).toEqual(['a\nb\tc\rd\be\ff\vg']) + }) + + it('decodes a backslash escape to one backslash', () => { + expect(literalsOf(String.raw`['a\\b']`)).toEqual([String.raw`a\b`]) + }) + + it('decodes an unrecognized escape to the character itself', () => { + // `\q` is a NonEscapeCharacter: it denotes `q`, which is what the Rust + // extractor reports too. + expect(literalsOf(String.raw`['./a\qb.css']`)).toEqual(['./aqb.css']) + }) + + it('drops a line continuation', () => { + expect(literalsOf("['a\\\nb']")).toEqual(['ab']) + }) + + it('reports a truncated unicode escape as incomplete', () => { + expect(readOf(String.raw`['./a\u12']`)).toEqual({ literals: [], complete: false }) + }) + + it('reports a malformed hex escape as incomplete', () => { + expect(readOf(String.raw`['./a\xZZ']`)).toEqual({ literals: [], complete: false }) + }) + + it('reports a legacy octal escape as incomplete', () => { + // Illegal in a module; guessing a value would be worse than falling back. + expect(readOf(String.raw`['./a\101']`)).toEqual({ literals: [], complete: false }) + }) + + it('leaves a literal with no escapes byte for byte unchanged', () => { + const css = `a::before { content: "x"; } [data-x="y"] { color: red; }` + expect(literalsOf(`['${css}']`)).toEqual([css]) + }) + + it('decodes an escaped quote inside a literal', () => { + // The literal denotes `it's.css`, which is what the Rust extractor + // reports and what has to be resolved against the filesystem. + expect(literalsOf(`['it\\'s.css']`)).toEqual([`it's.css`]) + }) + + it('ignores an apostrophe inside a block comment before an entry', () => { + expect(literalsOf(`[/* don't drop this */ './a.css']`)).toEqual(['./a.css']) + }) + + it('ignores an apostrophe inside a line comment before an entry', () => { + expect(literalsOf(`[\n // it's here\n './a.css',\n]`)).toEqual(['./a.css']) + }) + + it('ignores a comment between two entries', () => { + expect(literalsOf(`['./a.css', /* don't */ './b.css']`)).toEqual(['./a.css', './b.css']) + }) + + it('reports a non-literal entry as incomplete', () => { + // The literal alongside it is not a partial answer to act on: acting + // on it would drop the stylesheet the constant resolves to. + expect(readOf(`[SOME_CONST, './a.css']`).complete).toBe(false) + }) + + // ----------------------------------------------------------------- + // Spread elements (`...X`) and elisions (holes). + // + // `extract_string_array` asks every array element for `as_expression()`, + // and `ArrayExpressionElement::is_expression()` enumerates neither + // `SpreadElement` nor `Elision`. Both are therefore dropped BEFORE the + // value resolver runs, for every possible program — the const table is + // never consulted for them. Dropping them here keeps the array complete + // and matches what the component actually compiles to; calling it + // unknown would hand the class its siblings' stylesheets instead. + // + // Measured against the Rust extractor: + // [...SHARED, './own.css'] -> ["./own.css"] + // [...SHARED] -> [] + // [S, './own.css'] (S a const) -> ["./shared.css", "./own.css"] + // + // Every OTHER element IS an expression the resolver may fold from + // constants this scan cannot read, so those still mark the array + // unknown. Same reasoning as the decorator-level spread in + // `hasUnreadableKey`; the two now agree. + // ----------------------------------------------------------------- + it('drops a leading spread and reads the rest, like the compiler', () => { + expect(readOf(`[...SHARED, './a.css']`)).toEqual({ + literals: ['./a.css'], + complete: true, + }) + }) + + it('drops a trailing spread and reads the rest', () => { + expect(readOf(`['./a.css', ...SHARED]`)).toEqual({ + literals: ['./a.css'], + complete: true, + }) + }) + + it('reads a spread-only array as declaring nothing', () => { + expect(readOf(`[...SHARED]`)).toEqual({ literals: [], complete: true }) + }) + + it('reads an array of two spreads as declaring nothing', () => { + expect(readOf(`[...S1, ...S2]`)).toEqual({ literals: [], complete: true }) + }) + + it('drops a spread of an inline array literal', () => { + // The compiler drops the element whole, so a literal INSIDE the spread + // is not part of this component's styles. + expect(readOf(`[...['./shared.css'], './own.css']`)).toEqual({ + literals: ['./own.css'], + complete: true, + }) + }) + + it('never leaks a literal used as a computed member inside a spread', () => { + expect(readOf(`[...obj['./leak.css'], './own.css']`)).toEqual({ + literals: ['./own.css'], + complete: true, + }) + }) + + it('never leaks a literal passed as a call argument inside a spread', () => { + expect(readOf(`[...f('./leak.css'), './own.css']`)).toEqual({ + literals: ['./own.css'], + complete: true, + }) + }) + + it('never leaks a literal inside an object literal in a spread', () => { + expect(readOf(`[...Object.values({ k: './leak.css' }), './own.css']`)).toEqual({ + literals: ['./own.css'], + complete: true, + }) + }) + + it('keeps a comma inside parens from ending the spread element', () => { + expect(readOf(`[...f(a, b), './own.css']`)).toEqual({ + literals: ['./own.css'], + complete: true, + }) + }) + + it('keeps a comma inside a nested array from ending the spread element', () => { + expect(readOf(`[...['./x.css', './y.css'], './own.css']`)).toEqual({ + literals: ['./own.css'], + complete: true, + }) + }) + + it('ignores a block comment inside a spread', () => { + expect(readOf(`[.../* , './leak.css' */ SHARED, './own.css']`)).toEqual({ + literals: ['./own.css'], + complete: true, + }) + }) + + it('ignores an apostrophe and a comma inside a line comment in a spread', () => { + expect(readOf(`[...SHARED // don't , './leak.css'\n, './own.css']`)).toEqual({ + literals: ['./own.css'], + complete: true, + }) + }) + + it('skips an interpolated template literal inside a spread', () => { + // The `${...}` pushes a brace context, so the comma inside the template + // does not end the element. + expect(readOf("[...tag`${DIR}/a, b`, './own.css']")).toEqual({ + literals: ['./own.css'], + complete: true, + }) + }) + + it('drops a spread in a `styleUrls` array too', () => { + const src = `@Component({ styleUrls: [...SHARED, './a.css'] })\nclass Foo {}` + expect(readStringLiterals(src, locateStyleUrlsFor(src, 'Foo')!)).toEqual({ + literals: ['./a.css'], + complete: true, + }) + }) + + it('drops a spread in an inline `styles` array', () => { + expect(readOf(`[...SHARED_INLINE, ':host{}']`)).toEqual({ + literals: [':host{}'], + complete: true, + }) + }) + + it('reports an unterminated string inside a spread as incomplete', () => { + // The quote never closes, so where the element ends is unknowable. + // A real file cannot produce this range (the locator needs a balanced + // `]`), but the scan must refuse to guess rather than run off. + const src = `[...f('./leak.css)]` + expect(readStringLiterals(src, [0, src.length - 1])).toEqual({ + literals: [], + complete: false, + }) + }) + + it('reports an unclosed paren inside a spread as incomplete', () => { + const src = `[...f(a]` + expect(readStringLiterals(src, [0, src.length - 1])).toEqual({ + literals: [], + complete: false, + }) + }) + + it('reports a spread ending in an escape as incomplete', () => { + // The trailing escape consumes the array's own `]`, so the scan + // overshoots the range: incomplete, not a guess. + const src = String.raw`[...'a\]` + expect(readStringLiterals(src, [0, src.length - 1])).toEqual({ + literals: [], + complete: false, + }) + }) + + it('does not treat a leading decimal point as a spread', () => { + expect(readOf(`[.5, './a.css']`).complete).toBe(false) + }) + + it('does not treat two dots as a spread', () => { + expect(readOf(`[..X, './a.css']`).complete).toBe(false) + }) + + it('does not treat a member access as a spread', () => { + expect(readOf(`[STYLES.a, './a.css']`).complete).toBe(false) + }) + + it('does not treat an optional chain as a spread', () => { + expect(readOf(`[STYLES?.a, './a.css']`).complete).toBe(false) + }) + + it('still reports a bare identifier element as incomplete, unlike a spread', () => { + // Measured: the Rust extractor DOES fold `S` from the const table, so + // the literals gathered here are not the component's full style list. + expect(readOf(`[S, './own.css']`)).toEqual({ + literals: ['./own.css'], + complete: false, + }) + }) + + it('reads across a leading elision, which the compiler also drops', () => { + expect(readOf(`[, './a.css']`)).toEqual({ literals: ['./a.css'], complete: true }) + }) + + it('reads across an elision between two entries', () => { + expect(readOf(`['./a.css', , './b.css']`)).toEqual({ + literals: ['./a.css', './b.css'], + complete: true, + }) + }) + + it('reports an interpolated template literal as incomplete', () => { + // The raw slice is `${DIR}/a.css`, not a real path. The Rust extractor + // folds it; this scan cannot. + expect(readOf('[`${DIR}/a.css`]').complete).toBe(false) + }) + + it('reports a bare interpolated template literal as incomplete', () => { + expect(readOf('`${DIR}/a.css`').complete).toBe(false) + }) + + it('treats an escaped `${` in a template literal as ordinary text', () => { + // `hasInterpolation` reads the odd backslash as an escape, so the + // literal is complete; decoding then resolves `\$` to `$`, leaving the + // `${…}` as the literal text it denotes. The two must agree. + expect(readOf('[`\\${NOT_INTERPOLATED}.css`]')).toEqual({ + literals: ['${NOT_INTERPOLATED}.css'], + complete: true, + }) + }) + + it('treats `${` inside a quoted string as ordinary text', () => { + expect(readOf(`['\${DIR}/a.css']`)).toEqual({ + literals: ['${DIR}/a.css'], + complete: true, + }) + }) + + it('stops at an unterminated literal and reports incomplete', () => { + // The locator bounds the range, so the unterminated entry is dropped. + const src = `@Component({ styles: ['./a.css', './b.css] })\nclass Foo {}` + const range = locateStylesFieldFor(src, 'Foo') + if (range) { + const read = readStringLiterals(src, range) + expect(read.literals).toEqual(['./a.css']) + expect(read.complete).toBe(false) + } + }) + }) + describe('comment handling in @Component args', () => { it('does not get stuck on an apostrophe inside a line comment', () => { const src = `@Component({ @@ -409,6 +1275,131 @@ class Foo {}` const sRange = locateStylesFieldFor(src, 'Foo')! expect(src.slice(sRange[0], sRange[1] + 1)).toBe(`['real']`) }) + + // A comment may sit anywhere inside a style field declaration, and the + // Rust extractor reads straight past it. Every placement below was + // measured against `extractComponentUrls`, which reports `./x.css` for + // each one. Reading any of them as absent would tell the caller the + // class declares no styles, which strips the component's CSS. + describe('comment placement within a style field declaration', () => { + const urlsOf = (src: string) => { + const field = locateStyleFieldsFor(src, 'Foo')!.urls + expect(field.kind).toBe('literal') + return readStringLiterals(src, (field as { range: [number, number] }).range).literals + } + const decorator = (field: string) => + `@Component({ template: '

', ${field} })\nexport class Foo {}` + + it('reads a field with a block comment between the key and the colon', () => { + expect(urlsOf(decorator(`styleUrls /* why */: ['./x.css']`))).toEqual(['./x.css']) + }) + + it('reads a field with a line comment between the key and the colon', () => { + expect(urlsOf(decorator(`styleUrls // why\n: ['./x.css']`))).toEqual(['./x.css']) + }) + + it('reads a field with a comment between the colon and the value', () => { + expect(urlsOf(decorator(`styleUrls: /* why */ ['./x.css']`))).toEqual(['./x.css']) + }) + + it('reads a field with a comment before the key', () => { + expect(urlsOf(decorator(`/* why */ styleUrls: ['./x.css']`))).toEqual(['./x.css']) + }) + + it('reads a field with two comments between the key and the colon', () => { + expect(urlsOf(decorator(`styleUrls /* a */ /* b */: ['./x.css']`))).toEqual(['./x.css']) + }) + + it('reads a quoted key with a comment before the colon', () => { + expect(urlsOf(decorator(`'styleUrls' /* why */: ['./x.css']`))).toEqual(['./x.css']) + }) + + it('reads the singular `styleUrl` with a comment before the colon', () => { + expect(urlsOf(decorator(`styleUrl /* why */: './x.css'`))).toEqual(['./x.css']) + }) + + it('reads the singular `styleUrl` with a comment after the colon', () => { + expect(urlsOf(decorator(`styleUrl: /* why */ './x.css'`))).toEqual(['./x.css']) + }) + + it('reads an array whose first literal follows a comment', () => { + expect(urlsOf(decorator(`styleUrls: [/* why */ './x.css']`))).toEqual(['./x.css']) + }) + + it('reads an array whose first literal follows a line comment', () => { + expect(urlsOf(decorator(`styleUrls: [// why\n './x.css']`))).toEqual(['./x.css']) + }) + + it('reads an array with a comment before the separating comma', () => { + expect(urlsOf(decorator(`styleUrls: ['./x.css' /* why */, './y.css']`))).toEqual([ + './x.css', + './y.css', + ]) + }) + + it('reads an array with a comment after the separating comma', () => { + expect(urlsOf(decorator(`styleUrls: ['./x.css', /* why */ './y.css']`))).toEqual([ + './x.css', + './y.css', + ]) + }) + + it('reads an array with a comment after the last literal', () => { + expect(urlsOf(decorator(`styleUrls: ['./x.css' /* why */]`))).toEqual(['./x.css']) + }) + + it('reads an inline `styles` field with a comment before the colon', () => { + const src = decorator(`styles /* why */: ['.a{}']`) + const field = locateStyleFieldsFor(src, 'Foo')!.inline + expect(field.kind).toBe('literal') + expect( + readStringLiterals(src, (field as { range: [number, number] }).range).literals, + ).toEqual(['.a{}']) + }) + + // An array holding only a comment is still a syntactically valid empty + // array: a definite "no styles", not an unreadable value. + it('reads an array holding only a comment as definitively empty', () => { + const src = decorator(`styleUrls: [/* why */]`) + const field = locateStyleFieldsFor(src, 'Foo')!.urls + expect(field.kind).toBe('literal') + expect( + readStringLiterals(src, (field as { range: [number, number] }).range).literals, + ).toEqual([]) + }) + + // Comment *contents* must never be mistaken for structure. + it('ignores a colon, comma and brackets inside the comment', () => { + expect(urlsOf(decorator(`styleUrls /* a: b, c ] [ */: ['./x.css']`))).toEqual(['./x.css']) + }) + + it('ignores a decoy style field inside the comment', () => { + expect(urlsOf(decorator(`styleUrls /* styleUrl: './fake.css' */: ['./x.css']`))).toEqual([ + './x.css', + ]) + }) + + it('ignores an apostrophe inside a comment between the key and the colon', () => { + expect(urlsOf(decorator(`styleUrls /* don't */: ['./x.css']`))).toEqual(['./x.css']) + }) + + // A comment must not turn a shorthand back into "absent": the compiler + // resolves a same-file constant behind the singular `styleUrl` + // (verified: `./sh.css`), so this class must still reach the fallback. + it('keeps a shorthand `styleUrl` unreadable when a comment precedes the closing brace', () => { + const src = `const styleUrl = './sh.css'; +@Component({ template: '

', styleUrl /* why */ }) +export class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'unreadable' }) + }) + + it('keeps a shorthand `styleUrl` unreadable when a comment precedes the comma', () => { + const src = `const styleUrl = './sh.css'; +@Component({ template: '

', styleUrl /* why */, selector: 'a' }) +export class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'unreadable' }) + }) + }) }) // ----------------------------------------------------------------- diff --git a/napi/angular-compiler/test/hmr-hot-update.test.ts b/napi/angular-compiler/test/hmr-hot-update.test.ts index cc57a00d0..b95808c2b 100644 --- a/napi/angular-compiler/test/hmr-hot-update.test.ts +++ b/napi/angular-compiler/test/hmr-hot-update.test.ts @@ -16,7 +16,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type { Plugin, ModuleNode, HmrContext } from 'vite' -import { normalizePath } from 'vite' +import { normalizePath, resolveConfig } from 'vite' import { afterAll, beforeAll, describe, it, expect, vi } from 'vitest' import { angular } from '../vite-plugin/index.js' @@ -1204,6 +1204,20 @@ describe('@ng/component endpoint resolves the template per class', () => { ) } + // The component-file branch queues `pendingHmrUpdates` under `ctx.file` + // verbatim, and the endpoint looks the id up verbatim. Asserting the queued + // id here turns a spelling mismatch into a named failure instead of an + // unexplained empty body — the difference only shows up on Windows, where + // a normalized path and a `join()` path are different strings. + function expectDispatched(mockServer: any, componentId: string) { + const ids = mockServer._wsMessages + .filter((m: any) => m?.event === 'angular:component-update') + .map((m: any) => decodeURIComponent(m.data.id)) + expect(ids, 'expected the HMR dispatch to use the requested path spelling').toContain( + componentId, + ) + } + function getMiddleware(mockServer: any) { const middleware = (mockServer.middlewares.use as ReturnType).mock.calls[0]?.[0] expect(middleware, 'expected middleware to be registered').toBeDefined() @@ -1324,6 +1338,8 @@ describe('@ng/component endpoint resolves the template per class', () => { const ctx = createMockHmrContext(mixedPath, [{ id: mixedPath }], mockServer) await callHandleHotUpdate(plugin, ctx) + expectDispatched(mockServer, `${mixedPath}@InlineComponent`) + // InlineComponent must get its inline template — templateUrls.length > 0 // for the FILE must not shadow the per-class inline branch. const body = await invokeAngularMiddleware( @@ -1335,3 +1351,1052 @@ describe('@ng/component endpoint resolves the template per class', () => { expect(body).not.toContain('PC_EXT_MARKER') }) }) + +describe('@ng/component endpoint resolves the styles per class', () => { + // preprocessCSS needs a REAL resolved config to run; the shared mock config + // makes every external stylesheet fail to preprocess, which would hide what + // these tests are about. Other tests in this file keep the mock. + async function setupPluginWithRealConfig(plugin: Plugin) { + const mockServer = createMockServer() + + await callPluginHook( + plugin.config as Plugin['config'], + {} as any, + { command: 'serve', mode: 'development' } as any, + ) + const resolved = await resolveConfig( + { configFile: false, root: tempDir, logLevel: 'silent' }, + 'serve', + ) + await callPluginHook(plugin.configResolved as Plugin['configResolved'], resolved as any) + + if (typeof plugin.configureServer === 'function') { + await (plugin.configureServer as Function)(mockServer) + } + ;(mockServer as any).__angularWatchTemplate = () => {} + + return mockServer + } + + async function transformSource(plugin: Plugin, source: string, path: string) { + if (!plugin.transform || typeof plugin.transform === 'function') { + throw new Error('Expected plugin transform handler') + } + await plugin.transform.handler.call( + { error() {}, warn() {}, addWatchFile() {} } as any, + source, + path, + ) + } + + // The component-file branch queues `pendingHmrUpdates` under `ctx.file` + // verbatim, and the endpoint looks the id up verbatim. Asserting the queued + // id here turns a spelling mismatch into a named failure instead of an + // unexplained empty body — the difference only shows up on Windows, where + // a normalized path and a `join()` path are different strings. + function expectDispatched(mockServer: any, componentId: string) { + const ids = mockServer._wsMessages + .filter((m: any) => m?.event === 'angular:component-update') + .map((m: any) => decodeURIComponent(m.data.id)) + expect(ids, 'expected the HMR dispatch to use the requested path spelling').toContain( + componentId, + ) + } + + function getMiddleware(mockServer: any) { + const middleware = (mockServer.middlewares.use as ReturnType).mock.calls[0]?.[0] + expect(middleware, 'expected middleware to be registered').toBeDefined() + return middleware + } + + it('serves each component its own styleUrls in a multi-component file', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const firstCssPath = join(appDir, 'ps-first.component.css') + const secondCssPath = join(appDir, 'ps-second.component.css') + const multiPath = join(appDir, 'ps-multi.component.ts') + writeFileSync(firstCssPath, '.PS_FIRST_MARKER { color: red; }') + writeFileSync(secondCssPath, '.PS_SECOND_MARKER { color: blue; }') + + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-first', + template: '

first

', + styleUrls: ['./ps-first.component.css'], + }) + export class FirstComponent {} + @Component({ + selector: 'app-ps-second', + template: '

second

', + styleUrls: ['./ps-second.component.css'], + }) + export class SecondComponent {} + ` + writeFileSync(multiPath, source) + await transformSource(plugin, source, multiPath) + + // Edit the SECOND component's stylesheet; the fan-out queues both classes. + writeFileSync(secondCssPath, '.PS_SECOND_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(secondCssPath), + [{ id: normalizePath(secondCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + // SecondComponent's update module must carry only its OWN stylesheet, not + // the union of every styleUrl declared in the file. + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${multiPath}@SecondComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_SECOND_MARKER') + expect(body).not.toContain('PS_FIRST_MARKER') + }) + + it('serves the singular `styleUrl` of the requested class', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const soloCssPath = join(appDir, 'ps-solo.component.css') + const otherCssPath = join(appDir, 'ps-other.component.css') + const singularPath = join(appDir, 'ps-singular.component.ts') + writeFileSync(soloCssPath, '.PS_SOLO_MARKER { color: red; }') + writeFileSync(otherCssPath, '.PS_OTHER_MARKER { color: blue; }') + + // `styleUrl` (singular, Angular 17+) is a bare string, not an array. + // It is declared SECOND here so the file-level list does not happen to + // start with it. + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-other', + template: '

other

', + styleUrl: './ps-other.component.css', + }) + export class OtherComponent {} + @Component({ + selector: 'app-ps-solo', + template: '

solo

', + styleUrl: './ps-solo.component.css', + }) + export class SoloComponent {} + ` + writeFileSync(singularPath, source) + await transformSource(plugin, source, singularPath) + + writeFileSync(soloCssPath, '.PS_SOLO_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(soloCssPath), + [{ id: normalizePath(soloCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${singularPath}@SoloComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_SOLO_MARKER') + expect(body).not.toContain('PS_OTHER_MARKER') + }) + + it('serves the inline styles of a class whose sibling uses a styleUrl', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const extCssPath = join(appDir, 'ps-ext.component.css') + const mixedPath = join(appDir, 'ps-mixed.component.ts') + writeFileSync(extCssPath, '.PS_EXT_MARKER { color: red; }') + + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-ext', + template: '

ext

', + styleUrls: ['./ps-ext.component.css'], + }) + export class ExtComponent {} + @Component({ + selector: 'app-ps-inline', + template: '

inline

', + styles: ['.PS_INLINE_MARKER { color: blue; }'], + }) + export class InlineComponent {} + ` + writeFileSync(mixedPath, source) + await transformSource(plugin, source, mixedPath) + + // Edit only the inline styles; the strip-equality branch queues both + // classes in the file. + const edited = source.replace('color: blue', 'color: green') + writeFileSync(mixedPath, edited) + const ctx = createMockHmrContext(mixedPath, [{ id: mixedPath }], mockServer) + await callHandleHotUpdate(plugin, ctx) + + expectDispatched(mockServer, `${mixedPath}@InlineComponent`) + + // InlineComponent must get its OWN inline styles — a sibling's styleUrl + // making the FILE-level list non-empty must not shadow the inline branch. + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${mixedPath}@InlineComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_INLINE_MARKER') + expect(body).not.toContain('PS_EXT_MARKER') + }) + it('serves the styleUrls of a class whose array carries a comment', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const realCssPath = join(appDir, 'ps-commented.component.css') + const commentedPath = join(appDir, 'ps-commented.component.ts') + writeFileSync(realCssPath, '.PS_COMMENTED_MARKER { color: red; }') + + // An apostrophe inside a comment in the array body must not be mistaken + // for a string delimiter, which would swallow the real entry. + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-commented', + template: '

commented

', + styleUrls: [ + /* don't drop this */ + './ps-commented.component.css', + ], + }) + export class CommentedComponent {} + ` + writeFileSync(commentedPath, source) + await transformSource(plugin, source, commentedPath) + + writeFileSync(realCssPath, '.PS_COMMENTED_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(realCssPath), + [{ id: normalizePath(realCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${commentedPath}@CommentedComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_COMMENTED_MARKER') + }) + + it('serves the inline styles of a class whose array carries a comment', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const inlineCommentPath = join(appDir, 'ps-inline-comment.component.ts') + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-inline-comment', + template: '

inline

', + styles: [ + /* it's fine */ + '.PS_INLINE_COMMENT_MARKER { color: red; }', + ], + }) + export class InlineCommentComponent {} + ` + writeFileSync(inlineCommentPath, source) + await transformSource(plugin, source, inlineCommentPath) + + const edited = source.replace('color: red', 'color: green') + writeFileSync(inlineCommentPath, edited) + // Editing the `.ts` itself takes the component-file branch, which keys + // `componentsByFile` and `pendingHmrUpdates` on `ctx.file` verbatim. Use + // the same spelling here, for `transform` above, and for the endpoint + // request below: on Windows a normalized path and a `join()` path differ + // as strings, and the lookup would miss. Real Vite hands both hooks the + // same normalized spelling, so only a test can mix them. + const ctx = createMockHmrContext(inlineCommentPath, [{ id: inlineCommentPath }], mockServer) + await callHandleHotUpdate(plugin, ctx) + + expectDispatched(mockServer, `${inlineCommentPath}@InlineCommentComponent`) + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${inlineCommentPath}@InlineCommentComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_INLINE_COMMENT_MARKER') + }) + it('serves no styles to a class that declares none, even beside a styled sibling', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const styledCssPath = join(appDir, 'ps-bare-styled.component.css') + const barePath = join(appDir, 'ps-bare.component.ts') + writeFileSync(styledCssPath, '.PS_BARE_SIBLING_MARKER { color: red; }') + + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-styled', + template: '

styled

', + styleUrls: ['./ps-bare-styled.component.css'], + }) + export class StyledComponent {} + @Component({ + selector: 'app-ps-bare', + template: '

bare

', + }) + export class BareComponent {} + ` + writeFileSync(barePath, source) + await transformSource(plugin, source, barePath) + + writeFileSync(styledCssPath, '.PS_BARE_SIBLING_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(styledCssPath), + [{ id: normalizePath(styledCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + // BareComponent declares no styles at all: it must get NONE, not the + // file-level union of its sibling's scoped CSS. + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${barePath}@BareComponent`, + ) + expect(body).not.toBe('') + expect(body).not.toContain('PS_BARE_SIBLING_MARKER') + expect(body).not.toContain('styles:') + }) + + it('serves both inline styles and styleUrls when a class declares both', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const extCssPath = join(appDir, 'ps-merge-ext.component.css') + const mergePath = join(appDir, 'ps-merge.component.ts') + writeFileSync(extCssPath, '.PS_MERGE_EXTERNAL_MARKER { color: red; }') + + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-merge', + template: '

merge

', + styles: ['.PS_MERGE_INLINE_MARKER { color: blue; }'], + styleUrls: ['./ps-merge-ext.component.css'], + }) + export class MergeComponent {} + ` + writeFileSync(mergePath, source) + await transformSource(plugin, source, mergePath) + + writeFileSync(extCssPath, '.PS_MERGE_EXTERNAL_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(extCssPath), + [{ id: normalizePath(extCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${mergePath}@MergeComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_MERGE_INLINE_MARKER') + expect(body).toContain('PS_MERGE_EXTERNAL_MARKER') + // Angular's order: the decorator's own inline `styles` first, then the + // resolved `styleUrl(s)` content appended (see `resolve_styles`). + expect(body.indexOf('PS_MERGE_INLINE_MARKER')).toBeLessThan( + body.indexOf('PS_MERGE_EXTERNAL_MARKER'), + ) + }) + + it('falls back to the file-level styleUrls when a class uses a non-literal entry', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const constCssPath = join(appDir, 'ps-const.component.css') + const constPath = join(appDir, 'ps-const.component.ts') + writeFileSync(constCssPath, '.PS_CONST_MARKER { color: red; }') + + // The URL comes from a const, so the per-class locator finds the field but + // reads no string literal out of it. That must fall back to the file-level + // list (which the Rust extractor DOES fold), not serve an empty style set. + const source = ` + import { Component } from '@angular/core'; + const STYLE_URL = './ps-const.component.css'; + @Component({ + selector: 'app-ps-const', + template: '

const

', + styleUrls: [STYLE_URL], + }) + export class ConstComponent {} + ` + writeFileSync(constPath, source) + await transformSource(plugin, source, constPath) + + writeFileSync(constCssPath, '.PS_CONST_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(constCssPath), + [{ id: normalizePath(constCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${constPath}@ConstComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_CONST_MARKER') + }) + + it('falls back when the singular `styleUrl` is a same-file constant', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const ownCssPath = join(appDir, 'ps-singconst-own.component.css') + const sibCssPath = join(appDir, 'ps-singconst-sib.component.css') + const singConstPath = join(appDir, 'ps-singconst.component.ts') + writeFileSync(ownCssPath, '.PS_SINGCONST_OWN_MARKER { color: red; }') + writeFileSync(sibCssPath, '.PS_SINGCONST_SIB_MARKER { color: red; }') + + // The Rust extractor folds the const (verified: it reports ./own.css), + // but the text scan cannot. That is "unknown", not "no styles" — serving + // an empty set would strip this component's CSS entirely. + const source = ` + import { Component } from '@angular/core'; + const STYLE_URL = './ps-singconst-own.component.css'; + @Component({ + selector: 'app-ps-singconst', + template: '

const

', + styleUrl: STYLE_URL, + }) + export class SingConstComponent {} + @Component({ + selector: 'app-ps-singconst-sib', + template: '

sib

', + styleUrls: ['./ps-singconst-sib.component.css'], + }) + export class SingConstSiblingComponent {} + ` + writeFileSync(singConstPath, source) + await transformSource(plugin, source, singConstPath) + + writeFileSync(ownCssPath, '.PS_SINGCONST_OWN_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(ownCssPath), + [{ id: normalizePath(ownCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${singConstPath}@SingConstComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_SINGCONST_OWN_MARKER') + }) + + it('serves the own stylesheet of a class whose `styleUrls` key is escaped', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const ownCssPath = join(appDir, 'ps-esckey-own.component.css') + const sibCssPath = join(appDir, 'ps-esckey-sib.component.css') + const escKeyPath = join(appDir, 'ps-esckey.component.ts') + writeFileSync(ownCssPath, '.PS_ESCKEY_OWN_MARKER { color: red; }') + writeFileSync(sibCssPath, '.PS_ESCKEY_SIB_MARKER { color: red; }') + + // `style\u0055rls` IS `styleUrls` to the TypeScript parser, and the Rust + // extractor resolves it. Reading the key as absent said "this class has + // no styles" and served none, stripping the component's CSS. + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-esckey', + template: '

esc

', + style\\u0055rls: ['./ps-esckey-own.component.css'], + }) + export class EscKeyComponent {} + @Component({ + selector: 'app-ps-esckey-sib', + template: '

sib

', + styleUrls: ['./ps-esckey-sib.component.css'], + }) + export class EscKeySiblingComponent {} + ` + writeFileSync(escKeyPath, source) + await transformSource(plugin, source, escKeyPath) + + writeFileSync(ownCssPath, '.PS_ESCKEY_OWN_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(ownCssPath), + [{ id: normalizePath(ownCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + expectDispatched(mockServer, `${escKeyPath}@EscKeyComponent`) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${escKeyPath}@EscKeyComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_ESCKEY_OWN_MARKER') + // Decoding the key gives an exact per-class answer, so unlike the + // constant-valued fallback above, the sibling's CSS does not ride along. + expect(body).not.toContain('PS_ESCKEY_SIB_MARKER') + }) + + it('serves no styles for an explicitly empty `styleUrls` array', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const sibCssPath = join(appDir, 'ps-emptyurls-sib.component.css') + const emptyUrlsPath = join(appDir, 'ps-emptyurls.component.ts') + writeFileSync(sibCssPath, '.PS_EMPTYURLS_SIB_MARKER { color: red; }') + + // `styleUrls: []` is valid and definitive: this class has no styles. It + // must not inherit the file-level union. + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-emptyurls', + template: '

empty

', + styleUrls: [], + }) + export class EmptyUrlsComponent {} + @Component({ + selector: 'app-ps-emptyurls-sib', + template: '

sib

', + styleUrls: ['./ps-emptyurls-sib.component.css'], + }) + export class EmptyUrlsSiblingComponent {} + ` + writeFileSync(emptyUrlsPath, source) + await transformSource(plugin, source, emptyUrlsPath) + + writeFileSync(sibCssPath, '.PS_EMPTYURLS_SIB_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(sibCssPath), + [{ id: normalizePath(sibCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${emptyUrlsPath}@EmptyUrlsComponent`, + ) + expect(body).not.toBe('') + expect(body).not.toContain('PS_EMPTYURLS_SIB_MARKER') + }) + + it('serves no styles for an explicitly empty inline `styles` array', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const sibCssPath = join(appDir, 'ps-emptyinline-sib.component.css') + const emptyInlinePath = join(appDir, 'ps-emptyinline.component.ts') + writeFileSync(sibCssPath, '.PS_EMPTYINLINE_SIB_MARKER { color: red; }') + + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-emptyinline', + template: '

empty

', + styles: [], + }) + export class EmptyInlineComponent {} + @Component({ + selector: 'app-ps-emptyinline-sib', + template: '

sib

', + styleUrls: ['./ps-emptyinline-sib.component.css'], + }) + export class EmptyInlineSiblingComponent {} + ` + writeFileSync(emptyInlinePath, source) + await transformSource(plugin, source, emptyInlinePath) + + writeFileSync(sibCssPath, '.PS_EMPTYINLINE_SIB_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(sibCssPath), + [{ id: normalizePath(sibCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${emptyInlinePath}@EmptyInlineComponent`, + ) + expect(body).not.toBe('') + expect(body).not.toContain('PS_EMPTYINLINE_SIB_MARKER') + }) + + it('falls back when a `styleUrls` array mixes a constant with a literal', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const constCssPath = join(appDir, 'ps-mixed-const.component.css') + const litCssPath = join(appDir, 'ps-mixed-lit.component.css') + const mixedPath = join(appDir, 'ps-mixed.component.ts') + writeFileSync(constCssPath, '.PS_MIXED_CONST_MARKER { color: red; }') + writeFileSync(litCssPath, '.PS_MIXED_LIT_MARKER { color: red; }') + + // One entry is a const the text scan cannot read. Returning just the + // literal would silently drop a stylesheet — the array is unknown, not + // partially known. + const source = ` + import { Component } from '@angular/core'; + const MIXED_STYLE = './ps-mixed-const.component.css'; + @Component({ + selector: 'app-ps-mixed', + template: '

mixed

', + styleUrls: [MIXED_STYLE, './ps-mixed-lit.component.css'], + }) + export class MixedComponent {} + ` + writeFileSync(mixedPath, source) + await transformSource(plugin, source, mixedPath) + + writeFileSync(litCssPath, '.PS_MIXED_LIT_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(litCssPath), + [{ id: normalizePath(litCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${mixedPath}@MixedComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_MIXED_LIT_MARKER') + expect(body).toContain('PS_MIXED_CONST_MARKER') + }) + + it('falls back when a `styleUrl` template literal is interpolated', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const interpCssPath = join(appDir, 'ps-interp.component.css') + const interpPath = join(appDir, 'ps-interp.component.ts') + writeFileSync(interpCssPath, '.PS_INTERP_MARKER { color: red; }') + + // The Rust extractor folds this to ./ps-interp.component.css; the raw + // slice `${DIR}/ps-interp.component.css` is not a real path. + const source = ` + import { Component } from '@angular/core'; + const DIR = '.'; + @Component({ + selector: 'app-ps-interp', + template: '

interp

', + styleUrl: \`\${DIR}/ps-interp.component.css\`, + }) + export class InterpComponent {} + ` + writeFileSync(interpPath, source) + await transformSource(plugin, source, interpPath) + + writeFileSync(interpCssPath, '.PS_INTERP_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(interpCssPath), + [{ id: normalizePath(interpCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${interpPath}@InterpComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_INTERP_MARKER') + }) + + // Quoted metadata keys are valid TS, and the Rust extractor resolves + // them (verified: `'styleUrls'` reports its URL). Reading the key as + // absent makes this class look styleless, which serves it nothing. + it('serves the own styleUrls of a class whose key is single-quoted', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const quotedCssPath = join(appDir, 'ps-quoted-own.component.css') + const quotedSibCssPath = join(appDir, 'ps-quoted-sib.component.css') + const quotedPath = join(appDir, 'ps-quoted.component.ts') + writeFileSync(quotedCssPath, '.PS_QUOTED_OWN_MARKER { color: red; }') + writeFileSync(quotedSibCssPath, '.PS_QUOTED_SIB_MARKER { color: red; }') + + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-quoted', + template: '

quoted

', + 'styleUrls': ['./ps-quoted-own.component.css'], + }) + export class QuotedKeyComponent {} + @Component({ + selector: 'app-ps-quoted-sib', + template: '

sib

', + styleUrls: ['./ps-quoted-sib.component.css'], + }) + export class QuotedKeySiblingComponent {} + ` + writeFileSync(quotedPath, source) + await transformSource(plugin, source, quotedPath) + + writeFileSync(quotedCssPath, '.PS_QUOTED_OWN_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(quotedCssPath), + [{ id: normalizePath(quotedCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${quotedPath}@QuotedKeyComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_QUOTED_OWN_MARKER') + expect(body).not.toContain('PS_QUOTED_SIB_MARKER') + }) + + it('serves the own styleUrls of a class whose key is double-quoted', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const dqCssPath = join(appDir, 'ps-dquoted-own.component.css') + const dqSibCssPath = join(appDir, 'ps-dquoted-sib.component.css') + const dqPath = join(appDir, 'ps-dquoted.component.ts') + writeFileSync(dqCssPath, '.PS_DQUOTED_OWN_MARKER { color: red; }') + writeFileSync(dqSibCssPath, '.PS_DQUOTED_SIB_MARKER { color: red; }') + + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-dquoted', + template: '

dquoted

', + "styleUrls": ['./ps-dquoted-own.component.css'], + }) + export class DQuotedKeyComponent {} + @Component({ + selector: 'app-ps-dquoted-sib', + template: '

sib

', + styleUrls: ['./ps-dquoted-sib.component.css'], + }) + export class DQuotedKeySiblingComponent {} + ` + writeFileSync(dqPath, source) + await transformSource(plugin, source, dqPath) + + writeFileSync(dqCssPath, '.PS_DQUOTED_OWN_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(dqCssPath), + [{ id: normalizePath(dqCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${dqPath}@DQuotedKeyComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_DQUOTED_OWN_MARKER') + expect(body).not.toContain('PS_DQUOTED_SIB_MARKER') + }) + + // A computed key hides the field from this scan while the Rust extractor + // still resolves it, so the class must fall back rather than be served + // nothing — the file-level list carries its stylesheet. + it('falls back when a style field is declared under a computed key', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const computedCssPath = join(appDir, 'ps-computed-own.component.css') + const computedPath = join(appDir, 'ps-computed.component.ts') + writeFileSync(computedCssPath, '.PS_COMPUTED_OWN_MARKER { color: red; }') + + const source = ` + import { Component } from '@angular/core'; + const K = 'styleUrls'; + @Component({ + selector: 'app-ps-computed', + template: '

computed

', + [K]: ['./ps-computed-own.component.css'], + }) + export class ComputedKeyComponent {} + ` + writeFileSync(computedPath, source) + await transformSource(plugin, source, computedPath) + + writeFileSync(computedCssPath, '.PS_COMPUTED_OWN_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(computedCssPath), + [{ id: normalizePath(computedCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${computedPath}@ComputedKeyComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_COMPUTED_OWN_MARKER') + }) + + // A spread is the one unreadable form the compiler ALSO drops, so + // "declares no styles" is what the compiled component actually gets. + // Falling back here would hand this class its sibling's CSS instead. + it('serves no styles for a spread-only decorator, matching the compiler', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const spreadSibCssPath = join(appDir, 'ps-spread-sib.component.css') + const spreadPath = join(appDir, 'ps-spread.component.ts') + writeFileSync(spreadSibCssPath, '.PS_SPREAD_SIB_MARKER { color: red; }') + + const source = ` + import { Component } from '@angular/core'; + const BASE = { selector: 'app-ps-spread' }; + @Component({ + template: '

spread

', + ...BASE, + }) + export class SpreadComponent {} + @Component({ + selector: 'app-ps-spread-sib', + template: '

sib

', + styleUrls: ['./ps-spread-sib.component.css'], + }) + export class SpreadSiblingComponent {} + ` + writeFileSync(spreadPath, source) + await transformSource(plugin, source, spreadPath) + + writeFileSync(spreadSibCssPath, '.PS_SPREAD_SIB_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(spreadSibCssPath), + [{ id: normalizePath(spreadSibCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${spreadPath}@SpreadComponent`, + ) + expect(body).not.toBe('') + expect(body).not.toContain('PS_SPREAD_SIB_MARKER') + }) + + // A spread INSIDE a `styleUrls` array is dropped by the compiler the same + // way, before any constant is resolved — so this class compiles to exactly + // the one path it spells out. Reading the array as unknown would fall back + // to the file-level list and serve it its sibling's CSS as well. + it('serves only the spelled-out entry of a styleUrls array with a spread', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const ownCssPath = join(appDir, 'ps-spreadarr-own.component.css') + const sibCssPath = join(appDir, 'ps-spreadarr-sib.component.css') + const spreadArrPath = join(appDir, 'ps-spreadarr.component.ts') + writeFileSync(ownCssPath, '.PS_SPREADARR_OWN_MARKER { color: red; }') + writeFileSync(sibCssPath, '.PS_SPREADARR_SIB_MARKER { color: blue; }') + + const source = ` + import { Component } from '@angular/core'; + const SHARED = []; + @Component({ + selector: 'app-ps-spreadarr', + template: '

own

', + styleUrls: [...SHARED, './ps-spreadarr-own.component.css'], + }) + export class SpreadArrayComponent {} + @Component({ + selector: 'app-ps-spreadarr-sib', + template: '

sib

', + styleUrls: ['./ps-spreadarr-sib.component.css'], + }) + export class SpreadArraySiblingComponent {} + ` + writeFileSync(spreadArrPath, source) + await transformSource(plugin, source, spreadArrPath) + + // Edit the SIBLING's stylesheet; the fan-out queues both classes. + writeFileSync(sibCssPath, '.PS_SPREADARR_SIB_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(sibCssPath), + [{ id: normalizePath(sibCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${spreadArrPath}@SpreadArrayComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_SPREADARR_OWN_MARKER') + expect(body).not.toContain('PS_SPREADARR_SIB_MARKER') + }) + it('serves a styleUrls entry written with a JavaScript escape', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const escCssPath = join(appDir, 'ps-esc.component.css') + const escSibCssPath = join(appDir, 'ps-esc-sib.component.css') + const escPath = join(appDir, 'ps-esc.component.ts') + writeFileSync(escCssPath, '.PS_ESC_OWN_MARKER { color: red; }') + writeFileSync(escSibCssPath, '.PS_ESC_SIB_MARKER { color: blue; }') + + // `\u002e` is `.` — the cooked value is `./ps-esc.component.css`, which + // is what the Rust extractor sees. A raw read yields a path that does + // not exist, and the per-style catch would swallow the failure. + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-esc', + template: '

esc

', + styleUrls: ['\\u002e/ps-esc\\u002ecomponent.css'], + }) + export class EscComponent {} + @Component({ + selector: 'app-ps-esc-sib', + template: '

sib

', + styleUrls: ['./ps-esc-sib.component.css'], + }) + export class EscSiblingComponent {} + ` + writeFileSync(escPath, source) + await transformSource(plugin, source, escPath) + + writeFileSync(escSibCssPath, '.PS_ESC_SIB_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(escSibCssPath), + [{ id: normalizePath(escSibCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware(getMiddleware(mockServer), `${escPath}@EscComponent`) + expect(body).not.toBe('') + expect(body).toContain('PS_ESC_OWN_MARKER') + expect(body).not.toContain('PS_ESC_SIB_MARKER') + }) + + it('ignores a commented-out decorator when picking the styles of a class', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const realCssPath = join(appDir, 'ps-cmt-real.component.css') + const oldCssPath = join(appDir, 'ps-cmt-old.component.css') + const cmtSibCssPath = join(appDir, 'ps-cmt-sib.component.css') + const cmtPath = join(appDir, 'ps-cmt.component.ts') + writeFileSync(realCssPath, '.PS_CMT_REAL_MARKER { color: red; }') + writeFileSync(oldCssPath, '.PS_CMT_OLD_MARKER { color: blue; }') + writeFileSync(cmtSibCssPath, '.PS_CMT_SIB_MARKER { color: teal; }') + + // A leftover commented decorator sits between the real one and the class. + // Enumeration must not pair the class with the commented occurrence. + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-cmt', + template: '

cmt

', + styleUrls: ['./ps-cmt-real.component.css'], + }) + // @Component({ styleUrl: './ps-cmt-old.component.css' }) + export class CommentedComponent {} + @Component({ + selector: 'app-ps-cmt-sib', + template: '

sib

', + styleUrls: ['./ps-cmt-sib.component.css'], + }) + export class CommentedSiblingComponent {} + ` + writeFileSync(cmtPath, source) + await transformSource(plugin, source, cmtPath) + + writeFileSync(cmtSibCssPath, '.PS_CMT_SIB_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(cmtSibCssPath), + [{ id: normalizePath(cmtSibCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${cmtPath}@CommentedComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_CMT_REAL_MARKER') + expect(body).not.toContain('PS_CMT_OLD_MARKER') + expect(body).not.toContain('PS_CMT_SIB_MARKER') + }) + it('serves the own stylesheet of a class using a shorthand `styleUrl`', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const ownCssPath = join(appDir, 'ps-shorthand-own.component.css') + const sibCssPath = join(appDir, 'ps-shorthand-sib.component.css') + const shorthandPath = join(appDir, 'ps-shorthand.component.ts') + writeFileSync(ownCssPath, '.PS_SHORTHAND_OWN_MARKER { color: red; }') + writeFileSync(sibCssPath, '.PS_SHORTHAND_SIB_MARKER { color: red; }') + + // The shorthand key is a style field this scan cannot resolve, but the + // Rust extractor folds the constant behind it. Reading it as "declares + // no styles" would strip the component's CSS. + const source = ` + import { Component } from '@angular/core'; + const styleUrl = './ps-shorthand-own.component.css'; + @Component({ + selector: 'app-ps-shorthand', + template: '

shorthand

', + styleUrl, + }) + export class ShorthandComponent {} + @Component({ + selector: 'app-ps-shorthand-sib', + template: '

sib

', + styleUrls: ['./ps-shorthand-sib.component.css'], + }) + export class ShorthandSiblingComponent {} + ` + writeFileSync(shorthandPath, source) + await transformSource(plugin, source, shorthandPath) + + writeFileSync(ownCssPath, '.PS_SHORTHAND_OWN_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(ownCssPath), + [{ id: normalizePath(ownCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + expectDispatched(mockServer, `${shorthandPath}@ShorthandComponent`) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${shorthandPath}@ShorthandComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_SHORTHAND_OWN_MARKER') + // The sibling's stylesheet rides along, because this is the file-level + // fallback: it is the union for the file. That is the known limitation + // tracked in #456, asserted the same way as the singular-constant case + // above. The defect fixed here is the own stylesheet going MISSING. + }) +}) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 62a594337..3ae01fc69 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -39,10 +39,12 @@ import { emptyDelimitedRange, locateComponentDecorators, locateStylesFieldFor, + locateStyleFieldsFor, locateStylesInArgs, locateTemplateInArgs, locateTemplateStringFor, locateTemplateUrlFor, + readStringLiterals, } from './utils/decorator-fields.js' import { injectDtsDeclarations } from './utils/dts.js' @@ -686,10 +688,18 @@ export function angular(options: PluginOptions = {}): Plugin[] { // disk and run through Vite's preprocessCSS (so SCSS/LESS // resolve correctly); inline styles are extracted from the // .ts source as plain CSS strings. - let styles: string[] | null = null - if (styleUrls.length > 0) { + // + // Resolve per CLASS, like the template above: the file-level + // `styleUrls` is the union of every component in the file, so + // in a multi-component file it served a class its siblings' + // stylesheets — including a class declaring no styles at all — + // and, being non-empty, it also shadowed the inline `styles:` + // of a class that has no external ones. Fall back to the + // file-level list only when the class's own styles cannot be + // read (preserves the old behavior there). + const readStyles = async (urls: string[]): Promise => { const styleContents: string[] = [] - for (const styleUrl of styleUrls) { + for (const styleUrl of urls) { const stylePath = resolve(dir, styleUrl) try { let styleContent = await readFile(stylePath, 'utf-8') @@ -706,15 +716,28 @@ export function angular(options: PluginOptions = {}): Plugin[] { // Style file not found, continue without this style } } - if (styleContents.length > 0) { - styles = styleContents - } - } else { - // No external styleUrls — fall back to inline `styles: […]`. - const inlineStyles = extractInlineStyles(source, className) - if (inlineStyles !== null && inlineStyles.length > 0) { - styles = inlineStyles - } + return styleContents.length > 0 ? styleContents : null + } + let styles: string[] | null = null + const classStyles = extractClassStylesFor(source, className) + if (classStyles !== null) { + // Inline `styles` first, then the resolved `styleUrl(s)` + // content appended — the order `resolve_styles` produces in + // the compiler, which pushes resolved content onto the + // decorator's own `styles`. Whitespace-only entries are + // dropped to match Angular's `style.trim().length > 0`. + const external = + classStyles.urls.length > 0 ? ((await readStyles(classStyles.urls)) ?? []) : [] + const merged = [...classStyles.inline, ...external].filter( + (style) => style.trim().length > 0, + ) + styles = merged.length > 0 ? merged : null + } else if (styleUrls.length > 0) { + // The class's own styles could not be read — a decorator + // shape this text scan cannot parse, e.g. a styleUrl built + // from a constant the Rust extractor folds. Fall back to the + // file-level list, preserving the old behavior there. + styles = await readStyles(styleUrls) } const result = compileForHmrSync(templateContent, className, resolvedId, styles, { @@ -1428,6 +1451,42 @@ function extractTemplateUrlFor(code: string, className: string): string | null { return code.slice(range[0] + 1, range[1]) } +/** + * The styles one class declares in its own `@Component({...})`, split by + * source: inline `styles` and external `styleUrl(s)`. + * + * Handles every shape Angular accepts — `styles` and `styleUrls` as an array + * or a bare literal, plus the singular `styleUrl` (17+). Both arrays are + * empty for a class that declares no styles at all, which is a real answer: + * that component must be served nothing. + * + * Returns null when the answer is *unknown* rather than empty — the class's + * decorator could not be located, or it declares a style field whose value + * holds no string literal (a constant, an identifier, an unresolved + * interpolation). The caller falls back to the file-level list there, since + * the Rust extractor folds constants this text scan cannot. + */ +function extractClassStylesFor( + code: string, + className: string, +): { inline: string[]; urls: string[] } | null { + const fields = locateStyleFieldsFor(code, className) + if (!fields) return null + + const read = (field: (typeof fields)['inline']): string[] | null => { + if (field.kind === 'absent') return [] + if (field.kind === 'unreadable') return null + const { literals, complete } = readStringLiterals(code, field.range) + // A partial read is unknown, not partial knowledge: acting on the + // literals alone would drop whatever the unreadable elements name. + return complete ? literals : null + } + const inline = read(fields.inline) + const urls = read(fields.urls) + if (inline === null || urls === null) return null + return { inline, urls } +} + /** * Extract the inline styles from the `@Component({...})` decorator that * decorates the class named `className`, as a positional array. @@ -1444,20 +1503,8 @@ function extractTemplateUrlFor(code: string, className: string): string | null { function extractInlineStyles(code: string, className: string): string[] | null { const range = locateStylesFieldFor(code, className) if (!range) return null - const opener = code[range[0]] - if (opener !== '[') { - // Bare string form — return the inner contents as a single element. - return [code.slice(range[0] + 1, range[1])] - } - // Array form — walk string literals inside the array body in order. - const body = code.slice(range[0] + 1, range[1]) - const stringRe = /`([\s\S]*?)`|'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)"/g - const styles: string[] = [] - let m: RegExpExecArray | null - while ((m = stringRe.exec(body)) !== null) { - styles.push(m[1] ?? m[2] ?? m[3] ?? '') - } - return styles.length > 0 ? styles : null + const { literals } = readStringLiterals(code, range) + return literals.length > 0 ? literals : null } /** diff --git a/napi/angular-compiler/vite-plugin/utils/decorator-fields.ts b/napi/angular-compiler/vite-plugin/utils/decorator-fields.ts index 2dbc51448..ddea7b3fd 100644 --- a/napi/angular-compiler/vite-plugin/utils/decorator-fields.ts +++ b/napi/angular-compiler/vite-plugin/utils/decorator-fields.ts @@ -21,9 +21,11 @@ * literal `@Component` form is recognized. * - **Parenthesized decorator expressions** like `@(Component as any)(...)` * — uncommon and not supported. - * - **Quoted (`{ 'styles': [...] }`) or computed (`{ ['styles']: [...] }`) - * property keys** for the `styles`/`template` field. Almost never used - * in Angular; locator returns null for such forms. + * - **Computed property keys** (`{ ['styles']: [...] }`) and quoted keys + * carrying an escape (`{ 'style\u0055rls': [...] }`) can't be resolved + * to a name here. Plain quoted keys (`{ 'styles': [...] }`) are matched; + * the unresolvable forms are reported so the caller can fall back rather + * than read them as a field that isn't there. * - **Concatenated style strings** inside an array (`styles: ['a' + 'b']`) * are extracted as two separate elements; cosmetic but harmless because * the browser sees the same CSS either way. @@ -64,6 +66,9 @@ const ASCII_WORD_RE = /[A-Za-z0-9_$]/ const IDENT_START_RE = /[\p{L}_$]/u const IDENT_CONT_RE = /[\p{L}\p{N}_$]/u +/** The only decorator form recognized — see the module docstring. */ +const DECORATOR_NAME = 'Component' + /** Opener chars accepted as the value of `styles:` — `string | string[]`. */ const STYLES_OPENERS = '\'"`[' /** Opener chars accepted as the value of `template:` — just string literals. */ @@ -203,36 +208,247 @@ export function emptyDelimitedRange(code: string, range: [number, number]): stri * * Returns null if no qualifying field is found. */ -function locateFieldInsideArgs( +/** + * The state of one decorator field, which is three-valued: the key may be + * missing entirely, present with a value this scan can read, or present with + * a value it cannot (an identifier, a call, a concatenation). + * + * The third state is why this exists. Collapsing it into "absent" tells a + * caller the component declares nothing, when in truth the value is simply + * beyond a text scan — the Rust extractor folds same-file constants that this + * cannot. Those two need opposite fallbacks. + */ +export type FieldValue = + | { kind: 'absent' } + | { kind: 'unreadable' } + | { kind: 'literal'; range: [number, number] } + +/** + * Find a top-level field of the `@Component(...)` args and classify its + * value. See `FieldValue` for the three outcomes. + * + * The walk is identical to `locateFieldInsideArgs`, which is expressed on top + * of this; only the reporting differs. + */ +function findFieldInArgs( code: string, argsRange: [number, number], field: string, openerChars: string, -): [number, number] | null { + shorthandMeans: 'absent' | 'unreadable' = 'absent', +): FieldValue { const [openParen, closeParen] = argsRange const stack: Ctx[] = ['paren'] let i = openParen + 1 + // Keys sit at the start of the object and after each comma; `:` hands over + // to the value. Without this, a style-named identifier used as a VALUE + // (`selector: styleUrl`) would read as a shorthand property. + let atKey = true while (i < closeParen) { - // Check for a field-key match BEFORE advancing. The match is only - // valid at the @Component's immediate object-literal depth - // (`['paren', 'brace']`) — anything deeper is a nested literal that + // A key match is only valid at the @Component's immediate object-literal + // depth (`['paren', 'brace']`) — anything deeper is a nested literal that // isn't the component's metadata. - if (stack.length === 2 && stack[1] === 'brace' && isFieldKeyAt(code, i, field, closeParen)) { - let j = i + field.length - while (j < closeParen && WS_RE.test(code[j])) j++ - if (code[j] === ':') { - j++ - while (j < closeParen && WS_RE.test(code[j])) j++ - if (j < closeParen && openerChars.includes(code[j])) { - const end = findClosingDelim(code, j) - if (end !== -1 && end < closeParen) return [j, end] + if (stack.length === 2 && stack[1] === 'brace') { + const ch = code[i] + if (WS_RE.test(ch)) { + i++ + continue + } + const afterComment = skipComment(code, i, closeParen) + if (afterComment !== -1) { + i = afterComment + continue + } + if (ch === ',') { + atKey = true + i++ + continue + } + if (ch === ':') { + atKey = false + i++ + continue + } + + const afterKey = atKey ? matchFieldKeyAt(code, i, field, closeParen) : -1 + if (afterKey !== -1) { + const j = skipToToken(code, afterKey, closeParen) + if (code[j] === ':') { + const v = skipToToken(code, j + 1, closeParen) + if (v < closeParen && openerChars.includes(code[v])) { + const end = findClosingDelim(code, v) + // The literal is the value only when the property ENDS at its + // closing delimiter. `'./a.css' + SUFFIX` opens with a literal + // it does not denote, and so do `['./a.css'].concat(MORE)` and + // `'./a.css' as const`. Returning that leading piece would name + // a stylesheet the compiled component never had — and, being a + // confident answer, would skip the fallback that exists for + // values this scan cannot resolve. + if (end !== -1 && end < closeParen && endsPropertyValue(code, end + 1, closeParen)) { + return { kind: 'literal', range: [v, end] } + } + } + // The key is here; its value is not a shape we can read. + return { kind: 'unreadable' } + } + // Shorthand (`{ styleUrl }`): the key stands alone, so the value is + // a binding this scan cannot follow. Whether that is unknowable or + // genuinely nothing depends on the field — see `locateStyleFieldsFor`. + // Anything else after the key (`(` for a method or accessor) is not + // this field at all, so keep scanning. + if ((code[j] === ',' || code[j] === '}') && shorthandMeans === 'unreadable') { + return { kind: 'unreadable' } } } } i = advanceOneToken(code, i, stack, closeParen) } - return null + return { kind: 'absent' } +} + +/** + * Whether a property value ends at `i`. Inside an object literal a value is + * closed by `,` or the object's own `}` — or, defensively, the decorator's + * `)`. Whitespace and comments are skipped first, so a comment may sit + * between the value and its terminator. + * + * Anything else at that position means what was just read is only the LEADING + * part of a larger expression — a concatenation, a call, a TypeScript + * assertion — and so is not the value at all. Running out of text counts as + * ending, since nothing is left to extend the value with. + */ +function endsPropertyValue(code: string, i: number, end: number): boolean { + const j = skipToToken(code, i, end) + if (j >= end) return true + const ch = code[j] + return ch === ',' || ch === '}' || ch === ')' +} + +/** Index of the next character at or after `i` that is not whitespace or a comment. */ +function skipToToken(code: string, i: number, end: number): number { + let j = i + while (j < end) { + if (WS_RE.test(code[j])) { + j++ + continue + } + const afterComment = skipComment(code, j, end) + if (afterComment === -1) break + j = afterComment + } + return j +} + +function locateFieldInsideArgs( + code: string, + argsRange: [number, number], + field: string, + openerChars: string, +): [number, number] | null { + const found = findFieldInArgs(code, argsRange, field, openerChars) + return found.kind === 'literal' ? found.range : null +} + +/** + * If a key for `field` starts at `position`, return the index just past it; + * otherwise -1. Accepts the bare form (`styleUrls:`), the quoted forms + * (`'styleUrls':`, `"styleUrls":`), and either form written with escapes + * (`style\u0055rls`, `'style\u0055rls'`) — all of which are valid TS naming + * the same field, and all of which the Rust extractor resolves. + * + * Escapes are decoded rather than refused. Refusing them would only be safe + * in the sense of falling back, and the fallback serves the file-level style + * union — every sibling's CSS along with this class's own. Decoding keeps the + * per-class answer exact. A key whose escapes cannot be decoded is not + * matched here; `hasUnreadableKey` reports it as beyond this scan. + * + * Decoding does not loosen the match: the decoded name must still equal + * `field` exactly, so `styleUrl` and `styleUrls` stay distinct. + */ +function matchFieldKeyAt(code: string, position: number, field: string, limit: number): number { + if (isFieldKeyAt(code, position, field, limit)) return position + field.length + const quote = code[position] + if (quote === "'" || quote === '"') { + const close = findClosingDelim(code, position) + if (close === -1 || close >= limit) return -1 + // A quoted key is a string literal, so it decodes by string rules. + return decodeEscapes(code.slice(position + 1, close)) === field ? close + 1 : -1 + } + const id = readIdentifierKey(code, position, limit) + return id !== null && id.name === field ? id.end : -1 +} + +/** + * Read an identifier starting at `position`, decoding escapes, and return the + * decoded name with the index just past it. Returns null when nothing + * identifier-like starts there. + * + * `name` is null when the identifier carries an escape this scan cannot + * resolve — malformed hex, a truncated `\u`, a decoded character not valid at + * its position, or any escape other than `\uHHHH` / `\u{…}`, which are the + * only two JavaScript permits inside an identifier. The caller must treat + * that as unknown rather than as a key that isn't there: the Rust extractor + * parses what this cannot, so "absent" would strip a component's real CSS. + */ +function readIdentifierKey( + code: string, + position: number, + limit: number, +): { name: string | null; end: number } | null { + let i = position + let name = '' + let first = true + let malformed = false + + while (i < limit) { + const ch = code[i] + + if (ch === '\\') { + if (code[i + 1] !== 'u') { + // `\x41`, `\n`, … are string escapes; in an identifier they are a + // syntax error, so the name cannot be known from the text. + malformed = true + i += 2 + first = false + continue + } + let codePoint = -1 + let next: number + if (code[i + 2] === '{') { + const close = code.indexOf('}', i + 3) + if (close === -1 || close >= limit) return { name: null, end: i + 2 } + const hex = code.slice(i + 3, close) + if (HEX_ONLY_RE.test(hex)) codePoint = parseInt(hex, 16) + next = close + 1 + } else { + const hex = code.slice(i + 2, i + 6) + if (hex.length === 4 && HEX_ONLY_RE.test(hex)) codePoint = parseInt(hex, 16) + next = i + 6 + } + if (codePoint < 0 || codePoint > 0x10ffff) { + malformed = true + } else { + const decoded = String.fromCodePoint(codePoint) + if (!(first ? IDENT_START_RE : IDENT_CONT_RE).test(decoded)) malformed = true + name += decoded + } + i = next + first = false + continue + } + + if ((first ? IDENT_START_RE : IDENT_CONT_RE).test(ch)) { + name += ch + i++ + first = false + continue + } + break + } + + if (i === position) return null + return { name: malformed ? null : name, end: i } } /** @@ -266,26 +482,50 @@ export interface ComponentDecorator { * class (dangling, malformed, anonymous) are skipped — the caller sees only * well-formed component declarations. * - * The class-name scan is bounded between this decorator's closing `)` and - * either the next `@Component\s*\(` or end of file. That bound prevents one - * decorator's scan from accidentally consuming a sibling's class identifier, - * and combined with the comment- and string-aware walkers in - * `findClosingDelim` and `findClassName` it correctly handles `@Component` - * occurrences inside comments, strings, and template literals. + * Enumeration itself skips comments and string/template literals, so a + * commented-out or quoted `@Component(` is never treated as a decorator. + * The class-name scan is then bounded between one decorator's closing `)` + * and the next decorator's `@`, which stops it consuming a sibling's class + * identifier — a bound that is only correct because phantom occurrences + * were excluded first. * * See the module-level docstring for a full list of known limitations. */ export function locateComponentDecorators(code: string): ComponentDecorator[] { - // Pass 1: find every `@Component(...)` and bound its args list. + // Pass 1: find every `@Component(...)` and bound its args list. The walk + // skips comments and string/template literals, so a commented-out or + // quoted `@Component(` is not mistaken for a real decorator. That matters + // beyond ignoring it: a phantom occurrence between a real decorator and + // its class would bound the real one's class-name scan in pass 2, leaving + // the class paired with the phantom's metadata. type Found = { decoratorStart: number; openParen: number; closeParen: number } - const decoratorRe = /@Component\s*\(/g const found: Found[] = [] - let m: RegExpExecArray | null - while ((m = decoratorRe.exec(code)) !== null) { - const openParen = m.index + m[0].length - 1 - const closeParen = findClosingDelim(code, openParen) - if (closeParen === -1) continue - found.push({ decoratorStart: m.index, openParen, closeParen }) + let i = 0 + while (i < code.length) { + const afterComment = skipComment(code, i, code.length) + if (afterComment !== -1) { + i = afterComment + continue + } + const ch = code[i] + if (ch === "'" || ch === '"' || ch === '`') { + const close = findClosingDelim(code, i) + i = close === -1 ? code.length : close + 1 + continue + } + if (ch === '@' && code.startsWith(DECORATOR_NAME, i + 1)) { + let j = i + 1 + DECORATOR_NAME.length + while (j < code.length && WS_RE.test(code[j])) j++ + if (code[j] === '(') { + const closeParen = findClosingDelim(code, j) + if (closeParen !== -1) { + found.push({ decoratorStart: i, openParen: j, closeParen }) + i = closeParen + 1 + continue + } + } + } + i++ } // Pass 2: for each decorator, scan forward from its `)` to either the next @@ -435,3 +675,418 @@ export function locateTemplateUrlFor(code: string, className: string): [number, const found = locateComponentDecorators(code).find((d) => d.className === className) return found ? locateTemplateUrlInArgs(code, found.argsRange) : null } + +/** + * Locate the `styleUrls:` field inside a specific `@Component(...)` decorator + * identified by its `argsRange`. See `locateStylesInArgs` for when to prefer + * this over the className-based variant. Field matching is word-bounded, so + * `styleUrls` never matches the singular `styleUrl:` or the inline `styles:`. + */ +export function locateStyleUrlsInArgs( + code: string, + argsRange: [number, number], +): [number, number] | null { + return locateFieldInsideArgs(code, argsRange, 'styleUrls', STYLES_OPENERS) +} + +/** + * Locate the singular `styleUrl:` string field (Angular 17+) inside a + * specific `@Component(...)` decorator identified by its `argsRange`. Its + * value is a bare string, never an array — see `locateStyleUrlsInArgs` for + * the plural form. + */ +export function locateStyleUrlInArgs( + code: string, + argsRange: [number, number], +): [number, number] | null { + return locateFieldInsideArgs(code, argsRange, 'styleUrl', TEMPLATE_OPENERS) +} + +/** + * Locate the `styleUrls:` field inside the `@Component(...)` decorator that + * decorates the class named `className`. Convenience wrapper that finds the + * decorator by className and delegates to `locateStyleUrlsInArgs`. + */ +export function locateStyleUrlsFor(code: string, className: string): [number, number] | null { + const found = locateComponentDecorators(code).find((d) => d.className === className) + return found ? locateStyleUrlsInArgs(code, found.argsRange) : null +} + +/** + * Locate the singular `styleUrl:` field inside the `@Component(...)` + * decorator that decorates the class named `className`. Convenience wrapper + * that finds the decorator by className and delegates to + * `locateStyleUrlInArgs`. + */ +export function locateStyleUrlFor(code: string, className: string): [number, number] | null { + const found = locateComponentDecorators(code).find((d) => d.className === className) + return found ? locateStyleUrlInArgs(code, found.argsRange) : null +} + +/** + * Whether a template literal's raw contents contain an unescaped `${`, i.e. + * the literal interpolates and its text is not the final value. A leading + * run of backslashes escapes the `$` only when it is odd-length. + */ +function hasInterpolation(raw: string): boolean { + for (let i = raw.indexOf('${'); i !== -1; i = raw.indexOf('${', i + 2)) { + let backslashes = 0 + while (i - 1 - backslashes >= 0 && raw[i - 1 - backslashes] === '\\') backslashes++ + if (backslashes % 2 === 0) return true + } + return false +} + +const SINGLE_CHAR_ESCAPES: Record = { + n: '\n', + t: '\t', + r: '\r', + b: '\b', + f: '\f', + v: '\v', +} + +/** Line terminators that a backslash may continue across. */ +const LINE_TERMINATORS = new Set(['\n', '\r', '
', '
']) + +const HEX_ONLY_RE = /^[0-9a-fA-F]+$/ + +/** + * Decode the escape sequences in a string- or template-literal's raw source + * text into the value it actually denotes, or null if the text holds an + * escape this cannot resolve. + * + * The raw text is what the source spells; the decoded text is what the Rust + * extractor sees and what a path or a stylesheet must be compared against. + * `'./a.css'` names `./a.css`, and reading it raw yields a path that + * does not exist. + * + * Null is returned for a malformed escape (`\xZZ`, a truncated `\u12`) and + * for the legacy octal forms, which are outright errors in a module. Those + * are cases where guessing a value would be worse than telling the caller + * this literal is beyond the scan. + * + * A raw text with no backslash is returned unchanged, so the overwhelmingly + * common literal costs nothing and reads byte for byte as before. + */ +function decodeEscapes(raw: string): string | null { + if (!raw.includes('\\')) return raw + + let out = '' + let i = 0 + while (i < raw.length) { + const ch = raw[i] + if (ch !== '\\') { + out += ch + i++ + continue + } + + const next = raw[i + 1] + // A trailing backslash cannot occur in a well-delimited literal, since + // it would have escaped the closing quote. + if (next === undefined) return null + + // Line continuation: the backslash and the terminator both vanish. + if (LINE_TERMINATORS.has(next)) { + i += next === '\r' && raw[i + 2] === '\n' ? 3 : 2 + continue + } + + const single = SINGLE_CHAR_ESCAPES[next] + if (single !== undefined) { + out += single + i += 2 + continue + } + + if (next === 'x') { + const hex = raw.slice(i + 2, i + 4) + if (hex.length < 2 || !HEX_ONLY_RE.test(hex)) return null + out += String.fromCharCode(parseInt(hex, 16)) + i += 4 + continue + } + + if (next === 'u') { + if (raw[i + 2] === '{') { + const close = raw.indexOf('}', i + 3) + if (close === -1) return null + const hex = raw.slice(i + 3, close) + if (!HEX_ONLY_RE.test(hex)) return null + const code = parseInt(hex, 16) + if (code > 0x10ffff) return null + out += String.fromCodePoint(code) + i = close + 1 + continue + } + const hex = raw.slice(i + 2, i + 6) + if (hex.length < 4 || !HEX_ONLY_RE.test(hex)) return null + out += String.fromCharCode(parseInt(hex, 16)) + i += 6 + continue + } + + // `\0` is NUL only when no digit follows; with one it is a legacy octal + // escape, which a module rejects outright. `\1`-`\9` likewise. + if (next >= '0' && next <= '9') { + if (next === '0' && !(raw[i + 2] >= '0' && raw[i + 2] <= '9')) { + out += '\0' + i += 2 + continue + } + return null + } + + // Anything else denotes itself: `\\`, `\'`, `\"`, `` \` ``, `\$`, and + // any other non-escape character. + out += next + i += 2 + } + return out +} + +/** + * The literals read out of a field value, and whether every element was read. + * + * `complete: false` means the value holds something this scan cannot resolve — + * an identifier, an interpolated template literal, an unterminated string. + * The literals gathered alongside it are NOT a partial answer to act on: a + * caller that used them would silently drop the stylesheets the unreadable + * elements stand for. Treat an incomplete read as unknown. + * + * A spread element (`...X`) and an elision are the exceptions: the Rust + * extractor drops both before resolving anything, so skipping them leaves + * the read complete. + * + * An empty array is the opposite case: `complete: true` with no literals is a + * definitive "this field declares nothing". + */ +export interface StringLiteralsRead { + literals: string[] + complete: boolean +} + +/** + * Read the string literals out of a located field value, given the inclusive + * `[start, end]` range of its outer delimiters (the shape every `locate*` + * here returns). + * + * Handles both shapes Angular accepts for `styles` / `styleUrls`: + * - Array (`['…', "…", `…`]`, or any mix) → each literal, in order. + * - Bare single literal (`'…'`, `"…"`, `` `…` ``) → a one-element array. + * + * Inside the array body, whitespace, commas and comments are skipped, and + * each literal is delimited with `findClosingDelim`, so escape sequences and + * apostrophes inside comments cannot be mistaken for delimiters. A spread + * element is skipped whole — see the branch below for why that keeps the + * read complete. + * + * Returns the raw inner contents — no unescaping, no trimming — because HMR + * delivers these verbatim. + */ +export function readStringLiterals(code: string, range: [number, number]): StringLiteralsRead { + const [start, end] = range + if (code[start] !== '[') { + // Bare literal — the range already delimits it. + const raw = code.slice(start + 1, end) + if (code[start] === '`' && hasInterpolation(raw)) return { literals: [], complete: false } + const decoded = decodeEscapes(raw) + return decoded === null + ? { literals: [], complete: false } + : { literals: [decoded], complete: true } + } + + const literals: string[] = [] + let complete = true + let i = start + 1 + while (i < end) { + const ch = code[i] + if (WS_RE.test(ch) || ch === ',') { + i++ + continue + } + const afterComment = skipComment(code, i, end) + if (afterComment !== -1) { + i = afterComment + continue + } + if (ch === "'" || ch === '"' || ch === '`') { + const close = findClosingDelim(code, i) + // Unterminated literal: nothing further can be read reliably. + if (close === -1 || close >= end) { + complete = false + break + } + const raw = code.slice(i + 1, close) + const decoded = ch === '`' && hasInterpolation(raw) ? null : decodeEscapes(raw) + if (decoded === null) { + complete = false + } else { + literals.push(decoded) + } + i = close + 1 + continue + } + if (ch === '.' && code[i + 1] === '.' && code[i + 2] === '.') { + // A spread element (`...X`). The Rust extractor asks each element for + // `as_expression()`, and `ArrayExpressionElement::is_expression()` + // enumerates neither `SpreadElement` nor `Elision` — so a spread is + // dropped BEFORE any value resolution, for every possible program, + // with the const table never consulted. Dropping it here too leaves + // the array complete and matches what the class compiles to; calling + // it unknown would hand the class its siblings' stylesheets instead. + // (An elision needs no branch: the comma skip above already reads + // past a hole.) Same reasoning as the spread in `hasUnreadableKey`. + // + // The whole element is skipped, so a literal nested inside it is + // never mistaken for one of this class's styles. `advanceOneToken` + // does the delimiter tracking, so nesting, strings, template + // interpolations and comments all hold a comma harmlessly. + const spreadStack: Ctx[] = [] + let j = i + 3 + while (j < end) { + if (spreadStack.length === 0 && code[j] === ',') break + j = advanceOneToken(code, j, spreadStack, end) + } + // Ran past the array's closer, or ended inside an unclosed string, + // comment or bracket: where this element stops is unknowable, so + // report unknown rather than guess. `j > i`, so `i` still advances. + if (j > end || spreadStack.length > 0) { + complete = false + break + } + i = j + continue + } + // An identifier, call or nested array: this element IS an expression, + // which the Rust resolver may fold from constants this scan cannot + // read, so the array as a whole is unknown. Every branch above advances + // `i`, so the scan terminates. + complete = false + i++ + } + return { literals, complete } +} + +/** + * The style-related fields of one `@Component(...)`, each classified by + * `FieldValue`: absent, unreadable, or a literal range. + */ +export interface ClassStyleFields { + /** `styleUrls: [...]`, else the singular `styleUrl: '...'`. */ + urls: FieldValue + /** Inline `styles: [...] | '...'`. */ + inline: FieldValue +} + +/** + * Classify the style fields of the `@Component(...)` decorating `className`. + * + * Returns null when no such decorator could be located at all. Otherwise each + * member says whether the class declares that field, and whether its value + * can be read — see `FieldValue`, and note that "declares nothing" and + * "cannot be read" need opposite fallbacks at the call site. + * + * `styleUrls` wins over `styleUrl` when a decorator carries both, unless the + * plural form is absent; Angular itself rejects that combination, so this only + * makes the choice deterministic. One decorator scan serves all three lookups. + * + * A shorthand property (`{ styleUrl }`) is unreadable for the singular form + * only. Measured against the Rust extractor: it folds a same-file string + * constant behind `styleUrl`, so calling that absent would strip real CSS — + * but it drops the array-valued `styleUrls` and `styles` shorthands, so the + * compiled component genuinely has no styles from them and falling back would + * hand the class its siblings' stylesheets. Same reasoning as the spread in + * `hasUnreadableKey`: match what the component actually compiles to. + */ +export function locateStyleFieldsFor(code: string, className: string): ClassStyleFields | null { + const found = locateComponentDecorators(code).find((d) => d.className === className) + if (!found) return null + // A key this scan cannot read could BE a style field, so "absent" would be + // a guess rather than an answer. Only absent is promoted: a field we did + // read says exactly what it says, whatever else the object holds. + const blind = hasUnreadableKey(code, found.argsRange) + const classify = (value: FieldValue): FieldValue => + blind && value.kind === 'absent' ? { kind: 'unreadable' } : value + + const plural = findFieldInArgs(code, found.argsRange, 'styleUrls', STYLES_OPENERS) + return { + urls: classify( + plural.kind === 'absent' + ? findFieldInArgs(code, found.argsRange, 'styleUrl', TEMPLATE_OPENERS, 'unreadable') + : plural, + ), + inline: classify(findFieldInArgs(code, found.argsRange, 'styles', STYLES_OPENERS)), + } +} + +/** + * Whether the decorator's own object literal holds a key this scan cannot + * resolve to a name, which makes "the class declares no styles" unknowable. + * + * Two forms qualify, so reading them as absent would strip a component's CSS: + * - a computed key (`[K]: [...]`), which the Rust extractor DOES resolve; + * - a key whose escapes cannot be decoded — a malformed `\u`, or an escape + * that is illegal in an identifier. A key whose escapes DO decode is not + * unreadable: `matchFieldKeyAt` resolves it to a name exactly. + * + * A spread (`...BASE`) deliberately does NOT qualify. The Rust extractor + * drops it too, so the compiled component genuinely has no styles from it; + * treating the class as unknown would hand it its siblings' stylesheets + * instead, which is the contamination this classification exists to avoid. + */ +function hasUnreadableKey(code: string, argsRange: [number, number]): boolean { + const [openParen, closeParen] = argsRange + const stack: Ctx[] = ['paren'] + let i = openParen + 1 + // Position within the decorator's own object literal. Keys sit at the + // start and after each comma; `:` hands over to the value. + let atKey = true + + while (i < closeParen) { + if (stack.length === 2 && stack[1] === 'brace') { + const ch = code[i] + if (WS_RE.test(ch)) { + i++ + continue + } + const afterComment = skipComment(code, i, closeParen) + if (afterComment !== -1) { + i = afterComment + continue + } + if (ch === ',') { + atKey = true + i++ + continue + } + if (ch === ':') { + atKey = false + i++ + continue + } + if (atKey) { + // `[` here opens a computed key, not an array value: a value only + // follows a `:`, which would have cleared `atKey`. + if (ch === '[') return true + if (ch === "'" || ch === '"') { + const close = findClosingDelim(code, i) + if (close === -1 || close >= closeParen) return true + if (decodeEscapes(code.slice(i + 1, close)) === null) return true + atKey = false + i = close + 1 + continue + } + const id = readIdentifierKey(code, i, closeParen) + if (id !== null) { + if (id.name === null) return true + i = id.end + continue + } + } + } + i = advanceOneToken(code, i, stack, closeParen) + } + return false +}