Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ jobs:
- name: Build site
run: |
npm ci
npm test
npm run build
- name: Deploy PR preview
if: >-
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/deploy-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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).

Expand Down
65 changes: 62 additions & 3 deletions Sources/DisplayListWeb/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -415,13 +420,24 @@ 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()

guard source.contains(where: { !$0.isWhitespace }) else {
latestConversion = nil
latestOutput = ""
latestSharedEncoding = ""
setOutputPlaceholder(
direction == .descriptionToMinimal
? "Your minimal description will appear here."
Expand All @@ -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()
}
}

Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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!()
44 changes: 44 additions & 0 deletions Tests/URLStateTests.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
7 changes: 5 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
</head>
<body>
<header class="site-header">
<a class="wordmark" href="#top" aria-label="DisplayList Explorer home">
<a class="wordmark" href="./" aria-label="DisplayList Explorer home">
<span class="wordmark-mark">DL</span>
<span>DisplayList Explorer</span>
</a>
Expand Down Expand Up @@ -122,7 +122,10 @@ <h2 id="source-title">DisplayList Description</h2>
<span aria-hidden="true">▦</span> Statistics
</button>
</div>
<button id="copy-button" class="copy-button" type="button" disabled>Copy</button>
<div class="output-actions">
<button id="share-button" class="share-button" type="button" disabled>Copy link</button>
<button id="copy-button" class="copy-button" type="button" disabled>Copy</button>
</div>
</div>

<section id="minimal-panel" class="tab-panel is-active" role="tabpanel" aria-labelledby="minimal-tab">
Expand Down
21 changes: 21 additions & 0 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ import {
} from "@codemirror/commands";
import { bracketMatching, indentUnit } from "@codemirror/language";
import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
import {
sharedEncodingFromURL,
urlWithSharedEncoding,
urlWithoutSharedEncoding,
} from "./url-state.js";

const addMarksEffect = StateEffect.define();
const clearMarksEffect = StateEffect.define();
Expand Down Expand Up @@ -246,4 +251,20 @@ verticalLayout.addEventListener("change", updateSplitterOrientation);
window.addEventListener("resize", () => setPaneRatio(paneRatio));
updateSplitterOrientation();

globalThis.displayListURLState = {
readEncoding() {
return sharedEncodingFromURL(window.location.href);
},
setEncoding(encoding) {
const href = urlWithSharedEncoding(window.location.href, encoding);
window.history.replaceState(null, "", href);
return href;
},
clearEncoding() {
const href = urlWithoutSharedEncoding(window.location.href);
window.history.replaceState(null, "", href);
return href;
},
};

await init();
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"build": "vite build",
"build:pages": "vite build --base /DisplayListExplorer/",
"dev": "vite --host 127.0.0.1",
"preview": "npm run build:pages && vite preview --host 127.0.0.1 --base /DisplayListExplorer/"
"preview": "npm run build:pages && vite preview --host 127.0.0.1 --base /DisplayListExplorer/",
"test": "node --test Tests/URLStateTests.mjs"
},
"dependencies": {
"@bjorn3/browser_wasi_shim": "0.3.0",
Expand Down
24 changes: 22 additions & 2 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ body.is-resizing-panes {

.quiet-button,
.copy-button,
.share-button,
.tab {
border: 0;
cursor: pointer;
Expand All @@ -319,7 +320,8 @@ body.is-resizing-panes {
}

.quiet-button,
.copy-button {
.copy-button,
.share-button {
min-height: 34px;
border-radius: 8px;
padding: 0 11px;
Expand All @@ -341,7 +343,25 @@ body.is-resizing-panes {
background: var(--ink);
}

.copy-button:disabled {
.output-actions {
display: flex;
gap: 7px;
}

.share-button {
border: 1px solid var(--line-strong);
color: #566174;
background: #fff;
}

.share-button:hover:not(:disabled) {
border-color: #b8c8ea;
color: #1749b1;
background: #f5f8ff;
}

.copy-button:disabled,
.share-button:disabled {
color: #9da5b2;
cursor: default;
background: #e6e9ed;
Expand Down
Loading
Loading