diff --git a/pkg/sass-parser/CHANGELOG.md b/pkg/sass-parser/CHANGELOG.md index 01368d49d..3ed96e51e 100644 --- a/pkg/sass-parser/CHANGELOG.md +++ b/pkg/sass-parser/CHANGELOG.md @@ -8,7 +8,7 @@ ## 0.4.10 -* No user-visible changes. +* Add support for parsing the `@import` rule. ## 0.4.9 diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts index 4f7cf2ff1..29b39a2a1 100644 --- a/pkg/sass-parser/lib/index.ts +++ b/pkg/sass-parser/lib/index.ts @@ -35,6 +35,12 @@ export { ConfiguredVariableRaws, } from './src/configured-variable'; export {Container} from './src/container'; +export { + DynamicImport, + DynamicImportObjectProps, + DynamicImportProps, + DynamicImportRaws, +} from './src/dynamic-import'; export {AnyNode, Node, NodeProps, NodeType} from './src/node'; export {RawWithValue} from './src/raw-with-value'; export { @@ -64,6 +70,18 @@ export { NumberExpressionProps, NumberExpressionRaws, } from './src/expression/number'; +export { + ImportList, + ImportListObjectProps, + ImportListProps, + ImportListRaws, + NewImport, +} from './src/import-list'; +export { + ImportRule, + ImportRuleProps, + ImportRuleRaws, +} from './src/statement/import-rule'; export { IncludeRule, IncludeRuleProps, @@ -172,6 +190,11 @@ export { WhileRuleProps, WhileRuleRaws, } from './src/statement/while-rule'; +export { + StaticImport, + StaticImportProps, + StaticImportRaws, +} from './src/static-import'; /** Options that can be passed to the Sass parsers to control their behavior. */ export type SassParserOptions = Pick; diff --git a/pkg/sass-parser/lib/src/__snapshots__/argument.test.ts.snap b/pkg/sass-parser/lib/src/__snapshots__/argument.test.ts.snap index 85af46cc3..7572bff1c 100644 --- a/pkg/sass-parser/lib/src/__snapshots__/argument.test.ts.snap +++ b/pkg/sass-parser/lib/src/__snapshots__/argument.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`a argument toJSON with a name 1`] = ` +exports[`an argument toJSON with a name 1`] = ` { "inputs": [ { @@ -17,7 +17,7 @@ exports[`a argument toJSON with a name 1`] = ` } `; -exports[`a argument toJSON with no name 1`] = ` +exports[`an argument toJSON with no name 1`] = ` { "inputs": [ { @@ -33,7 +33,7 @@ exports[`a argument toJSON with no name 1`] = ` } `; -exports[`a argument toJSON with rest 1`] = ` +exports[`an argument toJSON with rest 1`] = ` { "inputs": [ { diff --git a/pkg/sass-parser/lib/src/__snapshots__/dynamic-import.test.ts.snap b/pkg/sass-parser/lib/src/__snapshots__/dynamic-import.test.ts.snap new file mode 100644 index 000000000..7ee82d952 --- /dev/null +++ b/pkg/sass-parser/lib/src/__snapshots__/dynamic-import.test.ts.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a dynamic import toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@import "foo"", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "sassType": "dynamic-import", + "source": <1:9-1:14 in 0>, + "url": "foo", +} +`; diff --git a/pkg/sass-parser/lib/src/__snapshots__/import-list.test.ts.snap b/pkg/sass-parser/lib/src/__snapshots__/import-list.test.ts.snap new file mode 100644 index 000000000..016e78095 --- /dev/null +++ b/pkg/sass-parser/lib/src/__snapshots__/import-list.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`an import list toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@import "foo", "bar.css"", + "hasBOM": false, + "id": "", + }, + ], + "nodes": [ + <"foo">, + <"bar.css">, + ], + "raws": {}, + "sassType": "import-list", + "source": <1:1-1:25 in 0>, +} +`; diff --git a/pkg/sass-parser/lib/src/__snapshots__/static-import.test.ts.snap b/pkg/sass-parser/lib/src/__snapshots__/static-import.test.ts.snap new file mode 100644 index 000000000..d5a5b833b --- /dev/null +++ b/pkg/sass-parser/lib/src/__snapshots__/static-import.test.ts.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a static import toJSON with modifiers 1`] = ` +{ + "inputs": [ + { + "css": "@import "foo.css" screen", + "hasBOM": false, + "id": "", + }, + ], + "modifiers": , + "raws": {}, + "sassType": "static-import", + "source": <1:9-1:25 in 0>, + "staticUrl": <"foo.css">, +} +`; + +exports[`a static import toJSON without modifiers 1`] = ` +{ + "inputs": [ + { + "css": "@import "foo.css"", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "sassType": "static-import", + "source": <1:9-1:18 in 0>, + "staticUrl": <"foo.css">, +} +`; diff --git a/pkg/sass-parser/lib/src/argument-list.ts b/pkg/sass-parser/lib/src/argument-list.ts index b252192ed..355944e25 100644 --- a/pkg/sass-parser/lib/src/argument-list.ts +++ b/pkg/sass-parser/lib/src/argument-list.ts @@ -119,7 +119,7 @@ export class ArgumentList this.append({value: convertExpression(inner.keywordRest), rest: true}); } } - if (this._nodes === undefined) this._nodes = []; + this._nodes ??= []; } clone(overrides?: Partial): this { diff --git a/pkg/sass-parser/lib/src/argument.test.ts b/pkg/sass-parser/lib/src/argument.test.ts index b800f934a..189d0f86c 100644 --- a/pkg/sass-parser/lib/src/argument.test.ts +++ b/pkg/sass-parser/lib/src/argument.test.ts @@ -11,7 +11,7 @@ import { scss, } from '..'; -describe('a argument', () => { +describe('an argument', () => { let node: Argument; beforeEach( () => diff --git a/pkg/sass-parser/lib/src/argument.ts b/pkg/sass-parser/lib/src/argument.ts index fa25a5659..9d44422a3 100644 --- a/pkg/sass-parser/lib/src/argument.ts +++ b/pkg/sass-parser/lib/src/argument.ts @@ -47,7 +47,7 @@ export interface ArgumentRaws { /** * The space symbols between the end of the argument value and the comma - * afterwards. Always empty for a argument that doesn't have a trailing comma. + * afterwards. Always empty for an argument that doesn't have a trailing comma. */ after?: string; } diff --git a/pkg/sass-parser/lib/src/dynamic-import.test.ts b/pkg/sass-parser/lib/src/dynamic-import.test.ts new file mode 100644 index 000000000..4eef1e70d --- /dev/null +++ b/pkg/sass-parser/lib/src/dynamic-import.test.ts @@ -0,0 +1,155 @@ +// Copyright 2025 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import {DynamicImport, ImportList, ImportRule, sass, scss} from '..'; + +describe('a dynamic import', () => { + let node: DynamicImport; + + function describeNode( + description: string, + create: () => DynamicImport, + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('dynamic-import')); + + it('has a url', () => expect(node.url).toBe('foo')); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@import "foo"').nodes[0] as ImportRule).imports + .nodes[0] as DynamicImport, + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@import "foo"').nodes[0] as ImportRule).imports + .nodes[0] as DynamicImport, + ); + + describe('constructed manually', () => { + describeNode('with a string', () => new DynamicImport('foo')); + + describeNode('with an object', () => new DynamicImport({url: 'foo'})); + }); + + describe('constructed from properties', () => { + describeNode( + 'with a string', + () => new ImportList({nodes: ['foo']}).nodes[0] as DynamicImport, + ); + + describeNode( + 'with an object', + () => new ImportList({nodes: [{url: 'foo'}]}).nodes[0] as DynamicImport, + ); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with default raws', () => { + it('with a simple URL', () => + expect(new DynamicImport('foo').toString()).toBe('"foo"')); + + it('with a URL that needs escaping', () => + expect(new DynamicImport('\\').toString()).toBe('"\\\\"')); + }); + + // raws.before is only used as part of a ImportList + it('ignores before', () => + expect( + new DynamicImport({ + url: 'foo', + raws: {before: '/**/'}, + }).toString(), + ).toBe('"foo"')); + + // raws.after is only used as part of a ImportList + it('ignores after', () => + expect( + new DynamicImport({ + url: 'foo', + raws: {after: '/**/'}, + }).toString(), + ).toBe('"foo"')); + + it('with matching url', () => + expect( + new DynamicImport({ + url: 'foo', + raws: {url: {raw: '"f\\6fo"', value: 'foo'}}, + }).toString(), + ).toBe('"f\\6fo"')); + + it('with non-matching url', () => + expect( + new DynamicImport({ + url: 'foo', + raws: {url: {raw: '"f\\41o"', value: 'fao'}}, + }).toString(), + ).toBe('"foo"')); + }); + }); + + describe('clone()', () => { + let original: DynamicImport; + beforeEach(() => { + original = (scss.parse('@import "foo"').nodes[0] as ImportRule).imports + .nodes[0] as DynamicImport; + // TODO: remove this once raws are properly parsed. + original.raws.before = '/**/'; + }); + + describe('with no overrides', () => { + let clone: DynamicImport; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('url', () => expect(clone.url).toBe('foo')); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {after: ' '}}).raws).toEqual({ + after: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + before: '/**/', + })); + }); + + describe('url', () => { + it('defined', () => + expect(original.clone({url: 'bar'}).url).toBe('bar')); + + it('undefined', () => + expect(original.clone({url: undefined}).url).toBe('foo')); + }); + }); + }); + + it('toJSON', () => + expect( + (scss.parse('@import "foo"').nodes[0] as ImportRule).imports.nodes[0], + ).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/dynamic-import.ts b/pkg/sass-parser/lib/src/dynamic-import.ts new file mode 100644 index 000000000..6a99aa8d6 --- /dev/null +++ b/pkg/sass-parser/lib/src/dynamic-import.ts @@ -0,0 +1,109 @@ +// Copyright 2025 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; + +import {StringExpression} from './expression/string'; +import {ImportList} from './import-list'; +import {LazySource} from './lazy-source'; +import {Node, NodeProps} from './node'; +import * as sassInternal from './sass-internal'; +import {RawWithValue} from './raw-with-value'; +import {Statement} from './statement'; +import * as utils from './utils'; + +/** + * The set of raws supported by {@link DynamicImport}. + * + * @category Statement + */ +export interface DynamicImportRaws { + /** + * The whitespace before {@link DynamicImport.url}. + */ + before?: string; + + /** The text of the string used to write {@link DynamicImport.url}. */ + url?: RawWithValue; + + /** + * The space symbols between the end of {@link DynamicImport.url} and the + * comma afterwards. Always empty for a URL that doesn't have a trailing + * comma. + */ + after?: string; +} + +/** + * The properties for {@link DynamicImport} that are passed as an object. + * + * @category Statement + */ +export type DynamicImportObjectProps = NodeProps & { + raws?: DynamicImportRaws; + url: string; +}; + +/** + * The initializer properties for {@link DynamicImport}. + * + * @category Statement + */ +export type DynamicImportProps = string | DynamicImportObjectProps; + +/** + * A single URL passed to an `@import` rule that's treated as a dynamic Sass + * load rather than a plain-CSS `@import` rule. This is always included in an + * {@link ImportRule}. + * + * @category Statement + */ +export class DynamicImport extends Node { + readonly sassType = 'dynamic-import' as const; + declare raws: DynamicImportRaws; + declare parent: ImportList | undefined; + + /** The URL of the stylesheet to load. */ + declare url: string; + + constructor(defaults: DynamicImportProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.DynamicImport); + constructor( + defaults?: DynamicImportProps, + inner?: sassInternal.DynamicImport, + ) { + if (typeof defaults === 'string') defaults = {url: defaults}; + super(defaults); + this.raws ??= {}; + + if (inner) { + this.source = new LazySource(inner); + this.url = inner.urlString; + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['raws', 'url']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['url'], inputs); + } + + /** @hidden */ + toString(): string { + return this.raws.url?.value === this.url + ? this.raws.url.raw + : new StringExpression({text: this.url, quotes: true}).toString(); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray> { + return []; + } +} diff --git a/pkg/sass-parser/lib/src/import-list.test.ts b/pkg/sass-parser/lib/src/import-list.test.ts new file mode 100644 index 000000000..9dafaf7b8 --- /dev/null +++ b/pkg/sass-parser/lib/src/import-list.test.ts @@ -0,0 +1,600 @@ +// Copyright 2025 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import {DynamicImport, ImportList, ImportRule, sass, scss} from '..'; + +type EachFn = Parameters[0]; + +let node: ImportList; +describe('an import list', () => { + function describeNode(description: string, create: () => ImportList): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => expect(node.sassType).toBe('import-list')); + + it('has a node', () => { + expect(node.nodes.length).toBe(1); + expect(node.nodes[0]).toHaveProperty('url', 'foo'); + expect(node.nodes[0].parent).toBe(node); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => (scss.parse('@import "foo"').nodes[0] as ImportRule).imports, + ); + + describeNode( + 'parsed as Sass', + () => (sass.parse('@import "foo"').nodes[0] as ImportRule).imports, + ); + + describe('constructed manually', () => { + describeNode('with a string', () => new ImportList('foo')); + + describe('with an array', () => { + describeNode( + 'with an Import', + () => new ImportList([new DynamicImport('foo')]), + ); + + describeNode( + 'with DynamicImportProps', + () => new ImportList([{url: 'foo'}]), + ); + + describeNode('with a string', () => new ImportList(['foo'])); + }); + + describe('with an object', () => { + describeNode( + 'with an Import', + () => new ImportList({nodes: [new DynamicImport('foo')]}), + ); + + describeNode( + 'with DynamicImportProps', + () => new ImportList({nodes: [{url: 'foo'}]}), + ); + + describeNode('with a string', () => new ImportList({nodes: ['foo']})); + }); + }); + + describe('constructed from properties', () => { + describeNode( + 'an object', + () => + new ImportRule({ + imports: {nodes: [{url: 'foo'}]}, + }).imports, + ); + + describeNode( + 'an array', + () => new ImportRule({imports: [{url: 'foo'}]}).imports, + ); + + describeNode('a string', () => new ImportRule({imports: 'foo'}).imports); + }); + + describe('can add', () => { + beforeEach(() => void (node = new ImportList())); + + it('a single import', () => { + const imp = new DynamicImport('foo'); + node.append(imp); + expect(node.nodes).toEqual([imp]); + expect(imp).toHaveProperty('parent', node); + }); + + it('a list of imports', () => { + const foo = new DynamicImport('foo'); + const bar = new DynamicImport('bar'); + node.append([foo, bar]); + expect(node.nodes).toEqual([foo, bar]); + }); + + it('import properties', () => { + node.append({url: 'foo'}); + expect(node.nodes[0]).toBeInstanceOf(DynamicImport); + expect(node.nodes[0]).toHaveProperty('url', 'foo'); + expect(node.nodes[0]).toHaveProperty('parent', node); + }); + + it('an array of import properties', () => { + node.append([{url: 'foo'}]); + expect(node.nodes[0]).toBeInstanceOf(DynamicImport); + expect(node.nodes[0]).toHaveProperty('url', 'foo'); + expect(node.nodes[0]).toHaveProperty('parent', node); + }); + + it('undefined', () => { + node.append(undefined); + expect(node.nodes).toHaveLength(0); + }); + }); + + describe('append', () => { + beforeEach( + () => void (node = new ImportList([{url: 'foo'}, {url: 'bar'}])), + ); + + it('adds multiple children to the end', () => { + node.append({url: 'baz'}, {url: 'qux'}); + expect(node.nodes[0]).toHaveProperty('url', 'foo'); + expect(node.nodes[1]).toHaveProperty('url', 'bar'); + expect(node.nodes[2]).toHaveProperty('url', 'baz'); + expect(node.nodes[3]).toHaveProperty('url', 'qux'); + }); + + it('can be called during iteration', () => + testEachMutation(['foo', 'bar', 'baz'], 0, () => + node.append({url: 'baz'}), + )); + + it('returns itself', () => expect(node.append()).toBe(node)); + }); + + describe('each', () => { + beforeEach( + () => void (node = new ImportList([{url: 'foo'}, {url: 'bar'}])), + ); + + it('calls the callback for each node', () => { + const fn: EachFn = jest.fn(); + node.each(fn); + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({url: 'foo'}), + 0, + ); + expect(fn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({url: 'bar'}), + 1, + ); + }); + + it('returns undefined if the callback is void', () => + expect(node.each(() => {})).toBeUndefined()); + + it('returns false and stops iterating if the callback returns false', () => { + const fn: EachFn = jest.fn(() => false); + expect(node.each(fn)).toBe(false); + expect(fn).toHaveBeenCalledTimes(1); + }); + }); + + describe('every', () => { + beforeEach( + () => + void (node = new ImportList([ + {url: 'foo'}, + {url: 'bar'}, + {url: 'baz'}, + ])), + ); + + it('returns true if the callback returns true for all elements', () => + expect(node.every(() => true)).toBe(true)); + + it('returns false if the callback returns false for any element', () => + expect( + node.every(element => 'url' in element && element.url !== 'bar'), + ).toBe(false)); + }); + + describe('index', () => { + beforeEach( + () => + void (node = new ImportList([ + {url: 'foo'}, + {url: 'bar'}, + {url: 'baz'}, + ])), + ); + + it('returns the first index of a given import', () => + expect(node.index(node.nodes[2])).toBe(2)); + + it('returns a number as-is', () => expect(node.index(3)).toBe(3)); + }); + + describe('insertAfter', () => { + beforeEach( + () => + void (node = new ImportList({ + nodes: [{url: 'foo'}, {url: 'bar'}, {url: 'baz'}], + })), + ); + + it('inserts a node after the given element', () => { + node.insertAfter(node.nodes[1], {url: 'qux'}); + expect(node.nodes[0]).toHaveProperty('url', 'foo'); + expect(node.nodes[1]).toHaveProperty('url', 'bar'); + expect(node.nodes[2]).toHaveProperty('url', 'qux'); + expect(node.nodes[3]).toHaveProperty('url', 'baz'); + }); + + it('inserts a node at the beginning', () => { + node.insertAfter(-1, {url: 'qux'}); + expect(node.nodes[0]).toHaveProperty('url', 'qux'); + expect(node.nodes[1]).toHaveProperty('url', 'foo'); + expect(node.nodes[2]).toHaveProperty('url', 'bar'); + expect(node.nodes[3]).toHaveProperty('url', 'baz'); + }); + + it('inserts a node at the end', () => { + node.insertAfter(3, {url: 'qux'}); + expect(node.nodes[0]).toHaveProperty('url', 'foo'); + expect(node.nodes[1]).toHaveProperty('url', 'bar'); + expect(node.nodes[2]).toHaveProperty('url', 'baz'); + expect(node.nodes[3]).toHaveProperty('url', 'qux'); + }); + + it('inserts multiple nodes', () => { + node.insertAfter(1, [{url: 'qux'}, {url: 'qax'}, {url: 'qix'}]); + expect(node.nodes[0]).toHaveProperty('url', 'foo'); + expect(node.nodes[1]).toHaveProperty('url', 'bar'); + expect(node.nodes[2]).toHaveProperty('url', 'qux'); + expect(node.nodes[3]).toHaveProperty('url', 'qax'); + expect(node.nodes[4]).toHaveProperty('url', 'qix'); + expect(node.nodes[5]).toHaveProperty('url', 'baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['foo', 'bar', ['baz', 5]], 1, () => + node.insertAfter(0, [{url: 'qux'}, {url: 'qax'}, {url: 'qix'}]), + )); + + it('inserts after an iterator', () => + testEachMutation(['foo', 'bar', 'qux', 'qax', 'qix', 'baz'], 1, () => + node.insertAfter(1, [{url: 'qux'}, {url: 'qax'}, {url: 'qix'}]), + )); + + it('returns itself', () => + expect(node.insertAfter(0, {url: 'qux'})).toBe(node)); + }); + + describe('insertBefore', () => { + beforeEach( + () => + void (node = new ImportList([ + {url: 'foo'}, + {url: 'bar'}, + {url: 'baz'}, + ])), + ); + + it('inserts a node before the given element', () => { + node.insertBefore(node.nodes[1], {url: 'qux'}); + expect(node.nodes[0]).toHaveProperty('url', 'foo'); + expect(node.nodes[1]).toHaveProperty('url', 'qux'); + expect(node.nodes[2]).toHaveProperty('url', 'bar'); + expect(node.nodes[3]).toHaveProperty('url', 'baz'); + }); + + it('inserts a node at the beginning', () => { + node.insertBefore(0, {url: 'qux'}); + expect(node.nodes[0]).toHaveProperty('url', 'qux'); + expect(node.nodes[1]).toHaveProperty('url', 'foo'); + expect(node.nodes[2]).toHaveProperty('url', 'bar'); + expect(node.nodes[3]).toHaveProperty('url', 'baz'); + }); + + it('inserts a node at the end', () => { + node.insertBefore(4, {url: 'qux'}); + expect(node.nodes[0]).toHaveProperty('url', 'foo'); + expect(node.nodes[1]).toHaveProperty('url', 'bar'); + expect(node.nodes[2]).toHaveProperty('url', 'baz'); + expect(node.nodes[3]).toHaveProperty('url', 'qux'); + }); + + it('inserts multiple nodes', () => { + node.insertBefore(1, [{url: 'qux'}, {url: 'qax'}, {url: 'qix'}]); + expect(node.nodes[0]).toHaveProperty('url', 'foo'); + expect(node.nodes[1]).toHaveProperty('url', 'qux'); + expect(node.nodes[2]).toHaveProperty('url', 'qax'); + expect(node.nodes[3]).toHaveProperty('url', 'qix'); + expect(node.nodes[4]).toHaveProperty('url', 'bar'); + expect(node.nodes[5]).toHaveProperty('url', 'baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['foo', 'bar', ['baz', 5]], 1, () => + node.insertBefore(1, [{url: 'qux'}, {url: 'qax'}, {url: 'qix'}]), + )); + + it('inserts after an iterator', () => + testEachMutation(['foo', 'bar', 'qux', 'qax', 'qix', 'baz'], 1, () => + node.insertBefore(2, [{url: 'qux'}, {url: 'qax'}, {url: 'qix'}]), + )); + + it('returns itself', () => + expect(node.insertBefore(0, {url: 'qux'})).toBe(node)); + }); + + describe('prepend', () => { + beforeEach( + () => + void (node = new ImportList([ + {url: 'foo'}, + {url: 'bar'}, + {url: 'baz'}, + ])), + ); + + it('inserts one node', () => { + node.prepend({url: 'qux'}); + expect(node.nodes[0]).toHaveProperty('url', 'qux'); + expect(node.nodes[1]).toHaveProperty('url', 'foo'); + expect(node.nodes[2]).toHaveProperty('url', 'bar'); + expect(node.nodes[3]).toHaveProperty('url', 'baz'); + }); + + it('inserts multiple nodes', () => { + node.prepend({url: 'qux'}, {url: 'qax'}, {url: 'qix'}); + expect(node.nodes[0]).toHaveProperty('url', 'qux'); + expect(node.nodes[1]).toHaveProperty('url', 'qax'); + expect(node.nodes[2]).toHaveProperty('url', 'qix'); + expect(node.nodes[3]).toHaveProperty('url', 'foo'); + expect(node.nodes[4]).toHaveProperty('url', 'bar'); + expect(node.nodes[5]).toHaveProperty('url', 'baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['foo', 'bar', ['baz', 5]], 1, () => + node.prepend({url: 'qux'}, {url: 'qax'}, {url: 'qix'}), + )); + + it('returns itself', () => expect(node.prepend({url: 'qux'})).toBe(node)); + }); + + describe('push', () => { + beforeEach( + () => void (node = new ImportList([{url: 'foo'}, {url: 'bar'}])), + ); + + it('inserts one node', () => { + node.push(new DynamicImport({url: 'baz'})); + expect(node.nodes[0]).toHaveProperty('url', 'foo'); + expect(node.nodes[1]).toHaveProperty('url', 'bar'); + expect(node.nodes[2]).toHaveProperty('url', 'baz'); + }); + + it('can be called during iteration', () => + testEachMutation(['foo', 'bar', 'baz'], 0, () => + node.push(new DynamicImport({url: 'baz'})), + )); + + it('returns itself', () => + expect(node.push(new DynamicImport({url: 'baz'}))).toBe(node)); + }); + + describe('removeAll', () => { + beforeEach( + () => + void (node = new ImportList([ + {url: 'foo'}, + {url: 'bar'}, + {url: 'baz'}, + ])), + ); + + it('removes all nodes', () => { + node.removeAll(); + expect(node.nodes).toHaveLength(0); + }); + + it("removes a node's parents", () => { + const child = node.nodes[1]; + node.removeAll(); + expect(child).toHaveProperty('parent', undefined); + }); + + it('can be called during iteration', () => + testEachMutation(['foo'], 0, () => node.removeAll())); + + it('returns itself', () => expect(node.removeAll()).toBe(node)); + }); + + describe('removeChild', () => { + beforeEach( + () => + void (node = new ImportList([ + {url: 'foo'}, + {url: 'bar'}, + {url: 'baz'}, + ])), + ); + + it('removes a matching node', () => { + node.removeChild(node.nodes[0]); + expect(node.nodes[0]).toHaveProperty('url', 'bar'); + expect(node.nodes[1]).toHaveProperty('url', 'baz'); + }); + + it('removes a node at index', () => { + node.removeChild(1); + expect(node.nodes[0]).toHaveProperty('url', 'foo'); + expect(node.nodes[1]).toHaveProperty('url', 'baz'); + }); + + it("removes a node's parents", () => { + const child = node.nodes[1]; + node.removeChild(1); + expect(child).toHaveProperty('parent', undefined); + }); + + it('removes a node before the iterator', () => + testEachMutation(['foo', 'bar', ['baz', 1]], 1, () => + node.removeChild(1), + )); + + it('removes a node after the iterator', () => + testEachMutation(['foo', 'bar'], 1, () => node.removeChild(2))); + + it('returns itself', () => expect(node.removeChild(0)).toBe(node)); + }); + + describe('some', () => { + beforeEach( + () => + void (node = new ImportList([ + {url: 'foo'}, + {url: 'bar'}, + {url: 'baz'}, + ])), + ); + + it('returns false if the callback returns false for all elements', () => + expect(node.some(() => false)).toBe(false)); + + it('returns true if the callback returns true for any element', () => + expect( + node.some(element => 'url' in element && element.url === 'bar'), + ).toBe(true)); + }); + + describe('first', () => { + it('returns the first element', () => + expect( + new ImportList([{url: 'foo'}, {url: 'bar'}, {url: 'baz'}]).first, + ).toHaveProperty('url', 'foo')); + + it('returns undefined for an empty list', () => + expect(new ImportList().first).toBeUndefined()); + }); + + describe('last', () => { + it('returns the last element', () => + expect( + new ImportList({nodes: [{url: 'foo'}, {url: 'bar'}, {url: 'baz'}]}) + .last, + ).toHaveProperty('url', 'baz')); + + it('returns undefined for an empty list', () => + expect(new ImportList().last).toBeUndefined()); + }); + + // TODO: test before and after raws for children + describe('stringifies', () => { + it('with default raws', () => + expect( + new ImportList([{url: 'foo'}, {url: 'bar'}, {url: 'baz'}]).toString(), + ).toBe('"foo", "bar", "baz"')); + + it('with an import with before', () => + expect( + new ImportList([ + {url: 'foo', raws: {before: '/**/'}}, + {url: 'bar'}, + {url: 'baz'}, + ]).toString(), + ).toBe('/**/"foo", "bar", "baz"')); + + it('with an import with after', () => + expect( + new ImportList([ + {url: 'foo', raws: {after: '/**/'}}, + {url: 'bar'}, + {url: 'baz'}, + ]).toString(), + ).toBe('"foo"/**/, "bar", "baz"')); + }); + + describe('clone', () => { + let original: ImportList; + beforeEach( + () => + void (original = new ImportList({ + nodes: [{url: 'foo'}, {url: 'bar'}], + })), + ); + + describe('with no overrides', () => { + let clone: ImportList; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('nodes', () => { + expect(clone.nodes[0]).toHaveProperty('url', 'foo'); + expect(clone.nodes[0].parent).toBe(clone); + expect(clone.nodes[1]).toHaveProperty('url', 'bar'); + expect(clone.nodes[1].parent).toBe(clone); + }); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['nodes'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + + describe('sets parent for', () => { + it('nodes', () => + expect(clone.nodes[0]).toHaveProperty('parent', clone)); + }); + }); + + describe('overrides', () => { + describe('nodes', () => { + it('defined', () => { + const clone = original.clone({nodes: [{url: 'qux'}]}); + expect(clone.nodes[0]).toHaveProperty('url', 'qux'); + }); + + it('undefined', () => { + const clone = original.clone({nodes: undefined}); + expect(clone.nodes).toHaveLength(2); + expect(clone.nodes[0]).toHaveProperty('url', 'foo'); + expect(clone.nodes[1]).toHaveProperty('url', 'bar'); + }); + }); + }); + }); + + it('toJSON', () => + expect( + (scss.parse('@import "foo", "bar.css"').nodes[0] as ImportRule).imports, + ).toMatchSnapshot()); +}); + +/** + * Runs `node.each`, asserting that it sees an import with each string value + * and index in {@link elements} in order. If an index isn't explicitly + * provided, it defaults to the index in {@link elements}. + * + * When it reaches {@link indexToModify}, it calls {@link modify}, which is + * expected to modify `node.nodes`. + */ +function testEachMutation( + elements: ([string, number] | string)[], + indexToModify: number, + modify: () => void, +): void { + const fn: EachFn = jest.fn((child, i) => { + if (i === indexToModify) modify(); + }); + node.each(fn); + + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + const [url, index] = Array.isArray(element) ? element : [element, i]; + expect(fn).toHaveBeenNthCalledWith( + i + 1, + expect.objectContaining({url}), + index, + ); + } + expect(fn).toHaveBeenCalledTimes(elements.length); +} diff --git a/pkg/sass-parser/lib/src/import-list.ts b/pkg/sass-parser/lib/src/import-list.ts new file mode 100644 index 000000000..ad579a123 --- /dev/null +++ b/pkg/sass-parser/lib/src/import-list.ts @@ -0,0 +1,317 @@ +// Copyright 2025 Google Inc. Import of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; + +import {Container} from './container'; +import {DynamicImport, DynamicImportProps} from './dynamic-import'; +import {LazySource} from './lazy-source'; +import {Node, NodeProps} from './node'; +import {ImportRule} from './statement/import-rule'; +import {StaticImport, StaticImportProps} from './static-import'; +import * as sassInternal from './sass-internal'; +import * as utils from './utils'; + +/** + * The type of new imports that can be passed into an {@link ImportList}. + * + * @category Statement + */ +export type NewImport = + | StaticImport + | DynamicImport + | StaticImportProps + | DynamicImportProps + | ReadonlyArray< + | StaticImport + | DynamicImport + | StaticImportProps + | DynamicImportProps + | undefined + > + | undefined; + +/** + * The set of raws supported by {@link ImportList}. + * + * @category Statement + */ +export type ImportListRaws = {}; + +/** + * The initializer properties for {@link ImportList} passed as an options + * object. + * + * @category Statement + */ +export interface ImportListObjectProps extends NodeProps { + raws?: ImportListRaws; + nodes: Array; +} + +/** + * The initializer properties for {@link ImportList}. + * + * @category Statement + */ +export type ImportListProps = + | string + | Array + | ImportListObjectProps; + +/** + * A `@import` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class ImportList + extends Node + implements Container +{ + readonly sassType = 'import-list' as const; + declare parent: ImportRule | undefined; + declare raws: ImportListRaws; + + /** The imports loaded by this rule. */ + get nodes(): ReadonlyArray { + return this._nodes!; + } + /** @hidden */ + set nodes(nodes: Array) { + // This *should* only ever be called by the superclass constructor. + this._nodes = nodes; + } + private declare _nodes?: Array; + + get name(): string { + return 'import'; + } + set name(value: string) { + throw new Error("ImportList.name can't be overwritten."); + } + + /** + * Iterators that are currently active within this rule's {@link nodes}. + * Their indices refer to the last position that has already been sent to the + * callback, and are updated when {@link _imports} is modified. + */ + readonly #iterators: Array<{index: number}> = []; + + constructor(defaults?: ImportListProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ImportRule); + constructor(defaults?: ImportListProps, inner?: sassInternal.ImportRule) { + if (typeof defaults === 'string') { + super({nodes: [defaults]}); + } else if (Array.isArray(defaults)) { + super({nodes: defaults}); + } else { + super(defaults); + } + this.raws ??= {}; + this._nodes ??= []; + + if (inner) { + this.source = new LazySource(inner); + for (const imp of inner.imports) { + this.append( + 'urlString' in imp + ? new DynamicImport(undefined, imp) + : new StaticImport(undefined, imp), + ); + } + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['raws', 'nodes']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['nodes'], inputs); + } + + append(...nodes: NewImport[]): this { + // TODO - postcss/postcss#1957: Mark this as dirty + this._nodes!.push(...this._normalizeList(nodes)); + return this; + } + + each( + callback: ( + node: DynamicImport | StaticImport, + index: number, + ) => false | void, + ): false | undefined { + const iterator = {index: 0}; + this.#iterators.push(iterator); + + try { + while (iterator.index < this.nodes.length) { + const result = callback(this.nodes[iterator.index], iterator.index); + if (result === false) return false; + iterator.index += 1; + } + return undefined; + } finally { + this.#iterators.splice(this.#iterators.indexOf(iterator), 1); + } + } + + every( + condition: ( + node: DynamicImport | StaticImport, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean { + return this.nodes.every(condition); + } + + index(child: DynamicImport | StaticImport | number): number { + return typeof child === 'number' ? child : this.nodes.indexOf(child); + } + + insertAfter( + oldNode: DynamicImport | StaticImport | number, + newNode: NewImport, + ): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const index = this.index(oldNode); + const normalized = this._normalize(newNode); + this._nodes!.splice(index + 1, 0, ...normalized); + + for (const iterator of this.#iterators) { + if (iterator.index > index) iterator.index += normalized.length; + } + + return this; + } + + insertBefore( + oldNode: DynamicImport | StaticImport | number, + newNode: NewImport, + ): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const index = this.index(oldNode); + const normalized = this._normalize(newNode); + this._nodes!.splice(index, 0, ...normalized); + + for (const iterator of this.#iterators) { + if (iterator.index >= index) iterator.index += normalized.length; + } + + return this; + } + + prepend(...nodes: NewImport[]): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const normalized = this._normalizeList(nodes); + this._nodes!.unshift(...normalized); + + for (const iterator of this.#iterators) { + iterator.index += normalized.length; + } + + return this; + } + + push(child: DynamicImport | StaticImport): this { + return this.append(child); + } + + removeAll(): this { + // TODO - postcss/postcss#1957: Mark this as dirty + for (const node of this.nodes) { + if (typeof node !== 'string') node.parent = undefined; + } + this._nodes!.length = 0; + return this; + } + + removeChild(child: DynamicImport | StaticImport | number): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const index = this.index(child); + child = this._nodes![index]; + if (typeof child === 'object') child.parent = undefined; + this._nodes!.splice(index, 1); + + for (const iterator of this.#iterators) { + if (iterator.index >= index) iterator.index--; + } + + return this; + } + + some( + condition: ( + node: DynamicImport | StaticImport, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean { + return this.nodes.some(condition); + } + + get first(): DynamicImport | StaticImport | undefined { + return this.nodes[0]; + } + + get last(): DynamicImport | StaticImport | undefined { + return this.nodes[this.nodes.length - 1]; + } + + /** + * Normalizes the many types of node that can be used with Interpolation + * methods. + */ + private _normalize(nodes: NewImport): Array { + const result: Array = []; + for (const node of Array.isArray(nodes) ? nodes : [nodes]) { + if (node === undefined) { + continue; + } else if (typeof node === 'object' && 'sassType' in node) { + node.parent = this; + result.push(node); + } else { + const constructed = + typeof node === 'string' || 'url' in node + ? new DynamicImport(node) + : new StaticImport(node); + constructed.parent = this; + result.push(constructed); + } + } + return result; + } + + /** Like {@link _normalize}, but also flattens a list of nodes. */ + private _normalizeList( + nodes: ReadonlyArray, + ): Array { + return nodes.flatMap(node => this._normalize(node)); + } + + /** @hidden */ + toString(): string { + return this.nodes + .map( + (imp, i) => + (imp.raws?.before ?? (i === 0 ? '' : ' ')) + + imp + + (imp.raws?.after ?? ''), + ) + .join(','); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return this.nodes; + } +} diff --git a/pkg/sass-parser/lib/src/node.d.ts b/pkg/sass-parser/lib/src/node.d.ts index 2c28227c2..f63de46e8 100644 --- a/pkg/sass-parser/lib/src/node.d.ts +++ b/pkg/sass-parser/lib/src/node.d.ts @@ -25,9 +25,12 @@ export type NodeType = | 'argument-list' | 'configuration' | 'configured-variable' + | 'dynamic-import' + | 'import-list' | 'interpolation' | 'parameter' - | 'parameter-list'; + | 'parameter-list' + | 'static-import'; /** The constructor properties shared by all Sass AST nodes. */ export type NodeProps = postcss.NodeProps; diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index e8a1acdfe..12ef5908c 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -180,6 +180,19 @@ declare namespace SassInternal { readonly children: Statement[]; } + class ImportRule extends Statement { + readonly imports: (DynamicImport | StaticImport)[]; + } + + class DynamicImport extends SassNode { + readonly urlString: string; + } + + class StaticImport extends SassNode { + readonly url: Interpolation; + readonly modifiers: Interpolation | null; + } + class IncludeRule extends Statement { readonly namespace: string | null; readonly name: string; @@ -346,6 +359,9 @@ export type FunctionRule = SassInternal.FunctionRule; export type IfRule = SassInternal.IfRule; export type IfClause = SassInternal.IfClause; export type ElseClause = SassInternal.ElseClause; +export type ImportRule = SassInternal.ImportRule; +export type DynamicImport = SassInternal.DynamicImport; +export type StaticImport = SassInternal.StaticImport; export type IncludeRule = SassInternal.IncludeRule; export type LoudComment = SassInternal.LoudComment; export type MediaRule = SassInternal.MediaRule; @@ -381,6 +397,7 @@ export interface StatementVisitorObject { visitForwardRule(node: ForwardRule): T; visitFunctionRule(node: FunctionRule): T; visitIfRule(node: IfRule): T; + visitImportRule(node: ImportRule): T; visitIncludeRule(node: IncludeRule): T; visitLoudComment(node: LoudComment): T; visitMediaRule(node: MediaRule): T; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/import-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/import-rule.test.ts.snap new file mode 100644 index 000000000..07a9595e9 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/import-rule.test.ts.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`an @import rule toJSON 1`] = ` +{ + "imports": <"foo", "bar" screen>, + "inputs": [ + { + "css": "@import "foo", "bar" screen", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "sassType": "import-rule", + "source": <1:1-1:28 in 0>, + "type": "atrule", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/import-rule.test.ts b/pkg/sass-parser/lib/src/statement/import-rule.test.ts new file mode 100644 index 000000000..beb82944d --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/import-rule.test.ts @@ -0,0 +1,167 @@ +// Copyright 2025 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import {ImportRule, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('an @import rule', () => { + let node: ImportRule; + function describeNode(description: string, create: () => ImportRule): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('atrule')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('import-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('import')); + + it('has an import list', () => + expect(node.imports.nodes[0]).toHaveProperty('url', 'foo')); + + it('has matching params', () => expect(node.params).toBe('"foo"')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@import "foo"').nodes[0] as ImportRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@import "foo"').nodes[0] as ImportRule, + ); + + describeNode( + 'constructed manually', + () => + new ImportRule({ + imports: 'foo', + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + imports: 'foo', + }), + ); + + describe('throws an error when assigned a new', () => { + beforeEach(() => void (node = new ImportRule({imports: 'foo'}))); + + it('name', () => expect(() => (node.name = 'bar')).toThrow()); + + it('params', () => expect(() => (node.params = 'bar')).toThrow()); + }); + + it('assigned a new import list', () => { + node = new ImportRule({imports: 'foo'}); + node.imports = 'bar'; + expect(node.imports.nodes[0]).toHaveProperty('url', 'bar'); + expect(node.params).toBe('"bar"'); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect(new ImportRule({imports: 'foo'}).toString()).toBe( + '@import "foo"', + )); + }); + }); + + describe('clone', () => { + let original: ImportRule; + beforeEach(() => { + original = scss.parse('@import "foo", "bar" screen') + .nodes[0] as ImportRule; + // TODO: remove this once raws are properly parsed + original.raws.afterName = ' '; + }); + + describe('with no overrides', () => { + let clone: ImportRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('"foo", "bar" screen')); + + it('imports', () => { + expect(clone.imports.nodes[0]).toHaveProperty('url', 'foo'); + expect(clone.imports.nodes[1]).toHaveInterpolation( + 'staticUrl', + '"bar"', + ); + }); + + it('raws', () => expect(clone.raws).toEqual({afterName: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['imports', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {before: ' '}}).raws).toEqual({ + before: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + afterName: ' ', + })); + }); + + describe('imports', () => { + describe('defined', () => { + let clone: ImportRule; + beforeEach(() => { + clone = original.clone({imports: 'baz'}); + }); + + it('changes imports', () => + expect(clone.imports.nodes[0]).toHaveProperty('url', 'baz')); + + it('changes params', () => expect(clone.params).toBe('"baz"')); + }); + + describe('undefined', () => { + let clone: ImportRule; + beforeEach(() => { + clone = original.clone({imports: undefined}); + }); + + it('preserves imports', () => { + expect(clone.imports.nodes[0]).toHaveProperty('url', 'foo'); + expect(clone.imports.nodes[1]).toHaveInterpolation( + 'staticUrl', + '"bar"', + ); + }); + + it('preserves params', () => + expect(clone.params).toBe('"foo", "bar" screen')); + }); + }); + }); + }); + + it('toJSON', () => + expect( + scss.parse('@import "foo", "bar" screen').nodes[0], + ).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/import-rule.ts b/pkg/sass-parser/lib/src/statement/import-rule.ts new file mode 100644 index 000000000..986304c43 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/import-rule.ts @@ -0,0 +1,124 @@ +// Copyright 2025 Google Inc. Import of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; +import type {AtRuleRaws} from 'postcss/lib/at-rule'; + +import {ImportList, ImportListProps} from '../import-list'; +import {LazySource} from '../lazy-source'; +import {NodeProps} from '../node'; +import * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {Statement, StatementWithChildren} from '.'; +import {_AtRule} from './at-rule-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link ImportRule}. + * + * @category Statement + */ +export type ImportRuleRaws = Omit; + +/** + * The initializer properties for {@link ImportRule}. + * + * @category Statement + */ +export interface ImportRuleProps extends NodeProps { + raws?: ImportRuleRaws; + imports: ImportListProps; +} + +/** + * An `@import` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class ImportRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'import-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: ImportRuleRaws; + declare readonly nodes: undefined; + + /** The imports loaded by this rule. */ + get imports(): ImportList { + return this._imports!; + } + set imports(imports: ImportList | ImportListProps) { + if (this._imports) { + this._imports.parent = undefined; + } + this._imports = + imports instanceof ImportList ? imports : new ImportList(imports); + this._imports.parent = this; + } + private declare _imports: ImportList; + + get name(): string { + return 'import'; + } + set name(value: string) { + throw new Error("ImportRule.name can't be overwritten."); + } + + get params(): string { + return this.imports.toString(); + } + set params(value: string | number | undefined) { + throw new Error("ImportRule.params can't be overwritten."); + } + + /** + * Iterators that are currently active within this rule's {@link imports}. + * Their indices refer to the last position that has already been sent to the + * callback, and are updated when {@link _imports} is modified. + */ + readonly #iterators: Array<{index: number}> = []; + + constructor(defaults: ImportRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ImportRule); + constructor(defaults?: ImportRuleProps, inner?: sassInternal.ImportRule) { + super(defaults as unknown as postcss.AtRuleProps); + this.raws ??= {}; + + if (inner) { + this.source = new LazySource(inner); + this.imports = new ImportList(undefined, inner); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['raws', 'imports']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['imports'], inputs); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.imports]; + } +} + +interceptIsClean(ImportRule); diff --git a/pkg/sass-parser/lib/src/statement/index.ts b/pkg/sass-parser/lib/src/statement/index.ts index 3a42244d9..012c5ed94 100644 --- a/pkg/sass-parser/lib/src/statement/index.ts +++ b/pkg/sass-parser/lib/src/statement/index.ts @@ -21,6 +21,7 @@ import {ForRule, ForRuleProps} from './for-rule'; import {ForwardRule, ForwardRuleProps} from './forward-rule'; import {FunctionRule, FunctionRuleProps} from './function-rule'; import {IfRule, IfRuleProps} from './if-rule'; +import {ImportRule, ImportRuleProps} from './import-rule'; import {IncludeRule, IncludeRuleProps} from './include-rule'; import {MixinRule, MixinRuleProps} from './mixin-rule'; import {ReturnRule, ReturnRuleProps} from './return-rule'; @@ -64,6 +65,7 @@ export type StatementType = | 'forward-rule' | 'function-rule' | 'if-rule' + | 'import-rule' | 'include-rule' | 'mixin-rule' | 'return-rule' @@ -88,6 +90,7 @@ export type AtRule = | FunctionRule | GenericAtRule | IfRule + | ImportRule | IncludeRule | MixinRule | ReturnRule @@ -141,6 +144,7 @@ export type ChildProps = | FunctionRuleProps | GenericAtRuleProps | IfRuleProps + | ImportRuleProps | IncludeRuleProps | MixinRuleProps | ReturnRuleProps @@ -218,6 +222,7 @@ const visitor = sassInternal.createStatementVisitor({ } return rules; }, + visitImportRule: inner => new ImportRule(undefined, inner), visitIncludeRule: inner => new IncludeRule(undefined, inner), visitExtendRule: inner => { const paramsInterpolation = new Interpolation(undefined, inner.selector); @@ -372,6 +377,8 @@ export function normalize( result.push(new ErrorRule(node)); } else if ('ifCondition' in node) { result.push(new IfRule(node)); + } else if ('imports' in node) { + result.push(new ImportRule(node)); } else if ('includeName' in node) { result.push(new IncludeRule(node)); } else if ('fromExpression' in node) { diff --git a/pkg/sass-parser/lib/src/static-import.test.ts b/pkg/sass-parser/lib/src/static-import.test.ts new file mode 100644 index 000000000..92cb74263 --- /dev/null +++ b/pkg/sass-parser/lib/src/static-import.test.ts @@ -0,0 +1,250 @@ +// Copyright 2025 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import {ImportList, ImportRule, StaticImport, sass, scss} from '..'; + +describe('a static import', () => { + let node: StaticImport; + + describe('with no modifiers', () => { + function describeNode( + description: string, + create: () => StaticImport, + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('static-import')); + + it('has a url', () => + expect(node).toHaveInterpolation('staticUrl', '"foo.css"')); + + it('has no modifiers', () => expect(node.modifiers).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@import "foo.css"').nodes[0] as ImportRule).imports + .nodes[0] as StaticImport, + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@import "foo.css"').nodes[0] as ImportRule).imports + .nodes[0] as StaticImport, + ); + + describe('constructed manually', () => { + describeNode('with a string', () => new StaticImport('"foo.css"')); + + describeNode( + 'with an object', + () => new StaticImport({staticUrl: '"foo.css"'}), + ); + }); + + describeNode( + 'constructed from properties', + () => + new ImportList({nodes: [{staticUrl: '"foo.css"'}]}) + .nodes[0] as StaticImport, + ); + }); + + describe('with modifiers', () => { + function describeNode( + description: string, + create: () => StaticImport, + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('static-import')); + + it('has a url', () => + expect(node).toHaveInterpolation('staticUrl', '"foo.css"')); + + it('has modifiers', () => + expect(node).toHaveInterpolation('modifiers', 'screen')); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@import "foo.css" screen').nodes[0] as ImportRule).imports + .nodes[0] as StaticImport, + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@import "foo.css" screen').nodes[0] as ImportRule).imports + .nodes[0] as StaticImport, + ); + + describeNode( + 'constructed manually', + () => new StaticImport({staticUrl: '"foo.css"', modifiers: 'screen'}), + ); + + describeNode( + 'constructed from properties', + () => + new ImportList({nodes: [{staticUrl: '"foo.css"', modifiers: 'screen'}]}) + .nodes[0] as StaticImport, + ); + }); + + // TODO: test `@import url("foo")` when the expression-level syntax is + // representable. + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with default raws', () => { + it('without modifiers', () => + expect(new StaticImport('"foo.css"').toString()).toBe('"foo.css"')); + + // TODO: test `@import url("foo")` when the expression-level syntax is + // representable. + + it('with modifiers', () => + expect( + new StaticImport({ + staticUrl: '"foo.css"', + modifiers: 'screen', + }).toString(), + ).toBe('"foo.css" screen')); + }); + + // raws.before is only used as part of a ImportList + it('ignores before', () => + expect( + new StaticImport({ + staticUrl: '"foo.css"', + raws: {before: '/**/'}, + }).toString(), + ).toBe('"foo.css"')); + + // raws.after is only used as part of a ImportList + it('ignores after', () => + expect( + new StaticImport({ + staticUrl: '"foo.css"', + raws: {after: '/**/'}, + }).toString(), + ).toBe('"foo.css"')); + + describe('with between', () => { + it('without modifiers', () => + expect( + new StaticImport({ + staticUrl: '"foo.css"', + raws: {between: '/**/'}, + }).toString(), + ).toBe('"foo.css"')); + + it('with modifiers', () => + expect( + new StaticImport({ + staticUrl: '"foo.css"', + modifiers: 'screen', + raws: {between: '/**/'}, + }).toString(), + ).toBe('"foo.css"/**/screen')); + }); + }); + }); + + describe('clone()', () => { + let original: StaticImport; + beforeEach(() => { + original = (scss.parse('@import "foo.css" screen').nodes[0] as ImportRule) + .imports.nodes[0] as StaticImport; + // TODO: remove this once raws are properly parsed. + original.raws.before = '/**/'; + }); + + describe('with no overrides', () => { + let clone: StaticImport; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('staticUrl', () => + expect(clone).toHaveInterpolation('staticUrl', '"foo.css"')); + + it('modifiers', () => + expect(clone).toHaveInterpolation('modifiers', 'screen')); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['raws', 'staticUrl', 'modifiers'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {after: ' '}}).raws).toEqual({ + after: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + before: '/**/', + })); + }); + + describe('staticUrl', () => { + it('defined', () => + expect(original.clone({staticUrl: '"bar.css"'})).toHaveInterpolation( + 'staticUrl', + '"bar.css"', + )); + + it('undefined', () => + expect(original.clone({staticUrl: undefined})).toHaveInterpolation( + 'staticUrl', + '"foo.css"', + )); + }); + + describe('modifiers', () => { + it('defined', () => + expect(original.clone({modifiers: 'print'})).toHaveInterpolation( + 'modifiers', + 'print', + )); + + it('undefined', () => + expect( + original.clone({modifiers: undefined}).modifiers, + ).toBeUndefined()); + }); + }); + }); + + describe('toJSON', () => { + it('without modifiers', () => + expect( + (scss.parse('@import "foo.css"').nodes[0] as ImportRule).imports + .nodes[0], + ).toMatchSnapshot()); + + it('with modifiers', () => + expect( + (scss.parse('@import "foo.css" screen').nodes[0] as ImportRule).imports + .nodes[0], + ).toMatchSnapshot()); + }); +}); diff --git a/pkg/sass-parser/lib/src/static-import.ts b/pkg/sass-parser/lib/src/static-import.ts new file mode 100644 index 000000000..9c6b7a8e8 --- /dev/null +++ b/pkg/sass-parser/lib/src/static-import.ts @@ -0,0 +1,147 @@ +// Copyright 2025 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; + +import {ImportList} from './import-list'; +import {Interpolation, InterpolationProps} from './interpolation'; +import {LazySource} from './lazy-source'; +import {Node, NodeProps} from './node'; +import * as sassInternal from './sass-internal'; +import * as utils from './utils'; + +/** + * The set of raws supported by {@link StaticImport}. + * + * @category Statement + */ +export interface StaticImportRaws { + /** + * The whitespace before {@link StaticImport.staticUrl}. + */ + before?: string; + + /** + * The whitespace between {@link StaticImport.staticUrl} and {@link + * StaticImport.modifiers}. Always empty if `modifiers` is undefined. + */ + between?: string; + + /** + * The space symbols between {@link StaticImport.modifiers} (if it's defined) + * or {@link StaticImport.staticUrl} (otherwise) URL and the comma afterwards. + * Always empty for a URL that doesn't have a trailing comma. + */ + after?: string; +} + +/** + * The initializer properties for {@link StaticImport}. + * + * @category Statement + */ +export type StaticImportProps = NodeProps & { + raws?: StaticImportRaws; + staticUrl: InterpolationProps; + modifiers?: InterpolationProps; +}; + +/** + * A single URL passed to an `@import` rule that's treated as a plain-CSS + * `@import` rather than a dynamic Sass load. This is always included in an + * {@link ImportRule}. + * + * @category Statement + */ +export class StaticImport extends Node { + readonly sassType = 'static-import' as const; + declare raws: StaticImportRaws; + declare parent: ImportList | undefined; + + /** The URL of the imported stylesheet. */ + get staticUrl(): Interpolation { + return this._staticUrl!; + } + set staticUrl(value: Interpolation | InterpolationProps) { + if (this._staticUrl) this._staticUrl.parent = undefined; + const staticUrl = + value instanceof Interpolation ? value : new Interpolation(value); + staticUrl.parent = this; + this._staticUrl = staticUrl; + } + private declare _staticUrl?: Interpolation; + + /** + * The additional modifiers, like media queries and `supports()`, attached to + * this import. + */ + get modifiers(): Interpolation | undefined { + return this._modifiers!; + } + set modifiers(value: Interpolation | InterpolationProps | undefined) { + if (this._modifiers) this._modifiers.parent = undefined; + if (value) { + const modifiers = + value instanceof Interpolation ? value : new Interpolation(value); + modifiers.parent = this; + this._modifiers = modifiers; + } else { + this._modifiers = undefined; + } + } + private declare _modifiers?: Interpolation; + + constructor(defaults: string | StaticImportProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.StaticImport); + constructor( + defaults?: string | StaticImportProps, + inner?: sassInternal.StaticImport, + ) { + if (typeof defaults === 'string') defaults = {staticUrl: defaults}; + super(defaults); + this.raws ??= {}; + + if (inner) { + this.source = new LazySource(inner); + this.staticUrl = new Interpolation(undefined, inner.url); + if (inner.modifiers) { + this.modifiers = new Interpolation(undefined, inner.modifiers); + } + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + 'staticUrl', + {name: 'modifiers', explicitUndefined: true}, + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['staticUrl', 'modifiers'], inputs); + } + + /** @hidden */ + toString(): string { + // TODO: If staticUrl is of the form `url("...")`, it gets parsed as an + // interpolation around a `url()` function, which isn't actually valid as + // source in this position. Normalize that here. + return ( + this.staticUrl + + (this.modifiers ? (this.raws.between ?? ' ') + this.modifiers : '') + ); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + const result = [this.staticUrl]; + if (this.modifiers) result.push(this.modifiers); + return result; + } +} diff --git a/pkg/sass-parser/lib/src/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts index c0a2a3e3e..4abcb9337 100644 --- a/pkg/sass-parser/lib/src/stringifier.ts +++ b/pkg/sass-parser/lib/src/stringifier.ts @@ -39,6 +39,7 @@ import {ForwardRule} from './statement/forward-rule'; import {FunctionRule} from './statement/function-rule'; import {GenericAtRule} from './statement/generic-at-rule'; import {IfRule} from './statement/if-rule'; +import {ImportRule} from './statement/import-rule'; import {IncludeRule} from './statement/include-rule'; import {MixinRule} from './statement/mixin-rule'; import {ReturnRule} from './statement/return-rule'; @@ -148,6 +149,10 @@ export class Stringifier extends PostCssStringifier { this.sassAtRule(node); } + private ['import-rule'](node: ImportRule, semicolon: boolean): void { + this.sassAtRule(node, semicolon); + } + private ['include-rule'](node: IncludeRule, semicolon: boolean): void { this.sassAtRule(node, semicolon); }