diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b55f39..c69a32c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,7 @@ jobs: - name: Build site run: | npm ci + npm test npm run build - name: Deploy PR preview if: >- diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 44f1dbf..db63ef3 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -38,6 +38,7 @@ jobs: - name: Build site run: | npm ci + npm test npm run build -- --base "${{ steps.pages.outputs.base_path }}/" - uses: actions/upload-pages-artifact@v5 with: diff --git a/README.md b/README.md index 9f720a0..7b563d3 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ The parser and converter are written in Swift. [JavaScriptKit](https://github.co - Links source and `minimalDescription` ranges with bidirectional hover highlighting. - Provides dedicated `minimalDesc`, encoding info, and occurrence statistics tabs. - Highlights every matching source range when a statistics row is hovered or focused. +- Copies compact, self-contained links that reopen a shared `minimalDescription`. - Runs entirely in the browser; pasted descriptions are not uploaded. - Tests the conversion engine natively with SwiftPM before every deployment. @@ -22,6 +23,13 @@ intentionally omits rendering details, reconstructed descriptions mark an unknow identifier with `?` and an unrecoverable payload or effect kind with `*`. Converting the reconstructed description forward again preserves the original `minimalDescription`. +### Shared links + +`Copy link` stores the canonical `minimalDescription`, rather than the much larger source +description, as versioned UTF-8 Base64URL in the URL fragment. Opening the link decodes that value +and starts in reverse-conversion mode. Fragments are not included in HTTP requests, so shared +DisplayList data is only decoded in the browser. + The mapping follows OpenSwiftUI's [`DisplayListPrinter.swift`](https://github.com/OpenSwiftUIProject/OpenSwiftUI/blob/main/Sources/OpenSwiftUICore/Render/DisplayList/DisplayListPrinter.swift), audited for SwiftUI 6.5.4. The interaction model is inspired by [SwiftFiddle/swiftregex](https://github.com/SwiftFiddle/swiftregex). The CodeMirror decoration and statistics interaction patterns are adapted from [SwiftFiddle/swift-ast-explorer](https://github.com/SwiftFiddle/swift-ast-explorer); see [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md). diff --git a/Sources/DisplayListWeb/main.swift b/Sources/DisplayListWeb/main.swift index 7fe3c39..4fafc6a 100644 --- a/Sources/DisplayListWeb/main.swift +++ b/Sources/DisplayListWeb/main.swift @@ -6,6 +6,7 @@ private let editor = JSObject.global.displayListEditor.object! private let output = document.getElementById("minimal-output").object! private let errorMessage = document.getElementById("conversion-error").object! private let copyButton = document.getElementById("copy-button").object! +private let shareButton = document.getElementById("share-button").object! private let clearButton = document.getElementById("clear-button").object! private let sampleButton = document.getElementById("sample-button").object! private let status = document.getElementById("wasm-status").object! @@ -21,6 +22,7 @@ private let outputTitle = document.getElementById("output-title").object! private let outputTabLabel = document.getElementById("output-tab-label").object! private let forwardLimitation = document.getElementById("forward-limitation").object! private let reverseLimitation = document.getElementById("reverse-limitation").object! +private let urlState = JSObject.global.displayListURLState.object! private enum ConversionDirection: Equatable { case descriptionToMinimal @@ -53,8 +55,11 @@ private var direction = ConversionDirection.descriptionToMinimal private var latestConversion: ExplorerConversion? private var latestSource = "" private var latestOutput = "" +private var latestSharedEncoding = "" private var highlightedOutputElements: [JSObject] = [] private var activeHighlightKey: String? +private var isInitializingEditor = true +private var isURLStateActive = false private let sampleDescription = """ (display-list @@ -415,6 +420,16 @@ private func swapDirection() { _ = editor.focus!() } +private func syncSharedEncodingURL() { + guard isURLStateActive else { return } + + if latestSharedEncoding.isEmpty { + _ = urlState.clearEncoding!() + } else { + _ = urlState.setEncoding!(latestSharedEncoding) + } +} + private func convert(_ source: String) { latestSource = source clearHighlight() @@ -422,6 +437,7 @@ private func convert(_ source: String) { guard source.contains(where: { !$0.isWhitespace }) else { latestConversion = nil latestOutput = "" + latestSharedEncoding = "" setOutputPlaceholder( direction == .descriptionToMinimal ? "Your minimal description will appear here." @@ -430,36 +446,51 @@ private func convert(_ source: String) { mappingSummary.textContent = "No linked ranges" errorMessage.hidden = .boolean(true) copyButton.disabled = .boolean(true) + shareButton.disabled = .boolean(true) renderStatistics(nil) + syncSharedEncodingURL() return } do { let conversion: ExplorerConversion + let sharedEncoding: String switch direction { case .descriptionToMinimal: - conversion = ExplorerConversion(try DisplayListDescriptionConverter.convert(source)) + let result = try DisplayListDescriptionConverter.convert(source) + conversion = ExplorerConversion(result) + sharedEncoding = result.minimalDescription case .minimalToDescription: - conversion = ExplorerConversion(try DisplayListMinimalDescriptionConverter.convert(source)) + let result = try DisplayListMinimalDescriptionConverter.convert(source) + conversion = ExplorerConversion(result) + sharedEncoding = try DisplayListDescriptionConverter + .convert(result.description) + .minimalDescription } latestConversion = conversion latestOutput = conversion.output + latestSharedEncoding = sharedEncoding renderMappedOutput(conversion) mappingSummary.textContent = .string( "\(conversion.spans.count) linked ranges · \(conversion.usedEncodingIDs.count) encodings" ) errorMessage.hidden = .boolean(true) copyButton.disabled = .boolean(false) + shareButton.disabled = .boolean(false) renderStatistics(conversion) + syncSharedEncodingURL() } catch { latestConversion = nil latestOutput = "" + latestSharedEncoding = "" setOutputPlaceholder("Unable to convert this input.") mappingSummary.textContent = "Conversion failed" errorMessage.textContent = .string(String(describing: error)) errorMessage.hidden = .boolean(false) copyButton.disabled = .boolean(true) + shareButton.disabled = .boolean(true) renderStatistics(nil) + syncSharedEncodingURL() } } @@ -481,6 +512,9 @@ private func selectTab(_ name: String) { private func installEventHandlers() { let changeClosure = JSClosure { arguments in + if !isInitializingEditor { + isURLStateActive = true + } convert(arguments.first?.string ?? "") return .undefined } @@ -537,6 +571,25 @@ private func installEventHandlers() { copyButton.onclick = .object(copyClosure) retainedClosures.append(copyClosure) + let shareClosure = JSClosure { _ in + guard !latestSharedEncoding.isEmpty else { return .undefined } + isURLStateActive = true + guard let href = urlState.setEncoding!(latestSharedEncoding).string else { + return .undefined + } + _ = JSObject.global.navigator.clipboard.writeText(href) + shareButton.textContent = "Link copied" + + let resetClosure = JSClosure { _ in + shareButton.textContent = "Copy link" + return .undefined + } + _ = JSObject.global.setTimeout!(resetClosure, 1_400) + return .undefined + } + shareButton.onclick = .object(shareClosure) + retainedClosures.append(shareClosure) + let directionClosure = JSClosure { _ in swapDirection() return .undefined @@ -559,8 +612,14 @@ private func installEventHandlers() { renderInfo() installEventHandlers() selectTab("minimal") +let initialSharedEncoding = urlState.readEncoding!().string +if initialSharedEncoding != nil { + direction = .minimalToDescription + isURLStateActive = true +} updateDirectionInterface() status.textContent = "SwiftWasm ready" _ = status.classList.add("is-ready") -_ = editor.setValue!(sample(for: direction)) +_ = editor.setValue!(initialSharedEncoding ?? sample(for: direction)) +isInitializingEditor = false _ = editor.focus!() diff --git a/Tests/URLStateTests.mjs b/Tests/URLStateTests.mjs new file mode 100644 index 0000000..f01e14d --- /dev/null +++ b/Tests/URLStateTests.mjs @@ -0,0 +1,44 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + decodeSharedEncoding, + encodeSharedEncoding, + sharedEncodingFromURL, + urlWithSharedEncoding, + urlWithoutSharedEncoding, +} from "../url-state.js"; + +test("shared encodings round-trip UTF-8 text through Base64URL", () => { + const encoding = "(DL(I:7 C)(I:8(E O(I:9 V:空视图))))"; + const payload = encodeSharedEncoding(encoding); + + assert.match(payload, /^v1\.[A-Za-z0-9_-]+$/); + assert.equal(decodeSharedEncoding(payload), encoding); +}); + +test("shared URL preserves the deployment path and query", () => { + const encoding = "(DL(I:42 C))"; + const href = urlWithSharedEncoding( + "https://example.com/DisplayListExplorer/?preview=1#top", + encoding, + ); + const url = new URL(href); + + assert.equal(url.pathname, "/DisplayListExplorer/"); + assert.equal(url.search, "?preview=1"); + assert.equal(sharedEncodingFromURL(href), encoding); +}); + +test("invalid or unsupported shared payloads are ignored", () => { + assert.equal(sharedEncodingFromURL("https://example.com/#encoding=v2.abc"), null); + assert.equal(sharedEncodingFromURL("https://example.com/#encoding=v1.%25"), null); + assert.equal(sharedEncodingFromURL("https://example.com/#encoding=v1."), null); +}); + +test("clearing an encoding preserves unrelated fragment parameters", () => { + const href = urlWithoutSharedEncoding( + "https://example.com/#tab=statistics&encoding=v1.REw", + ); + + assert.equal(href, "https://example.com/#tab=statistics"); +}); diff --git a/index.html b/index.html index 8309760..a46d4a3 100644 --- a/index.html +++ b/index.html @@ -20,7 +20,7 @@