Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix charset handling #447

Merged
merged 2 commits into from Dec 14, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
29 changes: 23 additions & 6 deletions index.js
Expand Up @@ -107,10 +107,7 @@ function AtImport(options) {

// Strip additional statements.
bundle.forEach(stmt => {
if (stmt.type === "import") {
stmt.node.parent = undefined
styles.append(stmt.node)
} else if (stmt.type === "media") {
if (["charset", "import", "media"].includes(stmt.type)) {
stmt.node.parent = undefined
styles.append(stmt.node)
} else if (stmt.type === "nodes") {
Expand Down Expand Up @@ -150,15 +147,33 @@ function AtImport(options) {
}, Promise.resolve())
})
.then(() => {
let charset
const imports = []
const bundle = []

function handleCharset(stmt) {
if (!charset) charset = stmt
// charsets aren't case-sensitive, so convert to lower case to compare
else if (
stmt.node.params.toLowerCase() !==
charset.node.params.toLowerCase()
) {
throw new Error(
`Incompatable @charset statements:
${stmt.node.params} specified in ${stmt.node.source.input.file}
${charset.node.params} specified in ${charset.node.source.input.file}`
)
}
}

// squash statements and their children
statements.forEach(stmt => {
if (stmt.type === "import") {
if (stmt.type === "charset") handleCharset(stmt)
else if (stmt.type === "import") {
if (stmt.children) {
stmt.children.forEach((child, index) => {
if (child.type === "import") imports.push(child)
else if (child.type === "charset") handleCharset(child)
else bundle.push(child)
// For better output
if (index === 0) child.parent = stmt
Expand All @@ -169,7 +184,9 @@ function AtImport(options) {
}
})

return imports.concat(bundle)
return charset
? [charset, ...imports.concat(bundle)]
: imports.concat(bundle)
})
}

Expand Down
31 changes: 19 additions & 12 deletions lib/parse-statements.js
Expand Up @@ -29,6 +29,7 @@ module.exports = function (result, styles) {
if (node.type === "atrule") {
if (node.name === "import") stmt = parseImport(result, node)
else if (node.name === "media") stmt = parseMedia(result, node)
else if (node.name === "charset") stmt = parseCharset(result, node)
}

if (stmt) {
Expand Down Expand Up @@ -64,20 +65,34 @@ function parseMedia(result, atRule) {
}
}

function parseCharset(result, atRule) {
if (atRule.prev()) {
return result.warn("@charset must precede all other statements", {
node: atRule,
})
}
return {
type: "charset",
node: atRule,
media: [],
}
}

function parseImport(result, atRule) {
let prev = getPrev(atRule)
let prev = atRule.prev()
if (prev) {
do {
if (
prev.type !== "atrule" ||
(prev.name !== "import" && prev.name !== "charset")
prev.type !== "comment" &&
(prev.type !== "atrule" ||
(prev.name !== "import" && prev.name !== "charset"))
) {
return result.warn(
"@import must precede all other statements (besides @charset)",
{ node: atRule }
)
}
prev = getPrev(prev)
prev = prev.prev()
} while (prev)
}

Expand Down Expand Up @@ -128,11 +143,3 @@ function parseImport(result, atRule) {

return stmt
}

function getPrev(item) {
let prev = item.prev()
while (prev && prev.type === "comment") {
prev = prev.prev()
}
return prev
}
2 changes: 2 additions & 0 deletions test/fixtures/charset-error.css
@@ -0,0 +1,2 @@
@charset "foobar";
@import "imports/charset.css";
4 changes: 4 additions & 0 deletions test/fixtures/charset-import.css
@@ -0,0 +1,4 @@
@charset "UTF-8";
@import "test/fixtures/imports/foo.css";
@import "test/fixtures/imports/charset.css";
bar{}
3 changes: 3 additions & 0 deletions test/fixtures/charset-import.expected.css
@@ -0,0 +1,3 @@
@charset "UTF-8";
foo{}
bar{}
1 change: 1 addition & 0 deletions test/fixtures/imports/charset.css
@@ -0,0 +1 @@
@charset "UTF-8";
32 changes: 32 additions & 0 deletions test/import.js
Expand Up @@ -46,6 +46,38 @@ test("should not fail with absolute and local import", t => {
.then(result => t.is(result.css, "@import url('http://');\nfoo{}"))
})

test("should keep @charset first", t => {
const base = '@charset "UTF-8";\n@import url(http://);'
return postcss()
.use(atImport())
.process(base, { from: undefined })
.then(result => {
t.is(result.warnings().length, 0)
t.is(result.css, base)
})
})

test(
"should handle multiple @charset statements",
checkFixture,
"charset-import"
)

test("should error if incompatable @charset statements", t => {
t.plan(2)
const file = "test/fixtures/charset-error.css"
return postcss()
.use(atImport())
.process(readFileSync(file), { from: file })
.catch(err => {
t.truthy(err)
t.regex(
err.message,
/Incompatable @charset statements:.+specified in.+specified in.+/s
)
})
})

test("should error when file not found", t => {
t.plan(1)
const file = "test/fixtures/imports/import-missing.css"
Expand Down
17 changes: 17 additions & 0 deletions test/lint.js
Expand Up @@ -78,6 +78,23 @@ test("should not warn when @charset or @import statement before", t => {
})
})

test("should warn when @charset is not first", t => {
return Promise.all([
processor.process(`a {} @charset "utf-8";`, { from: undefined }),
processor.process(`@media {} @charset "utf-8";`, { from: undefined }),
processor.process(`/* foo */ @charset "utf-8";`, { from: undefined }),
processor.process(`@import "bar.css"; @charset "utf-8";`, {
from: "test/fixtures/imports/foo.css",
}),
]).then(results => {
results.forEach(result => {
const warnings = result.warnings()
t.is(warnings.length, 1)
t.is(warnings[0].text, "@charset must precede all other statements")
})
})
})

test("should warn when a user didn't close an import with ;", t => {
return processor
.process(`@import url('http://') :root{}`, { from: undefined })
Expand Down