From 9534ba239c296ca3bb6eab68f9a38da0473a2cff Mon Sep 17 00:00:00 2001 From: orta Date: Thu, 21 May 2026 06:00:16 +0100 Subject: [PATCH 1/3] Adds support for crossword compiler rectangular puzzles --- README.md | 16 +- .../src/crossCompilerXMLToXD.ts | 474 ++++++++++++++++ packages/xd-crossword-tools/src/index.ts | 1 + packages/xd-crossword-tools/src/xdLints.ts | 22 +- .../tests/crossCompilerXMLToXD.test.ts | 519 ++++++++++++++++++ .../crosscompiler/globe-2026-february.xml | 2 + website/src/MassImport.tsx | 2 +- website/src/components/MultiDragAndDrop.tsx | 7 +- website/src/components/RootContext.tsx | 4 +- website/src/components/SingleDragAndDrop.tsx | 6 +- website/src/components/XDEditor.tsx | 6 +- 11 files changed, 1045 insertions(+), 14 deletions(-) create mode 100644 packages/xd-crossword-tools/src/crossCompilerXMLToXD.ts create mode 100644 packages/xd-crossword-tools/tests/crossCompilerXMLToXD.test.ts create mode 100644 packages/xd-crossword-tools/tests/crosscompiler/globe-2026-february.xml diff --git a/README.md b/README.md index b2a860d..7dab9ea 100644 --- a/README.md +++ b/README.md @@ -4231,7 +4231,7 @@ npx xd-crossword-tools "https://puzzleme.amuselabs.com/pmm/crossword?id=abc123&s npx xd-crossword-tools puzzle.puz "https://puzzleme.amuselabs.com/pmm/crossword?id=abc123&set=..." -o ./output ``` -Supported input formats: `.puz`, `.jpz`, `.xml` (UClick), `.json` (Amuse Labs), `.txt` (Across text), and URLs which contain PuzzleMe crosswords. +Supported input formats: `.puz`, `.jpz`, `.xml` (UClick or Crossword Compiler), `.json` (Amuse Labs), `.txt` (Across text), and URLs which contain PuzzleMe crosswords. ## Import / Export @@ -4283,6 +4283,20 @@ const xd = jpzToXD(jpz) The jpz format import supports barred crosswords. +### Crossword Compiler .xml to .xd + +Crossword Compiler exports XML conforming to the [`rectangular-puzzle`](https://crossword.info/xml/rectangular-puzzle.xsd) schema. The importer handles standard and barred grids, circled cells, pre-filled letters, multi-letter rebus cells, inline clue markup (``, ``, ``, etc.), solver instructions, and per-clue metadata (`citation`, `hint-url`, `tags`, plus `is-theme` on the word). + +Multi-word answers are preserved using the xd split character: a `` (or a `` when no explicit solution is given) becomes `~ REAR|VIEW` with `splitcharacter: |` in the metadata. + +```ts +import { crossCompilerXMLToXD } from "xd-crossword-tools" + +const xmlResponse = await fetch(url) +const xmlString = await xmlResponse.text() +const xd = crossCompilerXMLToXD(xmlString) +``` + ### Across Text (.puz.txt) to .xd This library supports both v1 and v2 formats, including rebus cells and circled cells (MARK flag). diff --git a/packages/xd-crossword-tools/src/crossCompilerXMLToXD.ts b/packages/xd-crossword-tools/src/crossCompilerXMLToXD.ts new file mode 100644 index 0000000..a988aa3 --- /dev/null +++ b/packages/xd-crossword-tools/src/crossCompilerXMLToXD.ts @@ -0,0 +1,474 @@ +import { Clue, CrosswordJSON, Tile } from "xd-crossword-tools-parser" +import { JSONToXD } from "./JSONtoXD" +import { XMLParser } from "fast-xml-parser" + +// -- fast-xml-parser preserveOrder helpers (mirrors jpzToXD) -- +type FxpNode = { [key: string]: any } + +function nodeName(node: FxpNode): string | undefined { + return Object.keys(node).find((k) => k !== ":@" && k !== "#text") +} + +function nodeChildren(node: FxpNode): FxpNode[] { + const name = nodeName(node) + return name ? node[name] : [] +} + +function attr(node: FxpNode, key: string): string | undefined { + return node[":@"]?.[key] +} + +function findChild(nodes: FxpNode[], name: string): FxpNode | undefined { + return nodes.find((n) => nodeName(n) === name) +} + +function filterChildren(nodes: FxpNode[], name: string): FxpNode[] { + return nodes.filter((n) => nodeName(n) === name) +} + +function textContent(nodes: FxpNode[]): string { + let result = "" + for (const n of nodes) { + if ("#text" in n) { + result += n["#text"] + } else { + result += textContent(nodeChildren(n)) + } + } + return result +} + +/** + * Convert clue content (which may contain , , , , , , etc.) + * into xd inline markup. + */ +function convertNodesToXDMarkup(nodes: FxpNode[]): string { + if (nodes.length === 1 && nodeName(nodes[0]) === "span") { + nodes = nodeChildren(nodes[0]) + } + + const tagMap: { [tag: string]: { open: string; close: string } } = { + i: { open: "{/", close: "/}" }, + em: { open: "{/", close: "/}" }, + b: { open: "{*", close: "*}" }, + strong: { open: "{*", close: "*}" }, + u: { open: "{_", close: "_}" }, + s: { open: "{-", close: "-}" }, + strike: { open: "{-", close: "-}" }, + del: { open: "{-", close: "-}" }, + sub: { open: "{~", close: "~}" }, + sup: { open: "{^", close: "^}" }, + } + + let result = "" + for (const node of nodes) { + if ("#text" in node) { + result += node["#text"] + continue + } + + const tag = nodeName(node) + if (!tag) continue + const children = nodeChildren(node) + + if (tag === "img") { + const src = attr(node, "src") ?? "" + const alt = attr(node, "alt") ?? "" + result += alt ? `{![${src}|${alt}]!}` : `{![${src}]!}` + } else if (tag === "a") { + const href = attr(node, "href") ?? "" + const text = convertNodesToXDMarkup(children) + result += `{@${text}|${href}@}` + } else if (tag === "br") { + result += " " + } else if (tag in tagMap) { + const { open, close } = tagMap[tag] + result += `${open}${convertNodesToXDMarkup(children)}${close}` + } else { + result += convertNodesToXDMarkup(children) + } + } + + return result +} + +/** Parses an attribute like "1-6" or "5" into [start, end] (1-based, inclusive). */ +function parseRange(raw: string | undefined): [number, number] | undefined { + if (!raw) return undefined + const parts = raw.split("-").map((p) => parseInt(p, 10)) + if (parts.some((n) => Number.isNaN(n))) return undefined + if (parts.length === 1) return [parts[0], parts[0]] + return [parts[0], parts[1]] +} + +interface ResolvedWord { + /** 0-based cell positions (col, row) in order along the word. */ + cells: Array<{ col: number; row: number }> + /** Optional explicit display solution (e.g. "lady chatterleys lover"). */ + solution?: string + /** Whether the word is flagged as part of a puzzle theme. */ + isTheme: boolean + /** "across" if the word lies horizontally, "down" if vertical. */ + direction: "across" | "down" +} + +function resolveWord(wordEl: FxpNode): ResolvedWord | undefined { + const xRange = parseRange(attr(wordEl, "x")) + const yRange = parseRange(attr(wordEl, "y")) + const solution = attr(wordEl, "solution") + const isTheme = attr(wordEl, "is-theme") === "true" + + // Explicit children take precedence (matches the jpz style usage). + const cellChildren = filterChildren(nodeChildren(wordEl), "cells") + if (cellChildren.length > 0) { + const cells = cellChildren.map((c) => ({ + col: parseInt(attr(c, "x")!, 10) - 1, + row: parseInt(attr(c, "y")!, 10) - 1, + })) + const allSameRow = cells.every((c) => c.row === cells[0].row) + const allSameCol = cells.every((c) => c.col === cells[0].col) + const direction: "across" | "down" = allSameCol && !allSameRow ? "down" : "across" + return { cells, solution, isTheme, direction } + } + + if (!xRange || !yRange) return undefined + + const cells: Array<{ col: number; row: number }> = [] + if (xRange[0] !== xRange[1] && yRange[0] === yRange[1]) { + for (let x = xRange[0]; x <= xRange[1]; x++) cells.push({ col: x - 1, row: yRange[0] - 1 }) + return { cells, solution, isTheme, direction: "across" } + } + if (yRange[0] !== yRange[1] && xRange[0] === xRange[1]) { + for (let y = yRange[0]; y <= yRange[1]; y++) cells.push({ col: xRange[0] - 1, row: y - 1 }) + return { cells, solution, isTheme, direction: "down" } + } + // Single cell or diagonal — treat as across by default. + for (let x = xRange[0]; x <= xRange[1]; x++) { + for (let y = yRange[0]; y <= yRange[1]; y++) cells.push({ col: x - 1, row: y - 1 }) + } + return { cells, solution, isTheme, direction: "across" } +} + +/** + * Convert a word's explicit display solution (e.g. "rear-view", "fashion photographer") + * into the split indexes JSONToXD expects: a value of N means "insert the split + * character after grid tile N", i.e. between tiles N and N+1. + */ +function splitsFromSolution(solution: string, cellCount: number): number[] { + const splits: number[] = [] + let cellIndex = 0 + for (const ch of [...solution]) { + if (ch === " " || ch === "-") { + if (cellIndex > 0 && cellIndex < cellCount) splits.push(cellIndex - 1) + } else { + cellIndex++ + } + } + return splits +} + +/** + * Convert a `format` attribute like "4,5", "4-4", or "3,11,5" into split indexes. + * Comma and hyphen both mark a split boundary. Used as a fallback when a `` + * has no `solution=` attribute but the clue's `format` carries length info. + */ +function splitsFromFormat(format: string, cellCount: number): number[] { + const lengths = format.split(/[,\-]/).map((p) => parseInt(p.trim(), 10)) + if (lengths.some((n) => !Number.isFinite(n) || n <= 0)) return [] + if (lengths.length < 2) return [] + if (lengths.reduce((a, b) => a + b, 0) !== cellCount) return [] + const splits: number[] = [] + let running = 0 + for (let i = 0; i < lengths.length - 1; i++) { + running += lengths[i] + splits.push(running - 1) + } + return splits +} + +/** + * Pick a single-character symbol for a rebus entry, avoiding collisions with + * the upper-case letters used in the grid and with anything already assigned. + */ +function pickRebusSymbol(used: Set): string { + const candidates = "0123456789abcdefghijklmnopqrstuvwxyz" + for (const c of candidates) if (!used.has(c)) return c + throw new Error("Ran out of rebus symbols (more than 36 distinct rebuses)") +} + +/** + * Converts a crossword-compiler XML string (the format published from Crossword Compiler, + * documented at https://crossword.info/xml/rectangular-puzzle.xsd) into an xd file. + */ +export function crossCompilerXMLToXD(xmlString: string): string { + const fxpParser = new XMLParser({ + preserveOrder: true, + ignoreAttributes: false, + attributeNamePrefix: "", + processEntities: false, + trimValues: false, + }) + + const parsed: FxpNode[] = fxpParser.parse(xmlString) + + // Root is (or for the applet variant). + const root = parsed.find((n) => { + const name = nodeName(n) + return name !== undefined && name !== "?xml" + }) + if (!root) throw new Error("Could not find root element in crossword-compiler XML") + + const rootChildren = nodeChildren(root) + const rectangularPuzzle = findChild(rootChildren, "rectangular-puzzle") + if (!rectangularPuzzle) throw new Error("Could not find rectangular-puzzle element in crossword-compiler XML") + + const rpChildren = nodeChildren(rectangularPuzzle) + const crosswordEl = findChild(rpChildren, "crossword") + if (!crosswordEl) throw new Error("Could not find crossword element in crossword-compiler XML") + + // Metadata + const metadataEl = findChild(rpChildren, "metadata") + const metaChildren = metadataEl ? nodeChildren(metadataEl) : [] + const readMeta = (tag: string): string => { + const el = findChild(metaChildren, tag) + return el ? textContent(nodeChildren(el)).trim() : "" + } + + const meta: CrosswordJSON["meta"] = { + title: readMeta("title") || "Untitled", + author: readMeta("creator") || "Unknown Author", + editor: readMeta("editor"), + date: readMeta("created"), + copyright: readMeta("copyright") || readMeta("rights"), + } + const description = readMeta("description") + const publisher = readMeta("publisher") + if (description) meta.description = description + if (publisher) meta.publisher = publisher + + // Solver instructions (sibling of ) become the Notes section. + const instructionsEl = findChild(rpChildren, "instructions") + const notes = instructionsEl ? textContent(nodeChildren(instructionsEl)).trim() : "" + + // Grid + const cwChildren = nodeChildren(crosswordEl) + const gridEl = findChild(cwChildren, "grid") + if (!gridEl) throw new Error("Could not find grid element in crossword-compiler XML") + + const gridWidth = parseInt(attr(gridEl, "width")!, 10) + const gridHeight = parseInt(attr(gridEl, "height")!, 10) + const tiles: Tile[][] = Array.from({ length: gridHeight }, () => Array(gridWidth).fill({ type: "blank" })) + + // Rebus tracking — symbol assignment is shared across the whole grid so the + // same multi-letter solution always maps to the same symbol. + const rebuses: Record = {} + const rebusByWord: Record = {} + const usedSymbols = new Set() + + // Track decorative cell features so we can render a single Design section. + type CellDecor = { left?: boolean; top?: boolean; circle?: boolean } + const cellDecor: { [key: string]: CellDecor } = {} + let hasAnyBars = false + let hasAnyCircle = false + + // Pre-filled letters become a sparse Start grid. + const start: string[][] = Array.from({ length: gridHeight }, () => Array(gridWidth).fill("")) + let hasAnyStart = false + + const gridChildren = nodeChildren(gridEl) + for (const cell of filterChildren(gridChildren, "cell")) { + const x = parseInt(attr(cell, "x")!, 10) - 1 + const y = parseInt(attr(cell, "y")!, 10) - 1 + + const type = attr(cell, "type") + if (type === "block" || type === "void") { + tiles[y][x] = { type: "blank" } + continue + } + + const solution = attr(cell, "solution") ?? "?" + const upper = solution.toUpperCase() + + if (upper.length > 1) { + // Multi-letter cell ⇒ rebus. Re-use a symbol for the same word. + let symbol = rebusByWord[upper] + if (!symbol) { + symbol = pickRebusSymbol(usedSymbols) + usedSymbols.add(symbol) + rebusByWord[upper] = symbol + rebuses[symbol] = upper + } + tiles[y][x] = { type: "rebus", symbol, word: upper } + } else { + tiles[y][x] = { type: "letter", letter: upper } + } + + // Bars + if (attr(cell, "left-bar") === "true" || attr(cell, "top-bar") === "true") { + meta.form = "barred" + const key = `${y},${x}` + if (!cellDecor[key]) cellDecor[key] = {} + if (attr(cell, "left-bar") === "true") cellDecor[key].left = true + if (attr(cell, "top-bar") === "true") cellDecor[key].top = true + hasAnyBars = true + } + + // Circles + if (attr(cell, "background-shape") === "circle") { + const key = `${y},${x}` + if (!cellDecor[key]) cellDecor[key] = {} + cellDecor[key].circle = true + hasAnyCircle = true + } + + // Pre-filled letters: any `solve-state` (the letters the solver starts with) + // or `hint="true"` (filled-in helper cell) belongs in the Start section. + const solveState = attr(cell, "solve-state") + if (solveState && solveState.length > 0) { + start[y][x] = solveState.toUpperCase() + hasAnyStart = true + } else if (attr(cell, "hint") === "true") { + start[y][x] = upper + hasAnyStart = true + } + } + + // Resolve every word into its grid cells. + const wordEls = filterChildren(cwChildren, "word") + const wordMap = new Map() + for (const wordEl of wordEls) { + const id = attr(wordEl, "id") + if (!id) continue + const resolved = resolveWord(wordEl) + if (resolved) wordMap.set(id, resolved) + } + + // Build the joined answer letters and the matching tile list for a word. + const collectWordCells = (word: ResolvedWord): { answer: string; tiles: Tile[] } => { + let answer = "" + const wordTiles: Tile[] = [] + for (const { col, row } of word.cells) { + const tile = tiles[row]?.[col] + if (!tile) continue + wordTiles.push(tile) + if (tile.type === "letter") answer += tile.letter + else if (tile.type === "rebus") answer += tile.word + } + return { answer, tiles: wordTiles } + } + + // Clues — up to two blocks (across + down). The direction can be + // ambiguous (titles are sometimes empty), so we infer from each word's geometry. + const clueEls: FxpNode[] = [] + for (const cluesEl of filterChildren(cwChildren, "clues")) { + for (const c of filterChildren(nodeChildren(cluesEl), "clue")) clueEls.push(c) + } + + const clues: CrosswordJSON["clues"] = { across: [], down: [] } + const splitChar = "|" + let anySplits = false + + for (const clueEl of clueEls) { + const wordID = attr(clueEl, "word") + if (!wordID) continue + const word = wordMap.get(wordID) + if (!word) continue + + const numAttr = attr(clueEl, "number") + const number = numAttr ? parseInt(numAttr, 10) : NaN + if (!Number.isFinite(number)) continue + + const body = convertNodesToXDMarkup(nodeChildren(clueEl)).trim() + const { answer, tiles: wordTiles } = collectWordCells(word) + if (!answer) continue + + // Splits: prefer the word's explicit solution (e.g. "rear-view"), otherwise + // fall back to the clue's format attribute (e.g. "4-4"). + let splits: number[] = [] + if (word.solution) { + splits = splitsFromSolution(word.solution, wordTiles.length) + } + if (splits.length === 0) { + const format = attr(clueEl, "format") + if (format) splits = splitsFromFormat(format, wordTiles.length) + } + if (splits.length > 0) anySplits = true + + // Per-clue metadata (citation, hint URL, tags, theme flag). + const metadata: Record = {} + const citation = attr(clueEl, "citation") + if (citation) metadata.citation = citation + const hintUrl = attr(clueEl, "hint-url") + if (hintUrl) metadata.hintURL = hintUrl + const tags = attr(clueEl, "tags") + if (tags) metadata.tags = tags + if (word.isTheme) metadata.theme = "true" + + const first = word.cells[0] + const clue: Clue = { + number, + body, + answer: answer.toUpperCase(), + position: { col: first.col, index: first.row }, + direction: word.direction === "across" ? "across" : "down", + display: [], + tiles: wordTiles, + ...(splits.length > 0 && { splits }), + ...(Object.keys(metadata).length > 0 && { metadata }), + } + clues[word.direction].push(clue) + } + + if (anySplits) meta.splitcharacter = splitChar + + clues.across.sort((a, b) => a.number - b.number) + clues.down.sort((a, b) => a.number - b.number) + + // Build a unified Design section covering bars and circles. + let design: CrosswordJSON["design"] | undefined + if (hasAnyBars || hasAnyCircle) { + const styleMap = new Map() + const positions: string[][] = Array.from({ length: gridHeight }, () => Array(gridWidth).fill("")) + let styleLetterCode = 65 // 'A' + + for (const [cellKey, decor] of Object.entries(cellDecor)) { + const [y, x] = cellKey.split(",").map(Number) + const styleKey = `${decor.left ? "L" : ""}${decor.top ? "T" : ""}${decor.circle ? "C" : ""}` + if (!styleKey) continue + if (!styleMap.has(styleKey)) styleMap.set(styleKey, String.fromCharCode(styleLetterCode++)) + positions[y][x] = styleMap.get(styleKey)! + } + + const styles: { [key: string]: { [prop: string]: string } } = {} + for (const [styleKey, letter] of styleMap) { + const styleObj: { [prop: string]: string } = {} + if (styleKey.includes("L")) styleObj["bar-left"] = "true" + if (styleKey.includes("T")) styleObj["bar-top"] = "true" + if (styleKey.includes("C")) styleObj["background"] = "circle" + styles[letter] = styleObj + } + + design = { styles, positions } + } + + // Rebus map goes through meta.rebus, joined as "0=AB 1=CD". + if (Object.keys(rebuses).length > 0) { + meta.rebus = Object.entries(rebuses) + .map(([symbol, word]) => `${symbol}=${word}`) + .join(" ") + } + + const crosswordJSON: CrosswordJSON = { + meta, + tiles, + clues, + notes, + rebuses, + unknownSections: {}, + report: { success: true, errors: [], warnings: [] }, + ...(design && { design }), + ...(hasAnyStart && { start }), + } + + return JSONToXD(crosswordJSON) +} diff --git a/packages/xd-crossword-tools/src/index.ts b/packages/xd-crossword-tools/src/index.ts index ec37d80..8d3e0ee 100644 --- a/packages/xd-crossword-tools/src/index.ts +++ b/packages/xd-crossword-tools/src/index.ts @@ -7,6 +7,7 @@ export { editorInfoAtCursor, type PositionInfo } from "./editorInfoAtCursor" export { JSONToPuzJSON } from "./JSONToPuzJSON" export { xdDiff } from "./xdDiff" export { jpzToXD } from "./jpzToXD" +export { crossCompilerXMLToXD } from "./crossCompilerXMLToXD" export { encode as puzEncode, decode as puzDecode } from "./vendor/puzjs" export type { Puz2JSONResult } from "./vendor/puzjs" export { runLinterForClue } from "./xdLints" diff --git a/packages/xd-crossword-tools/src/xdLints.ts b/packages/xd-crossword-tools/src/xdLints.ts index c771256..672fe0f 100644 --- a/packages/xd-crossword-tools/src/xdLints.ts +++ b/packages/xd-crossword-tools/src/xdLints.ts @@ -1,6 +1,20 @@ -import { Clue, Report } from "xd-crossword-tools-parser" +import { Clue, CrosswordJSON, Report } from "xd-crossword-tools-parser" -export const runLinterForClue = (clue: Clue, ordinal: "across" | "down") => { +/** + * Runs linting checks on a single clue and returns any issues found. + * + * Checks include: + * - Answer words appearing in the clue body or hint (giving away the answer) + * - `-across` / `-down` references in the clue body without `refs` metadata set + * (only when the puzzle has a `splitCharacter` defined) + * - Multi-word answers (via splits) whose hint is missing a `:` qualifier + * (e.g. `: Abbr.`, `: Hyph.`, `: 2 wds.`) + * + * @param clue - The clue to lint + * @param ordinal - Whether this is an across or down clue + * @param crossword - The full crossword JSON; used to read puzzle-level meta (e.g. splitCharacter) + */ +export const runLinterForClue = (clue: Clue, ordinal: "across" | "down", crossword?: CrosswordJSON) => { const reports: Report[] = [] const lowerClueBody = clue.body.toLocaleLowerCase() @@ -43,7 +57,9 @@ export const runLinterForClue = (clue: Clue, ordinal: "across" | "down") => { } // If you're referring to another clue, you probably need to do this - if (lowerClueBody.includes("-across") || lowerClueBody.includes("-down")) { + // Only relevant when a splitCharacter is defined in the meta, as that's when cross-clue refs are meaningful + const splitCharacter = crossword?.meta.splitCharacter || (crossword?.meta as Record | undefined)?.["splitcharacter"] + if (splitCharacter && (lowerClueBody.includes("-across") || lowerClueBody.includes("-down"))) { if (!clue.metadata?.refs) addReport(`Clue ${ref} has a -across or -down hint, but no refs are provided`) } diff --git a/packages/xd-crossword-tools/tests/crossCompilerXMLToXD.test.ts b/packages/xd-crossword-tools/tests/crossCompilerXMLToXD.test.ts new file mode 100644 index 0000000..cdccac7 --- /dev/null +++ b/packages/xd-crossword-tools/tests/crossCompilerXMLToXD.test.ts @@ -0,0 +1,519 @@ +import { readFileSync } from "fs" +import { crossCompilerXMLToXD } from "../src/crossCompilerXMLToXD" +import { xdToJSON } from "xd-crossword-tools-parser" +import { describe, it, expect } from "vitest" + +const globeXML = readFileSync(__dirname + "/crosscompiler/globe-2026-february.xml", "utf8") + +describe(crossCompilerXMLToXD.name, () => { + it("converts a small inline crossword-compiler xml fixture", () => { + const xml = ` + + + + Tiny test + Ada Lovelace + 2026 + A small fixture + + + + + + + + + + + + + + + + + + + + Across + Feline + Conflict + + + Down + Female sheep, with W + Foot finger + + + +` + + const res = crossCompilerXMLToXD(xml) + expect(res).toMatchInlineSnapshot(` + "## Metadata + + title: Tiny test + author: Ada Lovelace + editor: + date: + copyright: 2026 + description: A small fixture + + ## Grid + + CAT + O.O + WAR + + ## Clues + + A1. Feline ~ CAT + A5. Conflict ~ WAR + + D1. Female sheep, with W ~ COW + D3. Foot finger ~ TOR" + `) + }) + + it("infers across vs down per clue from word geometry", () => { + const xml = ` + + + + Direction inference + Test + + + + + + + + + + + + + + + First across + First down + + + + Second across + Second down + + + +` + + const res = crossCompilerXMLToXD(xml) + expect(res).toContain("A1. First across ~ AB") + expect(res).toContain("A2. Second across ~ CD") + expect(res).toContain("D1. First down ~ AC") + expect(res).toContain("D2. Second down ~ BD") + }) + + it("converts inline html tags inside clues to xd markup", () => { + const xml = ` + + + + Markup + Test + + + + + + + + + + + + + + Across + Writer of the Divina Commedia + H2O fact + + + Down + Bold intro + Visit our site + + + +` + + const res = crossCompilerXMLToXD(xml) + expect(res).toContain("A1. Writer of the {/Divina Commedia/} ~ AB") + expect(res).toContain("A2. H{~2~}O fact ~ CD") + expect(res).toContain("D1. {*Bold*} intro ~ AC") + expect(res).toContain("D2. Visit {@our site|https://example.com@} ~ BD") + }) + + it("treats cells marked type=block as blanks", () => { + const xml = ` + + + + Blocks + Test + + + + + + + + + + + + + + + + + + Across + Middle row + + + Down + Left col + Right col + + + +` + + const res = crossCompilerXMLToXD(xml) + expect(res).toContain("A.B\nCDE\nF.G") + expect(res).toContain("A3. Middle row ~ CDE") + expect(res).toContain("D1. Left col ~ ACF") + expect(res).toContain("D2. Right col ~ BEG") + }) + + it("emits a Design section with bar-left and bar-top styles", () => { + const xml = ` + + + + Bars + Test + + + + + + + + + + + + + + Across + Top row + Bottom row + + + Down + Left col + Right col + + + +` + + const res = crossCompilerXMLToXD(xml) + expect(res).toContain("form: barred") + expect(res).toContain("A { bar-left: true }") + expect(res).toContain("B { bar-top: true }") + expect(res).toContain("C { bar-left: true; bar-top: true }") + }) + + it("converts into the Notes section", () => { + const xml = ` + + + + Notes test + Test + + Each answer is a word that can precede or follow "fire". + + + + + + + + + + + + + Across + Top + Bottom + + + Down + Left + Right + + + +` + + const res = crossCompilerXMLToXD(xml) + expect(res).toContain("## Notes\n\nEach answer is a word that can precede or follow \"fire\".") + }) + + it("emits a Design section combining bars and circles", () => { + const xml = ` + + + + Circles + Test + + + + + + + + + + + + + + Across + Top + Bottom + + + Down + Left + Right + + + +` + + const res = crossCompilerXMLToXD(xml) + expect(res).toContain("background: circle") + expect(res).toContain("bar-left: true") + expect(res).toContain("bar-top: true; background: circle") + }) + + it("converts solve-state and hint cells into a Start section", () => { + const xml = ` + + + + Start + Test + + + + + + + + + + + + + + + + Across + Feline + Night bird + + + Down + Bovine + Past tense of tell + + + +` + + const res = crossCompilerXMLToXD(xml) + const start = res.split("## Start\n\n")[1].trim() + // Row 1: C (solve-state), no fill, T (hint=true) → "C.T" + // Row 2: no pre-fills → "..." + expect(start).toBe("C.T\n...") + }) + + it("converts multi-letter cell solutions into rebuses", () => { + const xml = ` + + + + Rebus + Test + + + + + + + + + + + + + + + + Across + Letters one through four + Letters five through seven + + + Down + Left col + Right col + + + +` + + const res = crossCompilerXMLToXD(xml) + expect(res).toContain("rebus: 0=AB") + // Grid uses the symbol for the rebus cell + expect(res).toContain("0CD\nEFG") + // Clue answer contains the full multi-letter word + expect(res).toContain("A1. Letters one through four ~ ABCD") + expect(res).toContain("D1. Left col ~ ABE") + }) + + it("derives splits from a clue's format attribute when no word solution is given", () => { + const xml = ` + + + + Format splits + Test + + + + + + + + + + + + + + + + + + + + Across + Started fresh + Plain word + + + Down + Left + Right + + + +` + + const res = crossCompilerXMLToXD(xml) + expect(res).toContain("splitcharacter: |") + expect(res).toContain("A1. Started fresh ~ NEW|UP") + // format="5" matches the cell count exactly with no separators ⇒ no split + expect(res).toContain("A2. Plain word ~ ABCDE") + }) + + it("attaches clue metadata for citation, hint-url, tags, and is-theme", () => { + const xml = ` + + + + Clue metadata + Test + + + + + + + + + + + + + + Across + Theme entry + Plain + + + Down + Left + Right + + + +` + + const res = crossCompilerXMLToXD(xml) + expect(res).toContain("A1 ^citation: OED") + expect(res).toContain("A1 ^hintURL: https://example.com/help") + expect(res).toContain("A1 ^tags: theme,easy") + expect(res).toContain("A1 ^theme: true") + }) + + it("parses the 69x69 Globe puzzle fixture", () => { + const res = crossCompilerXMLToXD(globeXML) + + // Title is empty in the source, falls back to "Untitled". + expect(res).toContain("title: Untitled") + + // The grid should be 69 rows of 69 cells. + const grid = res.split("## Grid\n\n")[1].split("\n\n")[0] + const rows = grid.split("\n") + expect(rows.length).toBe(69) + rows.forEach((row) => expect(row.length).toBe(69)) + + // Spot-check known clues from the fixture. + expect(res).toContain("A1. Go around ~ BYPASS") + // Words whose contains spaces/hyphens are split + // with `|`, matching the splitcharacter declared in the metadata. + expect(res).toContain("splitcharacter: |") + expect(res).toContain("A4. Kind of 14-Down in your car ~ REAR|VIEW") + expect(res).toContain("A15. Richard Avedon or Sarah Moon, e.g. ~ FASHION|PHOTOGRAPHER") + expect(res).toContain("D1. Fortitude or spine ~ BACKBONE") + + // It should round-trip through the xd parser without errors. + const parsed = xdToJSON(res) + expect(parsed.report.success).toBe(true) + expect(parsed.tiles.length).toBe(69) + expect(parsed.tiles[0].length).toBe(69) + // Splits should be preserved on the parsed clues. + const a4 = parsed.clues.across.find((c) => c.number === 4) + expect(a4?.splits).toEqual([3]) + }) +}) diff --git a/packages/xd-crossword-tools/tests/crosscompiler/globe-2026-february.xml b/packages/xd-crossword-tools/tests/crosscompiler/globe-2026-february.xml new file mode 100644 index 0000000..e0fff2e --- /dev/null +++ b/packages/xd-crossword-tools/tests/crosscompiler/globe-2026-february.xml @@ -0,0 +1,2 @@ + +<b>Across</b>Go aroundKind of 14-Down in your carRichard Avedon or Sarah Moon, e.g.Itch to travelPrison restraintsDinosaur with large thumb spikes17-syllable poemWorkplace hierarchy with "rungs"Any toon whose archenemy is GargamelHenley eventWinter Olympics site of 1964 and 1976Thinking Face or Clapping Hands, e.g.Clown's hairpieceShops that hold items until loans are repaidShoulder-to-elbow boneShinerPilfersIn the wings, perhapsRudeCabbage dish to go with a German sausageNeighbour of Lebanon and IsraelWrinkly Jamaican fruitGolf course trimmerPoverty-strickenMozart and Christoph Waltz, by birthCarmaker with a four-ring logoPeeping TomSecond-year students in the U.S., for shortNovel whose title character is Oliver Mellors"You can't _____ the truth!" (movie line)One using a crib sheetIt makes yellow mustard yellowDunce cap shapeConvert to charged particlesPortable PC"...let no man put _____"Two-dot punctuation markHollywood awardsLyft competitorStealthy assassin in blackWhiskey and vermouth cocktailDestructive insectOne-of-a-kindCopyTroublingMay birthstonesArcaneBird who says, "The sky is falling!"Albania, Bulgaria, Canada, Denmark, and 28 othersSlopeTie gamePunch in the mouth, slangilyShe recorded Maneater and Turn Off the LightPecan confectionsNon-metric land areaRelishJoke-tellerRoadside stopoversMinor adjustmentCN Tower statState flower of New MexicoLost Horizon paradiseHyde's alter egoWonderwall bandMuch-watched movie of 2025With no discernable patternShort accountPopinjaysFutileOddLike a pitcher's perfect gameStarr of pop musicShrine statueOddExtended one's stintIron Man portrayerK worth 5 points, e.g.Protected from splattering food_____ Mysteries (Yannick Bisson TV series)December $$$ at workRoad runner?Natural sonar used by batsWander aboutToronto transport vehicles on tracksSamson and _____Corner piecePermissibleTrattoria rice dishTV trophiesMono- or semi-, for exampleBartleby, the _____ (Melville story)Horticultural artWrinkle-reducing injectionTheatre area above the ground floorBetZealousCosmic paybackGovernment system of Morocco and Saudi ArabiaDryer fluffRussian provinceAnimated series with a robot named BenderLine that Canadians require a passport to crossDiscoverer of four Jovian moonsAll-purposeBe a poor loserBand led by 426-AcrossRearrangement of the clue for 226-AcrossHarvey Wallbanger ingredientPuppyHome of 218-AcrossOdysseus, to the RomansIce cream flavour5th Dimension song of 1967Hindu mysticBuilding additionKathy Bates film directed by Rob ReinerShort raceSport invented in Washington state in 1965 (yes, 1965)Concrete-cracking toolOrienteering skillRosemary, for oneHot bookSchool danceDesert reptileMiddlemarch authorItalian whiteSinger Newton-JohnRiver through Vienna and BudapestWastewater conduitWatered, as cropsCupcake toppingEntertainment industryManual transmission pedalStimulate, as interestBizet person?Nine of diamonds?MarinerElevator directionBoulevard of Broken Dreams bandMuffler for winterUniversity-educated classClassification system in biologyCaptain's postHighway menaceSteven Tyler bandInkblots examOtalgiaDizzy feelingAntigone's dad"Hit the road!"Talk out againAmerigo Vespucci, vis-a-vis "America"Geisha's robeCourt proceedingsPeanut, for oneBorneo sultanate1942 Disney classicMeritFacsimileWill additionApt rearrangement of TERROR'S LOCALERecyclable metalCity encircled by Joshua's armySlightly openDawn2026, in the Chinese calendarPiano-playing dog on The Muppet ShowBearnaise sauce flavouringPottery oven10 to the 100th powerEmployees in mansionsNon, je ne regrette rien singerArchitect FrankBad Girls singer DonnaLaundry choreDeadly, toxic3x5 item for a recipeSkulk about to find prey"Walk-in" health facilityGreek geometerWrote "merchandise" as "mdse.", e.g.Coral islandBest Picture of 1985, with Meryl StreepHeroic tale____ Bobby (faithful Edinburgh dog)Sail supportLarge insect with antler-like mandiblesinfo@hockeycanada.ca, for oneHarry's houseCanal opened in 1869Plagued by misfortuneBeaconPraise highlySwiss Chalet sideEggplantsConnoisseur of fine foodConstitutionalFawlty Towers characterSurgical glove materialHis pet's a meowing snail named GaryStreep's co-star in 354-AcrossLike Kojak or Mr. CleanWealthy investorBat firstBlimp or helicopter, e.g.Ajax or Comet, e.g.Mountaineer's tool+The Tempest sorcererPlayground favourite"Delicious!"Slacks shadeThe Cask of _____ (Poe story)KingdomReady to skateDoing a second draft ofGiza attractionMonastery leaderCause of rustHollaback Girl singer GwenProton's localePathway high overhead of a stageFilm that won Cher an OscarExtended family1937 novella whose Spanish title is De ratones y hombresThe Lion King villainEvent for Félix Dolci"Hot" Mexican servingsIdyllic"Ear" eaten at a cookoutBlack box that's actually bright orangeKodak founder GeorgeWhere pitchers warm upToward the rudderRigoletto composerPiece of parsleyLight bulb moment, or Jan. 6Bottomless pitsElement #10DinersLabyrinth designer of mythTiramisu ingredientCalifornia roll, e.g.MaroonWhere the Jetsons live in Skypad Apartments12:35, for exampleEucalyptus munchersGarden maze wallColoured part of the eye"Poison" shrubAye-aye on Madagascar, e.g.Personal transport used by 007 in ThunderballAdonis-likePeaksWorkplace noteNewbornBreakfast bowlfulLike the statement "Less is more"It might come with your bill at a Chinese restaurantGordie Howe's team for 25 seasonsGarlicky cucumber-and-yogurt dipNorthernmost national capitalBanjo soundOrnate 18th-century styleEclair-shapedThe Trojan Women playwrightPictographic letterFlour-and-butter sauce thickenerEnergy point in yoga_____ alcoholDollar coinsUrge strongly1970s TV series with a Shaolin monk in 1870s AmericaPuttin' on the ____Nick who played Ron Swanson on TVBlack-and-blueQuantum theorist MaxAuthor whose writing inspired the musical CabaretShorten again, as a skirt495-Down note, in the U.K.Hair untangler with teeth_____ Passage (waterway sought by Franklin)Buffalo NHLerWater surrounded by the Canadian ShieldGustoLarge sea duckSong that says, "She's just a girl who claims that I am the one, but the kid is not my son"It might offer a trifle"Okay!"Suave and debonairBarn-adjacent spaceWhere Casablanca isItem in an ambulanceHe could eat no fat, in a nursery rhymeWhite-plumed heronBigfootLike tax evasionAll setClassic back-and-forth gagsLeg boneLaughably absurdWhat might result from spring cleaningOphthalmologistsHe played Alan Turing in The Imitation GameVisual representationAlanis Morissette hit that mentions "a traffic jam when you're already late"<b>Down</b>Fortitude or spineNot temporaryBaby-bringing birdLike 34, 56 and 8, but not 21 or 55People with lots to offer?Harrison Ford roleBends out of shape, like a wet boardImportant person?Not hipO Canada, for usCompete while clasping handsLooking glassAdoTeddy bear, e.g.Overrun, as with verminDocumentary voiceCleanlinessIce cream flavourArtifact found by 7-Down in his "Last Crusade"TV channel that airs River MonstersChinese province known for its spicy cuisineThe Icarus Agenda authorResult of an ego-deflating lossGreek victory goddessBotanical swellingMost baggyCinnamon-and-sugar cookieBank robberyGroup solidarityDecrease, as volumeTurn indicatorSpider-Man's girlfriend (or her strap shoe?)Drop from the willFiery Andalusian danceGive, as homeworkElement swallowed to enhance X-ray imagesWinter spikes36-inch rulersLike an eye-pleasing routeGas thief's toolWriggle uncomfortablyV-E Day celebrantsLike an important final exam, or a crisis at workCause shame toVietnam's capitalYellow citrus fruitGregorian _____Bitter chemical in red winePrague natives"My!"Opposed toOne-way signActor Wyle of The PittThey bring their suitsEdible gourdsGraceful birdTwo-speaker systemSecret storeGame with winds and dragonsSon of Enoch who lived to age 969Chopper's landing sitePierre Berton: "A Canadian is somebody who knows how to make love in a _____"Jacket made from hideHairy spiderNew _____ on life"Deafening silence" or "guest host", e.g.Of outstanding qualityWord after baby, fist or speedPotter's materialDentist's toolFaucet problemFlorida islandsVagueActor Reeves of Bill & Ted Face the MusicAlice in drag, for Daniel CraigTV host EllenNotre Dame's Fighting _____Teen idolOhio city where Procter & Gamble is headquarteredLost and found, for exampleStadium display showing game progressDream upWorked undercoverGreat Balls of Fire rockerOnly female character in Milne's Winnie the Pooh storiesWoman saved by PerseusIn the sackAttorney's assistantT. Roosevelt in 1901: "______ and carry a big stick; you will go far"TV's Remington _____Ultimatum wordsEmploying a "good enough" problem solving strategyRavi Shankar's instrument"The Red Priest", for VivaldiUnfaithful spouseJohn Belushi's Blues Brothers co-starProverbial place for batsCasa Loma cityYale's homeDice with eight facesBreakfast melonOn the floor belowEavesdropLine from a songRussian-American film composer TiomkinRifle throughJoyfulPeriodic table creatorOut in printMikado character with the title Lord High Everything ElseDesperateHose attachment25 or 6 to 4 bandStable workerVariety showInventor NikolaStreet talkCaesar's conquestWheel of Fortune purchaseAncient fable writerAdult-oriented entertainmentNeedle caseBraid of hairCertain primateFigure skating jumpStranger Things streaming serviceGarlicky shrimp dishThe Color Purple authorRelaxing soakCathedral's instrumentMaker of stringed instrumentsWaters between Alaska and SiberiaCanada's highest peakMacaroni shapeCreative inspirationHostile takeoverUncannyTaper offRudolf of balletNewsboy's cryComfortable positionViolinists hold themSwim in the buffParis riverLord of the Rings elf played by Orlando BloomCanadian Red _____ (pre-1965 flags)Picture puzzlePizza cheeseDevious plotGoogles oneselfStandard, usualUp a treeChemical element symbolized WShoulder gestureEarningsFrozen snowmanCaribbean Queen singer BillyOverdoes the criticismShatner and Nimoy seriesSecond order of angelsMnemonic devicesAnteater, frog or nightjar, e.g.The Babadook, e.g.LeaptAngkor Wat's countryActor who plays Heimdall in the Marvel moviesAlternative to a la carteCompany co-founded by Paul AllenEquivalent of kosher in 334-DownBirth-relatedFlag symbolSharp replyNymph who loved NarcissusNumber with no fractional partCook in hot oilBuy stocks or bondsLure inBattered frank on a stickVinson _____ (Antarctica's highest peak)The Barber of Seville composerWarms up, as leftoversCounterpart of VenusSome Enchanted _____ (love song from South Pacific)There's honour _____ thievesPlayful aquatic animalWalks through shallow waterCompany co-founded by Steve WozniakSentence structureFive Pillars faithVancouver Island city famous for its sweet "bar"Add more vocals toSlantPotato or yam, e.g.Sticky stuffSoda fountain drinkBritish singer whose sixth studio album inspired 2024's Brat SummerExtracurricular group in search of mates?Fedora materialTitle character in The Merchant of VeniceNarnia lionBiblical angelGlacial ridgeBeethoven's only operaExcessive worshipShowing good judgmentHe wrote the famous opening line "Mother died today, or maybe it was yesterday"BlushScratch 'n' _____x and y, on a graphNarcissist's afflictionLies in waitSmugly complacentBrooke Henderson's sportRare earth element whose symbol is YbSly animalsComing into existenceAromaticItalian custard of egg yolks and MarsalaGrateful Dead leaderPasta wheatOff-white paint shadeTV series about Laura Palmer's murderWater filter brandSpinner inside an engine that evens out rotationFamous theoretical physicist RichardDanger while ascending EverestRough drawingCharles's momBlended family of 1970s TVDog studier IvanYou put paste on it oftenThick as _____ (Jethro Tull album)Italian child educator MariaCandidAncient PeruvianEverglades reptile_____ stand (theatre snack bar)DampnessExperts say to avoid them 1-2 hours before sleepRaw beef dish of VeniceSoldierBreathing organLargest arteryCity served by Indira Gandhi International AirportOld West lawman wearing a starLike BigfootAsia's longest riverAlpine social gatheringIt consists of FC Augsburg, FC Bayern Munchen and 16 othersFemale graduatesMechanical manCreator of the Tintin booksClose male friendshipLike caviar and pretzelsSeemingly supernatural abilityBrief tussleOwned apartmentIdenticalCrashes someone's selfieMexican hatPopular pandemic loafUnderstand by instinctY-shaped weapon_____ Bader GinsburgJane Austen novelMuralist RiveraDomino's serviceYoung eelAcorn-dropping treesLike a restless sleepDecision reversalThe Island of Dr. _____ (H. G. Wells novel)To be: Fr.Like Sir Robert Borden, among prime ministersThe _____ of the Earth (Ken Follett novel)Vintage furniture items of some home officesHotel room door signMontreal filmmaker _____ DolanShaded with intersecting sets of parallel linesAristotle's schoolFDR radio broadcastSpeedyLanguage of PakistanArtist's standThe "O" of NATOInuit boatSleepless in Seattle director NoraAuthor's baneAverage citizenWord meaning grimy that also starts with GR-JavaArmy chaplain, informallyWay inWarren Buffett, by birthAnne Frank and Pepys, for twoFibOversensitive natureLike Buckminster Fuller's domeHurtDental filling materialHumiliatedPlatter spinnerLeaf's central veinGerman gummy bear companyLawrence of Arabia star Peter"Alas, poor ____!" (Hamlet line)Religious riftInterior designEmerald, essentiallyStorage room at the topKiki's duet partner on Don't Go Breaking My HeartEarly computer (or actor Michael backward)Family Matters nerdUniversity student's declarationNobel Peace Prize cityLegendary storyPicnic pests diff --git a/website/src/MassImport.tsx b/website/src/MassImport.tsx index d2e858c..1e4a19d 100644 --- a/website/src/MassImport.tsx +++ b/website/src/MassImport.tsx @@ -209,7 +209,7 @@ function MassImport() {

