Compare commits
8 commits
main
...
fix/css-pa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb628d7cc9 | ||
|
|
e18642a616 | ||
|
|
6f94fc231e | ||
|
|
53509a8231 | ||
|
|
d9d5ea31f3 | ||
|
|
2aa3682cb5 | ||
|
|
667f1c7521 | ||
|
|
354b706e88 |
3 changed files with 184 additions and 21 deletions
|
|
@ -1,9 +1,12 @@
|
||||||
import dedent from 'dedent'
|
import dedent from 'dedent'
|
||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
import { describe } from 'vitest'
|
import { describe } from 'vitest'
|
||||||
import { candidate, css, html, js, json, test, ts, yaml } from '../utils'
|
import { candidate, css, html, js, json, test, ts, yaml } from '../utils'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
const STANDALONE_BINARY = (() => {
|
const STANDALONE_BINARY = (() => {
|
||||||
switch (os.platform()) {
|
switch (os.platform()) {
|
||||||
case 'win32':
|
case 'win32':
|
||||||
|
|
@ -2101,6 +2104,33 @@ test(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
test(
|
||||||
|
'CSS parse errors should include filename and line number',
|
||||||
|
{
|
||||||
|
fs: {
|
||||||
|
'package.json': json`
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"tailwindcss": "workspace:^",
|
||||||
|
"@tailwindcss/cli": "workspace:^"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
'input.css': css`
|
||||||
|
.test {
|
||||||
|
color: red;
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ exec, expect }) => {
|
||||||
|
await expect(exec('pnpm tailwindcss --input input.css --output dist/out.css')).rejects.toThrow(
|
||||||
|
/CssSyntaxError: .*input.css: 3:2: Invalid declaration: `\*\/`/,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
function withBOM(text: string): string {
|
function withBOM(text: string): string {
|
||||||
return '\uFEFF' + text
|
return '\uFEFF' + text
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,12 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
|
||||||
return CSS.parse(string.replaceAll(/\r?\n/g, lineEndings === 'Windows' ? '\r\n' : '\n'))
|
return CSS.parse(string.replaceAll(/\r?\n/g, lineEndings === 'Windows' ? '\r\n' : '\n'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseWithLoc(string: string) {
|
||||||
|
return CSS.parse(string.replaceAll(/\r?\n/g, lineEndings === 'Windows' ? '\r\n' : '\n'), {
|
||||||
|
from: 'input.css',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
describe('comments', () => {
|
describe('comments', () => {
|
||||||
it('should parse a comment and ignore it', () => {
|
it('should parse a comment and ignore it', () => {
|
||||||
expect(
|
expect(
|
||||||
|
|
@ -1145,7 +1151,20 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
|
||||||
color: blue;
|
color: blue;
|
||||||
}
|
}
|
||||||
`),
|
`),
|
||||||
).toThrowErrorMatchingInlineSnapshot(`[Error: Missing opening {]`)
|
).toThrowErrorMatchingInlineSnapshot(`[CssSyntaxError: Missing opening {]`)
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
parseWithLoc(`
|
||||||
|
.foo {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar
|
||||||
|
/* ^ Missing opening { */
|
||||||
|
color: blue;
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
).toThrowErrorMatchingInlineSnapshot(`[CssSyntaxError: input.css: 9:10: Missing opening {]`)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should error when curly brackets are unbalanced (closing)', () => {
|
it('should error when curly brackets are unbalanced (closing)', () => {
|
||||||
|
|
@ -1160,7 +1179,22 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
|
||||||
|
|
||||||
/* ^ Missing closing } */
|
/* ^ Missing closing } */
|
||||||
`),
|
`),
|
||||||
).toThrowErrorMatchingInlineSnapshot(`[Error: Missing closing } at .bar]`)
|
).toThrowErrorMatchingInlineSnapshot(`[CssSyntaxError: Missing closing } at .bar]`)
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
parseWithLoc(`
|
||||||
|
.foo {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
color: blue;
|
||||||
|
|
||||||
|
/* ^ Missing closing } */
|
||||||
|
`),
|
||||||
|
).toThrowErrorMatchingInlineSnapshot(
|
||||||
|
`[CssSyntaxError: input.css: 6:10: Missing closing } at .bar]`,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should error when an unterminated string is used', () => {
|
it('should error when an unterminated string is used', () => {
|
||||||
|
|
@ -1172,7 +1206,19 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
`),
|
`),
|
||||||
).toThrowErrorMatchingInlineSnapshot(`[Error: Unterminated string: "Hello world!"]`)
|
).toThrowErrorMatchingInlineSnapshot(`[CssSyntaxError: Unterminated string: "Hello world!"]`)
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
parseWithLoc(css`
|
||||||
|
.foo {
|
||||||
|
content: "Hello world!
|
||||||
|
/* ^ missing " */
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
).toThrowErrorMatchingInlineSnapshot(
|
||||||
|
`[CssSyntaxError: input.css: 3:21: Unterminated string: "Hello world!"]`,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should error when an unterminated string is used with a `;`', () => {
|
it('should error when an unterminated string is used with a `;`', () => {
|
||||||
|
|
@ -1184,18 +1230,38 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
`),
|
`),
|
||||||
).toThrowErrorMatchingInlineSnapshot(`[Error: Unterminated string: "Hello world!;"]`)
|
).toThrowErrorMatchingInlineSnapshot(`[CssSyntaxError: Unterminated string: "Hello world!;"]`)
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
parseWithLoc(css`
|
||||||
|
.foo {
|
||||||
|
content: "Hello world!;
|
||||||
|
/* ^ missing " */
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
).toThrowErrorMatchingInlineSnapshot(
|
||||||
|
`[CssSyntaxError: input.css: 3:21: Unterminated string: "Hello world!;"]`,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should error when incomplete custom properties are used', () => {
|
it('should error when incomplete custom properties are used', () => {
|
||||||
expect(() => parse('--foo')).toThrowErrorMatchingInlineSnapshot(
|
expect(() => parse('--foo')).toThrowErrorMatchingInlineSnapshot(
|
||||||
`[Error: Invalid custom property, expected a value]`,
|
`[CssSyntaxError: Invalid custom property, expected a value]`,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(() => parseWithLoc('--foo')).toThrowErrorMatchingInlineSnapshot(
|
||||||
|
`[CssSyntaxError: input.css: 1:0: Invalid custom property, expected a value]`,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should error when incomplete custom properties are used inside rules', () => {
|
it('should error when incomplete custom properties are used inside rules', () => {
|
||||||
expect(() => parse('.foo { --bar }')).toThrowErrorMatchingInlineSnapshot(
|
expect(() => parse('.foo { --bar }')).toThrowErrorMatchingInlineSnapshot(
|
||||||
`[Error: Invalid custom property, expected a value]`,
|
`[CssSyntaxError: Invalid custom property, expected a value]`,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(() => parseWithLoc('.foo { --bar }')).toThrowErrorMatchingInlineSnapshot(
|
||||||
|
`[CssSyntaxError: input.css: 1:7: Invalid custom property, expected a value]`,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1207,12 +1273,27 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
|
||||||
/* ^ missing ' * /;
|
/* ^ missing ' * /;
|
||||||
}
|
}
|
||||||
`),
|
`),
|
||||||
).toThrowErrorMatchingInlineSnapshot(`[Error: Unterminated string: 'Hello world!']`)
|
).toThrowErrorMatchingInlineSnapshot(`[CssSyntaxError: Unterminated string: 'Hello world!']`)
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
parseWithLoc(css`
|
||||||
|
.foo {
|
||||||
|
--bar: 'Hello world!
|
||||||
|
/* ^ missing ' * /;
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
).toThrowErrorMatchingInlineSnapshot(
|
||||||
|
`[CssSyntaxError: input.css: 3:19: Unterminated string: 'Hello world!']`,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should error when a declaration is incomplete', () => {
|
it('should error when a declaration is incomplete', () => {
|
||||||
expect(() => parse('.foo { bar }')).toThrowErrorMatchingInlineSnapshot(
|
expect(() => parse('.foo { bar }')).toThrowErrorMatchingInlineSnapshot(
|
||||||
`[Error: Invalid declaration: \`bar\`]`,
|
`[CssSyntaxError: Invalid declaration: \`bar\`]`,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(() => parseWithLoc('.foo { bar }')).toThrowErrorMatchingInlineSnapshot(
|
||||||
|
`[CssSyntaxError: input.css: 1:7: Invalid declaration: \`bar\`]`,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ import {
|
||||||
type Declaration,
|
type Declaration,
|
||||||
type Rule,
|
type Rule,
|
||||||
} from './ast'
|
} from './ast'
|
||||||
import type { Source } from './source-maps/source'
|
import { createLineTable } from './source-maps/line-table'
|
||||||
|
import type { Source, SourceLocation } from './source-maps/source'
|
||||||
|
|
||||||
const BACKSLASH = 0x5c
|
const BACKSLASH = 0x5c
|
||||||
const SLASH = 0x2f
|
const SLASH = 0x2f
|
||||||
|
|
@ -36,6 +37,30 @@ export interface ParseOptions {
|
||||||
from?: string
|
from?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS syntax error with source location information.
|
||||||
|
*/
|
||||||
|
export class CssSyntaxError extends Error {
|
||||||
|
loc: SourceLocation | null
|
||||||
|
|
||||||
|
constructor(message: string, loc: SourceLocation | null) {
|
||||||
|
if (loc) {
|
||||||
|
let source = loc[0]
|
||||||
|
let start = createLineTable(source.code).find(loc[1])
|
||||||
|
message = `${source.file}: ${start.line}:${start.column}: ${message}`
|
||||||
|
}
|
||||||
|
|
||||||
|
super(message)
|
||||||
|
|
||||||
|
this.name = 'CssSyntaxError'
|
||||||
|
this.loc = loc
|
||||||
|
|
||||||
|
if (Error.captureStackTrace) {
|
||||||
|
Error.captureStackTrace(this, CssSyntaxError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function parse(input: string, opts?: ParseOptions) {
|
export function parse(input: string, opts?: ParseOptions) {
|
||||||
let source: Source | null = opts?.from ? { file: opts.from, code: input } : null
|
let source: Source | null = opts?.from ? { file: opts.from, code: input } : null
|
||||||
|
|
||||||
|
|
@ -138,7 +163,7 @@ export function parse(input: string, opts?: ParseOptions) {
|
||||||
|
|
||||||
// Start of a string.
|
// Start of a string.
|
||||||
else if (currentChar === SINGLE_QUOTE || currentChar === DOUBLE_QUOTE) {
|
else if (currentChar === SINGLE_QUOTE || currentChar === DOUBLE_QUOTE) {
|
||||||
let end = parseString(input, i, currentChar)
|
let end = parseString(input, i, currentChar, source)
|
||||||
|
|
||||||
// Adjust `buffer` to include the string.
|
// Adjust `buffer` to include the string.
|
||||||
buffer += input.slice(i, end + 1)
|
buffer += input.slice(i, end + 1)
|
||||||
|
|
@ -192,7 +217,7 @@ export function parse(input: string, opts?: ParseOptions) {
|
||||||
|
|
||||||
// Start of a string.
|
// Start of a string.
|
||||||
else if (peekChar === SINGLE_QUOTE || peekChar === DOUBLE_QUOTE) {
|
else if (peekChar === SINGLE_QUOTE || peekChar === DOUBLE_QUOTE) {
|
||||||
j = parseString(input, j, peekChar)
|
j = parseString(input, j, peekChar, source)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start of a comment.
|
// Start of a comment.
|
||||||
|
|
@ -269,7 +294,12 @@ export function parse(input: string, opts?: ParseOptions) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let declaration = parseDeclaration(buffer, colonIdx)
|
let declaration = parseDeclaration(buffer, colonIdx)
|
||||||
if (!declaration) throw new Error(`Invalid custom property, expected a value`)
|
if (!declaration) {
|
||||||
|
throw new CssSyntaxError(
|
||||||
|
`Invalid custom property, expected a value`,
|
||||||
|
source ? [source, start, i] : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (source) {
|
if (source) {
|
||||||
declaration.src = [source, start, i]
|
declaration.src = [source, start, i]
|
||||||
|
|
@ -334,7 +364,10 @@ export function parse(input: string, opts?: ParseOptions) {
|
||||||
let declaration = parseDeclaration(buffer)
|
let declaration = parseDeclaration(buffer)
|
||||||
if (!declaration) {
|
if (!declaration) {
|
||||||
if (buffer.length === 0) continue
|
if (buffer.length === 0) continue
|
||||||
throw new Error(`Invalid declaration: \`${buffer.trim()}\``)
|
throw new CssSyntaxError(
|
||||||
|
`Invalid declaration: \`${buffer.trim()}\``,
|
||||||
|
source ? [source, bufferStart, i] : null,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (source) {
|
if (source) {
|
||||||
|
|
@ -391,7 +424,7 @@ export function parse(input: string, opts?: ParseOptions) {
|
||||||
closingBracketStack[closingBracketStack.length - 1] !== ')'
|
closingBracketStack[closingBracketStack.length - 1] !== ')'
|
||||||
) {
|
) {
|
||||||
if (closingBracketStack === '') {
|
if (closingBracketStack === '') {
|
||||||
throw new Error('Missing opening {')
|
throw new CssSyntaxError('Missing opening {', source ? [source, i, i] : null)
|
||||||
}
|
}
|
||||||
|
|
||||||
closingBracketStack = closingBracketStack.slice(0, -1)
|
closingBracketStack = closingBracketStack.slice(0, -1)
|
||||||
|
|
@ -453,7 +486,12 @@ export function parse(input: string, opts?: ParseOptions) {
|
||||||
// Attach the declaration to the parent.
|
// Attach the declaration to the parent.
|
||||||
if (parent) {
|
if (parent) {
|
||||||
let node = parseDeclaration(buffer, colonIdx)
|
let node = parseDeclaration(buffer, colonIdx)
|
||||||
if (!node) throw new Error(`Invalid declaration: \`${buffer.trim()}\``)
|
if (!node) {
|
||||||
|
throw new CssSyntaxError(
|
||||||
|
`Invalid declaration: \`${buffer.trim()}\``,
|
||||||
|
source ? [source, bufferStart, i] : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (source) {
|
if (source) {
|
||||||
node.src = [source, bufferStart, i]
|
node.src = [source, bufferStart, i]
|
||||||
|
|
@ -492,7 +530,7 @@ export function parse(input: string, opts?: ParseOptions) {
|
||||||
// `)`
|
// `)`
|
||||||
else if (currentChar === CLOSE_PAREN) {
|
else if (currentChar === CLOSE_PAREN) {
|
||||||
if (closingBracketStack[closingBracketStack.length - 1] !== ')') {
|
if (closingBracketStack[closingBracketStack.length - 1] !== ')') {
|
||||||
throw new Error('Missing opening (')
|
throw new CssSyntaxError('Missing opening (', source ? [source, i, i] : null)
|
||||||
}
|
}
|
||||||
|
|
||||||
closingBracketStack = closingBracketStack.slice(0, -1)
|
closingBracketStack = closingBracketStack.slice(0, -1)
|
||||||
|
|
@ -534,10 +572,17 @@ export function parse(input: string, opts?: ParseOptions) {
|
||||||
// have a leftover `parent`, then it means that we have an unterminated block.
|
// have a leftover `parent`, then it means that we have an unterminated block.
|
||||||
if (closingBracketStack.length > 0 && parent) {
|
if (closingBracketStack.length > 0 && parent) {
|
||||||
if (parent.kind === 'rule') {
|
if (parent.kind === 'rule') {
|
||||||
throw new Error(`Missing closing } at ${parent.selector}`)
|
throw new CssSyntaxError(
|
||||||
|
`Missing closing } at ${parent.selector}`,
|
||||||
|
parent.src ? [parent.src[0], parent.src[1], parent.src[1]] : null,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parent.kind === 'at-rule') {
|
if (parent.kind === 'at-rule') {
|
||||||
throw new Error(`Missing closing } at ${parent.name} ${parent.params}`)
|
throw new CssSyntaxError(
|
||||||
|
`Missing closing } at ${parent.name} ${parent.params}`,
|
||||||
|
parent.src ? [parent.src[0], parent.src[1], parent.src[1]] : null,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -594,7 +639,12 @@ function parseDeclaration(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseString(input: string, startIdx: number, quoteChar: number): number {
|
function parseString(
|
||||||
|
input: string,
|
||||||
|
startIdx: number,
|
||||||
|
quoteChar: number,
|
||||||
|
source: Source | null = null,
|
||||||
|
): number {
|
||||||
let peekChar: number
|
let peekChar: number
|
||||||
|
|
||||||
// We need to ensure that the closing quote is the same as the opening
|
// We need to ensure that the closing quote is the same as the opening
|
||||||
|
|
@ -636,8 +686,9 @@ function parseString(input: string, startIdx: number, quoteChar: number): number
|
||||||
(input.charCodeAt(i + 1) === LINE_BREAK ||
|
(input.charCodeAt(i + 1) === LINE_BREAK ||
|
||||||
(input.charCodeAt(i + 1) === CARRIAGE_RETURN && input.charCodeAt(i + 2) === LINE_BREAK))
|
(input.charCodeAt(i + 1) === CARRIAGE_RETURN && input.charCodeAt(i + 2) === LINE_BREAK))
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new CssSyntaxError(
|
||||||
`Unterminated string: ${input.slice(startIdx, i + 1) + String.fromCharCode(quoteChar)}`,
|
`Unterminated string: ${input.slice(startIdx, i + 1) + String.fromCharCode(quoteChar)}`,
|
||||||
|
source ? [source, startIdx, i + 1] : null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -655,8 +706,9 @@ function parseString(input: string, startIdx: number, quoteChar: number): number
|
||||||
peekChar === LINE_BREAK ||
|
peekChar === LINE_BREAK ||
|
||||||
(peekChar === CARRIAGE_RETURN && input.charCodeAt(i + 1) === LINE_BREAK)
|
(peekChar === CARRIAGE_RETURN && input.charCodeAt(i + 1) === LINE_BREAK)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new CssSyntaxError(
|
||||||
`Unterminated string: ${input.slice(startIdx, i) + String.fromCharCode(quoteChar)}`,
|
`Unterminated string: ${input.slice(startIdx, i) + String.fromCharCode(quoteChar)}`,
|
||||||
|
source ? [source, startIdx, i + 1] : null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue