webtweak 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/overlay/interact.min.js +4 -0
- package/overlay/overlay.css +162 -0
- package/overlay/overlay.js +758 -0
- package/package.json +29 -0
- package/reconcile/SKILL.md +51 -0
- package/reconcile/scripts/wtreconcile.py +174 -0
- package/webtweak.js +380 -0
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "webtweak",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local visual editor for hand-coded HTML/CSS — captures edits as patches, Claude reconciles them into source",
|
|
5
|
+
"bin": {
|
|
6
|
+
"webtweak": "webtweak.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"webtweak.js",
|
|
10
|
+
"overlay/",
|
|
11
|
+
"reconcile/"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"visual-editor",
|
|
18
|
+
"css",
|
|
19
|
+
"html",
|
|
20
|
+
"wysiwyg",
|
|
21
|
+
"local"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/stueydubs/webtweak.git"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/stueydubs/webtweak"
|
|
29
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: webtweak-reconcile
|
|
3
|
+
description: Reconcile visual edits captured by the webtweak tool into a site's real source. Reads a <page>.webtweak.json edits file, locates each patched element by its fingerprint, writes clean CSS in the site's house conventions (single-element scope by default), translates nudge intent into clean margin/padding, and marks batches reconciled. Use when the user has finished a webtweak session, says "reconcile my webtweak edits", "apply the webtweak changes", mentions a *.webtweak.json file, or wants webtweak edits folded into source and optionally pushed.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# webtweak reconcile
|
|
7
|
+
|
|
8
|
+
The second half of the webtweak loop. The webtweak tool captures visual edits as *intent* and never touches source; this skill turns that intent into clean source. Reconcile is judgment work - when a match or a scope decision is genuinely ambiguous, ask rather than guess. That is the whole reason this half is a skill and not code.
|
|
9
|
+
|
|
10
|
+
## Input
|
|
11
|
+
|
|
12
|
+
A `<page>.webtweak.json` file sitting next to the edited page:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
{ target, batches: [ { sessionId, savedAt, viewport, status, patches: [ { fingerprint, changes: { ...cssProps, nudge? } } ] } ] }
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Only `status: "pending"` batches are reconciled. `reconciled` batches are history - never re-apply them.
|
|
19
|
+
|
|
20
|
+
- `fingerprint`: `{ tag, id, classes, text, ownText, selector, siblingIndex, openTag }`. `ownText` is the element's own direct text (excluding descendants') - prefer it for matching leaf/text elements; `text` includes descendant text - use it to disambiguate containers. `openTag` is the opening tag with any injected inline `style` stripped, so it matches clean source. `selector` is a positional `nth-of-type` path - a weak tiebreaker only. `siblingIndex` is the element's 0-based position among siblings sharing its tag+classes - use it to name *which* one when several are otherwise identical.
|
|
21
|
+
- `changes`: CSS property→value (kebab-case). A position nudge lives *inside* `changes` as `changes.nudge = { dx, dy }` (a 4px-snapped pixel offset), not a separate patch field.
|
|
22
|
+
- `viewport`: the authoring window width in px (an integer).
|
|
23
|
+
- **Captured values can be computed, not authored.** Several controls read `getComputedStyle`, so the value may be resolved rather than what the author wrote - do **not** treat these as ground truth; cross-check against source before writing (see step 5): `line-height`/`letter-spacing` may arrive as absolute px instead of a unitless ratio or em; `margin`/`padding` as resolved 4-value px that has lost `auto` (centering) or `%`; `width`/`height` as fixed px over an authored `%`/`auto`/`max-width`; colours may be alpha-stripped (a transparent element reads as opaque `#000000`).
|
|
24
|
+
|
|
25
|
+
## Workflow
|
|
26
|
+
|
|
27
|
+
1. **Find the work.** Locate the edits file (a given path, or `*.webtweak.json` beside the page). Run `scripts/wtreconcile.py pending <file>` for a summary of pending batches (add `--full`, or read the file directly, for complete fingerprints). If none, say so and stop.
|
|
28
|
+
2. **Read the house style.** Open the stylesheet(s) governing the page. Note indentation, selector conventions, units, custom properties, and spelling - match them.
|
|
29
|
+
3. **Locate each element.** Resolve the fingerprint the way a human would, in priority order: `id` (before accepting, confirm the located element's `tag` matches `fingerprint.tag` - guards a stale id moved to a different element) → `classes` + `ownText`/`text` (+ `tag`) → `openTag`. Use `selector` only as a last-resort tiebreaker or confirmation, never as a primary locator - it is a positional `nth-of-type` path captured on the injected DOM, so it is the least trustworthy signal and can be stale. If two candidates still match equally well (identical siblings), use `siblingIndex` to name which one; if it is still genuinely ambiguous, STOP and ask - never guess.
|
|
30
|
+
4. **Decide scope** (per patch). Default: change only the element that was edited. If it is targeted by a shared class AND the change looks systemic (every sibling changed alike, or it is the sole instance of that class), ask "just this one, or all `.class`?". If single-element scope needs a selector hook the source lacks, prefer the captured `selector`; only add a class to the HTML after asking.
|
|
31
|
+
5. **Translate the changes.**
|
|
32
|
+
- Plain CSS props → write as-is into the governing rule (or a targeted rule for single-element scope). One gotcha: a multi-word `font-family` may arrive unquoted (e.g. `font-family: Helvetica Neue`) - quote the family name on write (`"Helvetica Neue"`) so the CSS is valid.
|
|
33
|
+
- **Suspect computed-not-authored values** (per the Input caveat) - check each against the source declaration before writing, don't bake the resolved value:
|
|
34
|
+
- `line-height` as px (e.g. `33.6px`): if source authored a unitless ratio or em, keep that form - recompute the ratio from the new px ÷ the element's font-size, or ask for the ratio. Same for em `letter-spacing`.
|
|
35
|
+
- `margin`/`padding` as 4-value px where source had `auto` (centering) or `%`: preserve the `auto`/`%`; only change the side(s) the user actually moved, not the whole shorthand.
|
|
36
|
+
- `width`/`height` as fixed px where source was `%`/`auto`/`max-width`-governed: confirm "fixed px or keep it fluid?" rather than baking px and breaking responsiveness.
|
|
37
|
+
- `background-color`/`color` **absent** where you'd expect one: the overlay shows a transparent colour as `#000000` in the swatch and treats clicking that shown value as a no-op revert, so no patch is emitted even if the user meant to set solid black. If a black background/colour is clearly intended (e.g. visible in a screenshot) but no patch is present, ask before writing one.
|
|
38
|
+
- `width`/`height` on a non-replaced `inline` element: the overlay disables these inputs and the resize grips for inline elements, so this patch can no longer be emitted. If you see one in an older edits file, skip it and note it ("dropped width on inline `<code>` - needs `display:inline-block` first").
|
|
39
|
+
- `nudge {dx, dy}` → clean spacing. The offset is a `translate(dx, dy)`, so **positive dx = moved right, positive dy = moved down**. Map to margins with the matching sign: `dy>0` (down) → add to `margin-top`; `dy<0` (up) → reduce `margin-top` (go negative if needed); `dx>0` (right) → add to `margin-left`; `dx<0` (left) → reduce `margin-left`. Worked example: `nudge {dx: 0, dy: -8}` means dragged up 8px → take 8px off `margin-top` (e.g. `margin: 20px 0` → `margin: 12px 0`). Never bake in `transform` or `position: absolute`. If the nudge is large or flow cannot express it, flag it as a v2 reorder and skip it.
|
|
40
|
+
6. **Check responsiveness.** The batch `viewport` is the width the edits were authored at. If a width/size change made at a wide viewport would obviously break mobile, warn and offer to scope it to a media query.
|
|
41
|
+
7. **Write** the CSS into the stylesheet already governing the element, in house conventions. Show a concise diff summary.
|
|
42
|
+
8. **Mark done.** `scripts/wtreconcile.py mark <file> <sessionId>` flips that batch to `reconciled` (timestamped); it stays in the file as history, never delete it. On success it prints `marked N batch(es) reconciled` (N≥1) and exits 0; on a wrong/unknown sessionId it prints `... nothing marked` to stderr and exits non-zero. Treat a non-zero exit (or the absence of a `marked N` success line) as: nothing was flipped, so the edits are still pending and would re-apply next run - resolve that before telling the user it's done.
|
|
43
|
+
9. **Stop at source.** Reconcile's job ends at writing source and marking the batch. Never push, commit, or deploy unless the user explicitly asks for it in this session - summarise what changed and let them decide.
|
|
44
|
+
|
|
45
|
+
## Helper script
|
|
46
|
+
|
|
47
|
+
`scripts/wtreconcile.py` (Python stdlib only):
|
|
48
|
+
|
|
49
|
+
- `pending <file>` - one-line summary per pending patch; add `--full` for the complete patch JSON (fingerprints + changes)
|
|
50
|
+
- `mark <file> [sessionId]` - flip the matching pending batch to `reconciled` with a timestamp. Omitting the sessionId marks the single pending batch, but **fails** (marks nothing) if more than one is pending, so reconciling one session can't silently retire another. Prints `marked N` + exits 0 on success; exits non-zero and marks nothing on a no-match or an ambiguous bare `mark`.
|
|
51
|
+
- `status <file>` - counts (pending vs reconciled) + newest pending save time, for a quick "is this file fully reconciled?" check
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Helpers for the webtweak-reconcile skill.
|
|
3
|
+
|
|
4
|
+
Deterministic bookkeeping over a <page>.webtweak.json edits file so Claude
|
|
5
|
+
doesn't hand-edit JSON: list pending batches, mark batches reconciled, and
|
|
6
|
+
report status. Python stdlib only.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import NoReturn
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _die(msg: str) -> NoReturn:
|
|
19
|
+
sys.stderr.write(f"wtreconcile: {msg}\n")
|
|
20
|
+
raise SystemExit(1)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load(path: str) -> dict:
|
|
24
|
+
try:
|
|
25
|
+
doc = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
26
|
+
except FileNotFoundError:
|
|
27
|
+
_die(f"{path} not found")
|
|
28
|
+
except json.JSONDecodeError as e:
|
|
29
|
+
_die(f"{path} is not valid JSON (corrupt edits file): {e}")
|
|
30
|
+
except OSError as e:
|
|
31
|
+
_die(f"cannot read {path}: {e}")
|
|
32
|
+
# Guard the shape the way the server's apply_batch does, so a valid-JSON-but-wrong
|
|
33
|
+
# structure dies cleanly instead of as a raw AttributeError mid-iteration.
|
|
34
|
+
if not isinstance(doc, dict):
|
|
35
|
+
_die(f"{path} is not a JSON object (corrupt edits file)")
|
|
36
|
+
batches = doc.get("batches")
|
|
37
|
+
if batches is not None and (not isinstance(batches, list)
|
|
38
|
+
or any(not isinstance(b, dict) for b in batches)):
|
|
39
|
+
_die(f"{path} has a malformed batches array (corrupt edits file)")
|
|
40
|
+
return doc
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _save(path: str, doc: dict) -> None:
|
|
44
|
+
# Atomic + flushed: temp file in the same dir, fsync, then replace - so an
|
|
45
|
+
# interrupted mark never truncates the edits file (it has no .bak fallback here).
|
|
46
|
+
p = Path(path)
|
|
47
|
+
tmp = p.parent / (p.name + ".tmp")
|
|
48
|
+
try:
|
|
49
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
50
|
+
f.write(json.dumps(doc, indent=2) + "\n")
|
|
51
|
+
f.flush()
|
|
52
|
+
os.fsync(f.fileno())
|
|
53
|
+
tmp.replace(p)
|
|
54
|
+
except BaseException:
|
|
55
|
+
tmp.unlink(missing_ok=True)
|
|
56
|
+
raise
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _changes_summary(changes: dict) -> str:
|
|
60
|
+
parts = []
|
|
61
|
+
for k, v in changes.items():
|
|
62
|
+
if k == "nudge" and isinstance(v, dict):
|
|
63
|
+
parts.append(f"nudge({v.get('dx')},{v.get('dy')})") # surface drag magnitude
|
|
64
|
+
else:
|
|
65
|
+
parts.append(k)
|
|
66
|
+
return ", ".join(parts)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _describe(fp: dict) -> str:
|
|
70
|
+
s = fp.get("tag", "?")
|
|
71
|
+
if fp.get("id"):
|
|
72
|
+
s += "#" + fp["id"]
|
|
73
|
+
elif fp.get("classes"):
|
|
74
|
+
s += "." + fp["classes"][0]
|
|
75
|
+
text = (fp.get("ownText") or fp.get("text") or "").strip()
|
|
76
|
+
if text:
|
|
77
|
+
s += f' "{text[:40]}"'
|
|
78
|
+
return s
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def pending(args) -> None:
|
|
82
|
+
doc = _load(args.file)
|
|
83
|
+
pend = [(i, b) for i, b in enumerate(doc.get("batches", []))
|
|
84
|
+
if b.get("status") == "pending"]
|
|
85
|
+
|
|
86
|
+
if args.full: # full patch JSON (fingerprints + changes) for deep work
|
|
87
|
+
out = [
|
|
88
|
+
{"index": i, "sessionId": b.get("sessionId"), "savedAt": b.get("savedAt"),
|
|
89
|
+
"viewport": b.get("viewport"), "patchCount": len(b.get("patches", [])),
|
|
90
|
+
"patches": b.get("patches", [])}
|
|
91
|
+
for i, b in pend
|
|
92
|
+
]
|
|
93
|
+
json.dump(out, sys.stdout, indent=2)
|
|
94
|
+
print()
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
if not pend:
|
|
98
|
+
print("no pending batches")
|
|
99
|
+
return
|
|
100
|
+
for i, b in pend: # cheap orientation summary (read the file itself for full fingerprints)
|
|
101
|
+
patches = b.get("patches", [])
|
|
102
|
+
print(f"[{i}] session={b.get('sessionId')} saved={b.get('savedAt')} "
|
|
103
|
+
f"viewport={b.get('viewport')} patches={len(patches)}")
|
|
104
|
+
for p in patches:
|
|
105
|
+
print(f" - {_describe(p.get('fingerprint', {}))} [{_changes_summary(p.get('changes') or {})}]")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def mark(args) -> None:
|
|
109
|
+
doc = _load(args.file)
|
|
110
|
+
now = datetime.now().isoformat(timespec="seconds")
|
|
111
|
+
candidates = [
|
|
112
|
+
b for b in doc.get("batches", [])
|
|
113
|
+
if b.get("status") == "pending"
|
|
114
|
+
and (args.session is None or b.get("sessionId") == args.session)
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
if not candidates:
|
|
118
|
+
if args.session is not None:
|
|
119
|
+
_die(f"no pending batch with sessionId '{args.session}' - nothing marked")
|
|
120
|
+
_die("no pending batches to mark")
|
|
121
|
+
|
|
122
|
+
# Refuse to bulk-retire multiple sessions on a bare `mark`: each pending batch is a
|
|
123
|
+
# separate session that may not have been reconciled yet, and marking it loses it.
|
|
124
|
+
if args.session is None and len(candidates) > 1:
|
|
125
|
+
ids = ", ".join(str(b.get("sessionId") or "?") for b in candidates)
|
|
126
|
+
_die(f"{len(candidates)} pending batches ({ids}); pass a sessionId to mark one "
|
|
127
|
+
f"at a time - reconcile each before marking it")
|
|
128
|
+
|
|
129
|
+
for b in candidates:
|
|
130
|
+
b["status"] = "reconciled"
|
|
131
|
+
b["reconciledAt"] = now
|
|
132
|
+
|
|
133
|
+
_save(args.file, doc)
|
|
134
|
+
print(f"marked {len(candidates)} batch(es) reconciled")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def status(args) -> None:
|
|
138
|
+
doc = _load(args.file)
|
|
139
|
+
batches = doc.get("batches", [])
|
|
140
|
+
pend = [b for b in batches if b.get("status") == "pending"]
|
|
141
|
+
recon = [b for b in batches if b.get("status") == "reconciled"]
|
|
142
|
+
last_pending = max((b.get("savedAt") or "" for b in pend), default="")
|
|
143
|
+
print(f"target: {doc.get('target')}")
|
|
144
|
+
print(f"pending: {len(pend)}")
|
|
145
|
+
print(f"reconciled: {len(recon)}")
|
|
146
|
+
print(f"last pending: {last_pending or '-'}")
|
|
147
|
+
print("fully reconciled" if not pend else f"{len(pend)} batch(es) awaiting reconcile")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def main() -> None:
|
|
151
|
+
ap = argparse.ArgumentParser(description="webtweak-reconcile helpers")
|
|
152
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
153
|
+
|
|
154
|
+
p = sub.add_parser("pending", help="list pending batches (summary; --full for patch JSON)")
|
|
155
|
+
p.add_argument("file")
|
|
156
|
+
p.add_argument("--full", action="store_true", help="dump full patch JSON, not a summary")
|
|
157
|
+
p.set_defaults(fn=pending)
|
|
158
|
+
|
|
159
|
+
m = sub.add_parser("mark", help="mark pending batch(es) reconciled")
|
|
160
|
+
m.add_argument("file")
|
|
161
|
+
m.add_argument("session", nargs="?", default=None,
|
|
162
|
+
help="sessionId to mark (all pending if omitted)")
|
|
163
|
+
m.set_defaults(fn=mark)
|
|
164
|
+
|
|
165
|
+
s = sub.add_parser("status", help="report pending vs reconciled counts")
|
|
166
|
+
s.add_argument("file")
|
|
167
|
+
s.set_defaults(fn=status)
|
|
168
|
+
|
|
169
|
+
args = ap.parse_args()
|
|
170
|
+
args.fn(args)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__":
|
|
174
|
+
main()
|
package/webtweak.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// webtweak - a local visual editor for hand-coded HTML/CSS pages.
|
|
3
|
+
//
|
|
4
|
+
// Opens a local source .html file in the browser with an editing overlay,
|
|
5
|
+
// captures visual changes as machine-readable patches, and writes them to a
|
|
6
|
+
// running-history edits file (<name>.webtweak.json) next to the page. Claude
|
|
7
|
+
// then reconciles those patches into the real source. See CONTEXT.md / ADR-0001.
|
|
8
|
+
//
|
|
9
|
+
// Node.js stdlib only. No dependencies.
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const http = require('node:http');
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const os = require('node:os');
|
|
16
|
+
const { execFile } = require('node:child_process');
|
|
17
|
+
|
|
18
|
+
const TOOL_DIR = path.dirname(path.resolve(__filename));
|
|
19
|
+
const OVERLAY_DIR = path.join(TOOL_DIR, 'overlay');
|
|
20
|
+
const RESERVED = '/__webtweak__/';
|
|
21
|
+
const MAX_BODY = 8 * 1024 * 1024; // 8 MB cap on a save payload
|
|
22
|
+
|
|
23
|
+
const OVERLAY_ASSETS = {
|
|
24
|
+
'overlay.js': 'application/javascript; charset=utf-8',
|
|
25
|
+
'overlay.css': 'text/css; charset=utf-8',
|
|
26
|
+
'interact.min.js': 'application/javascript; charset=utf-8',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const MIME = {
|
|
30
|
+
'.html': 'text/html; charset=utf-8',
|
|
31
|
+
'.htm': 'text/html; charset=utf-8',
|
|
32
|
+
'.css': 'text/css; charset=utf-8',
|
|
33
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
34
|
+
'.mjs': 'application/javascript; charset=utf-8',
|
|
35
|
+
'.json': 'application/json',
|
|
36
|
+
'.png': 'image/png',
|
|
37
|
+
'.jpg': 'image/jpeg',
|
|
38
|
+
'.jpeg': 'image/jpeg',
|
|
39
|
+
'.gif': 'image/gif',
|
|
40
|
+
'.svg': 'image/svg+xml',
|
|
41
|
+
'.ico': 'image/x-icon',
|
|
42
|
+
'.webp': 'image/webp',
|
|
43
|
+
'.avif': 'image/avif',
|
|
44
|
+
'.woff': 'font/woff',
|
|
45
|
+
'.woff2':'font/woff2',
|
|
46
|
+
'.ttf': 'font/ttf',
|
|
47
|
+
'.otf': 'font/otf',
|
|
48
|
+
'.eot': 'application/vnd.ms-fontobject',
|
|
49
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
50
|
+
'.xml': 'text/xml; charset=utf-8',
|
|
51
|
+
'.mp4': 'video/mp4',
|
|
52
|
+
'.webm': 'video/webm',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// --- pure functions (no I/O) -----------------------------------------------
|
|
56
|
+
|
|
57
|
+
function overlayMarkup(targetName) {
|
|
58
|
+
// Use ': ' separator to match Python's json.dumps format (tests rely on this).
|
|
59
|
+
const cfg = '{"target": ' + JSON.stringify(targetName) + '}';
|
|
60
|
+
return (
|
|
61
|
+
'\n<!-- webtweak overlay (injected, not part of source) -->\n' +
|
|
62
|
+
`<script>window.__WEBTWEAK__ = ${cfg};</script>\n` +
|
|
63
|
+
`<link rel="stylesheet" href="${RESERVED}overlay.css">\n` +
|
|
64
|
+
`<script src="${RESERVED}interact.min.js"></script>\n` +
|
|
65
|
+
`<script src="${RESERVED}overlay.js" defer></script>\n`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function injectOverlay(html, targetName) {
|
|
70
|
+
const markup = overlayMarkup(targetName);
|
|
71
|
+
const idx = html.toLowerCase().lastIndexOf('</body>');
|
|
72
|
+
if (idx === -1) return html + markup;
|
|
73
|
+
return html.slice(0, idx) + markup + html.slice(idx);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function applyBatch(doc, payload, now) {
|
|
77
|
+
if (doc && typeof doc === 'object' && Array.isArray(doc.batches)) {
|
|
78
|
+
doc = Object.assign({}, doc);
|
|
79
|
+
} else {
|
|
80
|
+
doc = { target: payload.target || null, batches: [] };
|
|
81
|
+
}
|
|
82
|
+
if (!doc.target && payload.target) doc.target = payload.target;
|
|
83
|
+
|
|
84
|
+
const patches = Array.isArray(payload.patches) ? payload.patches : [];
|
|
85
|
+
const session = payload.sessionId || 'unknown';
|
|
86
|
+
const batches = doc.batches.slice();
|
|
87
|
+
|
|
88
|
+
if (!patches.length) {
|
|
89
|
+
// Empty save: user reverted every edit this session - drop their pending batch.
|
|
90
|
+
doc.batches = batches.filter(b =>
|
|
91
|
+
!(b && typeof b === 'object' && b.sessionId === session && b.status === 'pending')
|
|
92
|
+
);
|
|
93
|
+
return doc;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const batch = {
|
|
97
|
+
sessionId: session,
|
|
98
|
+
savedAt: now,
|
|
99
|
+
viewport: payload.viewport || null,
|
|
100
|
+
status: 'pending',
|
|
101
|
+
patches,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const idx = batches.findIndex(b =>
|
|
105
|
+
b && typeof b === 'object' && b.sessionId === session && b.status === 'pending'
|
|
106
|
+
);
|
|
107
|
+
if (idx >= 0) batches[idx] = batch;
|
|
108
|
+
else batches.push(batch);
|
|
109
|
+
doc.batches = batches;
|
|
110
|
+
return doc;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function writeJsonAtomic(filePath, doc) {
|
|
114
|
+
const tmp = filePath + '.tmp';
|
|
115
|
+
try {
|
|
116
|
+
fs.writeFileSync(tmp, JSON.stringify(doc, null, 2) + '\n', 'utf8');
|
|
117
|
+
fs.renameSync(tmp, filePath);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
120
|
+
throw e;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- HTTP helpers ----------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
function send(res, code, body, ctype) {
|
|
127
|
+
const buf = Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf8');
|
|
128
|
+
res.writeHead(code, {
|
|
129
|
+
'Content-Type': ctype,
|
|
130
|
+
'Content-Length': buf.length,
|
|
131
|
+
'Cache-Control': 'no-store',
|
|
132
|
+
});
|
|
133
|
+
res.end(buf);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function sendError(res, code, msg) {
|
|
137
|
+
send(res, code, `${code} ${msg}\n`, 'text/plain; charset=utf-8');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function log(msg) {
|
|
141
|
+
process.stderr.write(` webtweak: ${msg}\n`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- request handlers ------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
function serveOverlayAsset(name, res) {
|
|
147
|
+
const asset = path.resolve(OVERLAY_DIR, name);
|
|
148
|
+
// Path-traversal guard: must stay inside OVERLAY_DIR
|
|
149
|
+
if (asset !== OVERLAY_DIR &&
|
|
150
|
+
!asset.startsWith(OVERLAY_DIR + path.sep)) {
|
|
151
|
+
return sendError(res, 404, 'Unknown webtweak asset');
|
|
152
|
+
}
|
|
153
|
+
const ctype = OVERLAY_ASSETS[name];
|
|
154
|
+
if (!ctype) return sendError(res, 404, 'Unknown webtweak asset');
|
|
155
|
+
let buf;
|
|
156
|
+
try { buf = fs.readFileSync(asset); }
|
|
157
|
+
catch (_) { return sendError(res, 404, 'Unknown webtweak asset'); }
|
|
158
|
+
send(res, 200, buf, ctype);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function serveEdits(editsPath, res) {
|
|
162
|
+
let body = '{"batches": []}';
|
|
163
|
+
try {
|
|
164
|
+
const raw = fs.readFileSync(editsPath, 'utf8');
|
|
165
|
+
JSON.parse(raw); // validate; fall back to empty on corrupt
|
|
166
|
+
body = raw;
|
|
167
|
+
} catch (_) {}
|
|
168
|
+
send(res, 200, body, 'application/json');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function serveHtml(filePath, targetName, res) {
|
|
172
|
+
let html;
|
|
173
|
+
try { html = fs.readFileSync(filePath, 'utf8'); }
|
|
174
|
+
catch (e) { return sendError(res, 500, 'Read error'); }
|
|
175
|
+
send(res, 200, injectOverlay(html, targetName), 'text/html; charset=utf-8');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function serveStatic(filePath, res) {
|
|
179
|
+
let buf;
|
|
180
|
+
try { buf = fs.readFileSync(filePath); }
|
|
181
|
+
catch (_) { return sendError(res, 404, 'Not found'); }
|
|
182
|
+
const ctype = MIME[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
|
|
183
|
+
send(res, 200, buf, ctype);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function handleSave(body, targetName, serveRoot, res) {
|
|
187
|
+
let payload;
|
|
188
|
+
try { payload = JSON.parse(body || '{}'); }
|
|
189
|
+
catch (_) { return sendError(res, 400, 'Bad JSON'); }
|
|
190
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload))
|
|
191
|
+
return sendError(res, 400, 'Bad JSON: expected an object');
|
|
192
|
+
|
|
193
|
+
const stem = path.basename(targetName, path.extname(targetName));
|
|
194
|
+
const editsPath = path.join(serveRoot, stem + '.webtweak.json');
|
|
195
|
+
|
|
196
|
+
let doc = null;
|
|
197
|
+
if (fs.existsSync(editsPath)) {
|
|
198
|
+
let raw;
|
|
199
|
+
try { raw = fs.readFileSync(editsPath, 'utf8'); }
|
|
200
|
+
catch (e) {
|
|
201
|
+
// Transient read error - propagate; don't touch the file
|
|
202
|
+
return send(res, 500, JSON.stringify({ ok: false, error: e.message }), 'application/json');
|
|
203
|
+
}
|
|
204
|
+
try { doc = JSON.parse(raw); }
|
|
205
|
+
catch (_) {
|
|
206
|
+
// Corrupt JSON - back up and start fresh
|
|
207
|
+
const stamp = Date.now();
|
|
208
|
+
const backup = editsPath + '.' + stamp + '.bak';
|
|
209
|
+
try { fs.renameSync(editsPath, backup); } catch (_) {}
|
|
210
|
+
log(`edits file corrupt; backed up to ${path.basename(backup)}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!payload.target) payload = Object.assign({ target: targetName }, payload);
|
|
215
|
+
const now = new Date().toISOString().slice(0, 19);
|
|
216
|
+
doc = applyBatch(doc, payload, now);
|
|
217
|
+
try { writeJsonAtomic(editsPath, doc); }
|
|
218
|
+
catch (e) {
|
|
219
|
+
return send(res, 500, JSON.stringify({ ok: false, error: e.message }), 'application/json');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const n = (payload.patches || []).length;
|
|
223
|
+
log(`saved ${n} patch(es) -> ${path.basename(editsPath)}`);
|
|
224
|
+
send(res, 200, JSON.stringify({ ok: true, file: path.basename(editsPath), patches: n }), 'application/json');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function createHandler(targetPath, serveRoot) {
|
|
228
|
+
const targetName = path.basename(targetPath);
|
|
229
|
+
const stem = path.basename(targetName, path.extname(targetName));
|
|
230
|
+
|
|
231
|
+
return function (req, res) {
|
|
232
|
+
const rawPath = (req.url || '/').split('?')[0];
|
|
233
|
+
|
|
234
|
+
// --- webtweak API endpoints and overlay assets -------------------------
|
|
235
|
+
if (rawPath.startsWith(RESERVED)) {
|
|
236
|
+
const name = rawPath.slice(RESERVED.length);
|
|
237
|
+
|
|
238
|
+
if (name === 'edits' && req.method === 'GET') {
|
|
239
|
+
return serveEdits(path.join(serveRoot, stem + '.webtweak.json'), res);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (name === 'save' && req.method === 'POST') {
|
|
243
|
+
const lenStr = req.headers['content-length'];
|
|
244
|
+
const length = parseInt(lenStr, 10);
|
|
245
|
+
if (!lenStr || isNaN(length) || length < 0) return sendError(res, 400, 'Bad Content-Length');
|
|
246
|
+
if (length > MAX_BODY) return sendError(res, 413, 'Payload too large');
|
|
247
|
+
|
|
248
|
+
const chunks = [];
|
|
249
|
+
let received = 0;
|
|
250
|
+
req.on('data', chunk => {
|
|
251
|
+
received += chunk.length;
|
|
252
|
+
if (received <= MAX_BODY) chunks.push(chunk);
|
|
253
|
+
});
|
|
254
|
+
req.on('end', () => {
|
|
255
|
+
if (received > MAX_BODY) return sendError(res, 413, 'Payload too large');
|
|
256
|
+
if (received < length) return sendError(res, 400, 'Incomplete request body');
|
|
257
|
+
handleSave(Buffer.concat(chunks).toString('utf8'), targetName, serveRoot, res);
|
|
258
|
+
});
|
|
259
|
+
req.on('error', () => sendError(res, 400, 'Incomplete request body'));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return serveOverlayAsset(name, res);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// --- static file serving -----------------------------------------------
|
|
267
|
+
if (req.method !== 'GET' && req.method !== 'HEAD')
|
|
268
|
+
return sendError(res, 405, 'Method not allowed');
|
|
269
|
+
|
|
270
|
+
let decoded;
|
|
271
|
+
try { decoded = decodeURIComponent(rawPath); }
|
|
272
|
+
catch (_) { return sendError(res, 400, 'Bad URL'); }
|
|
273
|
+
|
|
274
|
+
// Resolve and contain within serveRoot (path-traversal guard)
|
|
275
|
+
const local = path.resolve(serveRoot, decoded.replace(/^\/+/, ''));
|
|
276
|
+
if (local !== serveRoot &&
|
|
277
|
+
!local.startsWith(serveRoot + path.sep))
|
|
278
|
+
return sendError(res, 403, 'Forbidden');
|
|
279
|
+
|
|
280
|
+
// No directory listings
|
|
281
|
+
let stat;
|
|
282
|
+
try { stat = fs.statSync(local); }
|
|
283
|
+
catch (_) { return sendError(res, 404, 'Not found'); }
|
|
284
|
+
if (stat.isDirectory()) return sendError(res, 404, 'No listing');
|
|
285
|
+
|
|
286
|
+
const ext = path.extname(local).toLowerCase();
|
|
287
|
+
if (ext === '.html' || ext === '.htm') return serveHtml(local, targetName, res);
|
|
288
|
+
serveStatic(local, res);
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// --- browser opener --------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
function openBrowser(url) {
|
|
295
|
+
const cmds = {
|
|
296
|
+
darwin: ['open', [url]],
|
|
297
|
+
win32: ['cmd', ['/c', 'start', '', url]],
|
|
298
|
+
};
|
|
299
|
+
const [cmd, args] = cmds[os.platform()] || ['xdg-open', [url]];
|
|
300
|
+
execFile(cmd, args, { detached: true }, () => {});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// --- server ----------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
function serve(targetPath, port, openBrowserFlag) {
|
|
306
|
+
const serveRoot = path.dirname(targetPath);
|
|
307
|
+
const handler = createHandler(targetPath, serveRoot);
|
|
308
|
+
const server = http.createServer(handler);
|
|
309
|
+
|
|
310
|
+
server.listen(port, '127.0.0.1', () => {
|
|
311
|
+
const actual = server.address().port;
|
|
312
|
+
const url = `http://127.0.0.1:${actual}/${path.basename(targetPath)}`;
|
|
313
|
+
process.stdout.write(`webtweak editing: ${targetPath}\n`);
|
|
314
|
+
process.stdout.write(` serving ${serveRoot}\n`);
|
|
315
|
+
// Flush before remaining lines so the test harness sees the port immediately.
|
|
316
|
+
process.stdout.write(` listening on 127.0.0.1:${actual}\n`, () => {
|
|
317
|
+
process.stdout.write(` open ${url}\n`);
|
|
318
|
+
process.stdout.write(` Ctrl-C to stop.\n\n`);
|
|
319
|
+
});
|
|
320
|
+
if (openBrowserFlag) openBrowser(url);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
server.on('error', e => {
|
|
324
|
+
const hint = e.code === 'EADDRINUSE'
|
|
325
|
+
? `cannot bind port ${port}. Try --port 0 for any free port.`
|
|
326
|
+
: e.message;
|
|
327
|
+
process.stderr.write(`webtweak: ${hint}\n`);
|
|
328
|
+
process.exit(1);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
process.on('SIGINT', () => {
|
|
332
|
+
process.stdout.write('\nwebtweak stopped.\n');
|
|
333
|
+
server.close(() => process.exit(0));
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// --- CLI -------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
function main() {
|
|
340
|
+
const args = process.argv.slice(2);
|
|
341
|
+
let htmlFile = null, port = 8723, noBrowser = false;
|
|
342
|
+
|
|
343
|
+
for (let i = 0; i < args.length; i++) {
|
|
344
|
+
if ((args[i] === '--port') && args[i + 1]) {
|
|
345
|
+
port = parseInt(args[++i], 10);
|
|
346
|
+
if (isNaN(port)) { process.stderr.write('webtweak: --port must be a number\n'); process.exit(1); }
|
|
347
|
+
} else if (args[i] === '--no-browser') {
|
|
348
|
+
noBrowser = true;
|
|
349
|
+
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
350
|
+
process.stdout.write('Usage: webtweak <page.html> [--port N] [--no-browser]\n');
|
|
351
|
+
process.exit(0);
|
|
352
|
+
} else if (!args[i].startsWith('-')) {
|
|
353
|
+
htmlFile = args[i];
|
|
354
|
+
} else {
|
|
355
|
+
process.stderr.write(`webtweak: unknown option ${args[i]}\n`);
|
|
356
|
+
process.exit(1);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (!htmlFile) {
|
|
361
|
+
process.stderr.write('webtweak: path to an .html file is required\n');
|
|
362
|
+
process.stderr.write('Usage: webtweak <page.html> [--port N] [--no-browser]\n');
|
|
363
|
+
process.exit(1);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const targetPath = path.resolve(htmlFile);
|
|
367
|
+
if (!fs.existsSync(targetPath)) {
|
|
368
|
+
process.stderr.write(`webtweak: not a file: ${targetPath}\n`);
|
|
369
|
+
process.exit(1);
|
|
370
|
+
}
|
|
371
|
+
const ext = path.extname(targetPath).toLowerCase();
|
|
372
|
+
if (ext !== '.html' && ext !== '.htm') {
|
|
373
|
+
process.stderr.write(`webtweak: expected an .html file, got ${ext || 'no extension'}\n`);
|
|
374
|
+
process.exit(1);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
serve(targetPath, port, !noBrowser);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
main();
|