- Supported formats: .puz, .jpz, .json (amuse), .xml (uclick) + Supported formats: .puz, .jpz, .json (amuse), .xml (uclick or Crossword Compiler)

Drop multiple files or folders to convert them all at once.

diff --git a/website/src/components/MultiDragAndDrop.tsx b/website/src/components/MultiDragAndDrop.tsx index 1dba72e..bee5c11 100644 --- a/website/src/components/MultiDragAndDrop.tsx +++ b/website/src/components/MultiDragAndDrop.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback } from "react" -import { jpzToXD, puzToXD, amuseToXD, uclickXMLToXD, acrossTextToXD } from "xd-crossword-tools" +import { jpzToXD, puzToXD, amuseToXD, uclickXMLToXD, crossCompilerXMLToXD, acrossTextToXD } from "xd-crossword-tools" interface ConversionResult { filename: string @@ -63,12 +63,13 @@ export const MultiDragAndDrop: React.FC = ({ onFilesProce if (file.name.endsWith(".xml")) { const xmlText = await file.text() - const xd = uclickXMLToXD(xmlText) + const isCrosswordCompiler = xmlText.includes("crossword-compiler") || xmlText.includes("rectangular-puzzle") + const xd = isCrosswordCompiler ? crossCompilerXMLToXD(xmlText) : uclickXMLToXD(xmlText) return { filename: file.name, status: "success", xd, - originalFormat: "UClick XML", + originalFormat: isCrosswordCompiler ? "Crossword Compiler XML" : "UClick XML", } } diff --git a/website/src/components/RootContext.tsx b/website/src/components/RootContext.tsx index d25bf44..b518f52 100644 --- a/website/src/components/RootContext.tsx +++ b/website/src/components/RootContext.tsx @@ -68,10 +68,10 @@ export const RootProvider = ({ children }: React.PropsWithChildren) => { // Run linter for each clue for (const clue of state.clues.across) { - reports.push(...runLinterForClue(clue, "across")) + reports.push(...runLinterForClue(clue, "across", state)) } for (const clue of state.clues.down) { - reports.push(...runLinterForClue(clue, "down")) + reports.push(...runLinterForClue(clue, "down", state)) } // Run grid validation diff --git a/website/src/components/SingleDragAndDrop.tsx b/website/src/components/SingleDragAndDrop.tsx index 1c6d4a3..3a971e7 100644 --- a/website/src/components/SingleDragAndDrop.tsx +++ b/website/src/components/SingleDragAndDrop.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback, use, useRef, createContext } from "react" -import { jpzToXD, puzToXD, amuseToXD, uclickXMLToXD, acrossTextToXD } from "xd-crossword-tools" +import { jpzToXD, puzToXD, amuseToXD, uclickXMLToXD, crossCompilerXMLToXD, acrossTextToXD } from "xd-crossword-tools" import { decode } from "xd-crossword-tools/src/vendor/puzjs" @@ -66,7 +66,9 @@ const processFile = async ( if (file.name.endsWith(".xml")) { const xmlText = await file.text() - const xd = uclickXMLToXD(xmlText) + // Crossword Compiler XML uses the rectangular-puzzle schema; UClick XML doesn't. + const isCrosswordCompiler = xmlText.includes("crossword-compiler") || xmlText.includes("rectangular-puzzle") + const xd = isCrosswordCompiler ? crossCompilerXMLToXD(xmlText) : uclickXMLToXD(xmlText) setXD(xd) setLastFileContext({ content: xmlText, filename: file.name }) } diff --git a/website/src/components/XDEditor.tsx b/website/src/components/XDEditor.tsx index 97aae96..fc7e836 100644 --- a/website/src/components/XDEditor.tsx +++ b/website/src/components/XDEditor.tsx @@ -22,6 +22,8 @@ export const XDEditor = (props: {}) => { const wrapperElement = useRef(null) const setDefaultHeight = useRef(false) const editorInstanceRef = useRef(null) + const editorInfoRef = useRef(editorInfo) + editorInfoRef.current = editorInfo // When the inner content height changes, handle the resize const updateHeight = useCallback((e: monaco.editor.IContentSizeChangedEvent) => { @@ -45,12 +47,12 @@ export const XDEditor = (props: {}) => { e.onDidContentSizeChange(updateHeight) e.onDidChangeCursorPosition((e) => { - const info = editorInfo?.(e.position.lineNumber - 1, e.position.column - 1) + const info = editorInfoRef.current?.(e.position.lineNumber - 1, e.position.column - 1) setCursorInfo(info || null) }) }, - [updateHeight, editorInfo, setCursorInfo] + [updateHeight, setCursorInfo] ) // Update Monaco markers when validation reports change From 85f174301a7532dfbc0ed5c25842e25551c1c474 Mon Sep 17 00:00:00 2001 From: orta Date: Thu, 21 May 2026 06:26:36 +0100 Subject: [PATCH 2/3] CHANGELOG --- CHANGELOG.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 742c295..713ac22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ This isn't a comprehensive doc because to our knowledge there are no OSS consumers of this lib, but for posterities sake here are the breaking changes: +### 13.2.0 + +- Adds support for Crossword Compiler XML files +- Makes some of the Puzzmo-y feeling linters only apply if you have split character on + ### 13.1.0 - Switched the xml parsing library from xml-parser to fast-xml-parser. It may claim to be faster, but it can handle more complicated XML setups. This is mostly useful for the jpz -> xd clue parsing which should cover more cases now @@ -12,7 +17,6 @@ This isn't a comprehensive doc because to our knowledge there are no OSS consume - The markup has switched from some regexes to a real parser. - ### 12.3.0 - Adds a CLI, see the README for examples of usage @@ -27,7 +31,7 @@ This isn't a comprehensive doc because to our knowledge there are no OSS consume ### 12.1.0 -- Adds some functions for handling importing from a Puzzleme URL. Built on code found in https://github.com/thisisparker/xword-dl and https://github.com/jpd236/kotwords +- Adds some functions for handling importing from a Puzzleme URL. Built on code found in and ### 12.0.0 From 037ad52973826a2e87255026aa1eb5ddd288ebcb Mon Sep 17 00:00:00 2001 From: orta Date: Thu, 21 May 2026 06:27:48 +0100 Subject: [PATCH 3/3] Simpler readme --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7dab9ea..18752d3 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ There are two packages here: ### Example -Let's take this free `.puz`: https://dehodson.github.io/crossword-puzzles/crosswords/alpha-bits/ +Let's take this free `.puz`: Their .puz file turns into this xd: @@ -4285,9 +4285,7 @@ The jpz format import supports barred crosswords. ### Crossword Compiler .xml to .xd -Crossword Compiler exports XML conforming to the [`rectangular-puzzle`](https://crossword.info/xml/rectangular-puzzle.xsd) schema. The importer handles standard and barred grids, circled cells, pre-filled letters, multi-letter rebus cells, inline clue markup (``, ``, ``, etc.), solver instructions, and per-clue metadata (`citation`, `hint-url`, `tags`, plus `is-theme` on the word). - -Multi-word answers are preserved using the xd split character: a `` (or a `` when no explicit solution is given) becomes `~ REAR|VIEW` with `splitcharacter: |` in the metadata. +Crossword Compiler exports XML conforming to the [`rectangular-puzzle`](https://crossword.info/xml/rectangular-puzzle.xsd) schema. ```ts import { crossCompilerXMLToXD } from "xd-crossword-tools" @@ -4644,7 +4642,7 @@ The `xd-crossword-tools-parser` package exports several utility functions for wo NPM package publishing happens automatically via GitHub Actions when changes are pushed to the `main` branch. The workflow compares local package versions with published versions on npm and only publishes if versions have been bumped. -#### To Prepare a New Release: +#### To Prepare a New Release 1. **Update the changelog** (if applicable) to document changes in this release