diff --git a/lib/src/parse/sass.dart b/lib/src/parse/sass.dart index 78dea3179..689131bf1 100644 --- a/lib/src/parse/sass.dart +++ b/lib/src/parse/sass.dart @@ -268,7 +268,6 @@ class SassParser extends StylesheetParser { _readIndentation(); } - if (!buffer.trailingString.trimRight().endsWith("*/")) buffer.write(" */"); return LoudComment(buffer.interpolation(scanner.spanFrom(start))); } diff --git a/lib/src/visitor/async_evaluate.dart b/lib/src/visitor/async_evaluate.dart index e4a65d12e..1c838b8ac 100644 --- a/lib/src/visitor/async_evaluate.dart +++ b/lib/src/visitor/async_evaluate.dart @@ -1911,8 +1911,10 @@ final class _EvaluateVisitor _endOfImports++; } - _parent.addChild(ModifiableCssComment( - await _performInterpolation(node.text), node.span)); + var text = await _performInterpolation(node.text); + // Indented syntax doesn't require */ + if (!text.endsWith("*/")) text += " */"; + _parent.addChild(ModifiableCssComment(text, node.span)); return null; } diff --git a/lib/src/visitor/evaluate.dart b/lib/src/visitor/evaluate.dart index cc2458bab..e3552506c 100644 --- a/lib/src/visitor/evaluate.dart +++ b/lib/src/visitor/evaluate.dart @@ -5,7 +5,7 @@ // DO NOT EDIT. This file was generated from async_evaluate.dart. // See tool/grind/synchronize.dart for details. // -// Checksum: ebf292c26dcfdd7f61fd70ce3dc9e0be2b6708b3 +// Checksum: 2ab69d23a3b34cb54ddd74e2e854614dda582174 // // ignore_for_file: unused_import @@ -1903,8 +1903,10 @@ final class _EvaluateVisitor _endOfImports++; } - _parent.addChild( - ModifiableCssComment(_performInterpolation(node.text), node.span)); + var text = _performInterpolation(node.text); + // Indented syntax doesn't require */ + if (!text.endsWith("*/")) text += " */"; + _parent.addChild(ModifiableCssComment(text, node.span)); return null; } diff --git a/pkg/sass-parser/jest.config.ts b/pkg/sass-parser/jest.config.ts index bdf7ad067..d7cc13f80 100644 --- a/pkg/sass-parser/jest.config.ts +++ b/pkg/sass-parser/jest.config.ts @@ -3,6 +3,7 @@ const config = { roots: ['lib'], testEnvironment: 'node', setupFilesAfterEnv: ['jest-extended/all', '/test/setup.ts'], + verbose: false, }; export default config; diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts index 57efc09eb..a0c3b6451 100644 --- a/pkg/sass-parser/lib/index.ts +++ b/pkg/sass-parser/lib/index.ts @@ -33,6 +33,11 @@ export { InterpolationRaws, NewNodeForInterpolation, } from './src/interpolation'; +export { + CssComment, + CssCommentProps, + CssCommentRaws, +} from './src/statement/css-comment'; export { DebugRule, DebugRuleProps, diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index af571b437..ed9987141 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -101,6 +101,10 @@ declare namespace SassInternal { readonly isExclusive: boolean; } + class LoudComment extends Statement { + readonly text: Interpolation; + } + class Stylesheet extends ParentStatement {} class StyleRule extends ParentStatement { @@ -143,6 +147,7 @@ export type EachRule = SassInternal.EachRule; export type ErrorRule = SassInternal.ErrorRule; export type ExtendRule = SassInternal.ExtendRule; export type ForRule = SassInternal.ForRule; +export type LoudComment = SassInternal.LoudComment; export type Stylesheet = SassInternal.Stylesheet; export type StyleRule = SassInternal.StyleRule; export type Interpolation = SassInternal.Interpolation; @@ -158,6 +163,7 @@ export interface StatementVisitorObject { visitErrorRule(node: ErrorRule): T; visitExtendRule(node: ExtendRule): T; visitForRule(node: ForRule): T; + visitLoudComment(node: LoudComment): T; visitStyleRule(node: StyleRule): T; } diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/css-comment.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/css-comment.test.ts.snap new file mode 100644 index 000000000..1e19f31d6 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/css-comment.test.ts.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a CSS-style comment toJSON 1`] = ` +{ + "inputs": [ + { + "css": "/* foo */", + "hasBOM": false, + "id": "", + }, + ], + "raws": { + "closed": true, + "left": " ", + "right": " ", + }, + "sassType": "comment", + "source": <1:1-1:10 in 0>, + "text": "foo", + "textInterpolation": , + "type": "comment", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/comment-internal.d.ts b/pkg/sass-parser/lib/src/statement/comment-internal.d.ts new file mode 100644 index 000000000..eb49874bf --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/comment-internal.d.ts @@ -0,0 +1,31 @@ +// Copyright 2024 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 {Root} from './root'; +import {ChildNode, NewNode} from '.'; + +/** + * A fake intermediate class to convince TypeScript to use Sass types for + * various upstream methods. + * + * @hidden + */ +export class _Comment extends postcss.Comment { + // Override the PostCSS types to constrain them to Sass types only. + // Unfortunately, there's no way to abstract this out, because anything + // mixin-like returns an intersection type which doesn't actually override + // parent methods. See microsoft/TypeScript#59394. + + after(newNode: NewNode): this; + assign(overrides: Partial): this; + before(newNode: NewNode): this; + cloneAfter(overrides?: Partial): this; + cloneBefore(overrides?: Partial): this; + next(): ChildNode | undefined; + prev(): ChildNode | undefined; + replaceWith(...nodes: NewNode[]): this; + root(): Root; +} diff --git a/pkg/sass-parser/lib/src/statement/comment-internal.js b/pkg/sass-parser/lib/src/statement/comment-internal.js new file mode 100644 index 000000000..3304da6b3 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/comment-internal.js @@ -0,0 +1,5 @@ +// Copyright 2024 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. + +exports._Comment = require('postcss').Comment; diff --git a/pkg/sass-parser/lib/src/statement/css-comment.test.ts b/pkg/sass-parser/lib/src/statement/css-comment.test.ts new file mode 100644 index 000000000..ae1e5bc65 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/css-comment.test.ts @@ -0,0 +1,325 @@ +// Copyright 2024 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 {CssComment, Interpolation, Root, css, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a CSS-style comment', () => { + let node: CssComment; + function describeNode(description: string, create: () => CssComment): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has type comment', () => expect(node.type).toBe('comment')); + + it('has sassType comment', () => expect(node.sassType).toBe('comment')); + + it('has matching textInterpolation', () => + expect(node).toHaveInterpolation('textInterpolation', 'foo')); + + it('has matching text', () => expect(node.text).toBe('foo')); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('/* foo */').nodes[0] as CssComment + ); + + describeNode( + 'parsed as CSS', + () => css.parse('/* foo */').nodes[0] as CssComment + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('/* foo').nodes[0] as CssComment + ); + + describe('constructed manually', () => { + describeNode( + 'with an interpolation', + () => + new CssComment({ + textInterpolation: new Interpolation({nodes: ['foo']}), + }) + ); + + describeNode('with a text string', () => new CssComment({text: 'foo'})); + }); + + describe('constructed from ChildProps', () => { + describeNode('with an interpolation', () => + utils.fromChildProps({ + textInterpolation: new Interpolation({nodes: ['foo']}), + }) + ); + + describeNode('with a text string', () => + utils.fromChildProps({text: 'foo'}) + ); + }); + + describe('parses raws', () => { + describe('in SCSS', () => { + it('with whitespace before and after text', () => + expect((scss.parse('/* foo */').nodes[0] as CssComment).raws).toEqual({ + left: ' ', + right: ' ', + closed: true, + })); + + it('with whitespace before and after interpolation', () => + expect( + (scss.parse('/* #{foo} */').nodes[0] as CssComment).raws + ).toEqual({left: ' ', right: ' ', closed: true})); + + it('without whitespace before and after text', () => + expect((scss.parse('/*foo*/').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: true, + })); + + it('without whitespace before and after interpolation', () => + expect((scss.parse('/*#{foo}*/').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: true, + })); + + it('with whitespace and no text', () => + expect((scss.parse('/* */').nodes[0] as CssComment).raws).toEqual({ + left: ' ', + right: '', + closed: true, + })); + + it('with no whitespace and no text', () => + expect((scss.parse('/**/').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: true, + })); + }); + + describe('in Sass', () => { + // TODO: Test explicit whitespace after text and interpolation once we + // properly parse raws from somewhere other than the original text. + + it('with whitespace before text', () => + expect((sass.parse('/* foo').nodes[0] as CssComment).raws).toEqual({ + left: ' ', + right: '', + closed: false, + })); + + it('with whitespace before interpolation', () => + expect((sass.parse('/* #{foo}').nodes[0] as CssComment).raws).toEqual({ + left: ' ', + right: '', + closed: false, + })); + + it('without whitespace before and after text', () => + expect((sass.parse('/*foo').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: false, + })); + + it('without whitespace before and after interpolation', () => + expect((sass.parse('/*#{foo}').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: false, + })); + + it('with no whitespace and no text', () => + expect((sass.parse('/*').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: false, + })); + + it('with a trailing */', () => + expect((sass.parse('/* foo */').nodes[0] as CssComment).raws).toEqual({ + left: ' ', + right: ' ', + closed: true, + })); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect(new CssComment({text: 'foo'}).toString()).toBe('/* foo */')); + + it('with left', () => + expect( + new CssComment({ + text: 'foo', + raws: {left: '\n'}, + }).toString() + ).toBe('/*\nfoo */')); + + it('with right', () => + expect( + new CssComment({ + text: 'foo', + raws: {right: '\n'}, + }).toString() + ).toBe('/* foo\n*/')); + + it('with before', () => + expect( + new Root({ + nodes: [new CssComment({text: 'foo', raws: {before: '/**/'}})], + }).toString() + ).toBe('/**//* foo */')); + }); + }); + + describe('assigned new text', () => { + beforeEach(() => { + node = scss.parse('/* foo */').nodes[0] as CssComment; + }); + + it("removes the old text's parent", () => { + const oldText = node.textInterpolation!; + node.textInterpolation = 'bar'; + expect(oldText.parent).toBeUndefined(); + }); + + it("assigns the new interpolation's parent", () => { + const interpolation = new Interpolation({nodes: ['bar']}); + node.textInterpolation = interpolation; + expect(interpolation.parent).toBe(node); + }); + + it('assigns the interpolation explicitly', () => { + const interpolation = new Interpolation({nodes: ['bar']}); + node.textInterpolation = interpolation; + expect(node.textInterpolation).toBe(interpolation); + }); + + it('assigns the interpolation as a string', () => { + node.textInterpolation = 'bar'; + expect(node).toHaveInterpolation('textInterpolation', 'bar'); + }); + + it('assigns the interpolation as text', () => { + node.text = 'bar'; + expect(node).toHaveInterpolation('textInterpolation', 'bar'); + }); + }); + + describe('clone', () => { + let original: CssComment; + beforeEach( + () => void (original = scss.parse('/* foo */').nodes[0] as CssComment) + ); + + describe('with no overrides', () => { + let clone: CssComment; + beforeEach(() => { + clone = original.clone(); + }); + + describe('has the same properties:', () => { + it('textInterpolation', () => + expect(clone).toHaveInterpolation('textInterpolation', 'foo')); + + it('text', () => expect(clone.text).toBe('foo')); + + it('raws', () => + expect(clone.raws).toEqual({left: ' ', right: ' ', closed: true})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['textInterpolation', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('text', () => { + describe('defined', () => { + let clone: CssComment; + beforeEach(() => { + clone = original.clone({text: 'bar'}); + }); + + it('changes text', () => expect(clone.text).toBe('bar')); + + it('changes textInterpolation', () => + expect(clone).toHaveInterpolation('textInterpolation', 'bar')); + }); + + describe('undefined', () => { + let clone: CssComment; + beforeEach(() => { + clone = original.clone({text: undefined}); + }); + + it('preserves text', () => expect(clone.text).toBe('foo')); + + it('preserves textInterpolation', () => + expect(clone).toHaveInterpolation('textInterpolation', 'foo')); + }); + }); + + describe('textInterpolation', () => { + describe('defined', () => { + let clone: CssComment; + beforeEach(() => { + clone = original.clone({ + textInterpolation: new Interpolation({nodes: ['baz']}), + }); + }); + + it('changes text', () => expect(clone.text).toBe('baz')); + + it('changes textInterpolation', () => + expect(clone).toHaveInterpolation('textInterpolation', 'baz')); + }); + + describe('undefined', () => { + let clone: CssComment; + beforeEach(() => { + clone = original.clone({textInterpolation: undefined}); + }); + + it('preserves text', () => expect(clone.text).toBe('foo')); + + it('preserves textInterpolation', () => + expect(clone).toHaveInterpolation('textInterpolation', 'foo')); + }); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {right: ' '}}).raws).toEqual({ + right: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + left: ' ', + right: ' ', + closed: true, + })); + }); + }); + }); + + it('toJSON', () => + expect(scss.parse('/* foo */').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/css-comment.ts b/pkg/sass-parser/lib/src/statement/css-comment.ts new file mode 100644 index 000000000..f2565caa0 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/css-comment.ts @@ -0,0 +1,164 @@ +// Copyright 2024 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 type {CommentRaws} from 'postcss/lib/comment'; + +import {convertExpression} from '../expression/convert'; +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import {Interpolation} from '../interpolation'; +import * as utils from '../utils'; +import {ContainerProps, Statement, StatementWithChildren} from '.'; +import {_Comment} from './comment-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link CssComment}. + * + * @category Statement + */ +export interface CssCommentRaws extends CommentRaws { + /** + * In the indented syntax, this indicates whether a comment is explicitly + * closed with a `*\/`. It's ignored in other syntaxes. + * + * It defaults to false. + */ + closed?: boolean; +} + +/** + * The initializer properties for {@link CssComment}. + * + * @category Statement + */ +export type CssCommentProps = ContainerProps & { + raws?: CssCommentRaws; +} & ({text: string} | {textInterpolation: Interpolation | string}); + +/** + * A CSS-style "loud" comment. Extends [`postcss.Comment`]. + * + * [`postcss.Comment`]: https://postcss.org/api/#comment + * + * @category Statement + */ +export class CssComment + extends _Comment> + implements Statement +{ + readonly sassType = 'comment' as const; + declare parent: StatementWithChildren | undefined; + declare raws: CssCommentRaws; + + get text(): string { + return this.textInterpolation.toString(); + } + set text(value: string) { + this.textInterpolation = value; + } + + /** The interpolation that represents this selector's contents. */ + get textInterpolation(): Interpolation { + return this._textInterpolation!; + } + set textInterpolation(textInterpolation: Interpolation | string) { + // TODO - postcss/postcss#1957: Mark this as dirty + if (this._textInterpolation) { + this._textInterpolation.parent = undefined; + } + if (typeof textInterpolation === 'string') { + textInterpolation = new Interpolation({ + nodes: [textInterpolation], + }); + } + textInterpolation.parent = this; + this._textInterpolation = textInterpolation; + } + private _textInterpolation?: Interpolation; + + constructor(defaults: CssCommentProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.LoudComment); + constructor(defaults?: CssCommentProps, inner?: sassInternal.LoudComment) { + super(defaults as unknown as postcss.CommentProps); + + if (inner) { + this.source = new LazySource(inner); + const nodes = [...inner.text.contents]; + + // The interpolation's contents are guaranteed to begin with a string, + // because Sass includes the `/*`. + let first = nodes[0] as string; + const firstMatch = first.match(/^\/\*([ \t\n\r\f]*)/)!; + this.raws.left ??= firstMatch[1]; + first = first.substring(firstMatch[0].length); + if (first.length === 0) { + nodes.shift(); + } else { + nodes[0] = first; + } + + // The interpolation will end with `*/` in SCSS, but not necessarily in + // the indented syntax. + let last = nodes.at(-1); + if (typeof last === 'string') { + const lastMatch = last.match(/([ \t\n\r\f]*)\*\/$/); + this.raws.right ??= lastMatch?.[1] ?? ''; + this.raws.closed = !!lastMatch; + if (lastMatch) { + last = last.substring(0, last.length - lastMatch[0].length); + if (last.length === 0) { + nodes.pop(); + } else { + nodes[0] = last; + } + } + } else { + this.raws.right ??= ''; + this.raws.closed = false; + } + + this.textInterpolation = new Interpolation(); + for (const child of nodes) { + this.textInterpolation.append( + typeof child === 'string' ? child : convertExpression(child) + ); + } + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode( + this, + overrides, + ['raws', 'textInterpolation'], + ['text'] + ); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['text', 'textInterpolation'], inputs); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.textInterpolation]; + } +} + +interceptIsClean(CssComment); diff --git a/pkg/sass-parser/lib/src/statement/index.ts b/pkg/sass-parser/lib/src/statement/index.ts index acc25bb4f..ddef69246 100644 --- a/pkg/sass-parser/lib/src/statement/index.ts +++ b/pkg/sass-parser/lib/src/statement/index.ts @@ -8,6 +8,7 @@ import {Interpolation} from '../interpolation'; import {LazySource} from '../lazy-source'; import {Node, NodeProps} from '../node'; import * as sassInternal from '../sass-internal'; +import {CssComment, CssCommentProps} from './css-comment'; import {GenericAtRule, GenericAtRuleProps} from './generic-at-rule'; import {DebugRule, DebugRuleProps} from './debug-rule'; import {EachRule, EachRuleProps} from './each-rule'; @@ -18,14 +19,14 @@ import {Rule, RuleProps} from './rule'; // TODO: Replace this with the corresponding Sass types once they're // implemented. -export {Comment, Declaration} from 'postcss'; +export {Declaration} from 'postcss'; /** * The union type of all Sass statements. * * @category Statement */ -export type AnyStatement = Root | Rule | GenericAtRule; +export type AnyStatement = Comment | Root | Rule | GenericAtRule; /** * Sass statement types. @@ -40,6 +41,7 @@ export type StatementType = | 'root' | 'rule' | 'atrule' + | 'comment' | 'debug-rule' | 'each-rule' | 'for-rule' @@ -52,6 +54,13 @@ export type StatementType = */ export type AtRule = DebugRule | EachRule | ErrorRule | ForRule | GenericAtRule; +/** + * All Sass statements that are comments. + * + * @category Statement + */ +export type Comment = CssComment; + /** * All Sass statements that are valid children of other statements. * @@ -59,7 +68,7 @@ export type AtRule = DebugRule | EachRule | ErrorRule | ForRule | GenericAtRule; * * @category Statement */ -export type ChildNode = Rule | AtRule; +export type ChildNode = Rule | AtRule | Comment; /** * The properties that can be used to construct {@link ChildNode}s. @@ -70,6 +79,7 @@ export type ChildNode = Rule | AtRule; */ export type ChildProps = | postcss.ChildProps + | CssCommentProps | DebugRuleProps | EachRuleProps | ErrorRuleProps @@ -138,6 +148,7 @@ const visitor = sassInternal.createStatementVisitor({ source: new LazySource(inner), }); }, + visitLoudComment: inner => new CssComment(undefined, inner), visitStyleRule: inner => new Rule(undefined, inner), }); @@ -249,6 +260,8 @@ export function normalize( result.push(new ForRule(node)); } else if ('errorExpression' in node) { result.push(new ErrorRule(node)); + } else if ('text' in node || 'textInterpolation' in node) { + result.push(new CssComment(node as CssCommentProps)); } else { result.push(...postcssNormalizeAndConvertToSass(self, node, sample)); } diff --git a/pkg/sass_api/CHANGELOG.md b/pkg/sass_api/CHANGELOG.md index 8284c79ae..4ce4a1375 100644 --- a/pkg/sass_api/CHANGELOG.md +++ b/pkg/sass_api/CHANGELOG.md @@ -2,6 +2,9 @@ * Remove the `CallableDeclaration()` constructor. +* Loud comments in the Sass syntax no longer automatically inject ` */` to the + end when parsed. + ## 10.4.8 * No user-visible changes.