🆕 Add new untracked directory simple-merge-template
This commit is contained in:
422
simple-merge-template/index.html
Normal file
422
simple-merge-template/index.html
Normal 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. Items: {{#items}}- {{value}} {{/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 line2 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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>
|
||||
3
simple-merge-template/site.json
Normal file
3
simple-merge-template/site.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"path":"simple-merge-template"
|
||||
}
|
||||
Reference in New Issue
Block a user