🆕 Add new untracked directory simple-merge-template

This commit is contained in:
2026-05-08 00:47:54 +08:00
parent 0e122bc642
commit 154bf10f41
2 changed files with 425 additions and 0 deletions

View File

@@ -0,0 +1,422 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Template Merge</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f5f5f5; color: #333; padding: 2rem; }
h1 { margin-bottom: 1.5rem; }
.container { max-width: 900px; margin: 0 auto; display: flex; flex-direction: column; gap: 1.5rem; }
label { font-weight: 600; display: block; margin-bottom: 0.3rem; }
textarea { width: 100%; min-height: 120px; font-family: monospace; font-size: 0.95rem; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; resize: vertical; }
.data-section { border: 1px solid #ccc; border-radius: 4px; padding: 0.75rem; background: #fff; }
.data-row { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; align-items: center; }
.data-row input { flex: 1; font-family: monospace; font-size: 0.9rem; padding: 0.35rem 0.5rem; border: 1px solid #ccc; border-radius: 4px; }
.data-row .remove-btn { background: #e55; color: #fff; border: none; border-radius: 4px; padding: 0.35rem 0.6rem; cursor: pointer; }
.btn-row { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
button { padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9rem; }
.add-btn { background: #4a9; color: #fff; }
.merge-btn { background: #36c; color: #fff; font-size: 1rem; padding: 0.7rem 1.5rem; }
.clear-btn { background: #aaa; color: #fff; }
.clear-btn:disabled, #copy-btn:disabled, #tpl-clear-btn:disabled { opacity: 0.4; cursor: not-allowed; }
#output { width: 100%; min-height: 150px; font-family: monospace; font-size: 0.95rem; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; background: #fff; white-space: pre-wrap; }
.hint { font-size: 0.8rem; color: #888; margin-top: 0.2rem; }
.error { color: #c33; font-size: 0.85rem; margin-top: 0.3rem; }
</style>
</head>
<body>
<div class="container">
<h1>Template Merge</h1>
<div>
<label for="template">Template</label>
<div style="position:relative;">
<textarea id="template" placeholder="Hello {{name}}, you have {{count}} messages.&#10;Items:&#10;{{#items}}- {{value}}&#10;{{/items}}"></textarea>
<button class="add-btn" onclick="pasteTemplate()" id="paste-btn" style="position:absolute;top:0.5rem;right:0.5rem;padding:0.35rem 0.6rem;">Paste</button>
<button class="clear-btn" onclick="clearTemplate()" id="tpl-clear-btn" style="position:absolute;top:0.5rem;right:4.7rem;padding:0.35rem 0.6rem;">Clear</button>
</div>
<div class="hint">Use <code>{{key}}</code> for variables, <code>{{#list}}...{{/list}}</code> for loop blocks. Use <code>{{value}}</code> when data is a simple string.</div>
</div>
<div class="data-section">
<label>Data List</label>
<div id="data-list"></div>
<div class="btn-row">
<button class="add-btn" onclick="addDataItem('')">+ String</button>
<button class="add-btn" onclick="addDataItem('{}')">+ JSON</button>
<button class="clear-btn" onclick="clearData()">Clear All</button>
<button class="add-btn" onclick="showJsonlImport()">Import JSONL</button>
<button class="add-btn" onclick="showTextImport()">Import Lines</button>
</div>
<div id="jsonl-panel" style="display:none; margin-top:0.5rem;">
<textarea id="jsonl-input" placeholder='{"key":"value"}\n{"key2":"value2"}' style="min-height:80px;"></textarea>
<div class="btn-row">
<button class="merge-btn" onclick="importJsonl()">Import</button>
<button class="clear-btn" onclick="hideJsonlImport()">Cancel</button>
</div>
<div id="jsonl-error" class="error"></div>
</div>
<div id="text-panel" style="display:none; margin-top:0.5rem;">
<textarea id="text-input" placeholder="line1&#10;line2&#10;line3" style="min-height:80px;"></textarea>
<div class="btn-row">
<button class="merge-btn" onclick="importLines()">Import</button>
<button class="clear-btn" onclick="hideTextImport()">Cancel</button>
</div>
</div>
</div>
<button class="merge-btn" onclick="merge()">Merge</button>
<div>
<label for="output">Output</label>
<div id="error" class="error"></div>
<div style="position:relative;">
<div id="output" readonly></div>
<button class="add-btn" onclick="copyOutput()" id="copy-btn" style="position:absolute;top:0.5rem;right:0.5rem;padding:0.35rem 0.6rem;" disabled>Copy</button>
</div>
</div>
</div>
<script>
let dataItems = [];
let itemIdCounter = 0;
// --- LocalStorage ---
function saveState() {
localStorage.setItem('template', document.getElementById('template').value);
localStorage.setItem('dataItems', JSON.stringify(dataItems));
}
function loadState() {
const tpl = localStorage.getItem('template');
if (tpl != null) document.getElementById('template').value = tpl;
const items = localStorage.getItem('dataItems');
if (items != null) {
try {
dataItems = JSON.parse(items);
itemIdCounter = dataItems.reduce((max, d) => Math.max(max, d.id), 0) + 1;
} catch {}
}
}
function addDataItem(value) {
const id = itemIdCounter++;
dataItems.push({ id, value });
renderDataList();
saveState();
}
function removeDataItem(id) {
dataItems = dataItems.filter(d => d.id !== id);
renderDataList();
saveState();
}
function updateDataItem(id, value) {
const item = dataItems.find(d => d.id === id);
if (item) item.value = value;
saveState();
}
function clearData() {
dataItems = [];
renderDataList();
saveState();
}
function showJsonlImport() {
document.getElementById('jsonl-panel').style.display = 'block';
document.getElementById('jsonl-error').textContent = '';
}
function hideJsonlImport() {
document.getElementById('jsonl-panel').style.display = 'none';
document.getElementById('jsonl-input').value = '';
}
function showTextImport() {
document.getElementById('text-panel').style.display = 'block';
}
function hideTextImport() {
document.getElementById('text-panel').style.display = 'none';
document.getElementById('text-input').value = '';
}
function importLines() {
const raw = document.getElementById('text-input').value.trim();
if (!raw) return;
raw.split(/\n/).filter(l => l.trim()).forEach(line => addDataItem(line.trim()));
hideTextImport();
}
function importJsonl() {
const raw = document.getElementById('jsonl-input').value.trim();
const errorEl = document.getElementById('jsonl-error');
errorEl.textContent = '';
if (!raw) { errorEl.textContent = 'Input is empty.'; return; }
const lines = raw.split(/\n/).filter(l => l.trim());
for (const line of lines) {
try {
JSON.parse(line.trim());
} catch {
errorEl.textContent = 'Invalid JSON line: ' + line.trim().slice(0, 60);
return;
}
addDataItem(line.trim());
}
hideJsonlImport();
}
function renderDataList() {
const container = document.getElementById('data-list');
container.innerHTML = '';
dataItems.forEach(item => {
const row = document.createElement('div');
row.className = 'data-row';
const isJson = item.value.trim().startsWith('{');
const placeholder = isJson ? '{"key": "value"}' : 'simple text value';
row.innerHTML = `<input value="${escapeHtml(item.value)}" placeholder="${placeholder}" oninput="updateDataItem(${item.id}, this.value)"><button class="remove-btn" onclick="removeDataItem(${item.id})">x</button>`;
container.appendChild(row);
});
document.querySelector('.clear-btn').disabled = dataItems.length === 0;
}
function escapeHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function copyOutput() {
const text = document.getElementById('output').textContent;
if (!text) return;
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById('copy-btn');
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
});
}
function pasteTemplate() {
navigator.clipboard.readText().then(text => {
document.getElementById('template').value = text;
updateTemplateButtons();
saveState();
});
}
let undoTimeout = null;
let undoContent = null;
let undoCountdown = null;
function clearTemplate() {
const tpl = document.getElementById('template').value;
if (!tpl) return;
undoContent = tpl;
document.getElementById('template').value = '';
const btn = document.getElementById('tpl-clear-btn');
btn.textContent = 'Undo (10)';
btn.onclick = restoreTemplate;
updateTemplateButtons();
saveState();
let seconds = 10;
if (undoTimeout) clearTimeout(undoTimeout);
if (undoCountdown) clearInterval(undoCountdown);
undoCountdown = setInterval(() => {
seconds--;
if (seconds <= 0) {
clearInterval(undoCountdown);
return;
}
btn.textContent = `Undo (${seconds})`;
}, 1000);
undoTimeout = setTimeout(() => {
clearInterval(undoCountdown);
btn.textContent = 'Clear';
btn.onclick = clearTemplate;
undoContent = null;
updateTemplateButtons();
}, 10000);
}
function restoreTemplate() {
if (undoContent == null) return;
document.getElementById('template').value = undoContent;
updateTemplateButtons();
saveState();
undoContent = null;
const btn = document.getElementById('tpl-clear-btn');
btn.textContent = 'Clear';
btn.onclick = clearTemplate;
if (undoTimeout) { clearTimeout(undoTimeout); undoTimeout = null; }
if (undoCountdown) { clearInterval(undoCountdown); undoCountdown = null; }
}
function updateTemplateButtons() {
const tpl = document.getElementById('template').value;
const btn = document.getElementById('tpl-clear-btn');
if (btn.textContent.startsWith('Undo')) {
btn.disabled = false;
} else {
btn.disabled = !tpl;
}
}
// --- AST Parser ---
function parseTemplate(template) {
const tokens = tokenize(template);
return buildAst(tokens);
}
function tokenize(template) {
const tokens = [];
let i = 0;
while (i < template.length) {
const openIdx = template.indexOf('{{', i);
if (openIdx === -1) {
if (i < template.length) tokens.push({ type: 'text', value: template.slice(i) });
break;
}
if (openIdx > i) tokens.push({ type: 'text', value: template.slice(i, openIdx) });
const closeIdx = template.indexOf('}}', openIdx + 2);
if (closeIdx === -1) {
tokens.push({ type: 'text', value: template.slice(openIdx) });
break;
}
const inner = template.slice(openIdx + 2, closeIdx).trim();
if (inner.startsWith('#')) {
tokens.push({ type: 'open', key: inner.slice(1).trim() });
} else if (inner.startsWith('/')) {
tokens.push({ type: 'close', key: inner.slice(1).trim() });
} else {
// Split on first ':' for default value: {{key:default}}
const colonIdx = inner.indexOf(':');
if (colonIdx > 0) {
tokens.push({ type: 'var', key: inner.slice(0, colonIdx), default: inner.slice(colonIdx + 1) });
} else {
tokens.push({ type: 'var', key: inner });
}
}
i = closeIdx + 2;
}
return tokens;
}
function buildAst(tokens) {
const ast = [];
let pos = 0;
function parseBlock(endType) {
const nodes = [];
while (pos < tokens.length) {
const tok = tokens[pos];
if (tok.type === endType) { pos++; return nodes; }
if (tok.type === 'text') { nodes.push({ type: 'text', value: tok.value }); pos++; continue; }
if (tok.type === 'var') { nodes.push({ type: 'var', key: tok.key, default: tok.default }); pos++; continue; }
if (tok.type === 'open') {
const key = tok.key;
pos++;
const children = parseBlock('close');
// verify matching key
nodes.push({ type: 'block', key, children });
continue;
}
pos++;
}
return nodes;
}
return parseBlock(null);
}
// --- Resolve helper ---
function resolvePath(ctx, keyPath) {
const parts = keyPath.split('.');
let obj = ctx;
for (const p of parts) {
if (obj == null || typeof obj !== 'object') return undefined;
obj = obj[p];
}
return obj;
}
// --- Render AST ---
function renderAst(ast, ctx) {
let result = '';
for (const node of ast) {
if (node.type === 'text') { result += node.value; }
else if (node.type === 'var') {
const val = resolvePath(ctx, node.key);
if (val != null) { result += String(val); }
else if (node.default !== undefined) { result += node.default; }
}
else if (node.type === 'block') {
const arr = resolvePath(ctx, node.key);
if (Array.isArray(arr)) {
for (const item of arr) {
result += renderAst(node.children, item);
}
}
}
}
return result;
}
// --- Merge action ---
function merge() {
const errorEl = document.getElementById('error');
const outputEl = document.getElementById('output');
errorEl.textContent = '';
outputEl.textContent = '';
const template = document.getElementById('template').value;
if (!template.trim()) { errorEl.textContent = 'Template is empty.'; return; }
let ast;
try {
ast = parseTemplate(template);
} catch (e) {
errorEl.textContent = 'Template parse error: ' + e.message;
return;
}
// Render template once per data item, concatenate results
const results = dataItems
.filter(item => item.value.trim())
.map(item => {
const trimmed = item.value.trim();
let ctx;
if (trimmed.startsWith('{')) {
try {
ctx = JSON.parse(trimmed);
} catch {
ctx = { value: trimmed };
}
} else {
ctx = { value: trimmed };
}
return renderAst(ast, ctx);
});
outputEl.textContent = results.join('\n');
document.getElementById('copy-btn').disabled = results.length === 0 || results.join('') === '';
}
// Init
document.getElementById('template').addEventListener('input', () => { saveState(); updateTemplateButtons(); });
loadState();
renderDataList();
updateTemplateButtons();
if (dataItems.length === 0) addDataItem('');
</script>
</body>
</html>

View File

@@ -0,0 +1,3 @@
{
"path":"simple-merge-template"
}