commit e9389d3f6d00689c7cf71421a6968635323e2e37
parent 8674e054b53941491e8ef34185874dc603660c35
Author: Michael Camilleri <[email protected]>
Date: Thu, 17 Sep 2026 18:43:23 +0900
Support iOS 27 and decode NYT clue entities
NYT's formatted clue text can carry HTML entities, which Crossmate
previously copied into rendered text. This commit decodes the ordinary
text entities and numeric Unicode references while retaining malformed
ones literally. Advancing the converter version makes an owned NYT
puzzle re-fetch and replace its source when it next opens, so existing
copies of the affected puzzle correct themselves.
iOS 27 is now the default Simulator runtime. Its accessibility rotor
builder rejects optional result-builder branches at the app's iOS 17.5
deployment target, so rotor entries are prepared before ForEach while
preserving their existing navigation behaviour.
Co-Authored-By: Codex GPT 5.6 Terra <[email protected]>
Diffstat:
11 files changed, 248 insertions(+), 44 deletions(-)
diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift
@@ -10,7 +10,7 @@ struct XD: Sendable {
/// owned NYT game should be re-fetched and re-converted. Only bumped when a
/// converter changes; puzzles generated in-house (Crossmake, bundled) carry
/// no converter version at all.
- static let currentConverterVersion = 12
+ static let currentConverterVersion = 13
/// Version of the XD→Puzzle/Core Data processing (`XD.parse`, `Puzzle(xd:)`,
/// and the cached summary fields). Held on the game entity, never
diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift
@@ -608,7 +608,7 @@ enum NYTToXDConverter {
// A non-empty `formatted` field alone isn't a theme signal: image
// clues mirror a bare symbol ("¥") or the plain text there. Only
// genuine emphasis markup marks a themer.
- return containsEmphasisMarkup(decodeBasicEntities(formatted))
+ return containsEmphasisMarkup(decodeHTMLEntities(formatted))
}
}
@@ -653,7 +653,7 @@ enum NYTToXDConverter {
/// round-trips, and any unrecognized residual tags — sub/sup, `<span>`,
/// stray layout markup — are dropped, preserving their text content.
private static func xdMarkup(fromFormatted formatted: String) -> String? {
- let decoded = decodeBasicEntities(formatted)
+ let decoded = decodeHTMLEntities(formatted)
guard containsConvertibleMarkup(decoded) else { return nil }
var out = decoded
for tag in markupTags {
@@ -663,13 +663,174 @@ enum NYTToXDConverter {
return out.replacing(/<[^>]+>/, with: "")
}
- private static func decodeBasicEntities(_ s: String) -> String {
- var out = s
- for (entity, char) in [("<", "<"), (">", ">"), (""", "\""),
- ("'", "'"), ("'", "'"), ("&", "&")] {
- out = out.replacingOccurrences(of: entity, with: char)
+ /// This compact set covers ordinary clue prose: Latin-1 characters,
+ /// typography, and spacing. Numeric references are decoded separately, so
+ /// any Unicode scalar can be represented without a named-table entry or
+ /// static data for rarely used mathematical entities.
+ private static let namedHTMLEntities: [String: String] = [
+ "AElig": "Æ",
+ "Aacute": "Á",
+ "Acirc": "Â",
+ "Agrave": "À",
+ "Aring": "Å",
+ "Atilde": "Ã",
+ "Auml": "Ä",
+ "Ccedil": "Ç",
+ "ETH": "Ð",
+ "Eacute": "É",
+ "Ecirc": "Ê",
+ "Egrave": "È",
+ "Euml": "Ë",
+ "Iacute": "Í",
+ "Icirc": "Î",
+ "Igrave": "Ì",
+ "Iuml": "Ï",
+ "Ntilde": "Ñ",
+ "Oacute": "Ó",
+ "Ocirc": "Ô",
+ "Ograve": "Ò",
+ "Oslash": "Ø",
+ "Otilde": "Õ",
+ "Ouml": "Ö",
+ "Prime": "″",
+ "THORN": "Þ",
+ "Uacute": "Ú",
+ "Ucirc": "Û",
+ "Ugrave": "Ù",
+ "Uuml": "Ü",
+ "Yacute": "Ý",
+ "aacute": "á",
+ "acirc": "â",
+ "acute": "´",
+ "aelig": "æ",
+ "agrave": "à",
+ "amp": "&",
+ "apos": "'",
+ "aring": "å",
+ "atilde": "ã",
+ "auml": "ä",
+ "bdquo": "„",
+ "brvbar": "¦",
+ "ccedil": "ç",
+ "cedil": "¸",
+ "cent": "¢",
+ "copy": "©",
+ "curren": "¤",
+ "deg": "°",
+ "divide": "÷",
+ "eacute": "é",
+ "ecirc": "ê",
+ "egrave": "è",
+ "emsp": " ",
+ "ensp": " ",
+ "eth": "ð",
+ "euml": "ë",
+ "frac12": "½",
+ "frac14": "¼",
+ "frac34": "¾",
+ "gt": ">",
+ "hellip": "…",
+ "iacute": "í",
+ "icirc": "î",
+ "iexcl": "¡",
+ "igrave": "ì",
+ "iquest": "¿",
+ "iuml": "ï",
+ "laquo": "«",
+ "ldquo": "“",
+ "lsquo": "‘",
+ "lt": "<",
+ "macr": "¯",
+ "mdash": "—",
+ "micro": "µ",
+ "middot": "·",
+ "minus": "−",
+ "nbsp": " ",
+ "ndash": "–",
+ "not": "¬",
+ "ntilde": "ñ",
+ "oacute": "ó",
+ "ocirc": "ô",
+ "ograve": "ò",
+ "ordf": "ª",
+ "ordm": "º",
+ "oslash": "ø",
+ "otilde": "õ",
+ "ouml": "ö",
+ "para": "¶",
+ "permil": "‰",
+ "plusmn": "±",
+ "pound": "£",
+ "prime": "′",
+ "quot": "\"",
+ "raquo": "»",
+ "rdquo": "”",
+ "reg": "®",
+ "rsquo": "’",
+ "sbquo": "‚",
+ "sect": "§",
+ "shy": "",
+ "sup1": "¹",
+ "sup2": "²",
+ "sup3": "³",
+ "szlig": "ß",
+ "thinsp": " ",
+ "thorn": "þ",
+ "times": "×",
+ "trade": "™",
+ "uacute": "ú",
+ "ucirc": "û",
+ "ugrave": "ù",
+ "uml": "¨",
+ "uuml": "ü",
+ "yacute": "ý",
+ "yen": "¥",
+ "yuml": "ÿ",
+ ]
+
+ /// Decodes semicolon-terminated named and numeric HTML entities while
+ /// preserving unrecognized or malformed references verbatim. This keeps
+ /// the converter's markup policy narrow: entity text is decoded, but only
+ /// the explicitly mapped emphasis tags become XD markup.
+ private static func decodeHTMLEntities(_ source: String) -> String {
+ var decoded = ""
+ var cursor = source.startIndex
+
+ while let ampersand = source[cursor...].firstIndex(of: "&") {
+ decoded += source[cursor..<ampersand]
+ let entityStart = source.index(after: ampersand)
+ guard let semicolon = source[entityStart...].firstIndex(of: ";"),
+ source.distance(from: entityStart, to: semicolon) <= 64,
+ let replacement = decodedHTMLEntity(
+ named: String(source[entityStart..<semicolon])
+ )
+ else {
+ decoded.append("&")
+ cursor = entityStart
+ continue
+ }
+ decoded += replacement
+ cursor = source.index(after: semicolon)
}
- return out
+
+ decoded += source[cursor...]
+ return decoded
+ }
+
+ private static func decodedHTMLEntity(named name: String) -> String? {
+ guard name.first == "#" else {
+ return namedHTMLEntities[name]
+ }
+
+ let digits = name.dropFirst()
+ let value: UInt32?
+ if digits.first == "x" || digits.first == "X" {
+ value = UInt32(digits.dropFirst(), radix: 16)
+ } else {
+ value = UInt32(digits, radix: 10)
+ }
+ guard let value, value != 0, let scalar = Unicode.Scalar(value) else { return nil }
+ return String(scalar)
}
private static func clueToken(_ clue: [String: Any]) -> String? {
diff --git a/Crossmate/Views/Puzzle/GridAccessibility.swift b/Crossmate/Views/Puzzle/GridAccessibility.swift
@@ -383,6 +383,29 @@ private struct PuzzleGridAccessibility: ViewModifier {
// MARK: - Rotors
+ private struct ClueRotorEntry: Identifiable {
+ let ref: Puzzle.ClueRef
+ let label: String
+ let position: GridPosition
+
+ var id: Puzzle.ClueRef { ref }
+ }
+
+ private func clueRotorEntries(for refs: [Puzzle.ClueRef]) -> [ClueRotorEntry] {
+ let puzzle = session.puzzle
+ let describer = CellAccessibilityDescriber(puzzle: puzzle, authorNames: [:])
+ return refs.compactMap { ref in
+ guard let cell = puzzle.cell(numbered: ref.number),
+ let label = describer.clueLabel(number: ref.number, direction: ref.direction)
+ else { return nil }
+ return ClueRotorEntry(
+ ref: ref,
+ label: label,
+ position: GridPosition(row: cell.row, col: cell.col)
+ )
+ }
+ }
+
/// Rotor entries for one direction's clue list, each jumping VoiceOver to
/// the word's first cell. The prepare closure aligns the session cursor
/// first so the jump lands with direction and selection already correct
@@ -391,19 +414,15 @@ private struct PuzzleGridAccessibility: ViewModifier {
private func clueRotorEntries(
for direction: Puzzle.Direction
) -> some AccessibilityRotorContent {
- let puzzle = session.puzzle
- let describer = CellAccessibilityDescriber(puzzle: puzzle, authorNames: [:])
- let clues = direction == .across ? puzzle.acrossClues : puzzle.downClues
- ForEach(clues) { clue in
- if let cell = puzzle.cell(numbered: clue.number),
- let label = describer.clueLabel(number: clue.number, direction: direction) {
- AccessibilityRotorEntry(
- Text(label),
- id: GridPosition(row: cell.row, col: cell.col),
- in: rotorNamespace
- ) {
- session.selectClue(direction: direction, number: clue.number)
- }
+ let clues = direction == .across ? session.puzzle.acrossClues : session.puzzle.downClues
+ let refs = clues.map { Puzzle.ClueRef(number: $0.number, direction: direction) }
+ ForEach(clueRotorEntries(for: refs)) { entry in
+ AccessibilityRotorEntry(
+ Text(entry.label),
+ id: entry.position,
+ in: rotorNamespace
+ ) {
+ session.selectClue(direction: entry.ref.direction, number: entry.ref.number)
}
}
}
@@ -414,18 +433,13 @@ private struct PuzzleGridAccessibility: ViewModifier {
/// rotor forever.
@AccessibilityRotorContentBuilder
private var incompleteClueRotorEntries: some AccessibilityRotorContent {
- let puzzle = session.puzzle
- let describer = CellAccessibilityDescriber(puzzle: puzzle, authorNames: [:])
- ForEach(incompleteClueRefs, id: \.self) { ref in
- if let cell = puzzle.cell(numbered: ref.number),
- let label = describer.clueLabel(number: ref.number, direction: ref.direction) {
- AccessibilityRotorEntry(
- Text(label),
- id: GridPosition(row: cell.row, col: cell.col),
- in: rotorNamespace
- ) {
- session.selectClue(direction: ref.direction, number: ref.number)
- }
+ ForEach(clueRotorEntries(for: incompleteClueRefs)) { entry in
+ AccessibilityRotorEntry(
+ Text(entry.label),
+ id: entry.position,
+ in: rotorNamespace
+ ) {
+ session.selectClue(direction: entry.ref.direction, number: entry.ref.number)
}
}
}
diff --git a/Scripts/build.sh b/Scripts/build.sh
@@ -2,7 +2,7 @@
set -euo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/select-simulator.sh"
-select_simulator "${1:-26}"
+select_simulator "${1:-27}"
if command -v xcode-build-server &>/dev/null; then
xcode-build-server config -scheme "Crossmate" -project "Crossmate.xcodeproj"
diff --git a/Scripts/run-demo.sh b/Scripts/run-demo.sh
@@ -9,7 +9,7 @@
# so it never collides with whatever device Xcode has open. iPhone and iPad each
# get their own demo device, so the two can coexist.
#
-# Usage: bash Scripts/run-demo.sh [iOS-major] (iPhone, default major 26)
+# Usage: bash Scripts/run-demo.sh [iOS-major] (iPhone, default major 27)
# bash Scripts/run-demo.sh --ipad [iOS-major] (iPad mini)
# bash Scripts/run-demo.sh [--ipad] --delete (remove the demo simulator)
# bash Scripts/run-demo.sh --v4-notice (also show the v4 reset notice)
@@ -23,7 +23,7 @@ source "${SCRIPT_DIR}/select-simulator.sh"
# the resolved device down, and a bare number overrides the iOS major version.
DEVICE_KIND="iphone"
DELETE=false
-MAJOR=26
+MAJOR=27
SHOW_V4_NOTICE=false
for arg in "$@"; do
case "$arg" in
diff --git a/Scripts/screenshots-ipad.sh b/Scripts/screenshots-ipad.sh
@@ -13,7 +13,7 @@ while getopts "c" opt; do
done
shift $((OPTIND - 1))
-MAJOR="${1:-26}"
+MAJOR="${1:-27}"
RUNTIME=$(xcrun simctl list runtimes available \
| grep "iOS ${MAJOR}\." \
| sed 's/.*iOS \([0-9.]*\).*/\1/' \
diff --git a/Scripts/screenshots-iphone.sh b/Scripts/screenshots-iphone.sh
@@ -14,7 +14,7 @@ while getopts "n" opt; do
done
shift $((OPTIND - 1))
-MAJOR="${1:-26}"
+MAJOR="${1:-27}"
select_simulator "$MAJOR"
echo "Using ${DEVICE}, iOS ${RUNTIME}"
diff --git a/Scripts/screenshots-marketing.sh b/Scripts/screenshots-marketing.sh
@@ -16,7 +16,7 @@ while getopts "p:n" opt; do
done
shift $((OPTIND - 1))
-MAJOR="${1:-26}"
+MAJOR="${1:-27}"
case "$PLATFORM" in
iphone|ipad) ;;
*)
@@ -27,7 +27,7 @@ esac
set_simulator_landscape_left() {
# The Device ▸ Orientation ▸ Landscape Left menu path (the extra
- # `menu bar item "Device"` level is required on the iOS 26 Simulator).
+ # `menu bar item "Device"` level is required on the iOS 27 Simulator).
# "Landscape Left" is absolute, so clicking it repeatedly is idempotent.
osascript \
-e 'tell application "Simulator" to activate' \
diff --git a/Scripts/select-simulator.sh b/Scripts/select-simulator.sh
@@ -1,10 +1,10 @@
#!/bin/bash
# Shared helper: resolves a usable iOS Simulator for the given major version.
-# Source this file, then call: select_simulator [major] (default major: 26)
+# Source this file, then call: select_simulator [major] (default major: 27)
# On success sets RUNTIME, DESTINATION_OS, and DEVICE; on failure prints to stderr and returns 1.
select_simulator() {
- local major="${1:-26}"
+ local major="${1:-27}"
local runtime_line
runtime_line=$(xcrun simctl list runtimes available \
diff --git a/Scripts/test-unit.sh b/Scripts/test-unit.sh
@@ -2,7 +2,7 @@
set -euo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/select-simulator.sh"
-select_simulator "${1:-26}"
+select_simulator "${1:-27}"
echo "Using ${DEVICE}, iOS ${RUNTIME}"
xcodebuild test \
diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift
@@ -503,6 +503,35 @@ struct NYTToXDConverterTests {
#expect(intents == [.emphasized])
}
+ @Test("Formatted clue decodes named and numeric HTML entities")
+ func formattedClueDecodesHTMLEntities() throws {
+ // NYT's 2026-09-10 25-Down used ` ` to preserve its deliberate
+ // double spacing within an italicized theme clue. The other forms
+ // cover the broader decoder rather than another one-off replacement.
+ let data = try puzzleJSON(
+ relatives: [nil, nil, nil, nil, nil, nil],
+ formattedOverrides: [
+ 0: "<i>Pla_e for a _ase? “Crème” … © 🙂</i>"
+ ]
+ )
+ let xd = try NYTToXDConverter.convert(jsonData: data)
+ #expect(xd.contains("A1. {/Pla_e for a \u{00A0}_ase? “Crème” … © 🙂/} ~ ABC"))
+
+ let puzzle = Puzzle(xd: try XD.parse(xd))
+ let clue = try #require(puzzle.acrossClues.first { $0.number == 1 })
+ #expect(clue.text == "Pla_e for a \u{00A0}_ase? “Crème” … © 🙂")
+ }
+
+ @Test("Formatted clue preserves unrecognized HTML entities")
+ func formattedCluePreservesUnrecognizedHTMLEntity() throws {
+ let data = try puzzleJSON(
+ relatives: [nil, nil, nil, nil, nil, nil],
+ formattedOverrides: [0: "<i>Unknown &madeup; entity</i>"]
+ )
+ let xd = try NYTToXDConverter.convert(jsonData: data)
+ #expect(xd.contains("A1. {/Unknown &madeup; entity/} ~ ABC"))
+ }
+
@Test("Underline formatted clue converts for display but is not a theme group")
func underlineClueConvertsButDoesNotGroup() throws {
let data = try puzzleJSON(