diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..b43c81d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,35 @@
+---
+name: Bug report
+about: Report something that is broken or behaving unexpectedly
+title: "[Bug] "
+labels: bug
+assignees: ""
+---
+
+## Summary
+
+Describe the problem clearly.
+
+## Steps To Reproduce
+
+1. Go to
+2. Click
+3. Observe
+
+## Expected Behavior
+
+Describe what you expected to happen.
+
+## Actual Behavior
+
+Describe what actually happened.
+
+## Environment
+
+- OS:
+- Browser:
+- Python version:
+
+## Notes
+
+Add screenshots, sample files, or extra context if helpful.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..e31cd78
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: true
+contact_links:
+ - name: Contribution Guide
+ url: https://github.com/oywino/python_xml_editor/blob/main/CONTRIBUTING.md
+ about: Read the project workflow and contribution notes before opening large changes.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..aa51a73
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,27 @@
+---
+name: Feature request
+about: Suggest an improvement or new capability
+title: "[Feature] "
+labels: enhancement
+assignees: ""
+---
+
+## Summary
+
+Describe the feature or improvement.
+
+## Problem
+
+What problem would this solve for users or maintainers?
+
+## Proposed Change
+
+Describe the preferred behavior or workflow.
+
+## Alternatives Considered
+
+List any alternatives you already considered.
+
+## Notes
+
+Add screenshots, examples, or links if helpful.
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..657b90b
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,11 @@
+## Summary
+
+- describe the change
+
+## Testing
+
+- describe how you tested it
+
+## UI Notes
+
+- add screenshots or short notes if the interface changed
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..dec9b23
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,37 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+ - chore/**
+ - feature/**
+ - fix/**
+ pull_request:
+
+jobs:
+ validate:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+
+ - name: Compile Python launcher
+ run: python -m py_compile app.py
+
+ - name: Verify required app files exist
+ run: |
+ test -f app.js
+ test -f index.html
+ test -f style.css
+
+ - name: Verify browser entrypoint references assets
+ run: |
+ grep -q 'style.css' index.html
+ grep -q 'app.js' index.html
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b7ed01c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+__pycache__/
+*.py[cod]
+*.log
+.DS_Store
+Thumbs.db
+.codex-gh/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..4936c2f
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,49 @@
+# Contributing
+
+## Workflow
+
+1. Branch from `main`.
+2. Keep changes focused and easy to review.
+3. Test locally before opening a pull request.
+4. Describe user-facing behavior changes clearly in the PR.
+
+Suggested branch prefixes:
+
+- `feature/` for new functionality
+- `fix/` for bug fixes
+- `chore/` for maintenance and repo setup
+
+## Pull Request Checklist
+
+- explain what changed
+- explain why it changed
+- note how it was tested
+- include screenshots for UI changes when useful
+- mention any parsing or export behavior changes explicitly
+
+## Local Run
+
+```bash
+python app.py
+```
+
+If needed on Windows:
+
+```bash
+py app.py
+```
+
+## Project Expectations
+
+- prefer simple, readable changes over heavy abstractions
+- keep the static app easy to run without extra dependencies
+- document changes that affect file formats, parsing, or export behavior
+
+## Issues
+
+Use the GitHub issue templates for:
+
+- bug reports
+- feature requests
+
+For larger changes, open an issue first so the implementation plan is easy to discuss.
diff --git a/README.md b/README.md
index e7d0361..8399e0a 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,86 @@
-# python_xml_editor
-This app is essentially a browser-based XML prompt editor with a very small Python launcher in front of it.
+# Python XML Editor
+
+`python_xml_editor` is a local browser-based XML prompt editor with a tiny Python launcher.
+
+It is designed for editing prompt documents that contain:
+
+- a free-form preamble, such as a Markdown heading or note
+- an XML body that can be edited visually instead of by hand
+
+The app runs entirely locally. Python serves the static files, and the browser handles parsing, editing, preview, and export.
+
+## Features
+
+- open `.md`, `.txt`, and `.xml` files
+- edit a prompt preamble separately from the XML structure
+- rename tags and edit attributes inline
+- add root, child, and sibling elements
+- reorder nodes with buttons or drag-and-drop
+- edit text nodes directly
+- 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
+- `index.html`: single HTML mount point
+- `app.js`: main application logic, parsing, tree editing, rendering, and export
+- `style.css`: application styling
+- `CONTRIBUTING.md`: contribution workflow and expectations
+- `docs/ARCHITECTURE.md`: architecture and state model notes
+
+## Requirements
+
+- Python 3.10 or newer is recommended
+- no third-party Python dependencies are required
+
+## Run Locally
+
+From the repository root:
+
+```bash
+python app.py
+```
+
+If your machine uses the Windows launcher instead of `python`, you can also use:
+
+```bash
+py app.py
+```
+
+The launcher will:
+
+1. find a free local port
+2. serve the repository directory over HTTP
+3. open `index.html` in your default browser
+
+Stop the server with `Ctrl+C`.
+
+## Development Workflow
+
+1. branch from `main`
+2. make and test your changes locally
+3. open a pull request with a clear summary
+4. include screenshots for UI changes when useful
+
+This repository includes:
+
+- a pull request template in `.github/pull_request_template.md`
+- issue templates for bugs and feature requests
+- a basic GitHub Actions workflow for pull requests and pushes
+
+## Architecture
+
+The application has two parts:
+
+1. a Python wrapper in `app.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.
+
+More detail is available in `docs/ARCHITECTURE.md`.
+
+## Notes
+
+- the XML parsing logic is custom and currently optimized for simple prompt-style XML
+- the app is intentionally lightweight and has no front-end framework or backend service
diff --git a/app.js b/app.js
new file mode 100644
index 0000000..3e55298
--- /dev/null
+++ b/app.js
@@ -0,0 +1,1144 @@
+(() => {
+ const SAMPLE_DOC = `# My AI Prompt
+
+
+
+
+ My Amazing Project
+
+
+ Describe your project goal here. What are you building?
+
+
+
+
+ Always provide step-by-step reasoning.
+ Ask for clarification when requirements are unclear.
+ Never speculate beyond the provided context.
+
+
+
+ Please respond in a structured, clear manner.
+
+`;
+
+ let idCounter = 0;
+
+ const state = {
+ doc: null,
+ showExport: false,
+ exportMode: 'ai',
+ copied: false,
+ showRaw: false,
+ preambleEditing: false,
+ preambleVal: '',
+ showHelp: false,
+ collapsed: {},
+ dragNodeId: null,
+ };
+
+ function generateId() {
+ idCounter += 1;
+ return `node_${idCounter}_${Math.random().toString(36).slice(2, 7)}`;
+ }
+
+ function parseAttributes(attrStr) {
+ const attrs = {};
+ const regex = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
+ let m;
+ while ((m = regex.exec(attrStr)) !== null) {
+ attrs[m[1]] = m[2] ?? m[3] ?? '';
+ }
+ return attrs;
+ }
+
+ function tokenize(xml) {
+ const tokens = [];
+ let i = 0;
+ while (i < xml.length) {
+ if (xml[i] === '<') {
+ const end = xml.indexOf('>', i);
+ if (end === -1) {
+ tokens.push({ type: 'text', text: xml.slice(i) });
+ break;
+ }
+ const tagContent = xml.slice(i + 1, end);
+
+ if (tagContent.startsWith('!--')) {
+ const commentEnd = xml.indexOf('-->', i);
+ if (commentEnd === -1) {
+ i = xml.length;
+ } else {
+ i = commentEnd + 3;
+ }
+ continue;
+ }
+
+ if (tagContent.startsWith('/')) {
+ tokens.push({ type: 'close', tag: tagContent.slice(1).trim() });
+ i = end + 1;
+ } else if (tagContent.endsWith('/')) {
+ const inner = tagContent.slice(0, -1).trim();
+ const spaceIdx = inner.search(/\s/);
+ const tag = spaceIdx === -1 ? inner : inner.slice(0, spaceIdx);
+ const attrStr = spaceIdx === -1 ? '' : inner.slice(spaceIdx);
+ tokens.push({ type: 'selfclose', tag, attributes: parseAttributes(attrStr) });
+ i = end + 1;
+ } else {
+ const spaceIdx = tagContent.search(/\s/);
+ const tag = spaceIdx === -1 ? tagContent : tagContent.slice(0, spaceIdx);
+ const attrStr = spaceIdx === -1 ? '' : tagContent.slice(spaceIdx);
+ tokens.push({ type: 'open', tag, attributes: parseAttributes(attrStr) });
+ i = end + 1;
+ }
+ } else {
+ const nextTag = xml.indexOf('<', i);
+ const text = nextTag === -1 ? xml.slice(i) : xml.slice(i, nextTag);
+ if (text.trim()) tokens.push({ type: 'text', text });
+ i = nextTag === -1 ? xml.length : nextTag;
+ }
+ }
+ return tokens;
+ }
+
+ function buildTree(tokens) {
+ const stack = [];
+ const roots = [];
+ for (const token of tokens) {
+ if (token.type === 'open') {
+ const node = {
+ id: generateId(),
+ type: 'element',
+ tag: token.tag,
+ attributes: token.attributes || {},
+ children: [],
+ };
+ if (stack.length > 0) {
+ node.parent = stack[stack.length - 1].id;
+ stack[stack.length - 1].children.push(node);
+ } else {
+ roots.push(node);
+ }
+ stack.push(node);
+ } else if (token.type === 'close') {
+ if (stack.length > 0) stack.pop();
+ } else if (token.type === 'selfclose') {
+ const node = {
+ id: generateId(),
+ type: 'element',
+ tag: token.tag,
+ attributes: token.attributes || {},
+ children: [],
+ };
+ if (stack.length > 0) {
+ node.parent = stack[stack.length - 1].id;
+ stack[stack.length - 1].children.push(node);
+ } else {
+ roots.push(node);
+ }
+ } else if (token.type === 'text') {
+ const textNode = {
+ id: generateId(),
+ type: 'text',
+ text: token.text.trim(),
+ children: [],
+ };
+ if (stack.length > 0) {
+ textNode.parent = stack[stack.length - 1].id;
+ stack[stack.length - 1].children.push(textNode);
+ } else {
+ roots.push(textNode);
+ }
+ }
+ }
+ return roots;
+ }
+
+ function parseDocument(input) {
+ const lines = input.split('\n');
+ let preamble = '';
+ let xmlStart = -1;
+ for (let i = 0; i < lines.length; i += 1) {
+ const trimmed = lines[i].trim();
+ if (trimmed.startsWith('<') && !trimmed.startsWith(' ` ${k}="${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();
+ result += `${pad}<${node.tag}${attrs}>${text}${node.tag}>\n`;
+ } else {
+ result += `${pad}<${node.tag}${attrs}>\n`;
+ result += serializeToXml(node.children, indent + 1);
+ result += `${pad}${node.tag}>\n`;
+ }
+ }
+ }
+ }
+ return result;
+ }
+
+ function serializeFlatXml(nodes) {
+ let result = '';
+ for (const node of nodes) {
+ if (node.type === 'text') {
+ const text = (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('')
+ : '';
+ if (node.children.length === 0) {
+ result += `<${node.tag}${attrs} />\n`;
+ } else {
+ result += `<${node.tag}${attrs}>\n`;
+ result += serializeFlatXml(node.children);
+ result += `${node.tag}>\n`;
+ }
+ }
+ }
+ return result;
+ }
+
+ function serializeDocument(doc) {
+ let result = '';
+ if (doc.preamble) result += `${doc.preamble}\n\n`;
+ result += serializeToXml(doc.root, 0);
+ return result.replace(/\n+$/, '');
+ }
+
+ function serializeDocumentFlat(doc) {
+ return serializeFlatXml(doc.root).replace(/\n+$/, '');
+ }
+
+ function serializeDocumentForAI(doc) {
+ return serializeDocumentFlat(doc);
+ }
+
+ function removeNodeById(nodes, id) {
+ return nodes.filter((n) => n.id !== id).map((n) => ({ ...n, children: removeNodeById(n.children, id) }));
+ }
+
+ function updateNodeById(nodes, id, updater) {
+ return nodes.map((n) => {
+ if (n.id === id) return updater(n);
+ return { ...n, children: updateNodeById(n.children, id, updater) };
+ });
+ }
+
+ function createElementNode(tag, parentId) {
+ return {
+ id: generateId(),
+ type: 'element',
+ tag,
+ attributes: {},
+ children: [{ id: generateId(), type: 'text', text: '', children: [], parent: undefined }],
+ parent: parentId,
+ };
+ }
+
+ function findNodeById(nodes, id) {
+ for (const node of nodes) {
+ if (node.id === id) return node;
+ const found = findNodeById(node.children, id);
+ if (found) return found;
+ }
+ return null;
+ }
+
+ function getNodeChildrenSiblings(nodes, nodeId) {
+ function find(list, parentId) {
+ for (const n of list) {
+ if (n.id === nodeId) return { siblings: list, parentId };
+ const found = find(n.children, n.id);
+ if (found) return found;
+ }
+ return null;
+ }
+ return find(nodes, null) || { siblings: nodes, parentId: null };
+ }
+
+ function siblingElementTags(siblings, excludeId) {
+ return new Set(
+ siblings
+ .filter((n) => n.type === 'element' && n.id !== excludeId && n.tag)
+ .map((n) => n.tag)
+ );
+ }
+
+ function uniqueTagName(base, usedNames) {
+ if (!usedNames.has(base)) return base;
+ let i = 2;
+ while (usedNames.has(`${base}_${i}`)) i += 1;
+ return `${base}_${i}`;
+ }
+
+ function isDescendant(node, maybeAncestorId) {
+ if (!node || !maybeAncestorId) return false;
+ if (node.id === maybeAncestorId) return true;
+ return node.children.some((child) => isDescendant(child, maybeAncestorId));
+ }
+
+ function insertNodeAt(nodes, node, parentId, index) {
+ if (parentId === null) {
+ const arr = [...nodes];
+ arr.splice(index, 0, node);
+ return arr;
+ }
+ return nodes.map((n) => {
+ if (n.id === parentId) {
+ const children = [...n.children];
+ children.splice(index, 0, { ...node, parent: parentId });
+ return { ...n, children };
+ }
+ return { ...n, children: insertNodeAt(n.children, node, parentId, index) };
+ });
+ }
+
+ function moveNode(nodes, nodeId, target) {
+ const entry = findNodeById(nodes, nodeId);
+ if (!entry) return nodes;
+ const targetParent = target.parentId ? findNodeById(nodes, target.parentId) : null;
+ if (targetParent && isDescendant(entry, target.parentId)) return nodes;
+ const movedNode = JSON.parse(JSON.stringify(entry));
+ movedNode.parent = target.parentId || undefined;
+ let result = removeNodeById(nodes, nodeId);
+ result = insertNodeAt(result, movedNode, target.parentId, target.index);
+ return result;
+ }
+
+ function setDoc(doc) {
+ state.doc = doc;
+ state.preambleVal = doc.preamble;
+ render();
+ }
+
+ function updateRoot(root) {
+ state.doc = { ...state.doc, root };
+ render();
+ }
+
+ function getExportContent() {
+ return state.exportMode === 'ai' ? serializeDocumentForAI(state.doc) : serializeDocument(state.doc);
+ }
+
+ function escapeHtml(text) {
+ return String(text)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+
+ function icon(text) {
+ const span = document.createElement('span');
+ span.textContent = text;
+ return span;
+ }
+
+ function btn(label, opts = {}) {
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = opts.className || 'btn';
+ b.textContent = label;
+ if (opts.title) b.title = opts.title;
+ if (opts.onClick) b.addEventListener('click', opts.onClick);
+ return b;
+ }
+
+ function renderTagEditor(node, nodes, container) {
+ const wrap = document.createElement('div');
+ wrap.style.display = 'flex';
+ wrap.style.flexDirection = 'column';
+ wrap.style.gap = '4px';
+
+ const input = document.createElement('input');
+ input.className = 'inline-input';
+ input.value = node.tag || '';
+ wrap.appendChild(input);
+
+ const errorLine = document.createElement('div');
+ errorLine.className = 'error-line hidden';
+ wrap.appendChild(errorLine);
+
+ function isDuplicate(name) {
+ const { siblings } = getNodeChildrenSiblings(nodes, node.id);
+ return siblingElementTags(siblings, node.id).has(name);
+ }
+
+ function showError(message) {
+ input.classList.add('error');
+ errorLine.textContent = `โ ${message}`;
+ errorLine.classList.remove('hidden');
+ container.closest('.node-card')?.classList.add('error');
+ }
+
+ function clearError() {
+ input.classList.remove('error');
+ errorLine.classList.add('hidden');
+ container.closest('.node-card')?.classList.remove('error');
+ }
+
+ function save() {
+ const name = input.value.trim();
+ if (!name) {
+ render();
+ return;
+ }
+ if (isDuplicate(name)) {
+ showError(`"${name}" already exists among siblings.`);
+ return;
+ }
+ clearError();
+ updateRoot(updateNodeById(nodes, node.id, (n) => ({ ...n, tag: name })));
+ }
+
+ input.addEventListener('input', () => {
+ const val = input.value.trim();
+ if (val && isDuplicate(val)) showError(`"${val}" already exists among siblings.`);
+ else clearError();
+ });
+ input.addEventListener('blur', save);
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') save();
+ if (e.key === 'Escape') render();
+ });
+
+ container.replaceChildren(wrap);
+ input.focus();
+ input.select();
+ }
+
+ function renderAttrEditor(node, nodes, container) {
+ const input = document.createElement('input');
+ input.className = 'attr-input';
+ input.value = node.attributes
+ ? Object.entries(node.attributes).map(([k, v]) => (v ? `${k}="${v}"` : k)).join(' ')
+ : '';
+
+ function save() {
+ const attrs = {};
+ const regex = /(\w+)(?:\s*=\s*"([^"]*)")?/g;
+ let m;
+ while ((m = regex.exec(input.value)) !== null) {
+ attrs[m[1]] = m[2] ?? '';
+ }
+ updateRoot(updateNodeById(nodes, node.id, (n) => ({ ...n, attributes: attrs })));
+ }
+
+ input.addEventListener('blur', save);
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') save();
+ if (e.key === 'Escape') render();
+ });
+
+ container.replaceChildren(input);
+ input.focus();
+ input.select();
+ }
+
+ function renderTextNode(node, depth, nodes) {
+ const row = document.createElement('div');
+ row.className = `node-row node-indent-${Math.min(depth, 10)}`;
+
+ const wrap = document.createElement('div');
+ wrap.className = 'text-row';
+
+ const iconEl = document.createElement('div');
+ iconEl.className = 'text-icon';
+ iconEl.textContent = 'โค';
+ wrap.appendChild(iconEl);
+
+ const textarea = document.createElement('textarea');
+ textarea.className = 'text-node-textarea';
+ textarea.placeholder = 'Text content...';
+ textarea.value = (node.text || '').trim();
+ textarea.rows = Math.max(1, textarea.value ? textarea.value.split('\n').length : 1);
+ textarea.addEventListener('input', () => {
+ 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 }));
+ });
+ 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;
+ }
+
+ function renderElementNode(node, depth, nodes) {
+ const row = document.createElement('div');
+ row.className = `node-row node-indent-${Math.min(depth, 10)}`;
+
+ const card = document.createElement('div');
+ card.className = 'node-card';
+ card.draggable = true;
+ card.addEventListener('dragstart', (e) => {
+ state.dragNodeId = node.id;
+ card.classList.add('dragging');
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', node.id);
+ });
+ card.addEventListener('dragend', () => {
+ state.dragNodeId = null;
+ card.classList.remove('dragging');
+ document.querySelectorAll('.drag-over').forEach((el) => el.classList.remove('drag-over'));
+ });
+ card.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ if (!state.dragNodeId || state.dragNodeId === node.id) return;
+ card.classList.add('drag-over');
+ });
+ card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
+ card.addEventListener('drop', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ card.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 }));
+ }
+ });
+
+ const grip = document.createElement('div');
+ grip.className = 'grip';
+ grip.textContent = 'โฎโฎ';
+ card.appendChild(grip);
+
+ const expand = document.createElement('button');
+ 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';
+ expand.addEventListener('click', () => {
+ state.collapsed[node.id] = !state.collapsed[node.id];
+ render();
+ });
+ card.appendChild(expand);
+
+ const main = document.createElement('div');
+ main.className = 'node-main';
+
+ const tagSymbol = document.createElement('span');
+ tagSymbol.className = 'tag-symbol';
+ tagSymbol.textContent = '๐ท';
+ main.appendChild(tagSymbol);
+
+ const tagWrap = document.createElement('div');
+ const tagBtn = document.createElement('button');
+ tagBtn.type = 'button';
+ tagBtn.className = 'tag-pill';
+ tagBtn.textContent = `<${node.tag}>`;
+ tagBtn.addEventListener('click', () => renderTagEditor(node, nodes, tagWrap));
+ tagWrap.appendChild(tagBtn);
+ main.appendChild(tagWrap);
+
+ const attrWrap = document.createElement('div');
+ const attrDisplay = node.attributes
+ ? Object.entries(node.attributes)
+ .filter(([, v]) => v !== undefined)
+ .map(([k, v]) => (v ? `${k}="${v}"` : k))
+ .join(' ')
+ : '';
+
+ if (attrDisplay) {
+ const attrBtn = document.createElement('button');
+ attrBtn.type = 'button';
+ attrBtn.className = 'attr-pill';
+ attrBtn.textContent = attrDisplay;
+ attrBtn.addEventListener('click', () => renderAttrEditor(node, nodes, attrWrap));
+ attrWrap.appendChild(attrBtn);
+ } else {
+ const attrBtn = document.createElement('button');
+ attrBtn.type = 'button';
+ attrBtn.className = 'attr-pill attr-placeholder';
+ attrBtn.textContent = '+ attr';
+ attrBtn.addEventListener('click', () => renderAttrEditor(node, nodes, attrWrap));
+ attrWrap.appendChild(attrBtn);
+ }
+ main.appendChild(attrWrap);
+ card.appendChild(main);
+
+ const actions = document.createElement('div');
+ actions.className = 'node-actions';
+
+ const moveUpBtn = document.createElement('button');
+ moveUpBtn.type = 'button';
+ moveUpBtn.className = 'action-btn';
+ moveUpBtn.title = 'Move up';
+ moveUpBtn.textContent = 'โฒ';
+ moveUpBtn.addEventListener('click', () => {
+ const { siblings, parentId } = getNodeChildrenSiblings(nodes, node.id);
+ const idx = siblings.findIndex((n) => n.id === node.id);
+ if (idx <= 0) return;
+ const arr = [...siblings];
+ [arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]];
+ if (parentId === null) updateRoot(arr);
+ else updateRoot(updateNodeById(nodes, parentId, (n) => ({ ...n, children: arr })));
+ });
+ actions.appendChild(moveUpBtn);
+
+ const moveDownBtn = document.createElement('button');
+ moveDownBtn.type = 'button';
+ moveDownBtn.className = 'action-btn';
+ moveDownBtn.title = 'Move down';
+ moveDownBtn.textContent = 'โผ';
+ moveDownBtn.addEventListener('click', () => {
+ const { siblings, parentId } = getNodeChildrenSiblings(nodes, node.id);
+ const idx = siblings.findIndex((n) => n.id === node.id);
+ if (idx >= siblings.length - 1) return;
+ const arr = [...siblings];
+ [arr[idx], arr[idx + 1]] = [arr[idx + 1], arr[idx]];
+ if (parentId === null) updateRoot(arr);
+ else updateRoot(updateNodeById(nodes, parentId, (n) => ({ ...n, children: arr })));
+ });
+ actions.appendChild(moveDownBtn);
+
+ const addChildBtn = document.createElement('button');
+ addChildBtn.type = 'button';
+ addChildBtn.className = 'action-btn green';
+ addChildBtn.title = 'Add child element';
+ addChildBtn.textContent = '+';
+ addChildBtn.addEventListener('click', () => {
+ const existing = new Set(node.children.filter((c) => c.type === 'element').map((c) => c.tag));
+ const name = uniqueTagName('new_tag', existing);
+ const child = createElementNode(name, node.id);
+ state.collapsed[node.id] = false;
+ updateRoot(updateNodeById(nodes, node.id, (n) => ({ ...n, children: [...n.children, child] })));
+ });
+ actions.appendChild(addChildBtn);
+
+ const addSiblingBtn = document.createElement('button');
+ addSiblingBtn.type = 'button';
+ addSiblingBtn.className = 'action-btn blue';
+ addSiblingBtn.title = 'Add sibling after';
+ addSiblingBtn.textContent = '+โ';
+ addSiblingBtn.addEventListener('click', () => {
+ const { siblings, parentId } = getNodeChildrenSiblings(nodes, node.id);
+ const idx = siblings.findIndex((n) => n.id === node.id);
+ const used = siblingElementTags(siblings, '');
+ const name = uniqueTagName('new_tag', used);
+ const newNode = createElementNode(name, parentId || undefined);
+ const newSiblings = [...siblings];
+ newSiblings.splice(idx + 1, 0, newNode);
+ if (parentId === null) updateRoot(newSiblings);
+ else updateRoot(updateNodeById(nodes, parentId, (n) => ({ ...n, children: newSiblings })));
+ });
+ actions.appendChild(addSiblingBtn);
+
+ const deleteBtn = document.createElement('button');
+ deleteBtn.type = 'button';
+ deleteBtn.className = 'action-btn red';
+ deleteBtn.title = 'Delete';
+ deleteBtn.textContent = '๐';
+ deleteBtn.addEventListener('click', () => updateRoot(removeNodeById(nodes, node.id)));
+ actions.appendChild(deleteBtn);
+
+ card.appendChild(actions);
+ row.appendChild(card);
+
+ const isCollapsed = !!state.collapsed[node.id];
+ if (!isCollapsed && hasChildren) {
+ const childrenWrap = document.createElement('div');
+ childrenWrap.className = 'children-wrap';
+ for (const child of node.children) {
+ childrenWrap.appendChild(renderNode(child, 0, nodes));
+ }
+ row.appendChild(childrenWrap);
+
+ const closing = document.createElement('div');
+ closing.className = 'node-card';
+ closing.style.marginTop = '4px';
+ const grip2 = document.createElement('div');
+ grip2.className = 'grip';
+ grip2.textContent = 'โฎโฎ';
+ closing.appendChild(grip2);
+ const spacer = document.createElement('div');
+ spacer.style.width = '28px';
+ spacer.style.marginLeft = '4px';
+ 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);
+ const closingTag = document.createElement('span');
+ closingTag.className = 'tag-pill-closing';
+ closingTag.textContent = `${node.tag}>`;
+ main2.appendChild(closingTag);
+ closing.appendChild(main2);
+ row.appendChild(closing);
+ }
+
+ return row;
+ }
+
+ function renderNode(node, depth, nodes) {
+ if (node.type === 'text') return renderTextNode(node, depth, nodes);
+ return renderElementNode(node, depth, nodes);
+ }
+
+ function downloadText(filename, content) {
+ const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ }
+
+ function renderHeader(root) {
+ const topbar = document.createElement('header');
+ topbar.className = 'topbar';
+ const inner = document.createElement('div');
+ inner.className = 'topbar-inner';
+
+ const brand = document.createElement('div');
+ brand.className = 'brand';
+ const iconBox = document.createElement('div');
+ iconBox.className = 'brand-icon';
+ iconBox.textContent = '>';
+ brand.appendChild(iconBox);
+ const title = document.createElement('div');
+ title.className = 'brand-title';
+ title.textContent = 'XML Prompt Editor';
+ brand.appendChild(title);
+ inner.appendChild(brand);
+
+ const spacer = document.createElement('div');
+ spacer.className = 'spacer';
+ inner.appendChild(spacer);
+
+ const toolbar = document.createElement('div');
+ toolbar.className = 'toolbar';
+
+ const newBtn = btn('๏ผ New', {
+ 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;
+ },
+ });
+ toolbar.appendChild(newBtn);
+
+ const openBtn = btn('โคด Open File', { className: 'btn' });
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = '.md,.txt,.xml';
+ input.className = 'hidden';
+ input.addEventListener('change', (e) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = (ev) => {
+ const text = String(ev.target?.result || '');
+ setDoc(parseDocument(text));
+ state.showRaw = false;
+ state.preambleEditing = false;
+ };
+ reader.readAsText(file);
+ input.value = '';
+ });
+ openBtn.addEventListener('click', () => input.click());
+ toolbar.appendChild(openBtn);
+ toolbar.appendChild(input);
+
+ const rawBtn = btn(state.showRaw ? '๐ Visual' : '๐ Raw', {
+ className: `btn ${state.showRaw ? 'btn-toggle-active' : ''}`,
+ onClick: () => {
+ state.showRaw = !state.showRaw;
+ render();
+ },
+ });
+ toolbar.appendChild(rawBtn);
+
+ const helpBtn = btn('?', {
+ className: 'btn btn-icon',
+ title: 'Help',
+ onClick: () => {
+ state.showHelp = !state.showHelp;
+ render();
+ },
+ });
+ toolbar.appendChild(helpBtn);
+
+ const exportBtn = btn('โค Export', {
+ className: 'btn btn-primary',
+ onClick: () => {
+ state.showExport = true;
+ state.copied = false;
+ render();
+ },
+ });
+ toolbar.appendChild(exportBtn);
+
+ inner.appendChild(toolbar);
+ topbar.appendChild(inner);
+ root.appendChild(topbar);
+ }
+
+ function renderHelp(root) {
+ if (!state.showHelp) return;
+ const help = document.createElement('div');
+ help.className = 'help-panel';
+ const inner = document.createElement('div');
+ inner.className = 'help-inner';
+ const grid = document.createElement('div');
+ grid.className = 'help-grid';
+ const items = [
+ ['Click tag name', 'rename it'],
+ ['Click attr text', 'edit attributes'],
+ ['Drag grip', 'move elements into another node'],
+ ['โฒโผ arrows', 'reorder siblings'],
+ ['+ button', 'add child element'],
+ ['+โ button', 'add sibling below'],
+ ['Trash icon', 'delete element'],
+ ['Text area', 'edit text content'],
+ ];
+ for (const [strong, rest] of items) {
+ const div = document.createElement('div');
+ div.innerHTML = `${escapeHtml(strong)} โ ${escapeHtml(rest)}`;
+ grid.appendChild(div);
+ }
+ inner.appendChild(grid);
+ help.appendChild(inner);
+ root.appendChild(help);
+ }
+
+ function renderPreambleCard(container) {
+ if (state.doc.preamble || state.preambleEditing) {
+ const card = document.createElement('div');
+ card.className = 'card';
+ const header = document.createElement('div');
+ header.className = 'card-header';
+ const title = document.createElement('div');
+ title.className = 'card-title';
+ title.textContent = 'PREAMBLE (MARKDOWN HEADER)';
+ header.appendChild(title);
+ if (!state.preambleEditing) {
+ const edit = document.createElement('button');
+ edit.type = 'button';
+ edit.className = 'edit-link';
+ edit.textContent = 'Edit';
+ edit.addEventListener('click', () => {
+ state.preambleEditing = true;
+ render();
+ });
+ header.appendChild(edit);
+ }
+ card.appendChild(header);
+
+ if (state.preambleEditing) {
+ const body = document.createElement('div');
+ body.className = 'card-body';
+ const textarea = document.createElement('textarea');
+ textarea.className = 'preamble-textarea';
+ textarea.value = state.preambleVal;
+ textarea.addEventListener('input', () => {
+ state.preambleVal = textarea.value;
+ });
+ body.appendChild(textarea);
+ const actions = document.createElement('div');
+ actions.className = 'action-row';
+ const save = document.createElement('button');
+ save.type = 'button';
+ save.className = 'mini-btn mini-btn-primary';
+ save.textContent = 'Save';
+ save.addEventListener('click', () => {
+ state.doc = { ...state.doc, preamble: state.preambleVal };
+ state.preambleEditing = false;
+ render();
+ });
+ actions.appendChild(save);
+ const cancel = document.createElement('button');
+ cancel.type = 'button';
+ cancel.className = 'mini-btn mini-btn-muted';
+ cancel.textContent = 'Cancel';
+ cancel.addEventListener('click', () => {
+ state.preambleVal = state.doc.preamble;
+ state.preambleEditing = false;
+ render();
+ });
+ actions.appendChild(cancel);
+ body.appendChild(actions);
+ card.appendChild(body);
+ } else {
+ const display = document.createElement('div');
+ display.className = 'preamble-display';
+ display.textContent = state.doc.preamble || 'No preamble';
+ if (!state.doc.preamble) display.classList.add('muted-empty');
+ display.addEventListener('click', () => {
+ state.preambleEditing = true;
+ render();
+ });
+ card.appendChild(display);
+ }
+ container.appendChild(card);
+ } else {
+ const add = document.createElement('button');
+ add.type = 'button';
+ add.className = 'add-preamble';
+ add.textContent = '+ Add preamble (e.g. Markdown title or comment)';
+ add.addEventListener('click', () => {
+ state.preambleEditing = true;
+ render();
+ });
+ container.appendChild(add);
+ }
+ }
+
+ function renderStructureCard(container) {
+ const card = document.createElement('div');
+ card.className = 'card';
+ const header = document.createElement('div');
+ header.className = 'card-header';
+ const iconEl = document.createElement('span');
+ iconEl.textContent = '>';
+ iconEl.style.color = '#94a3b8';
+ iconEl.style.fontSize = '13px';
+ header.appendChild(iconEl);
+ const title = document.createElement('div');
+ title.className = 'card-title';
+ title.textContent = 'XML Structure';
+ header.appendChild(title);
+ const subtle = document.createElement('div');
+ subtle.className = 'card-subtle';
+ subtle.textContent = `${state.doc.root.length} root element${state.doc.root.length !== 1 ? 's' : ''}`;
+ header.appendChild(subtle);
+ card.appendChild(header);
+
+ const body = document.createElement('div');
+ body.className = 'structure-body';
+ const list = document.createElement('div');
+ list.className = 'node-list';
+ for (const node of state.doc.root) {
+ list.appendChild(renderNode(node, 0, state.doc.root));
+ }
+ body.appendChild(list);
+
+ const addWrap = document.createElement('div');
+ addWrap.className = 'add-root-wrap';
+ const addRoot = document.createElement('button');
+ addRoot.type = 'button';
+ addRoot.className = 'add-root-btn';
+ addRoot.textContent = '+ Add root element';
+ addRoot.addEventListener('click', () => {
+ const used = new Set(state.doc.root.filter((n) => n.type === 'element').map((n) => n.tag));
+ const name = uniqueTagName('new_tag', used);
+ updateRoot([...state.doc.root, createElementNode(name)]);
+ });
+ addWrap.appendChild(addRoot);
+ body.appendChild(addWrap);
+ card.appendChild(body);
+ container.appendChild(card);
+ }
+
+ function renderRawCard(container) {
+ const card = document.createElement('div');
+ card.className = 'card';
+ const header = document.createElement('div');
+ header.className = 'card-header';
+ const iconEl = document.createElement('span');
+ iconEl.textContent = '๐';
+ iconEl.style.fontSize = '13px';
+ header.appendChild(iconEl);
+ const title = document.createElement('div');
+ title.className = 'card-title';
+ title.textContent = 'Raw text preview';
+ header.appendChild(title);
+ const subtle = document.createElement('div');
+ subtle.className = 'card-subtle';
+ subtle.textContent = 'Read-only โ switch to Visual to edit';
+ header.appendChild(subtle);
+ card.appendChild(header);
+
+ const pre = document.createElement('pre');
+ pre.className = 'raw-pre';
+ pre.textContent = serializeDocumentFlat(state.doc);
+ card.appendChild(pre);
+ container.appendChild(card);
+ }
+
+ function renderExportModal(root) {
+ if (!state.showExport) return;
+ const backdrop = document.createElement('div');
+ backdrop.className = 'modal-backdrop';
+ backdrop.addEventListener('click', (e) => {
+ if (e.target === backdrop) {
+ state.showExport = false;
+ render();
+ }
+ });
+
+ const modal = document.createElement('div');
+ modal.className = 'modal';
+
+ const header = document.createElement('div');
+ header.className = 'modal-header';
+ const left = document.createElement('div');
+ const title = document.createElement('div');
+ title.className = 'modal-title';
+ title.textContent = 'Export Prompt';
+ left.appendChild(title);
+ const subtitle = document.createElement('div');
+ subtitle.className = 'modal-subtitle';
+ subtitle.textContent = 'Choose export format below';
+ left.appendChild(subtitle);
+ header.appendChild(left);
+ const close = document.createElement('button');
+ close.type = 'button';
+ close.className = 'close-btn';
+ close.textContent = 'ร';
+ close.addEventListener('click', () => {
+ state.showExport = false;
+ render();
+ });
+ header.appendChild(close);
+ modal.appendChild(header);
+
+ const tabs = document.createElement('div');
+ tabs.className = 'modal-tabs';
+ const aiTab = document.createElement('button');
+ aiTab.type = 'button';
+ aiTab.className = `tab-btn ${state.exportMode === 'ai' ? 'active' : ''}`;
+ aiTab.textContent = 'AI-Ready (clean text)';
+ aiTab.addEventListener('click', () => {
+ state.exportMode = 'ai';
+ render();
+ });
+ const editorTab = document.createElement('button');
+ editorTab.type = 'button';
+ editorTab.className = `tab-btn ${state.exportMode === 'editor' ? 'active' : ''}`;
+ editorTab.textContent = 'Editor Format (full)';
+ editorTab.addEventListener('click', () => {
+ state.exportMode = 'editor';
+ render();
+ });
+ tabs.appendChild(aiTab);
+ tabs.appendChild(editorTab);
+ modal.appendChild(tabs);
+
+ const note = document.createElement('div');
+ note.className = 'modal-note';
+ note.textContent = state.exportMode === 'ai'
+ ? 'Clean XML output ready to paste into your AI prompt. No visual markers.'
+ : 'Full format including preamble โ use this to reload the file for editing.';
+ modal.appendChild(note);
+
+ const preWrap = document.createElement('div');
+ preWrap.className = 'modal-pre-wrap';
+ const pre = document.createElement('pre');
+ pre.className = 'modal-pre';
+ pre.textContent = getExportContent();
+ preWrap.appendChild(pre);
+ modal.appendChild(preWrap);
+
+ const actions = document.createElement('div');
+ actions.className = 'modal-actions';
+ const copy = document.createElement('button');
+ copy.type = 'button';
+ copy.className = 'copy-btn';
+ copy.textContent = state.copied ? 'Copied!' : 'Copy to Clipboard';
+ copy.addEventListener('click', async () => {
+ try {
+ await navigator.clipboard.writeText(getExportContent());
+ state.copied = true;
+ render();
+ setTimeout(() => {
+ state.copied = false;
+ render();
+ }, 1800);
+ } catch {
+ window.alert('Copy failed in this browser.');
+ }
+ });
+ actions.appendChild(copy);
+
+ const download = document.createElement('button');
+ download.type = 'button';
+ download.className = 'download-btn';
+ download.textContent = 'Download';
+ download.addEventListener('click', () => {
+ const ext = state.exportMode === 'ai' ? 'txt' : 'md';
+ const filename = `prompt_${state.exportMode === 'ai' ? 'ai_ready' : 'editor'}.${ext}`;
+ downloadText(filename, getExportContent());
+ });
+ actions.appendChild(download);
+
+ modal.appendChild(actions);
+ backdrop.appendChild(modal);
+ root.appendChild(backdrop);
+ }
+
+ function render() {
+ const app = document.getElementById('app');
+ app.replaceChildren();
+
+ const shell = document.createElement('div');
+ shell.className = 'app-shell';
+ renderHeader(shell);
+ renderHelp(shell);
+
+ const main = document.createElement('main');
+ const mainInner = document.createElement('div');
+ mainInner.className = 'main-inner';
+ if (state.showRaw) {
+ renderRawCard(mainInner);
+ } else {
+ renderPreambleCard(mainInner);
+ renderStructureCard(mainInner);
+ }
+ main.appendChild(mainInner);
+ shell.appendChild(main);
+ renderExportModal(shell);
+ app.appendChild(shell);
+ }
+
+ function init() {
+ state.doc = parseDocument(SAMPLE_DOC);
+ state.preambleVal = state.doc.preamble;
+ render();
+ }
+
+ window.addEventListener('DOMContentLoaded', init);
+})();
diff --git a/app.py b/app.py
new file mode 100644
index 0000000..655642f
--- /dev/null
+++ b/app.py
@@ -0,0 +1,57 @@
+#!/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
new file mode 100644
index 0000000..b6fd63e
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,94 @@
+# Architecture
+
+## Overview
+
+The application has two layers:
+
+1. `app.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.
+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.
+
+## Front-End Structure
+
+The JavaScript is organized as a single file with a few logical layers:
+
+- parsing helpers that split preamble text from XML and build a node tree
+- serialization helpers that convert the node tree back into text
+- tree update helpers for add, remove, rename, reorder, and move operations
+- rendering functions that rebuild the UI from current state
+
+The app uses a full re-render approach. After significant state changes, it calls `render()` and reconstructs the visible interface.
+
+## State Model
+
+Global state lives in a single `state` object in `app.js`.
+
+UI state includes:
+
+- current export modal visibility and export mode
+- raw view toggle
+- help panel toggle
+- copied-to-clipboard status
+- drag-and-drop state
+- collapsed tree state
+- preamble editing state
+
+Document state includes:
+
+- `doc.preamble`: free-form text before the XML block
+- `doc.root`: array of root nodes
+
+## Node Model
+
+The XML tree uses two node shapes.
+
+Element nodes contain:
+
+- `id`
+- `type: "element"`
+- `tag`
+- `attributes`
+- `children`
+- optional `parent`
+
+Text nodes contain:
+
+- `id`
+- `type: "text"`
+- `text`
+- `children`
+- optional `parent`
+
+## Parsing Model
+
+The editor accepts documents with a free-form preamble followed by XML.
+
+`parseDocument()`:
+
+1. scans line-by-line until it finds the start of XML
+2. treats everything before that as preamble
+3. tokenizes the XML text
+4. builds a nested tree structure from the tokens
+
+The parser is custom and lightweight. It is built for prompt-style XML rather than strict general-purpose XML compatibility.
+
+## Export Model
+
+The app supports two export modes:
+
+- AI-ready export: clean XML without the preamble
+- editor export: preamble plus XML, suitable for reopening in the editor
+
+## Maintenance Notes
+
+- 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
diff --git a/gh-codex.ps1 b/gh-codex.ps1
new file mode 100644
index 0000000..c4722ac
--- /dev/null
+++ b/gh-codex.ps1
@@ -0,0 +1,15 @@
+$ghPath = "C:\Program Files\GitHub CLI\gh.exe"
+$ghConfigDir = Join-Path $PSScriptRoot ".codex-gh"
+
+if (-not (Test-Path $ghPath)) {
+ Write-Error "GitHub CLI not found at $ghPath"
+ exit 1
+}
+
+if (-not (Test-Path $ghConfigDir)) {
+ New-Item -ItemType Directory -Path $ghConfigDir | Out-Null
+}
+
+$env:GH_CONFIG_DIR = $ghConfigDir
+& $ghPath @args
+exit $LASTEXITCODE
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..01fffed
--- /dev/null
+++ b/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ XML Prompt Editor
+
+
+
+
+
+
+
diff --git a/style.css b/style.css
new file mode 100644
index 0000000..c02c8fb
--- /dev/null
+++ b/style.css
@@ -0,0 +1,781 @@
+:root {
+ --bg: #f8fafc;
+ --card: #ffffff;
+ --border: #e2e8f0;
+ --border-soft: #f1f5f9;
+ --text: #0f172a;
+ --muted: #64748b;
+ --muted-2: #94a3b8;
+ --blue: #2563eb;
+ --blue-50: #eff6ff;
+ --blue-100: #dbeafe;
+ --blue-200: #bfdbfe;
+ --green-50: #f0fdf4;
+ --green-600: #16a34a;
+ --red-50: #fef2f2;
+ --red-300: #fca5a5;
+ --red-600: #dc2626;
+ --shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
+ --shadow-lg: 0 20px 45px rgba(15, 23, 42, 0.18);
+ --radius: 16px;
+ --mono: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, monospace;
+ --sans: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+ min-height: 100%;
+ background: var(--bg);
+ color: var(--text);
+ font-family: var(--sans);
+}
+
+button,
+input,
+textarea {
+ font: inherit;
+}
+
+button {
+ cursor: pointer;
+}
+
+.hidden {
+ display: none !important;
+}
+
+.app-shell {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.topbar {
+ position: sticky;
+ top: 0;
+ z-index: 40;
+ background: rgba(255,255,255,0.96);
+ backdrop-filter: blur(8px);
+ border-bottom: 1px solid var(--border);
+}
+
+.topbar-inner,
+.main-inner,
+.help-inner {
+ max-width: 980px;
+ margin: 0 auto;
+ padding-left: 16px;
+ padding-right: 16px;
+}
+
+.topbar-inner {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding-top: 12px;
+ padding-bottom: 12px;
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.brand-icon {
+ width: 28px;
+ height: 28px;
+ border-radius: 10px;
+ background: var(--blue);
+ color: #fff;
+ display: grid;
+ place-items: center;
+ font-weight: 700;
+ font-size: 13px;
+}
+
+.brand-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: #1e293b;
+}
+
+.spacer {
+ flex: 1;
+}
+
+.toolbar {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.btn {
+ border: 0;
+ background: transparent;
+ color: #475569;
+ font-size: 14px;
+ line-height: 1;
+ border-radius: 12px;
+ padding: 9px 12px;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ transition: background .15s ease, color .15s ease, box-shadow .15s ease, border-color .15s ease;
+}
+
+.btn:hover {
+ background: #f1f5f9;
+}
+
+.btn-primary {
+ background: var(--blue);
+ color: #fff;
+ box-shadow: var(--shadow);
+ padding-left: 16px;
+ padding-right: 16px;
+}
+
+.btn-primary:hover {
+ background: #1d4ed8;
+}
+
+.btn-toggle-active {
+ background: #e2e8f0;
+ color: #1e293b;
+}
+
+.btn-icon {
+ padding: 9px;
+ color: var(--muted-2);
+}
+
+.btn-icon:hover {
+ color: var(--muted);
+}
+
+.help-panel {
+ background: #eff6ff;
+ border-bottom: 1px solid #dbeafe;
+}
+
+.help-inner {
+ padding-top: 12px;
+ padding-bottom: 12px;
+}
+
+.help-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 14px 18px;
+ color: #1e40af;
+ font-size: 13px;
+}
+
+.help-grid strong {
+ font-weight: 600;
+}
+
+.main-inner {
+ width: 100%;
+ padding-top: 24px;
+ padding-bottom: 24px;
+}
+
+.card {
+ background: var(--card);
+ border: 1px solid var(--border);
+ border-radius: 20px;
+ box-shadow: var(--shadow);
+ overflow: hidden;
+}
+
+.card + .card,
+.card + .add-preamble,
+.add-preamble + .card {
+ margin-top: 16px;
+}
+
+.card-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--border-soft);
+ background: #f8fafc;
+}
+
+.card-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: #475569;
+}
+
+.card-subtle {
+ margin-left: auto;
+ font-size: 12px;
+ color: var(--muted-2);
+}
+
+.card-body {
+ padding: 16px;
+}
+
+.edit-link {
+ border: 0;
+ background: transparent;
+ color: #3b82f6;
+ font-size: 12px;
+ margin-left: auto;
+ padding: 0;
+}
+
+.edit-link:hover {
+ color: #1d4ed8;
+}
+
+.add-preamble {
+ width: 100%;
+ text-align: left;
+ border: 1px dashed var(--border);
+ background: transparent;
+ color: var(--muted-2);
+ border-radius: 16px;
+ padding: 12px 16px;
+ font-size: 14px;
+ transition: all .15s ease;
+}
+
+.add-preamble:hover {
+ border-color: #cbd5e1;
+ background: white;
+ color: var(--muted);
+}
+
+.preamble-display {
+ padding: 14px 16px;
+ color: var(--muted);
+ font-size: 14px;
+ font-family: var(--mono);
+ white-space: pre-wrap;
+ cursor: pointer;
+}
+
+.preamble-display:hover {
+ background: #f8fafc;
+}
+
+.preamble-textarea,
+.raw-pre,
+.text-node-textarea,
+.inline-input,
+.attr-input {
+ font-family: var(--mono);
+}
+
+.preamble-textarea {
+ width: 100%;
+ min-height: 84px;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 10px 12px;
+ font-size: 14px;
+ color: #334155;
+ resize: vertical;
+ outline: none;
+}
+
+.preamble-textarea:focus,
+.inline-input:focus,
+.attr-input:focus,
+.text-node-textarea:focus {
+ border-color: #93c5fd;
+ box-shadow: 0 0 0 3px rgba(59,130,246,.12);
+}
+
+.action-row {
+ display: flex;
+ gap: 8px;
+ margin-top: 10px;
+}
+
+.mini-btn {
+ border: 0;
+ border-radius: 10px;
+ padding: 8px 12px;
+ font-size: 12px;
+}
+
+.mini-btn-primary {
+ background: var(--blue);
+ color: #fff;
+}
+
+.mini-btn-primary:hover { background: #1d4ed8; }
+.mini-btn-muted { background: #f1f5f9; color: #475569; }
+.mini-btn-muted:hover { background: #e2e8f0; }
+
+.structure-body {
+ padding: 20px;
+}
+
+.node-list {
+ min-height: 40px;
+}
+
+.node-row {
+ margin-bottom: 4px;
+}
+
+.node-card {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ background: #fff;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ transition: border-color .15s ease, box-shadow .15s ease, opacity .15s ease, background .15s ease;
+}
+
+.node-card:hover {
+ border-color: #93c5fd;
+ box-shadow: var(--shadow);
+}
+
+.node-card.error {
+ border-color: var(--red-300);
+}
+
+.dragging {
+ opacity: .45;
+}
+
+.drag-over {
+ border-color: #60a5fa !important;
+ box-shadow: 0 0 0 3px rgba(59,130,246,.12);
+}
+
+.grip {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 34px;
+ align-self: stretch;
+ border-right: 1px solid var(--border-soft);
+ color: #cbd5e1;
+ user-select: none;
+ cursor: grab;
+}
+
+.expand-btn,
+.icon-btn,
+.action-btn {
+ border: 0;
+ background: transparent;
+ color: var(--muted-2);
+}
+
+.expand-btn {
+ width: 28px;
+ height: 28px;
+ border-radius: 8px;
+ margin-left: 4px;
+}
+
+.expand-btn:hover,
+.action-btn:hover,
+.icon-btn:hover {
+ background: #f8fafc;
+ color: var(--muted);
+}
+
+.node-main {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 8px 8px 0;
+ flex-wrap: wrap;
+}
+
+.tag-symbol {
+ color: #60a5fa;
+ font-size: 13px;
+}
+
+.tag-pill,
+.tag-pill-closing {
+ border: 1px solid #bfdbfe;
+ background: var(--blue-50);
+ color: #1d4ed8;
+ font-family: var(--mono);
+ font-size: 13px;
+ font-weight: 600;
+ border-radius: 8px;
+ padding: 4px 8px;
+}
+
+.tag-pill:hover {
+ background: #dbeafe;
+ border-color: #93c5fd;
+}
+
+.inline-input {
+ width: 220px;
+ border: 1px solid #93c5fd;
+ background: var(--blue-50);
+ border-radius: 8px;
+ padding: 4px 8px;
+ font-size: 13px;
+ color: #1d4ed8;
+ font-weight: 600;
+}
+
+.inline-input.error {
+ background: var(--red-50);
+ border-color: var(--red-300);
+ color: #991b1b;
+}
+
+.attr-pill {
+ border: 0;
+ background: transparent;
+ color: var(--muted-2);
+ font-family: var(--mono);
+ font-size: 12px;
+ padding: 4px 6px;
+ border-radius: 8px;
+}
+
+.attr-pill:hover {
+ background: #f8fafc;
+ color: var(--muted);
+}
+
+.attr-placeholder {
+ color: #cbd5e1;
+ font-size: 12px;
+ padding: 4px 6px;
+ border-radius: 8px;
+}
+
+.attr-placeholder:hover {
+ color: #94a3b8;
+}
+
+.attr-input {
+ flex: 1 1 200px;
+ min-width: 180px;
+ border: 1px solid #cbd5e1;
+ background: #f8fafc;
+ border-radius: 8px;
+ padding: 4px 8px;
+ font-size: 12px;
+ color: #475569;
+}
+
+.node-actions {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ padding-right: 8px;
+}
+
+.action-btn {
+ width: 28px;
+ height: 28px;
+ border-radius: 8px;
+ font-size: 13px;
+}
+
+.action-btn.green:hover {
+ background: var(--green-50);
+ color: var(--green-600);
+}
+
+.action-btn.blue:hover {
+ background: var(--blue-50);
+ color: var(--blue);
+}
+
+.action-btn.red:hover {
+ background: var(--red-50);
+ color: #ef4444;
+}
+
+.error-line {
+ margin-left: 74px;
+ margin-top: 4px;
+ color: var(--red-600);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.children-wrap {
+ margin-top: 4px;
+ margin-left: 24px;
+ padding-left: 12px;
+ border-left: 2px solid var(--blue-100);
+}
+
+.text-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 6px;
+ margin-bottom: 4px;
+}
+
+.text-icon {
+ color: #cbd5e1;
+ padding-top: 9px;
+ padding-left: 4px;
+ font-size: 12px;
+}
+
+.text-node-textarea {
+ flex: 1;
+ min-height: 36px;
+ border: 1px solid transparent;
+ background: transparent;
+ color: #334155;
+ border-radius: 8px;
+ padding: 8px 10px;
+ resize: vertical;
+ font-size: 14px;
+ line-height: 1.55;
+}
+
+.text-node-textarea:hover {
+ 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; }
+.node-indent-4 { margin-left: 96px; }
+.node-indent-5 { margin-left: 120px; }
+.node-indent-6 { margin-left: 144px; }
+.node-indent-7 { margin-left: 168px; }
+.node-indent-8 { margin-left: 192px; }
+.node-indent-9 { margin-left: 216px; }
+.node-indent-10 { margin-left: 240px; }
+
+.add-root-wrap {
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: 1px solid var(--border-soft);
+}
+
+.add-root-btn {
+ width: 100%;
+ border: 2px dashed var(--border);
+ border-radius: 12px;
+ background: transparent;
+ color: var(--muted-2);
+ font-size: 14px;
+ padding: 12px 16px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ transition: all .15s ease;
+}
+
+.add-root-btn:hover {
+ border-color: #93c5fd;
+ background: var(--blue-50);
+ color: #3b82f6;
+}
+
+.raw-pre {
+ margin: 0;
+ padding: 20px;
+ font-size: 12px;
+ line-height: 1.7;
+ color: #334155;
+ white-space: pre-wrap;
+ overflow: auto;
+ max-height: 70vh;
+}
+
+.modal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,.4);
+ backdrop-filter: blur(4px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 60;
+}
+
+.modal {
+ width: min(960px, calc(100vw - 32px));
+ max-height: 80vh;
+ display: flex;
+ flex-direction: column;
+ background: white;
+ border-radius: 22px;
+ box-shadow: var(--shadow-lg);
+ overflow: hidden;
+}
+
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 18px 24px 14px;
+ border-bottom: 1px solid var(--border-soft);
+}
+
+.modal-title {
+ font-size: 22px;
+ font-weight: 600;
+ color: #1e293b;
+}
+
+.modal-subtitle {
+ margin-top: 4px;
+ font-size: 14px;
+ color: var(--muted);
+}
+
+.close-btn {
+ width: 38px;
+ height: 38px;
+ border: 0;
+ border-radius: 12px;
+ background: transparent;
+ color: var(--muted-2);
+ font-size: 20px;
+}
+
+.close-btn:hover { background: #f1f5f9; color: var(--muted); }
+
+.modal-tabs {
+ display: flex;
+ gap: 8px;
+ padding: 16px 24px 0;
+}
+
+.tab-btn {
+ border: 0;
+ border-radius: 12px;
+ background: #f1f5f9;
+ color: #475569;
+ padding: 10px 16px;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.tab-btn.active {
+ background: var(--blue);
+ color: white;
+}
+
+.modal-note {
+ padding: 10px 24px 12px;
+ font-size: 12px;
+ color: var(--muted-2);
+}
+
+.modal-pre-wrap {
+ margin: 0 24px 16px;
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ background: #f8fafc;
+ overflow: auto;
+ flex: 1;
+}
+
+.modal-pre {
+ margin: 0;
+ padding: 16px;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-size: 12px;
+ color: #334155;
+ line-height: 1.7;
+ font-family: var(--mono);
+}
+
+.modal-actions {
+ display: flex;
+ gap: 10px;
+ padding: 0 24px 24px;
+}
+
+.copy-btn,
+.download-btn {
+ border: 0;
+ border-radius: 12px;
+ padding: 12px 16px;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.copy-btn {
+ background: #f1f5f9;
+ color: #334155;
+}
+
+.copy-btn:hover { background: #e2e8f0; }
+
+.download-btn {
+ background: var(--blue);
+ color: white;
+}
+
+.download-btn:hover { background: #1d4ed8; }
+
+.muted-empty {
+ color: #cbd5e1;
+ font-style: italic;
+}
+
+@media (max-width: 900px) {
+ .help-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 700px) {
+ .topbar-inner {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .spacer {
+ display: none;
+ }
+
+ .toolbar {
+ width: 100%;
+ }
+
+ .help-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .node-main {
+ min-width: 0;
+ }
+
+ .inline-input,
+ .attr-input {
+ width: 100%;
+ flex-basis: 100%;
+ }
+}