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/.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/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 8399e0a..4ccd94c 100644
--- a/README.md
+++ b/README.md
@@ -17,12 +17,14 @@ 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
## 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
@@ -39,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:
@@ -73,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.
@@ -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/XML_Editor.py b/XML_Editor.py
new file mode 100644
index 0000000..06b211b
--- /dev/null
+++ b/XML_Editor.py
@@ -0,0 +1,172 @@
+#!/usr/bin/env python3
+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:
+ with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
+ sock.bind((host, 0))
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ 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)
+
+ 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 = AppServer((HOST, port), QuietHandler)
+ url = f"http://{HOST}:{port}/index.html"
+
+ monitor = threading.Thread(target=monitor_browser_activity, args=(server,), daemon=True)
+ monitor.start()
+
+ print("XML Prompt Editor")
+ print(f"Serving: {url}")
+
+ try:
+ time.sleep(0.35)
+ open_in_new_browser_window(url)
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ server.shutdown()
+ server.server_close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/app.js b/app.js
index 3e55298..d8c9832 100644
--- a/app.js
+++ b/app.js
@@ -22,7 +22,12 @@
`;
+ 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_.:-]*$/;
const state = {
doc: null,
@@ -35,6 +40,9 @@
showHelp: false,
collapsed: {},
dragNodeId: null,
+ historyPast: [],
+ historyFuture: [],
+ pendingHistory: null,
};
function generateId() {
@@ -42,16 +50,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 +192,7 @@
const textNode = {
id: generateId(),
type: 'text',
- text: token.text.trim(),
+ text: decodeXmlEntities(token.text.trim()),
children: [],
};
if (stack.length > 0) {
@@ -178,18 +230,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}${node.tag}>\n`;
} else {
result += `${pad}<${node.tag}${attrs}>\n`;
@@ -206,13 +258,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`;
@@ -333,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);
}
@@ -356,10 +515,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 = {}) {
@@ -396,13 +640,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() {
@@ -411,6 +655,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 +669,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 +699,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 })));
}
@@ -481,22 +732,13 @@
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);
- 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.textContent = '🗑';
- del.addEventListener('click', () => updateRoot(removeNodeById(nodes, node.id)));
- actions.appendChild(del);
- wrap.appendChild(actions);
-
row.appendChild(wrap);
return row;
}
@@ -505,30 +747,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;
- card.classList.add('drag-over');
+ dragDepth += 1;
+ frame.classList.add('drag-over');
});
- card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
- card.addEventListener('drop', (e) => {
+ frame.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ if (!state.dragNodeId || state.dragNodeId === node.id) return;
+ frame.classList.add('drag-over');
+ });
+ frame.addEventListener('dragleave', () => {
+ dragDepth = Math.max(0, dragDepth - 1);
+ if (dragDepth === 0) frame.classList.remove('drag-over');
+ });
+ 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 }));
@@ -544,8 +802,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();
@@ -555,10 +815,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');
@@ -602,7 +859,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);
@@ -618,7 +875,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);
@@ -666,25 +923,24 @@
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);
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 = '⋮⋮';
@@ -695,18 +951,16 @@
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 = `${node.tag}>`;
main2.appendChild(closingTag);
closing.appendChild(main2);
- row.appendChild(closing);
+ frame.appendChild(closing);
}
+ row.appendChild(frame);
return row;
}
@@ -737,10 +991,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');
@@ -754,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);
@@ -772,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 = '';
@@ -834,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');
@@ -885,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');
@@ -1134,9 +1398,26 @@
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;
+ state.historyPast = [];
+ state.historyFuture = [];
+ state.pendingHistory = null;
+ loadDocIntoState(parseDocument(SAMPLE_DOC));
+ window.addEventListener('keydown', handleGlobalKeydown);
+ startHeartbeat();
render();
}
diff --git a/app.py b/app.py
deleted file mode 100644
index 655642f..0000000
--- a/app.py
+++ /dev/null
@@ -1,57 +0,0 @@
-#!/usr/bin/env python3
-from __future__ import annotations
-
-import contextlib
-import http.server
-import os
-import socket
-import threading
-import time
-import webbrowser
-from pathlib import Path
-
-BASE_DIR = Path(__file__).resolve().parent
-HOST = "127.0.0.1"
-
-
-def find_free_port(host: str = HOST) -> int:
- with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
- sock.bind((host, 0))
- sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
- return int(sock.getsockname()[1])
-
-
-class QuietHandler(http.server.SimpleHTTPRequestHandler):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, directory=str(BASE_DIR), **kwargs)
-
- def log_message(self, format: str, *args) -> None:
- return
-
-
-def main() -> None:
- port = find_free_port()
- server = http.server.ThreadingHTTPServer((HOST, port), QuietHandler)
- url = f"http://{HOST}:{port}/index.html"
-
- thread = threading.Thread(target=server.serve_forever, daemon=True)
- thread.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)
- except KeyboardInterrupt:
- pass
- finally:
- server.shutdown()
- server.server_close()
-
-
-if __name__ == "__main__":
- main()
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index b6fd63e..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.
@@ -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:
@@ -91,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/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
diff --git a/style.css b/style.css
index c02c8fb..b7ba318 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;
}
@@ -154,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 {
@@ -334,25 +354,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;
}
@@ -362,14 +387,26 @@ 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;
justify-content: center;
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;
}
@@ -379,12 +416,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;
}
@@ -393,7 +430,7 @@ button {
.action-btn:hover,
.icon-btn:hover {
background: #f8fafc;
- color: var(--muted);
+ color: #000;
}
.node-main {
@@ -406,8 +443,11 @@ button {
}
.tag-symbol {
- color: #60a5fa;
- font-size: 13px;
+ color: #000;
+ width: 17px;
+ height: 17px;
+ display: block;
+ flex: 0 0 auto;
}
.tag-pill,
@@ -489,10 +529,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 {
@@ -521,8 +577,7 @@ button {
.children-wrap {
margin-top: 4px;
margin-left: 24px;
- padding-left: 12px;
- border-left: 2px solid var(--blue-100);
+ padding: 4px 12px 4px 12px;
}
.text-row {
@@ -533,10 +588,10 @@ button {
}
.text-icon {
- color: #cbd5e1;
+ color: #000;
padding-top: 9px;
padding-left: 4px;
- font-size: 12px;
+ font-size: 13px;
}
.text-node-textarea {
@@ -556,10 +611,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; }
@@ -658,11 +709,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;