Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions swift/.changeset/css-wide-keywords-shadows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'lexical-swift': patch
---

Fixed the inline style declaration keeping the case of a CSS-wide keyword (`INHERIT`, `Unset`) for font names and shadows, and keeping a `box-shadow` or `text-shadow` of nothing but identifiers (`Foo`, `red, blue`) as written. It now lower-cases those keywords for every property and rejects such shadows, as jsdom does.
39 changes: 36 additions & 3 deletions swift/Sources/Lexical/DOM/CSSValues.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
// `css/values` vectors: hex and functional colors become `rgb()`/`rgba()`,
// numbers lose their redundant digits, a bare `0` length becomes `0px`,
// keywords are lowercased, `calc()` of a product is evaluated, commas are
// spaced and single-quoted strings are requoted. Values outside that are
// kept as written, with their whitespace collapsed; jsdom also rejects
// values it cannot parse, which this does not.
// spaced and single-quoted strings are requoted. A CSS-wide keyword is
// lowercased for every property, and a shadow of nothing but identifiers is
// rejected. Values outside that are kept as written, with their whitespace
// collapsed; jsdom also rejects values it cannot parse, which this does not.

/// Bits of `cssPropertyTraitsByName`.
struct CSSPropertyTraits: OptionSet {
Expand Down Expand Up @@ -63,6 +64,26 @@ func normalizeCSSValue(_ name: String, _ value: String) -> String? {
}
guard let raw = cssPropertyTraitsByName[name] else { return nil }
let traits = CSSPropertyTraits(rawValue: raw)
let trimmed = jsTrim(value)
// A CSS-wide keyword is the whole value, in lower case, whatever the
// property keeps of other identifiers.
let lowered = asciiLowercased(trimmed)
if cssWideKeywords.contains(lowered) { return lowered }
if traits.contains(.verbatim) {
// The verbatim properties are the shadows: jsdom keeps a shadow as
// written, but reads `none` as a keyword and rejects a value of
// nothing but other identifiers, which no shadow is.
if lowered == "none" { return "none" }
let onlyIdentifiers = tokenizeCSSValue(trimmed).allSatisfy { token in
switch token {
case .comma: return true
case .word(let word):
guard let first = word.first else { return false }
return (first.isLetter || first == "_") && !word.contains("(")
}
}
if onlyIdentifiers { return nil }
}
var out = ""
var needsSpace = false
for token in tokenizeCSSValue(value) {
Expand All @@ -79,6 +100,18 @@ func normalizeCSSValue(_ name: String, _ value: String) -> String? {
return out.isEmpty ? nil : out
}

/// The keywords every property accepts.
private let cssWideKeywords: Set<String> = [
"initial", "inherit", "unset", "revert", "revert-layer",
]

/// `string` with A to Z in lower case, as CSS matches keywords.
private func asciiLowercased(_ string: String) -> String {
String(
String.UnicodeScalarView(
string.unicodeScalars.map { ("A"..."Z").contains($0) ? Unicode.Scalar($0.value + 32)! : $0 }))
}

private enum CSSToken {
case word(String)
case comma
Expand Down
34 changes: 34 additions & 0 deletions swift/Tests/ConformanceTests/CSSTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,40 @@ struct CSSTests {
#expect(cssPropertyTraitsByName == expected)
}

/// What `element.style.setProperty(name, value)` reads back, as
/// `css.test.ts`'s `probe` asks jsdom.
private func probe(_ name: String, _ value: String) -> String {
let element = DOMDocument().createElement("div")
element.style.setProperty(name, value)
return element.style.getPropertyValue(name)
}

/// Each property behaves as the probes `css.test.ts` ran in jsdom found,
/// not only as the table says.
@Test func propertiesNormalizeAsJSDOM() throws {
for c in try Vectors.load("css/properties", as: PropertyCase.self) {
#expect(!probe(c.name, "initial").isEmpty, "\(c.name) accepts initial")
let color =
probe(c.name, "#f00") == "rgb(255, 0, 0)"
|| probe(c.name, "1px solid #f00") == "1px solid rgb(255, 0, 0)"
#expect(color == c.color, "\(c.name) color")
#expect((probe(c.name, "Foo") == "Foo") == c.preserveCase, "\(c.name) preserveCase")
#expect((probe(c.name, "1PX 1px RED") == "1PX 1px RED") == c.verbatim, "\(c.name) verbatim")
#expect((probe(c.name, "0") == "0px") == c.zeroLength, "\(c.name) zeroLength")
}
}

/// jsdom lowercases a CSS-wide keyword for every property, and rejects a
/// shadow of nothing but identifiers.
@Test func cssWideKeywordsAndShadows() {
#expect(probe("font-family", "INHERIT") == "inherit")
#expect(probe("box-shadow", "Unset") == "unset")
#expect(probe("box-shadow", "NONE") == "none")
#expect(probe("box-shadow", "Foo") == "")
#expect(probe("text-shadow", "red, blue") == "")
#expect(probe("text-shadow", "1px 1px RED") == "1px 1px RED")
}

@Test func cssTextNormalizesAsJSDOM() throws {
for c in try Vectors.load("css/values", as: ValueCase.self) {
let element = DOMDocument().createElement("span")
Expand Down
Loading