From f27aff3f1d87c470ff5189a3ff311bc0e2322fc7 Mon Sep 17 00:00:00 2001 From: oywino Date: Sun, 19 Apr 2026 18:12:40 +0200 Subject: [PATCH 1/8] Harden XML parsing and export behavior --- README.md | 3 ++ app.js | 84 +++++++++++++++++++++++++++++++++++--------- docs/ARCHITECTURE.md | 7 ++++ 3 files changed, 78 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 8399e0a..80ac689 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ The app runs entirely locally. Python serves the static files, and the browser h - add root, child, and sibling elements - reorder nodes with buttons or drag-and-drop - edit text nodes directly +- escape XML special characters on export +- support common XML-style attribute names such as `data-id` and `xml:lang` - preview raw XML output - export either AI-ready XML text or full editor format @@ -83,4 +85,5 @@ More detail is available in `docs/ARCHITECTURE.md`. ## Notes - the XML parsing logic is custom and currently optimized for simple prompt-style XML +- export escapes text and attribute values so characters like `&`, `<`, and `"` remain valid XML - the app is intentionally lightweight and has no front-end framework or backend service diff --git a/app.js b/app.js index 3e55298..32f9cff 100644 --- a/app.js +++ b/app.js @@ -23,6 +23,7 @@ `; let idCounter = 0; + const XML_NAME_RE = /^[A-Za-z_][A-Za-z0-9_.:-]*$/; const state = { doc: null, @@ -42,16 +43,60 @@ return `node_${idCounter}_${Math.random().toString(36).slice(2, 7)}`; } - function parseAttributes(attrStr) { + function isValidXmlName(name) { + return XML_NAME_RE.test(String(name || '')); + } + + function decodeXmlEntities(text) { + return String(text || '').replace(/&(#x?[0-9a-fA-F]+|amp|lt|gt|quot|apos);/g, (match, entity) => { + if (entity === 'amp') return '&'; + if (entity === 'lt') return '<'; + if (entity === 'gt') return '>'; + if (entity === 'quot') return '"'; + if (entity === 'apos') return "'"; + if (!entity.startsWith('#')) return match; + + const isHex = entity[1]?.toLowerCase() === 'x'; + const digits = isHex ? entity.slice(2) : entity.slice(1); + const codePoint = Number.parseInt(digits, isHex ? 16 : 10); + if (!Number.isFinite(codePoint)) return match; + + try { + return String.fromCodePoint(codePoint); + } catch { + return match; + } + }); + } + + function escapeXmlText(text) { + return String(text || '') + .replace(/&/g, '&') + .replace(//g, '>'); + } + + function escapeXmlAttribute(value) { + return escapeXmlText(value) + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function parseLooseAttributes(attrStr, { decodeValues = false } = {}) { const attrs = {}; - const regex = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g; + const regex = /([A-Za-z_][A-Za-z0-9_.:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'))?/g; let m; while ((m = regex.exec(attrStr)) !== null) { - attrs[m[1]] = m[2] ?? m[3] ?? ''; + const rawValue = m[2] ?? m[3] ?? ''; + attrs[m[1]] = decodeValues ? decodeXmlEntities(rawValue) : rawValue; } return attrs; } + function parseAttributes(attrStr) { + return parseLooseAttributes(attrStr, { decodeValues: true }); + } + function tokenize(xml) { const tokens = []; let i = 0; @@ -140,7 +185,7 @@ const textNode = { id: generateId(), type: 'text', - text: token.text.trim(), + text: decodeXmlEntities(token.text.trim()), children: [], }; if (stack.length > 0) { @@ -178,18 +223,18 @@ let result = ''; for (const node of nodes) { if (node.type === 'text') { - const text = (node.text || '').trim(); + const text = escapeXmlText((node.text || '').trim()); if (text) result += `${pad}${text}\n`; } else { const attrs = node.attributes - ? Object.entries(node.attributes).map(([k, v]) => ` ${k}="${v}"`).join('') + ? Object.entries(node.attributes).map(([k, v]) => ` ${k}="${escapeXmlAttribute(v)}"`).join('') : ''; if (node.children.length === 0) { result += `${pad}<${node.tag}${attrs} />\n`; } else { const hasOnlyText = node.children.length === 1 && node.children[0].type === 'text'; if (hasOnlyText) { - const text = (node.children[0].text || '').trim(); + const text = escapeXmlText((node.children[0].text || '').trim()); result += `${pad}<${node.tag}${attrs}>${text}\n`; } else { result += `${pad}<${node.tag}${attrs}>\n`; @@ -206,13 +251,13 @@ let result = ''; for (const node of nodes) { if (node.type === 'text') { - const text = (node.text || '').trim(); + const text = escapeXmlText((node.text || '').trim()); if (text) result += `${text}\n`; } else { const attrs = node.attributes ? Object.entries(node.attributes) .filter(([, v]) => v !== undefined) - .map(([k, v]) => ` ${k}="${v}"`).join('') + .map(([k, v]) => ` ${k}="${escapeXmlAttribute(v)}"`).join('') : ''; if (node.children.length === 0) { result += `<${node.tag}${attrs} />\n`; @@ -411,6 +456,10 @@ render(); return; } + if (!isValidXmlName(name)) { + showError('Use a valid XML name. Start with a letter or underscore, then use letters, numbers, ., -, _, or :.'); + return; + } if (isDuplicate(name)) { showError(`"${name}" already exists among siblings.`); return; @@ -421,7 +470,15 @@ input.addEventListener('input', () => { const val = input.value.trim(); - if (val && isDuplicate(val)) showError(`"${val}" already exists among siblings.`); + if (!val) { + clearError(); + return; + } + if (!isValidXmlName(val)) { + showError('Use a valid XML name. Start with a letter or underscore, then use letters, numbers, ., -, _, or :.'); + return; + } + if (isDuplicate(val)) showError(`"${val}" already exists among siblings.`); else clearError(); }); input.addEventListener('blur', save); @@ -443,12 +500,7 @@ : ''; function save() { - const attrs = {}; - const regex = /(\w+)(?:\s*=\s*"([^"]*)")?/g; - let m; - while ((m = regex.exec(input.value)) !== null) { - attrs[m[1]] = m[2] ?? ''; - } + const attrs = parseLooseAttributes(input.value); updateRoot(updateNodeById(nodes, node.id, (n) => ({ ...n, attributes: attrs }))); } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b6fd63e..58a4c2d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -80,6 +80,13 @@ The editor accepts documents with a free-form preamble followed by XML. The parser is custom and lightweight. It is built for prompt-style XML rather than strict general-purpose XML compatibility. +Current parser/serializer behavior intentionally includes a few pragmatic rules: + +- common XML entities in imported text and attribute values are decoded into the in-memory document model +- exported text and attribute values are escaped again so special characters remain valid XML +- attribute names support common XML-style characters such as `.`, `-`, `_`, and `:` +- tag rename validation uses a lightweight XML-name check to avoid exporting obviously invalid tag names + ## Export Model The app supports two export modes: From 0bf47d69ff03c6985f1e45ae115128a03c4e6471 Mon Sep 17 00:00:00 2001 From: oywino Date: Sun, 19 Apr 2026 18:25:47 +0200 Subject: [PATCH 2/8] v0.1.1 Add visible app version badge Show the current application version in the top header so local builds are easy to identify while testing. Bump the displayed app version to v0.1.1 for this release. --- app.js | 13 ++++++++++++- style.css | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app.js b/app.js index 32f9cff..ff2ea9b 100644 --- a/app.js +++ b/app.js @@ -22,6 +22,7 @@ `; + const APP_VERSION = 'v0.1.1'; let idCounter = 0; const XML_NAME_RE = /^[A-Za-z_][A-Za-z0-9_.:-]*$/; @@ -789,10 +790,20 @@ iconBox.className = 'brand-icon'; iconBox.textContent = ''; brand.appendChild(iconBox); + + const brandText = document.createElement('div'); + brandText.className = 'brand-text'; const title = document.createElement('div'); title.className = 'brand-title'; title.textContent = 'XML Prompt Editor'; - brand.appendChild(title); + brandText.appendChild(title); + + const version = document.createElement('div'); + version.className = 'brand-version'; + version.textContent = APP_VERSION; + brandText.appendChild(version); + + brand.appendChild(brandText); inner.appendChild(brand); const spacer = document.createElement('div'); diff --git a/style.css b/style.css index c02c8fb..11d760a 100644 --- a/style.css +++ b/style.css @@ -88,6 +88,13 @@ button { gap: 10px; } +.brand-text { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + .brand-icon { width: 28px; height: 28px; @@ -106,6 +113,18 @@ button { color: #1e293b; } +.brand-version { + border: 1px solid var(--blue-200); + border-radius: 999px; + background: var(--blue-50); + color: var(--blue); + font-family: var(--mono); + font-size: 11px; + font-weight: 700; + line-height: 1; + padding: 5px 8px; +} + .spacer { flex: 1; } From 6072750e128082c2ca69e087202cf75b6473476c Mon Sep 17 00:00:00 2001 From: oywino Date: Sun, 19 Apr 2026 18:38:34 +0200 Subject: [PATCH 3/8] v0.1.2 Open the app in a separate browser window Launch the editor in a new browser window when possible, remove the Ctrl+C shutdown prompt, and stop the local server automatically when the browser window closes.\n\nAdd a lightweight browser heartbeat so the Python launcher can detect when the window is gone and exit on its own. --- app.js | 17 +++++++- app.py | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 138 insertions(+), 8 deletions(-) diff --git a/app.js b/app.js index ff2ea9b..b9dbdbb 100644 --- a/app.js +++ b/app.js @@ -22,7 +22,8 @@ `; - const APP_VERSION = 'v0.1.1'; + const APP_VERSION = 'v0.1.2'; + const HEARTBEAT_INTERVAL_MS = 5000; let idCounter = 0; const XML_NAME_RE = /^[A-Za-z_][A-Za-z0-9_.:-]*$/; @@ -1197,9 +1198,23 @@ app.appendChild(shell); } + function sendHeartbeat() { + fetch('/__heartbeat', { + method: 'POST', + cache: 'no-store', + keepalive: true, + }).catch(() => {}); + } + + function startHeartbeat() { + sendHeartbeat(); + window.setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS); + } + function init() { state.doc = parseDocument(SAMPLE_DOC); state.preambleVal = state.doc.preamble; + startHeartbeat(); render(); } diff --git a/app.py b/app.py index 655642f..06b211b 100644 --- a/app.py +++ b/app.py @@ -2,16 +2,28 @@ from __future__ import annotations import contextlib +import http import http.server import os import socket +import subprocess import threading import time +import urllib.parse import webbrowser +import shlex from pathlib import Path +try: + import winreg +except ImportError: # pragma: no cover - Windows-only helper + winreg = None + BASE_DIR = Path(__file__).resolve().parent HOST = "127.0.0.1" +HEARTBEAT_PATH = "/__heartbeat" +STARTUP_TIMEOUT_SECONDS = 45 +HEARTBEAT_TIMEOUT_SECONDS = 12 def find_free_port(host: str = HOST) -> int: @@ -21,6 +33,20 @@ def find_free_port(host: str = HOST) -> int: return int(sock.getsockname()[1]) +class AppServer(http.server.ThreadingHTTPServer): + daemon_threads = True + + def __init__(self, server_address, handler_class): + super().__init__(server_address, handler_class) + self.started_at = time.monotonic() + self.last_activity_at = self.started_at + self.seen_browser_client = False + + def note_browser_activity(self) -> None: + self.last_activity_at = time.monotonic() + self.seen_browser_client = True + + class QuietHandler(http.server.SimpleHTTPRequestHandler): def __init__(self, *args, **kwargs): super().__init__(*args, directory=str(BASE_DIR), **kwargs) @@ -28,24 +54,113 @@ def __init__(self, *args, **kwargs): def log_message(self, format: str, *args) -> None: return + def do_GET(self) -> None: + path = urllib.parse.urlparse(self.path).path + if path != HEARTBEAT_PATH: + self.server.note_browser_activity() + super().do_GET() + + def do_POST(self) -> None: + path = urllib.parse.urlparse(self.path).path + if path != HEARTBEAT_PATH: + self.send_error(http.HTTPStatus.NOT_FOUND) + return + + self.server.note_browser_activity() + self.send_response(http.HTTPStatus.NO_CONTENT) + self.send_header("Cache-Control", "no-store") + self.end_headers() + + +def monitor_browser_activity(server: AppServer) -> None: + while True: + time.sleep(1) + now = time.monotonic() + + if server.seen_browser_client: + if now - server.last_activity_at > HEARTBEAT_TIMEOUT_SECONDS: + print("Browser window closed. Stopping server.") + server.shutdown() + return + elif now - server.started_at > STARTUP_TIMEOUT_SECONDS: + print("No browser connection detected. Stopping server.") + server.shutdown() + return + + +def get_default_browser_command() -> str | None: + if os.name != "nt" or winreg is None: + return None + + candidates = ( + r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice", + r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice", + ) + + prog_id = None + for subkey in candidates: + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, subkey) as key: + prog_id = winreg.QueryValueEx(key, "ProgId")[0] + break + except OSError: + continue + + if not prog_id: + return None + + try: + with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, fr"{prog_id}\shell\open\command") as key: + return str(winreg.QueryValueEx(key, None)[0]) + except OSError: + return None + + +def open_in_new_browser_window(url: str) -> bool: + command = get_default_browser_command() + if not command: + return webbrowser.open_new(url) + + try: + parts = shlex.split(command, posix=False) + except ValueError: + return webbrowser.open_new(url) + + if not parts: + return webbrowser.open_new(url) + + executable = parts[0].strip('"') + browser_name = Path(executable).name.lower() + + if browser_name == "firefox.exe": + extra_args = ["-new-window"] + elif browser_name in {"chrome.exe", "msedge.exe", "brave.exe", "opera.exe", "vivaldi.exe"}: + extra_args = ["--new-window"] + else: + return webbrowser.open_new(url) + + try: + subprocess.Popen([executable, *extra_args, url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return True + except OSError: + return webbrowser.open_new(url) + def main() -> None: port = find_free_port() - server = http.server.ThreadingHTTPServer((HOST, port), QuietHandler) + server = AppServer((HOST, port), QuietHandler) url = f"http://{HOST}:{port}/index.html" - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() + monitor = threading.Thread(target=monitor_browser_activity, args=(server,), daemon=True) + monitor.start() print("XML Prompt Editor") print(f"Serving: {url}") - print("Press Ctrl+C to stop.") try: time.sleep(0.35) - webbrowser.open(url) - while True: - time.sleep(1) + open_in_new_browser_window(url) + server.serve_forever() except KeyboardInterrupt: pass finally: From 5307367aeecbb78ef7cf3f6ef696affcf5219da8 Mon Sep 17 00:00:00 2001 From: oywino Date: Sun, 19 Apr 2026 18:45:50 +0200 Subject: [PATCH 4/8] v0.1.3 Use writable Git wrappers for local repo operations Add repo-local Git and GitHub wrapper scripts that point to a writable alternate Git metadata directory, and ignore that directory in the working tree.\n\nThis avoids the local permission errors that were blocking remote-tracking ref updates and branch config writes in the sandboxed .git directory, while keeping the app version badge aligned with the new release. --- .gitignore | 1 + app.js | 2 +- gh-codex.ps1 | 3 +++ git-codex.ps1 | 17 +++++++++++++++++ 4 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 git-codex.ps1 diff --git a/.gitignore b/.gitignore index b7ed01c..923e480 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ .DS_Store Thumbs.db .codex-gh/ +.git-codex/ diff --git a/app.js b/app.js index b9dbdbb..8f7cb68 100644 --- a/app.js +++ b/app.js @@ -22,7 +22,7 @@ `; - const APP_VERSION = 'v0.1.2'; + const APP_VERSION = 'v0.1.3'; const HEARTBEAT_INTERVAL_MS = 5000; let idCounter = 0; const XML_NAME_RE = /^[A-Za-z_][A-Za-z0-9_.:-]*$/; diff --git a/gh-codex.ps1 b/gh-codex.ps1 index c4722ac..8170ee5 100644 --- a/gh-codex.ps1 +++ b/gh-codex.ps1 @@ -1,5 +1,6 @@ $ghPath = "C:\Program Files\GitHub CLI\gh.exe" $ghConfigDir = Join-Path $PSScriptRoot ".codex-gh" +$gitDir = Join-Path $PSScriptRoot ".git-codex" if (-not (Test-Path $ghPath)) { Write-Error "GitHub CLI not found at $ghPath" @@ -11,5 +12,7 @@ if (-not (Test-Path $ghConfigDir)) { } $env:GH_CONFIG_DIR = $ghConfigDir +$env:GIT_DIR = $gitDir +$env:GIT_WORK_TREE = $PSScriptRoot & $ghPath @args exit $LASTEXITCODE diff --git a/git-codex.ps1 b/git-codex.ps1 new file mode 100644 index 0000000..79cb25a --- /dev/null +++ b/git-codex.ps1 @@ -0,0 +1,17 @@ +$gitPath = "C:\Program Files\Git\cmd\git.exe" +$gitDir = Join-Path $PSScriptRoot ".git-codex" + +if (-not (Test-Path $gitPath)) { + Write-Error "Git not found at $gitPath" + exit 1 +} + +if (-not (Test-Path $gitDir)) { + Write-Error "Writable Git directory not found at $gitDir" + exit 1 +} + +$env:GIT_DIR = $gitDir +$env:GIT_WORK_TREE = $PSScriptRoot +& $gitPath @args +exit $LASTEXITCODE From b617cb6591f9d07e295892d1ec2efae6a5e18426 Mon Sep 17 00:00:00 2001 From: oywino Date: Sun, 19 Apr 2026 19:23:44 +0200 Subject: [PATCH 5/8] v0.1.4 Rename the launcher and group XML elements into one frame Rename the Python launcher from app.py to XML_Editor.py and update the README, architecture notes, contribution guide, and CI compile check to match.\n\nRefactor element rendering so each XML node is displayed as one outlined visual unit from opening tag to closing tag, and add right-side breathing room so nested outlines no longer touch adjacent right edges.\n\nBump the visible in-app version badge to v0.1.4 for this release. --- .github/workflows/ci.yml | 2 +- CONTRIBUTING.md | 4 ++-- README.md | 8 +++---- app.py => XML_Editor.py | 0 app.js | 50 ++++++++++++++++++++++++++-------------- docs/ARCHITECTURE.md | 6 ++--- style.css | 32 ++++++++++++++++++------- 7 files changed, 67 insertions(+), 35 deletions(-) rename app.py => XML_Editor.py (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dec9b23..d7292b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: python-version: "3.11" - name: Compile Python launcher - run: python -m py_compile app.py + run: python -m py_compile XML_Editor.py - name: Verify required app files exist run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4936c2f..3606344 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,13 +24,13 @@ Suggested branch prefixes: ## Local Run ```bash -python app.py +python XML_Editor.py ``` If needed on Windows: ```bash -py app.py +py XML_Editor.py ``` ## Project Expectations diff --git a/README.md b/README.md index 80ac689..4ccd94c 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ The app runs entirely locally. Python serves the static files, and the browser h ## Project Structure -- `app.py`: local launcher that serves the app and opens the browser +- `XML_Editor.py`: local launcher that serves the app and opens the browser - `index.html`: single HTML mount point - `app.js`: main application logic, parsing, tree editing, rendering, and export - `style.css`: application styling @@ -41,13 +41,13 @@ The app runs entirely locally. Python serves the static files, and the browser h From the repository root: ```bash -python app.py +python XML_Editor.py ``` If your machine uses the Windows launcher instead of `python`, you can also use: ```bash -py app.py +py XML_Editor.py ``` The launcher will: @@ -75,7 +75,7 @@ This repository includes: The application has two parts: -1. a Python wrapper in `app.py` that starts a local HTTP server +1. a Python wrapper in `XML_Editor.py` that starts a local HTTP server 2. a plain JavaScript single-page app in `app.js` that parses mixed preamble + XML text into a tree, renders it visually, and serializes it back out for export The editor keeps all state in memory in the browser session and does not currently save automatically. diff --git a/app.py b/XML_Editor.py similarity index 100% rename from app.py rename to XML_Editor.py diff --git a/app.js b/app.js index 8f7cb68..d19baac 100644 --- a/app.js +++ b/app.js @@ -22,7 +22,7 @@ `; - const APP_VERSION = 'v0.1.3'; + const APP_VERSION = 'v0.1.4'; const HEARTBEAT_INTERVAL_MS = 5000; let idCounter = 0; const XML_NAME_RE = /^[A-Za-z_][A-Za-z0-9_.:-]*$/; @@ -443,13 +443,13 @@ input.classList.add('error'); errorLine.textContent = `⚠ ${message}`; errorLine.classList.remove('hidden'); - container.closest('.node-card')?.classList.add('error'); + container.closest('.element-frame, .node-card')?.classList.add('error'); } function clearError() { input.classList.remove('error'); errorLine.classList.add('hidden'); - container.closest('.node-card')?.classList.remove('error'); + container.closest('.element-frame, .node-card')?.classList.remove('error'); } function save() { @@ -559,30 +559,46 @@ const row = document.createElement('div'); row.className = `node-row node-indent-${Math.min(depth, 10)}`; + const frame = document.createElement('div'); + frame.className = 'element-frame'; + const card = document.createElement('div'); - card.className = 'node-card'; + card.className = 'node-card element-frame-header'; card.draggable = true; + let dragDepth = 0; card.addEventListener('dragstart', (e) => { state.dragNodeId = node.id; - card.classList.add('dragging'); + frame.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', node.id); }); card.addEventListener('dragend', () => { state.dragNodeId = null; - card.classList.remove('dragging'); + dragDepth = 0; + frame.classList.remove('dragging'); document.querySelectorAll('.drag-over').forEach((el) => el.classList.remove('drag-over')); }); - card.addEventListener('dragover', (e) => { + + frame.addEventListener('dragenter', (e) => { + e.preventDefault(); + if (!state.dragNodeId || state.dragNodeId === node.id) return; + dragDepth += 1; + frame.classList.add('drag-over'); + }); + frame.addEventListener('dragover', (e) => { e.preventDefault(); if (!state.dragNodeId || state.dragNodeId === node.id) return; - card.classList.add('drag-over'); + frame.classList.add('drag-over'); + }); + frame.addEventListener('dragleave', () => { + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) frame.classList.remove('drag-over'); }); - card.addEventListener('dragleave', () => card.classList.remove('drag-over')); - card.addEventListener('drop', (e) => { + frame.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); - card.classList.remove('drag-over'); + dragDepth = 0; + frame.classList.remove('drag-over'); const dragged = e.dataTransfer.getData('text/plain'); if (dragged && dragged !== node.id) { updateRoot(moveNode(nodes, dragged, { parentId: node.id, index: node.children.length })); @@ -725,20 +741,19 @@ actions.appendChild(deleteBtn); card.appendChild(actions); - row.appendChild(card); + frame.appendChild(card); const isCollapsed = !!state.collapsed[node.id]; if (!isCollapsed && hasChildren) { const childrenWrap = document.createElement('div'); - childrenWrap.className = 'children-wrap'; + childrenWrap.className = 'children-wrap element-frame-children'; for (const child of node.children) { childrenWrap.appendChild(renderNode(child, 0, nodes)); } - row.appendChild(childrenWrap); + frame.appendChild(childrenWrap); const closing = document.createElement('div'); - closing.className = 'node-card'; - closing.style.marginTop = '4px'; + closing.className = 'node-card element-frame-footer'; const grip2 = document.createElement('div'); grip2.className = 'grip'; grip2.textContent = '⋮⋮'; @@ -758,9 +773,10 @@ closingTag.textContent = ``; main2.appendChild(closingTag); closing.appendChild(main2); - row.appendChild(closing); + frame.appendChild(closing); } + row.appendChild(frame); return row; } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 58a4c2d..535bed4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,14 +4,14 @@ The application has two layers: -1. `app.py` launches a local HTTP server and opens the browser. +1. `XML_Editor.py` launches a local HTTP server and opens the browser. 2. `app.js` implements the editor as a plain JavaScript single-page app. There is no backend API, database, or framework runtime. All editing happens in memory in the browser. ## Runtime Flow -1. `app.py` finds a free local port. +1. `XML_Editor.py` finds a free local port. 2. It serves the repository directory with `http.server`. 3. It opens `index.html` in the default browser. 4. The browser loads `app.js` and initializes the editor with a sample document. @@ -98,4 +98,4 @@ The app supports two export modes: - there is currently no persistent storage layer - there are no third-party package managers or dependencies -- most future changes will happen in `app.js`, with `style.css` for UI styling and `app.py` only for launcher behavior +- most future changes will happen in `app.js`, with `style.css` for UI styling and `XML_Editor.py` only for launcher behavior diff --git a/style.css b/style.css index 11d760a..5defca8 100644 --- a/style.css +++ b/style.css @@ -353,25 +353,30 @@ button { margin-bottom: 4px; } -.node-card { - display: flex; - align-items: center; - gap: 4px; +.element-frame { background: #fff; border: 1px solid var(--border); - border-radius: 12px; + border-radius: 14px; transition: border-color .15s ease, box-shadow .15s ease, opacity .15s ease, background .15s ease; + overflow: hidden; } -.node-card:hover { +.element-frame:hover { border-color: #93c5fd; box-shadow: var(--shadow); } -.node-card.error { +.element-frame.error { border-color: var(--red-300); } +.node-card { + display: flex; + align-items: center; + gap: 4px; + background: transparent; +} + .dragging { opacity: .45; } @@ -381,6 +386,17 @@ button { box-shadow: 0 0 0 3px rgba(59,130,246,.12); } +.element-frame-header, +.element-frame-footer { + min-height: 44px; +} + +.element-frame-footer { + margin-top: 4px; + border-top: 1px solid var(--border-soft); + padding-top: 4px; +} + .grip { display: flex; align-items: center; @@ -540,7 +556,7 @@ button { .children-wrap { margin-top: 4px; margin-left: 24px; - padding-left: 12px; + padding: 4px 12px 4px 12px; border-left: 2px solid var(--blue-100); } From 011003e2640d415d141f81f9e166eb3546f4a557 Mon Sep 17 00:00:00 2001 From: oywino Date: Sun, 19 Apr 2026 19:42:12 +0200 Subject: [PATCH 6/8] v0.1.5 Improve icon visibility across the editor Increase the visibility of the small UI symbols used throughout the tree editor by switching key controls to crisp inline SVG icons and darkening the icon treatment to solid black.\n\nEnlarge the tag marker and delete icon, and make the collapse/expand arrow use the same vector arrow style as the move-up and move-down controls so those arrows render identically.\n\nBump the in-app version badge to v0.1.5 for this release. --- app.js | 117 +++++++++++++++++++++++++++++++++++++++++++++--------- style.css | 56 ++++++++++++++++++-------- 2 files changed, 138 insertions(+), 35 deletions(-) diff --git a/app.js b/app.js index d19baac..e38c76d 100644 --- a/app.js +++ b/app.js @@ -22,7 +22,7 @@ `; - const APP_VERSION = 'v0.1.4'; + const APP_VERSION = 'v0.1.5'; const HEARTBEAT_INTERVAL_MS = 5000; let idCounter = 0; const XML_NAME_RE = /^[A-Za-z_][A-Za-z0-9_.:-]*$/; @@ -403,10 +403,95 @@ .replace(/"/g, '"'); } - function icon(text) { - const span = document.createElement('span'); - span.textContent = text; - return span; + function createTagIcon() { + const ns = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(ns, 'svg'); + svg.setAttribute('viewBox', '0 0 16 16'); + svg.setAttribute('class', 'tag-symbol'); + svg.setAttribute('aria-hidden', 'true'); + + const tagPath = document.createElementNS(ns, 'path'); + tagPath.setAttribute('d', 'M2.5 5.5V2.5H5.5L13.5 10.5L10.5 13.5L2.5 5.5Z'); + tagPath.setAttribute('fill', 'none'); + tagPath.setAttribute('stroke', 'currentColor'); + tagPath.setAttribute('stroke-width', '1.5'); + tagPath.setAttribute('stroke-linejoin', 'round'); + + const hole = document.createElementNS(ns, 'circle'); + hole.setAttribute('cx', '4.5'); + hole.setAttribute('cy', '4.5'); + hole.setAttribute('r', '0.9'); + hole.setAttribute('fill', 'currentColor'); + + svg.append(tagPath, hole); + return svg; + } + + function createTrashIcon() { + const ns = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(ns, 'svg'); + svg.setAttribute('viewBox', '0 0 16 16'); + svg.setAttribute('class', 'delete-icon'); + svg.setAttribute('aria-hidden', 'true'); + + const lid = document.createElementNS(ns, 'path'); + lid.setAttribute('d', 'M5.5 3.5H10.5'); + lid.setAttribute('fill', 'none'); + lid.setAttribute('stroke', 'currentColor'); + lid.setAttribute('stroke-width', '1.5'); + lid.setAttribute('stroke-linecap', 'round'); + + const rim = document.createElementNS(ns, 'path'); + rim.setAttribute('d', 'M3 4.5H13'); + rim.setAttribute('fill', 'none'); + rim.setAttribute('stroke', 'currentColor'); + rim.setAttribute('stroke-width', '1.5'); + rim.setAttribute('stroke-linecap', 'round'); + + const body = document.createElementNS(ns, 'path'); + body.setAttribute('d', 'M4.5 5.5L5.1 12.5C5.15 13.05 5.61 13.5 6.17 13.5H9.83C10.39 13.5 10.85 13.05 10.9 12.5L11.5 5.5'); + body.setAttribute('fill', 'none'); + body.setAttribute('stroke', 'currentColor'); + body.setAttribute('stroke-width', '1.5'); + body.setAttribute('stroke-linecap', 'round'); + body.setAttribute('stroke-linejoin', 'round'); + + const leftLine = document.createElementNS(ns, 'path'); + leftLine.setAttribute('d', 'M6.5 7V11.5'); + leftLine.setAttribute('fill', 'none'); + leftLine.setAttribute('stroke', 'currentColor'); + leftLine.setAttribute('stroke-width', '1.5'); + leftLine.setAttribute('stroke-linecap', 'round'); + + const rightLine = document.createElementNS(ns, 'path'); + rightLine.setAttribute('d', 'M9.5 7V11.5'); + rightLine.setAttribute('fill', 'none'); + rightLine.setAttribute('stroke', 'currentColor'); + rightLine.setAttribute('stroke-width', '1.5'); + rightLine.setAttribute('stroke-linecap', 'round'); + + svg.append(lid, rim, body, leftLine, rightLine); + return svg; + } + + function createArrowIcon(direction) { + const ns = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(ns, 'svg'); + svg.setAttribute('viewBox', '0 0 16 16'); + svg.setAttribute('class', 'arrow-icon'); + svg.setAttribute('aria-hidden', 'true'); + + const triangle = document.createElementNS(ns, 'path'); + const pathByDirection = { + up: 'M8 4L12.5 11H3.5L8 4Z', + down: 'M3.5 5L12.5 5L8 12Z', + right: 'M5 3.5L12 8L5 12.5V3.5Z', + }; + triangle.setAttribute('d', pathByDirection[direction] || pathByDirection.down); + triangle.setAttribute('fill', 'currentColor'); + + svg.appendChild(triangle); + return svg; } function btn(label, opts = {}) { @@ -546,7 +631,7 @@ del.type = 'button'; del.className = 'action-btn red'; del.title = 'Delete text node'; - del.textContent = '🗑'; + del.appendChild(createTrashIcon()); del.addEventListener('click', () => updateRoot(removeNodeById(nodes, node.id))); actions.appendChild(del); wrap.appendChild(actions); @@ -614,8 +699,10 @@ expand.type = 'button'; expand.className = 'expand-btn'; const hasChildren = node.children.length > 0; - expand.textContent = hasChildren ? (state.collapsed[node.id] ? '▸' : '▾') : ''; expand.title = state.collapsed[node.id] ? 'Expand' : 'Collapse'; + if (hasChildren) { + expand.appendChild(createArrowIcon(state.collapsed[node.id] ? 'right' : 'down')); + } expand.addEventListener('click', () => { state.collapsed[node.id] = !state.collapsed[node.id]; render(); @@ -625,10 +712,7 @@ const main = document.createElement('div'); main.className = 'node-main'; - const tagSymbol = document.createElement('span'); - tagSymbol.className = 'tag-symbol'; - tagSymbol.textContent = '🏷'; - main.appendChild(tagSymbol); + main.appendChild(createTagIcon()); const tagWrap = document.createElement('div'); const tagBtn = document.createElement('button'); @@ -672,7 +756,7 @@ moveUpBtn.type = 'button'; moveUpBtn.className = 'action-btn'; moveUpBtn.title = 'Move up'; - moveUpBtn.textContent = '▲'; + moveUpBtn.appendChild(createArrowIcon('up')); moveUpBtn.addEventListener('click', () => { const { siblings, parentId } = getNodeChildrenSiblings(nodes, node.id); const idx = siblings.findIndex((n) => n.id === node.id); @@ -688,7 +772,7 @@ moveDownBtn.type = 'button'; moveDownBtn.className = 'action-btn'; moveDownBtn.title = 'Move down'; - moveDownBtn.textContent = '▼'; + moveDownBtn.appendChild(createArrowIcon('down')); moveDownBtn.addEventListener('click', () => { const { siblings, parentId } = getNodeChildrenSiblings(nodes, node.id); const idx = siblings.findIndex((n) => n.id === node.id); @@ -736,7 +820,7 @@ deleteBtn.type = 'button'; deleteBtn.className = 'action-btn red'; deleteBtn.title = 'Delete'; - deleteBtn.textContent = '🗑'; + deleteBtn.appendChild(createTrashIcon()); deleteBtn.addEventListener('click', () => updateRoot(removeNodeById(nodes, node.id))); actions.appendChild(deleteBtn); @@ -764,10 +848,7 @@ closing.appendChild(spacer); const main2 = document.createElement('div'); main2.className = 'node-main'; - const tagSymbol2 = document.createElement('span'); - tagSymbol2.className = 'tag-symbol'; - tagSymbol2.textContent = '🏷'; - main2.appendChild(tagSymbol2); + main2.appendChild(createTagIcon()); const closingTag = document.createElement('span'); closingTag.className = 'tag-pill-closing'; closingTag.textContent = ``; diff --git a/style.css b/style.css index 5defca8..a657b90 100644 --- a/style.css +++ b/style.css @@ -173,11 +173,12 @@ button { .btn-icon { padding: 9px; - color: var(--muted-2); + color: #000; + font-size: 15px; } .btn-icon:hover { - color: var(--muted); + color: #000; } .help-panel { @@ -404,7 +405,9 @@ button { width: 34px; align-self: stretch; border-right: 1px solid var(--border-soft); - color: #cbd5e1; + color: #000; + font-size: 14px; + font-weight: 600; user-select: none; cursor: grab; } @@ -414,12 +417,12 @@ button { .action-btn { border: 0; background: transparent; - color: var(--muted-2); + color: #000; } .expand-btn { - width: 28px; - height: 28px; + width: 30px; + height: 30px; border-radius: 8px; margin-left: 4px; } @@ -428,7 +431,7 @@ button { .action-btn:hover, .icon-btn:hover { background: #f8fafc; - color: var(--muted); + color: #000; } .node-main { @@ -441,8 +444,11 @@ button { } .tag-symbol { - color: #60a5fa; - font-size: 13px; + color: #000; + width: 17px; + height: 17px; + display: block; + flex: 0 0 auto; } .tag-pill, @@ -524,10 +530,26 @@ button { } .action-btn { - width: 28px; - height: 28px; + width: 30px; + height: 30px; border-radius: 8px; - font-size: 13px; + font-size: 14px; + font-weight: 600; +} + +.arrow-icon { + width: 14px; + height: 14px; + display: block; + margin: 0 auto; + flex: 0 0 auto; +} + +.delete-icon { + width: 18px; + height: 18px; + display: block; + margin: 0 auto; } .action-btn.green:hover { @@ -568,10 +590,10 @@ button { } .text-icon { - color: #cbd5e1; + color: #000; padding-top: 9px; padding-left: 4px; - font-size: 12px; + font-size: 13px; } .text-node-textarea { @@ -693,11 +715,11 @@ button { border: 0; border-radius: 12px; background: transparent; - color: var(--muted-2); - font-size: 20px; + color: #000; + font-size: 22px; } -.close-btn:hover { background: #f1f5f9; color: var(--muted); } +.close-btn:hover { background: #f1f5f9; color: #000; } .modal-tabs { display: flex; From 77bd6a32683b9a54ebee16fcbf2d639aedb93d8d Mon Sep 17 00:00:00 2001 From: oywino Date: Sun, 19 Apr 2026 19:49:32 +0200 Subject: [PATCH 7/8] v0.1.6 Remove the text-node delete button Remove the delete control from text-node rows so clearing text content is done directly in the textarea instead of through a misleading remove action.\n\nThis avoids the confusing visual state where deleting a text node made the surrounding element structure appear broken, and bumps the in-app version badge to v0.1.6 for this release. --- app.js | 13 +------------ style.css | 4 ---- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/app.js b/app.js index e38c76d..af9415c 100644 --- a/app.js +++ b/app.js @@ -22,7 +22,7 @@ `; - const APP_VERSION = 'v0.1.5'; + const APP_VERSION = 'v0.1.6'; const HEARTBEAT_INTERVAL_MS = 5000; let idCounter = 0; const XML_NAME_RE = /^[A-Za-z_][A-Za-z0-9_.:-]*$/; @@ -625,17 +625,6 @@ }); wrap.appendChild(textarea); - const actions = document.createElement('div'); - actions.className = 'text-actions'; - const del = document.createElement('button'); - del.type = 'button'; - del.className = 'action-btn red'; - del.title = 'Delete text node'; - del.appendChild(createTrashIcon()); - del.addEventListener('click', () => updateRoot(removeNodeById(nodes, node.id))); - actions.appendChild(del); - wrap.appendChild(actions); - row.appendChild(wrap); return row; } diff --git a/style.css b/style.css index a657b90..993dfb2 100644 --- a/style.css +++ b/style.css @@ -613,10 +613,6 @@ button { border-color: var(--border); } -.text-actions { - padding-top: 6px; -} - .node-indent-1 { margin-left: 24px; } .node-indent-2 { margin-left: 48px; } .node-indent-3 { margin-left: 72px; } From 7b681de4401b6ad46f3c2c1cac9e727f1b60545a Mon Sep 17 00:00:00 2001 From: oywino Date: Sun, 19 Apr 2026 20:06:02 +0200 Subject: [PATCH 8/8] v0.1.7 Add undo and redo shortcuts Add document-level undo and redo support for structural edits, preamble changes, and committed text edits using Ctrl+Z and Ctrl+Y.\n\nRoute document mutations through a shared history layer, keep the in-app version badge aligned to v0.1.7, and remove the remaining decorative vertical guide lines on the left side of element content. --- app.js | 135 ++++++++++++++++++++++++++++++++++++++++++++++++++---- style.css | 2 - 2 files changed, 126 insertions(+), 11 deletions(-) diff --git a/app.js b/app.js index af9415c..d8c9832 100644 --- a/app.js +++ b/app.js @@ -22,8 +22,10 @@ `; - const APP_VERSION = 'v0.1.6'; + const APP_VERSION = 'v0.1.7'; const HEARTBEAT_INTERVAL_MS = 5000; + const HISTORY_LIMIT = 100; + const TYPING_COMMIT_DELAY_MS = 800; let idCounter = 0; const XML_NAME_RE = /^[A-Za-z_][A-Za-z0-9_.:-]*$/; @@ -38,6 +40,9 @@ showHelp: false, collapsed: {}, dragNodeId: null, + historyPast: [], + historyFuture: [], + pendingHistory: null, }; function generateId() { @@ -380,17 +385,124 @@ return result; } - function setDoc(doc) { + function cloneDoc(doc) { + return JSON.parse(JSON.stringify(doc)); + } + + function docsEqual(a, b) { + return JSON.stringify(a) === JSON.stringify(b); + } + + function loadDocIntoState(doc) { state.doc = doc; state.preambleVal = doc.preamble; + } + + function pushHistorySnapshot(doc) { + state.historyPast.push(cloneDoc(doc)); + if (state.historyPast.length > HISTORY_LIMIT) state.historyPast.shift(); + } + + function clearPendingHistoryTimer() { + if (state.pendingHistory?.timerId) { + window.clearTimeout(state.pendingHistory.timerId); + } + } + + function schedulePendingHistoryCommit() { + if (!state.pendingHistory) return; + clearPendingHistoryTimer(); + state.pendingHistory.timerId = window.setTimeout(() => { + commitPendingHistory(); + }, TYPING_COMMIT_DELAY_MS); + } + + function beginPendingHistorySession(key) { + if (!state.doc) return; + if (state.pendingHistory?.key === key) { + schedulePendingHistoryCommit(); + return; + } + + commitPendingHistory(); + state.pendingHistory = { + key, + snapshot: cloneDoc(state.doc), + timerId: null, + }; + schedulePendingHistoryCommit(); + } + + function commitPendingHistory() { + if (!state.pendingHistory) return; + clearPendingHistoryTimer(); + const { snapshot } = state.pendingHistory; + state.pendingHistory = null; + + if (!state.doc || docsEqual(snapshot, state.doc)) return; + pushHistorySnapshot(snapshot); + state.historyFuture = []; + } + + function setDoc(doc, opts = {}) { + const { recordHistory = true } = opts; + commitPendingHistory(); + + if (state.doc && docsEqual(state.doc, doc)) return; + if (recordHistory && state.doc) { + pushHistorySnapshot(state.doc); + state.historyFuture = []; + } + + loadDocIntoState(doc); render(); } function updateRoot(root) { - state.doc = { ...state.doc, root }; + setDoc({ ...state.doc, root }); + } + + function undo() { + commitPendingHistory(); + if (state.historyPast.length === 0 || !state.doc) return; + + state.historyFuture.push(cloneDoc(state.doc)); + const previous = state.historyPast.pop(); + loadDocIntoState(previous); + state.preambleEditing = false; + render(); + } + + function redo() { + commitPendingHistory(); + if (state.historyFuture.length === 0 || !state.doc) return; + + pushHistorySnapshot(state.doc); + const next = state.historyFuture.pop(); + loadDocIntoState(next); + state.preambleEditing = false; render(); } + function isEditableTarget(target) { + return target instanceof HTMLElement + && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable); + } + + function handleGlobalKeydown(event) { + if (!(event.ctrlKey || event.metaKey) || event.altKey) return; + if (isEditableTarget(event.target)) return; + + const key = event.key.toLowerCase(); + if (key === 'z' && !event.shiftKey) { + event.preventDefault(); + undo(); + } else if (key === 'y' || (key === 'z' && event.shiftKey)) { + event.preventDefault(); + redo(); + } + } + function getExportContent() { return state.exportMode === 'ai' ? serializeDocumentForAI(state.doc) : serializeDocument(state.doc); } @@ -620,9 +732,11 @@ textarea.value = (node.text || '').trim(); textarea.rows = Math.max(1, textarea.value ? textarea.value.split('\n').length : 1); textarea.addEventListener('input', () => { + beginPendingHistorySession(`text:${node.id}`); textarea.rows = Math.max(1, textarea.value ? textarea.value.split('\n').length : 1); state.doc.root = updateNodeById(nodes, node.id, (n) => ({ ...n, text: textarea.value })); }); + textarea.addEventListener('blur', () => commitPendingHistory()); wrap.appendChild(textarea); row.appendChild(wrap); @@ -904,9 +1018,9 @@ className: 'btn', onClick: () => { if (!window.confirm('Start a new document? Unsaved changes will be lost.')) return; - setDoc(parseDocument('# New Prompt\n\n\n \n')); state.showRaw = false; state.preambleEditing = false; + setDoc(parseDocument('# New Prompt\n\n\n \n')); }, }); toolbar.appendChild(newBtn); @@ -922,9 +1036,9 @@ const reader = new FileReader(); reader.onload = (ev) => { const text = String(ev.target?.result || ''); - setDoc(parseDocument(text)); state.showRaw = false; state.preambleEditing = false; + setDoc(parseDocument(text)); }; reader.readAsText(file); input.value = ''; @@ -984,6 +1098,7 @@ ['+↓ button', 'add sibling below'], ['Trash icon', 'delete element'], ['Text area', 'edit text content'], + ['Ctrl+Z / Ctrl+Y', 'undo or redo document changes'], ]; for (const [strong, rest] of items) { const div = document.createElement('div'); @@ -1035,9 +1150,8 @@ save.className = 'mini-btn mini-btn-primary'; save.textContent = 'Save'; save.addEventListener('click', () => { - state.doc = { ...state.doc, preamble: state.preambleVal }; state.preambleEditing = false; - render(); + setDoc({ ...state.doc, preamble: state.preambleVal }); }); actions.appendChild(save); const cancel = document.createElement('button'); @@ -1298,8 +1412,11 @@ } function init() { - state.doc = parseDocument(SAMPLE_DOC); - state.preambleVal = state.doc.preamble; + state.historyPast = []; + state.historyFuture = []; + state.pendingHistory = null; + loadDocIntoState(parseDocument(SAMPLE_DOC)); + window.addEventListener('keydown', handleGlobalKeydown); startHeartbeat(); render(); } diff --git a/style.css b/style.css index 993dfb2..b7ba318 100644 --- a/style.css +++ b/style.css @@ -404,7 +404,6 @@ button { justify-content: center; width: 34px; align-self: stretch; - border-right: 1px solid var(--border-soft); color: #000; font-size: 14px; font-weight: 600; @@ -579,7 +578,6 @@ button { margin-top: 4px; margin-left: 24px; padding: 4px 12px 4px 12px; - border-left: 2px solid var(--blue-100); } .text-row {