From d77175c4e5ac7af3996990fddb6a00e4f6fb0a39 Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 14 Nov 2026 17:47:30 +0000 Subject: [PATCH] fix(swift): lower-case CSS-wide keywords and reject identifier-only shadows, as jsdom does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Swift's inline style declaration kept a CSS-wide keyword's case for properties that keep identifiers' case (`font-family: INHERIT`) or keep values as written (the shadows), and kept a `box-shadow` or `text-shadow` of nothing but identifiers (`Foo`, `red, blue`) as written. jsdom lower-cases `inherit`, `initial`, `unset`, `revert` and `revert-layer` for every property, reads `none` as a keyword, and rejects such a shadow, so upstream's HTML and Swift's differed for those values. `normalizeCSSValue` now does the same. `CSSTests` checks each property in `css/properties` with the probes `css.test.ts` ran in jsdom, not only the generated table, which is how the Rust port's runner found the difference, and pins the keyword and shadow cases. ## Test plan ### Before ``` $ swift test --skip-build --filter ConformanceTests.CSSTests ✘ Test cssWideKeywordsAndShadows() recorded an issue at CSSTests.swift:57:5: Expectation failed: probe("font-family", "INHERIT") == "inherit" ✘ Test cssWideKeywordsAndShadows() recorded an issue at CSSTests.swift:58:5: Expectation failed: probe("box-shadow", "Unset") == "unset" ✘ Test cssWideKeywordsAndShadows() recorded an issue at CSSTests.swift:59:5: Expectation failed: probe("box-shadow", "NONE") == "none" ✘ Test cssWideKeywordsAndShadows() recorded an issue at CSSTests.swift:60:5: Expectation failed: probe("box-shadow", "Foo") == "" ✘ Test cssWideKeywordsAndShadows() recorded an issue at CSSTests.swift:61:5: Expectation failed: probe("text-shadow", "red, blue") == "" ✘ Test propertiesNormalizeAsJSDOM() recorded an issue at CSSTests.swift:48:7: Expectation failed: (probe(c.name, "Foo") == "Foo") == c.preserveCase ✘ Test propertiesNormalizeAsJSDOM() recorded an issue at CSSTests.swift:48:7: Expectation failed: (probe(c.name, "Foo") == "Foo") == c.preserveCase ✘ Test propertiesNormalizeAsJSDOM() recorded an issue at CSSTests.swift:48:7: Expectation failed: (probe(c.name, "Foo") == "Foo") == c.preserveCase ✘ Test run with 4 tests in 1 suite failed after 0.082 seconds with 8 issues. ``` ### After ``` $ swift test --skip-build --filter ConformanceTests.CSSTests ✔ Test cssWideKeywordsAndShadows() passed after 0.002 seconds. ✔ Test cssTextNormalizesAsJSDOM() passed after 0.007 seconds. ✔ Test propertyTableMatchesVectors() passed after 0.008 seconds. ✔ Test propertiesNormalizeAsJSDOM() passed after 0.104 seconds. ✔ Test run with 4 tests in 1 suite passed after 0.105 seconds. $ swift test --skip-build --filter "ConformanceTests|LexicalTests|LexicalHTMLTests|LexicalSelectionTests|LexicalClipboardTests" ✔ Test run with 34 tests in 13 suites passed after 0.514 seconds. ``` The expected values were checked against jsdom (`element.style.setProperty(name, value)` then `getPropertyValue(name)`). Linux only; macOS and iOS run in CI. Amp-Thread-ID: https://ampcode.com/threads/T-506c8fa1-4309-40f1-9810-732b311eaeab --- swift/.changeset/css-wide-keywords-shadows.md | 5 +++ swift/Sources/Lexical/DOM/CSSValues.swift | 39 +++++++++++++++++-- swift/Tests/ConformanceTests/CSSTests.swift | 34 ++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 swift/.changeset/css-wide-keywords-shadows.md diff --git a/swift/.changeset/css-wide-keywords-shadows.md b/swift/.changeset/css-wide-keywords-shadows.md new file mode 100644 index 000000000000..5a0eb516164d --- /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 6f6c1fb6f827..b3a820ee8a07 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 a810ad36123a..22d804204550 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")