diff --git a/swift/.changeset/css-wide-keywords-shadows.md b/swift/.changeset/css-wide-keywords-shadows.md new file mode 100644 index 00000000000..5a0eb516164 --- /dev/null +++ b/swift/.changeset/css-wide-keywords-shadows.md @@ -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. diff --git a/swift/Sources/Lexical/DOM/CSSValues.swift b/swift/Sources/Lexical/DOM/CSSValues.swift index 6f6c1fb6f82..b3a820ee8a0 100644 --- a/swift/Sources/Lexical/DOM/CSSValues.swift +++ b/swift/Sources/Lexical/DOM/CSSValues.swift @@ -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 { @@ -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) { @@ -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 = [ + "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 diff --git a/swift/Tests/ConformanceTests/CSSTests.swift b/swift/Tests/ConformanceTests/CSSTests.swift index a810ad36123..22d80420455 100644 --- a/swift/Tests/ConformanceTests/CSSTests.swift +++ b/swift/Tests/ConformanceTests/CSSTests.swift @@ -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")