Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
One-time setup that gathers your project's design context and saves it to CLAUDE.md for future sessions.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/live-manual-edits-buffer.mjs
1/**2* Shared helpers for the pending-manual-edits buffer on disk.3*4* Location: .impeccable/live/pending-manual-edits.json (project-local).5* Schema: { version: 1, entries: [{ id, pageUrl, element, ops, stagedAt }] }6*7* Each entry corresponds to one Save action from the browser. Ops merge by8* (pageUrl, ref): if the user re-edits the same element before committing, the9* existing entry's `newText` is replaced and `originalText` is kept (it holds10* the real source state).11*/1213import fs from 'node:fs';14import path from 'node:path';15import { getLiveDir } from './impeccable-paths.mjs';1617const BUFFER_VERSION = 1;18const BUFFER_FILENAME = 'pending-manual-edits.json';1920export function getBufferPath(cwd = process.cwd()) {21return path.join(getLiveDir(cwd), BUFFER_FILENAME);22}2324export function readBuffer(cwd = process.cwd()) {25return readBufferInternal(cwd, { strict: false });26}2728export function readBufferStrict(cwd = process.cwd()) {29return readBufferInternal(cwd, { strict: true });30}3132function readBufferInternal(cwd, { strict }) {33const filePath = getBufferPath(cwd);34try {35const raw = fs.readFileSync(filePath, 'utf-8');36const parsed = JSON.parse(raw);37if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) {38if (strict) throw new Error('manual_edit_buffer_invalid_schema');39return { version: BUFFER_VERSION, entries: [] };40}41return { version: BUFFER_VERSION, entries: parsed.entries };42} catch (err) {43if (strict && err?.code !== 'ENOENT') {44throw new Error('manual_edit_buffer_unreadable: ' + (err.message || String(err)));45}46return { version: BUFFER_VERSION, entries: [] };47}48}4950export function writeBuffer(cwd, buffer) {51const filePath = getBufferPath(cwd);52fs.mkdirSync(path.dirname(filePath), { recursive: true });53fs.writeFileSync(filePath, JSON.stringify({ version: BUFFER_VERSION, entries: buffer.entries }, null, 2));54}5556/**57* Merge a new entry into the buffer. For each op in the new entry, if there's58* already a buffered op for the same (pageUrl, ref), update that op's newText59* and keep its original originalText (the true source state). Otherwise add60* the op (creating an entry if needed).61*62* Multiple ops in one Save are allowed; each is keyed by (pageUrl, ref).63*/64export function stageEntry(cwd, newEntry) {65const buf = readBufferStrict(cwd);66const pageUrl = newEntry.pageUrl;67for (const newOp of newEntry.ops) {68let mergedIntoExisting = false;69for (const existing of buf.entries) {70if (existing.pageUrl !== pageUrl) continue;71const existingOpIdx = existing.ops.findIndex((op) => op.ref === newOp.ref);72if (existingOpIdx >= 0) {73// Keep the original source text but refresh the latest DOM/source evidence.74existing.ops[existingOpIdx] = {75...newOp,76originalText: existing.ops[existingOpIdx].originalText,77newText: newOp.newText,78deleted: newOp.deleted || false,79};80if (newEntry.element) existing.element = newEntry.element;81existing.stagedAt = new Date().toISOString();82mergedIntoExisting = true;83break;84}85}86if (mergedIntoExisting) continue;87// No existing op for this (pageUrl, ref). Find or create an entry to hold it.88let entry = buf.entries.find((e) => e.pageUrl === pageUrl && e.id === newEntry.id);89if (!entry) {90entry = {91id: newEntry.id,92pageUrl,93element: newEntry.element,94ops: [],95stagedAt: new Date().toISOString(),96};97buf.entries.push(entry);98}99entry.ops.push(newOp);100entry.stagedAt = new Date().toISOString();101}102writeBuffer(cwd, buf);103return buf;104}105106/**107* Remove entries matching a predicate. Returns count of removed *ops* (not108* entries) so callers report a unit consistent with truncateBuffer and the109* pill's per-page op count. Empty entries (no ops left) are also pruned.110*/111export function removeEntries(cwd, predicate) {112const buf = readBuffer(cwd);113let removedOps = 0;114const kept = [];115for (const entry of buf.entries) {116if (predicate(entry)) {117removedOps += entry.ops?.length || 0;118} else if (entry.ops && entry.ops.length > 0) {119kept.push(entry);120}121}122buf.entries = kept;123writeBuffer(cwd, buf);124return removedOps;125}126127/**128* Count by page for the counter UI. Returns { totalCount, perPage: {[pageUrl]: count} }.129*/130export function countByPage(cwd = process.cwd()) {131const buf = readBuffer(cwd);132const perPage = {};133let totalCount = 0;134for (const entry of buf.entries) {135const n = entry.ops.length;136perPage[entry.pageUrl] = (perPage[entry.pageUrl] || 0) + n;137totalCount += n;138}139return { totalCount, perPage };140}141142/**143* Truncate the buffer to empty (used by discard-all). Returns the count of144* removed ops.145*/146export function truncateBuffer(cwd) {147const buf = readBuffer(cwd);148let removed = 0;149for (const entry of buf.entries) removed += entry.ops.length;150writeBuffer(cwd, { version: BUFFER_VERSION, entries: [] });151return removed;152}153