@nynb/sandpaper 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 +119 -0
- package/bin/brain-inject.js +23 -0
- package/bin/brain-stamp-check.js +36 -0
- package/bin/cli.js +62 -0
- package/brain/README.md +99 -0
- package/brain/assets/brain.css +472 -0
- package/brain/assets/brain.js +189 -0
- package/brain/assets/theme.css +88 -0
- package/package.json +19 -0
- package/public/sp-markdown.js +172 -0
- package/public/toolbar.css +220 -0
- package/public/toolbar.js +564 -0
- package/skill/sandpaper/SKILL.md +114 -0
- package/skill/sandpaper/commands/canvas.md +31 -0
- package/skill/sandpaper/commands/decide.md +16 -0
- package/skill/sandpaper/commands/help.md +25 -0
- package/skill/sandpaper/commands/init.md +113 -0
- package/skill/sandpaper/commands/learn.md +11 -0
- package/skill/sandpaper/commands/log.md +9 -0
- package/skill/sandpaper/commands/open.md +15 -0
- package/skill/sandpaper/commands/plan.md +16 -0
- package/skill/sandpaper/commands/serve.md +8 -0
- package/skill/sandpaper/commands/stamp.md +25 -0
- package/skill/sandpaper/commands/sync.md +17 -0
- package/skill/sandpaper/commands/theme.md +12 -0
- package/src/claude.js +226 -0
- package/src/edit.js +113 -0
- package/src/server.js +327 -0
- package/src/setup.js +564 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// brain.js — zero-dependency, file://-safe enhancements for the project brain.
|
|
2
|
+
// (1) Live-DOM search/filter over .entry blocks. (2) "Since you last looked" on the log.
|
|
3
|
+
// (3) Plan progress derived from task status. (4) Out-link resolver — keeps brain/
|
|
4
|
+
// publishable anywhere (one same-origin probe; the only network call in this file).
|
|
5
|
+
// (5) Whiteboard fold-fit — the canvas caps at the first fold, content scrolls inside.
|
|
6
|
+
(function () {
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
// ---- (1) search / filter ----
|
|
10
|
+
var q = document.getElementById('brain-q');
|
|
11
|
+
if (q) {
|
|
12
|
+
var entries = Array.prototype.slice.call(document.querySelectorAll('.entry[data-kind], .timeline li[data-kind]'));
|
|
13
|
+
var facetEls = Array.prototype.slice.call(document.querySelectorAll('.facet'));
|
|
14
|
+
var note = document.getElementById('brain-searchnote');
|
|
15
|
+
var activeFacet = '';
|
|
16
|
+
|
|
17
|
+
function apply() {
|
|
18
|
+
var term = q.value.trim().toLowerCase();
|
|
19
|
+
var shown = 0;
|
|
20
|
+
entries.forEach(function (e) {
|
|
21
|
+
var hay = (e.textContent + ' ' + (e.getAttribute('data-tags') || '') + ' ' + (e.getAttribute('data-kind') || '') + ' ' + (e.getAttribute('data-status') || '') + ' ' + (e.getAttribute('data-lens') || '')).toLowerCase();
|
|
22
|
+
var okTerm = !term || hay.indexOf(term) >= 0;
|
|
23
|
+
var okFacet = !activeFacet || e.getAttribute('data-kind') === activeFacet || e.getAttribute('data-status') === activeFacet || e.getAttribute('data-lens') === activeFacet;
|
|
24
|
+
var show = okTerm && okFacet;
|
|
25
|
+
e.classList.toggle('hidden', !show);
|
|
26
|
+
if (show) shown++;
|
|
27
|
+
});
|
|
28
|
+
if (note) note.textContent = (term || activeFacet) ? (shown + ' shown') : '';
|
|
29
|
+
}
|
|
30
|
+
q.addEventListener('input', apply);
|
|
31
|
+
facetEls.forEach(function (f) {
|
|
32
|
+
f.addEventListener('click', function () {
|
|
33
|
+
var val = f.getAttribute('data-facet') || '';
|
|
34
|
+
activeFacet = (activeFacet === val) ? '' : val;
|
|
35
|
+
facetEls.forEach(function (x) { x.classList.toggle('on', x === f && activeFacet); });
|
|
36
|
+
apply();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
// press "/" to focus search
|
|
40
|
+
document.addEventListener('keydown', function (e) {
|
|
41
|
+
if (e.key === '/' && document.activeElement !== q) { e.preventDefault(); q.focus(); }
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---- (2) "since you last looked" on the log ----
|
|
46
|
+
var logRows = Array.prototype.slice.call(document.querySelectorAll('.timeline li[data-cid], .entry--worklog[data-cid]'));
|
|
47
|
+
if (logRows.length) {
|
|
48
|
+
// freshness is the product: derive the day divider from the newest row instead of trusting stale hand-typed text
|
|
49
|
+
var day = document.querySelector('.tl-day');
|
|
50
|
+
var newestDate = logRows[0].getAttribute('data-date');
|
|
51
|
+
if (day && newestDate) {
|
|
52
|
+
var d = new Date(newestDate + 'T00:00:00');
|
|
53
|
+
if (!isNaN(d)) day.textContent = d.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' }) + ' · latest';
|
|
54
|
+
}
|
|
55
|
+
var KEY = 'brain:lastSeen';
|
|
56
|
+
var last = null;
|
|
57
|
+
try { last = localStorage.getItem(KEY); } catch (e) {}
|
|
58
|
+
var newest = logRows[0].getAttribute('data-cid'); // rows are newest-first
|
|
59
|
+
if (last && last !== newest) {
|
|
60
|
+
var unseen = 0;
|
|
61
|
+
for (var i = 0; i < logRows.length; i++) {
|
|
62
|
+
if (logRows[i].getAttribute('data-cid') === last) break;
|
|
63
|
+
logRows[i].classList.add('unseen'); unseen++;
|
|
64
|
+
}
|
|
65
|
+
var badge = document.getElementById('brain-unseen');
|
|
66
|
+
if (badge && unseen) { badge.textContent = unseen + ' new'; badge.hidden = false; }
|
|
67
|
+
}
|
|
68
|
+
var mark = document.getElementById('brain-markseen');
|
|
69
|
+
if (mark) mark.addEventListener('click', function () {
|
|
70
|
+
try { localStorage.setItem(KEY, newest); } catch (e) {}
|
|
71
|
+
logRows.forEach(function (r) { r.classList.remove('unseen'); });
|
|
72
|
+
var b = document.getElementById('brain-unseen'); if (b) b.hidden = true;
|
|
73
|
+
});
|
|
74
|
+
// record this visit as seen on unload
|
|
75
|
+
window.addEventListener('pagehide', function () { try { localStorage.setItem(KEY, newest); } catch (e) {} });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---- (3) plan progress: derive each initiative's bar + the overall % from child task status ----
|
|
79
|
+
var inits = Array.prototype.slice.call(document.querySelectorAll('.entry--initiative'));
|
|
80
|
+
if (inits.length) {
|
|
81
|
+
var allDone = 0, allTotal = 0;
|
|
82
|
+
inits.forEach(function (ini) {
|
|
83
|
+
var tasks = Array.prototype.slice.call(ini.querySelectorAll('.task[data-status]'));
|
|
84
|
+
var done = 0, doing = 0, blocked = 0;
|
|
85
|
+
tasks.forEach(function (t) {
|
|
86
|
+
var s = t.getAttribute('data-status');
|
|
87
|
+
if (s === 'done') done++; else if (s === 'doing') doing++; else if (s === 'blocked') blocked++;
|
|
88
|
+
});
|
|
89
|
+
var total = tasks.length; allDone += done; allTotal += total;
|
|
90
|
+
var pct = total ? Math.round(done / total * 100) : 0;
|
|
91
|
+
var bar = ini.querySelector('[data-progress] i'); if (bar) bar.style.width = pct + '%';
|
|
92
|
+
var lab = ini.querySelector('[data-progress-label]'); if (lab) lab.textContent = done + '/' + total + ' · ' + pct + '%';
|
|
93
|
+
var st = (total && done === total) ? 'done' : (blocked && !doing) ? 'blocked' : (doing || done) ? 'active' : 'planned';
|
|
94
|
+
var roll = ini.querySelector('[data-rollup]');
|
|
95
|
+
if (roll) { roll.textContent = st; roll.className = 'badge ' + (st === 'done' ? 'done' : st === 'active' ? 'wip' : st === 'blocked' ? 'open' : 'stub'); }
|
|
96
|
+
ini.setAttribute('data-derived', st);
|
|
97
|
+
});
|
|
98
|
+
var op = allTotal ? Math.round(allDone / allTotal * 100) : 0;
|
|
99
|
+
var ov = document.getElementById('plan-overall'); if (ov) ov.textContent = allDone + '/' + allTotal + ' · ' + op + '%';
|
|
100
|
+
var ob = document.getElementById('plan-overall-bar'); if (ob) ob.style.width = op + '%';
|
|
101
|
+
// per-PHASE rollup — sum tasks across each phase's initiatives (by data-phase, not position)
|
|
102
|
+
['0', '1'].forEach(function (ph) {
|
|
103
|
+
var pt = Array.prototype.slice.call(document.querySelectorAll('.entry--initiative[data-phase="' + ph + '"] .task[data-status]'));
|
|
104
|
+
var pd = pt.filter(function (t) { return t.getAttribute('data-status') === 'done'; }).length;
|
|
105
|
+
var pp = pt.length ? Math.round(pd / pt.length * 100) : 0;
|
|
106
|
+
var pbar = document.querySelector('[data-phase-progress="' + ph + '"] i'); if (pbar) pbar.style.width = pp + '%';
|
|
107
|
+
var plab = document.querySelector('[data-phase-label="' + ph + '"]'); if (plab) plab.textContent = pd + '/' + pt.length + ' · ' + pp + '%';
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- (4) out-link resolver: brain/ stays publishable anywhere ----
|
|
112
|
+
// Refs to canonical truth (spec · source · meta) are written RELATIVE — link, never copy —
|
|
113
|
+
// so they resolve on disk and whenever the whole repo is served. Deployed DETACHED (a
|
|
114
|
+
// brain/-only static host), they would 404; so: probe once per load for the repo above us
|
|
115
|
+
// (../package.json, name-checked, NO caching — the same origin can serve both modes), and
|
|
116
|
+
// when detached, resolve out-links AT CLICK TIME to the source base named in
|
|
117
|
+
// <meta name="sandpaper:source" content=".../blob/HEAD/" data-pkg="name">.
|
|
118
|
+
// No meta → dim them with a tooltip instead of 404ing. file:// counts as attached (it IS
|
|
119
|
+
// the disk). NOTE: out-of-brain detection must read the RAW href prefix — at a root deploy
|
|
120
|
+
// the URL resolver silently eats "../" at the boundary, so resolved URLs can't tell.
|
|
121
|
+
var me = document.querySelector('script[src*="assets/brain.js"]');
|
|
122
|
+
var ups = me ? ((me.getAttribute('src') || '').match(/\.\.\//g) || []).length : 0; // page depth below the brain root
|
|
123
|
+
var OUT = new Array(ups + 2).join('../'); // (ups+1) × "../" — the prefix that leaves brain/
|
|
124
|
+
var srcMeta = document.querySelector('meta[name="sandpaper:source"]');
|
|
125
|
+
var srcBase = srcMeta ? (srcMeta.getAttribute('content') || '') : '';
|
|
126
|
+
var srcPkg = srcMeta ? (srcMeta.getAttribute('data-pkg') || '') : '';
|
|
127
|
+
|
|
128
|
+
function outPath(a) { // the repo-relative path if this anchor leaves brain/, else null
|
|
129
|
+
var h = a.getAttribute('href') || '';
|
|
130
|
+
if (h.slice(0, OUT.length) !== OUT) return null;
|
|
131
|
+
var rest = h.slice(OUT.length);
|
|
132
|
+
return rest.slice(0, 3) === '../' ? null : rest; // above the repo root — unmappable
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
var outLinks = Array.prototype.slice.call(document.querySelectorAll('a[href]')).filter(outPath);
|
|
136
|
+
if (outLinks.length && location.protocol !== 'file:') {
|
|
137
|
+
fetch(OUT + 'package.json', { cache: 'no-store' })
|
|
138
|
+
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
|
139
|
+
.then(function (j) { if (!j || typeof j.name !== 'string' || (srcPkg && j.name !== srcPkg)) detached(); })
|
|
140
|
+
.catch(detached);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function detached() {
|
|
144
|
+
document.documentElement.classList.add('brain-detached');
|
|
145
|
+
if (srcBase) {
|
|
146
|
+
outLinks.forEach(function (a) { a.title = 'repo file — opens the copy on the source host'; });
|
|
147
|
+
document.addEventListener('click', rewrite, true);
|
|
148
|
+
document.addEventListener('auxclick', rewrite, true); // middle-click
|
|
149
|
+
} else {
|
|
150
|
+
var block = function (e) { e.preventDefault(); };
|
|
151
|
+
outLinks.forEach(function (a) {
|
|
152
|
+
a.classList.add('ref-detached');
|
|
153
|
+
a.title = 'lives in the repo — open the brain locally (or set the sandpaper:source meta) to follow';
|
|
154
|
+
a.addEventListener('click', block);
|
|
155
|
+
a.addEventListener('auxclick', block);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Resolve just-in-time, then RESTORE the raw href: the mutation must live only long enough
|
|
160
|
+
// for the browser's default navigation to read it. A rewrite left in the DOM gets captured
|
|
161
|
+
// by the refine toolbar's edit-in-place and committed to disk — an absolute URL baked into
|
|
162
|
+
// the brain file. Inert when something upstream (the toolbar) already claimed the click.
|
|
163
|
+
function rewrite(e) {
|
|
164
|
+
if (e.defaultPrevented) return;
|
|
165
|
+
var a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
|
|
166
|
+
if (!a) return;
|
|
167
|
+
var p = outPath(a);
|
|
168
|
+
if (!p) return;
|
|
169
|
+
var raw = a.getAttribute('href');
|
|
170
|
+
a.setAttribute('href', srcBase + p);
|
|
171
|
+
setTimeout(function () { a.setAttribute('href', raw); }, 0);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---- (5) whiteboard fold-fit ----
|
|
175
|
+
// CSS alone can't cap the canvas at the fold — how far down the whiteboard starts depends
|
|
176
|
+
// on the shell + NOW plate above it — so measure: cap = viewport − document offset − a
|
|
177
|
+
// breath. Re-fit on resize and once fonts settle (they shift the offset after first paint).
|
|
178
|
+
var wb = document.querySelector('.whiteboard');
|
|
179
|
+
if (wb) {
|
|
180
|
+
var fit = function () {
|
|
181
|
+
var top = wb.getBoundingClientRect().top + window.pageYOffset;
|
|
182
|
+
wb.style.maxHeight = Math.max(260, window.innerHeight - top - 26) + 'px';
|
|
183
|
+
};
|
|
184
|
+
fit();
|
|
185
|
+
window.addEventListener('resize', fit);
|
|
186
|
+
window.addEventListener('load', fit);
|
|
187
|
+
if (document.fonts && document.fonts.ready && document.fonts.ready.then) document.fonts.ready.then(fit);
|
|
188
|
+
}
|
|
189
|
+
})();
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/* theme.css — Sandpaper's single skin.
|
|
2
|
+
This is the ONE file a project owner edits to re-skin every Sandpaper surface
|
|
3
|
+
(the brain books, sandpaper.html, engg-spec.html). Change a value here and the
|
|
4
|
+
whole look shifts with it. Pure CSS custom properties, zero dependencies,
|
|
5
|
+
no build step, valid standalone CSS.
|
|
6
|
+
|
|
7
|
+
CURRENT SKIN — "AS-BUILT": the cyanotype engineering-drawing system shared with
|
|
8
|
+
sandpaper.sh. Cool drafting sheets, prussian plate, one working blue, a red
|
|
9
|
+
inspection accent. Type: Archivo (grotesk) + JetBrains Mono. (The previous warm
|
|
10
|
+
service-manual skin lives in git history if you ever want it back.)
|
|
11
|
+
|
|
12
|
+
NOTE: toolbar.css does NOT import this file (it is injected into arbitrary host
|
|
13
|
+
pages and must stand alone) — it carries its own #sp-panel-scoped --sp-* copy as a
|
|
14
|
+
fallback. But toolbar.js reads these :root tokens at runtime and overrides that copy
|
|
15
|
+
when present, so re-skinning here reaches the floating toolbar too — no hand-sync. */
|
|
16
|
+
:root{
|
|
17
|
+
/* ---- paper / surfaces (cool drafting sheets) ---- */
|
|
18
|
+
--paper:#F3F6F9; /* base page background */
|
|
19
|
+
--panel:#E9EFF4; /* raised panel / card / input background */
|
|
20
|
+
--paper-3:#DEE7EF; /* recessed progress track / lighter paper */
|
|
21
|
+
--card-feature:#EDF3F8; /* feature-card cool wash */
|
|
22
|
+
--plate:#0E2C50; /* dark drafting-plate / code-block background — the blueprint */
|
|
23
|
+
--white:#FCFDFE; /* search & inline input fields */
|
|
24
|
+
|
|
25
|
+
/* ---- ink / text ---- */
|
|
26
|
+
--ink:#18293E; /* primary text; cover-plate background */
|
|
27
|
+
--ink-2:#122136; /* darker ink variant */
|
|
28
|
+
--text:#2A3B50; /* body copy inside records, cards, callouts */
|
|
29
|
+
--text-prose:#243348; /* long-form wiki prose + lead */
|
|
30
|
+
--text-quote:#33445A; /* quoted text (toolbar blockquotes) */
|
|
31
|
+
--text-muted:#54687D; /* muted ledger / "today" text */
|
|
32
|
+
|
|
33
|
+
/* ---- accents (semantic, never decorative) ---- */
|
|
34
|
+
--clay:#1C5085; /* primary accent: links, interactive, WIP — the working blue */
|
|
35
|
+
--pine:#274768; /* secondary: success surface, sidebar, verdict */
|
|
36
|
+
--ochre:#B07818; /* warning / open — drafting amber */
|
|
37
|
+
--moss:#1F7A4D; /* done / verified / progress fill */
|
|
38
|
+
--rust:#C63B2F; /* error / blocked / gotcha — the inspection red */
|
|
39
|
+
--mute:#6B7E92; /* secondary text, idle, disabled */
|
|
40
|
+
|
|
41
|
+
/* ---- legible inks for tinted badges / the dark plate ---- */
|
|
42
|
+
--badge-done-ink:#1D5B3C; /* text on the "done/verified" badge */
|
|
43
|
+
--badge-open-ink:#7A5610; /* text on the "open" badge */
|
|
44
|
+
--on-plate-meta:#8FAAC6; /* metadata text on the dark cover plate */
|
|
45
|
+
--on-plate-link:#C9D8E7; /* links on the dark cover plate */
|
|
46
|
+
--on-plate-em:#FF9A8B; /* accent inside the NOW hero */
|
|
47
|
+
|
|
48
|
+
/* ---- status semantics (map onto the accents; always label-paired) ---- */
|
|
49
|
+
--ok:var(--moss); --wip:var(--clay); --open:var(--ochre); --blocked:var(--rust); --idle:var(--mute);
|
|
50
|
+
|
|
51
|
+
/* ---- hairlines, rules, and ink/paper/accent tints ---- */
|
|
52
|
+
--rule:rgba(24,41,62,.16); /* standard divider */
|
|
53
|
+
--rule-2:rgba(24,41,62,.08); /* faint divider */
|
|
54
|
+
--hairline:1px solid var(--rule); /* the canonical 1px border */
|
|
55
|
+
--tint-ink-code:rgba(28,80,133,.08); /* inline-code wash */
|
|
56
|
+
--tint-ink-stub:rgba(24,41,62,.08); /* stub / superseded badge wash */
|
|
57
|
+
--tint-ink-strike:rgba(24,41,62,.24); /* line-through decoration */
|
|
58
|
+
--tint-clay-sel:rgba(28,80,133,.22); /* text selection */
|
|
59
|
+
--tint-clay-badge:rgba(28,80,133,.13);/* wip / fixed badge wash */
|
|
60
|
+
--tint-clay-focus:rgba(28,80,133,.3); /* input focus ring */
|
|
61
|
+
--tint-ochre-badge:rgba(176,120,24,.16);/* open badge wash */
|
|
62
|
+
--tint-moss-badge:rgba(31,122,77,.14);/* done badge wash */
|
|
63
|
+
--tint-rust-badge:rgba(198,59,47,.12); /* blocking badge wash */
|
|
64
|
+
--tint-moss-glow:rgba(31,122,77,.25); /* health-dot glow ring */
|
|
65
|
+
--tint-paper-grid:rgba(243,246,249,.07);/* plate grid lines */
|
|
66
|
+
--tint-paper-border:rgba(243,246,249,.3);/* health pill border on plate */
|
|
67
|
+
|
|
68
|
+
/* ---- typography ---- */
|
|
69
|
+
--font-display:'Archivo',system-ui,-apple-system,sans-serif; /* display, used sparingly */
|
|
70
|
+
--font-body:'Archivo',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; /* body */
|
|
71
|
+
--font-mono:'JetBrains Mono',ui-monospace,Menlo,monospace; /* labels / code / metadata */
|
|
72
|
+
|
|
73
|
+
/* ---- radii (drafting-sharp) ---- */
|
|
74
|
+
--radius:3px; /* default container radius */
|
|
75
|
+
--radius-sm:2px; /* inline code, id chips */
|
|
76
|
+
--radius-md:4px; /* cards, panels */
|
|
77
|
+
--radius-lg:6px; /* floating toolbar panel */
|
|
78
|
+
--radius-pill:3px; /* chips, progress tracks, health badge — squared, like drawing labels */
|
|
79
|
+
|
|
80
|
+
/* ---- elevation ---- */
|
|
81
|
+
--shadow-panel:0 10px 30px rgba(10,20,40,.16);
|
|
82
|
+
|
|
83
|
+
/* ---- legacy aliases (keep brain.css's existing names resolving) ---- */
|
|
84
|
+
--paper-2:var(--panel);
|
|
85
|
+
--serif:var(--font-display);
|
|
86
|
+
--sans:var(--font-body);
|
|
87
|
+
--mono:var(--font-mono);
|
|
88
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nynb/sandpaper",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A living project brain + refine-in-place toolbar for Claude Code projects. npx @nynb/sandpaper install-skill / init / doctor / open.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "sandpaper": "bin/cli.js" },
|
|
7
|
+
"files": ["bin/", "src/", "public/", "skill/", "brain/assets/", "README.md"],
|
|
8
|
+
"keywords": ["claude-code", "skill", "project-brain", "living-docs", "documentation", "agent"],
|
|
9
|
+
"author": "Narayan <codevalley@live.com>",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"repository": { "type": "git", "url": "git+https://github.com/codevalley/sandpaper.git" },
|
|
12
|
+
"homepage": "https://github.com/codevalley/sandpaper#readme",
|
|
13
|
+
"bugs": "https://github.com/codevalley/sandpaper/issues",
|
|
14
|
+
"engines": { "node": ">=18" },
|
|
15
|
+
"scripts": {
|
|
16
|
+
"start": "node bin/cli.js help",
|
|
17
|
+
"test": "node test/parse-test.js && node test/markdown-test.js && node test/edit-test.js"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// sp-markdown.js — a tiny, dependency-free, XSS-safe markdown renderer for the Sandpaper toolbar.
|
|
2
|
+
// parseMarkdown / tokenizeInline are PURE (no DOM) and unit-tested in node.
|
|
3
|
+
// renderMarkdown builds DOM via createElement + textContent only — never innerHTML on model output.
|
|
4
|
+
|
|
5
|
+
// ---- block parser (pure) ----
|
|
6
|
+
export function parseMarkdown(md) {
|
|
7
|
+
const lines = String(md == null ? '' : md).replace(/\r\n?/g, '\n').split('\n');
|
|
8
|
+
const blocks = [];
|
|
9
|
+
let i = 0;
|
|
10
|
+
const isSep = (s) => s != null && /-/.test(s) && /^\s*\|?[\s:|-]+\|?\s*$/.test(s);
|
|
11
|
+
while (i < lines.length) {
|
|
12
|
+
const line = lines[i];
|
|
13
|
+
if (/^\s*$/.test(line)) { i++; continue; } // blank
|
|
14
|
+
|
|
15
|
+
const fence = line.match(/^```(\w*)\s*$/); // fenced code
|
|
16
|
+
if (fence) {
|
|
17
|
+
const buf = []; i++;
|
|
18
|
+
while (i < lines.length && !/^```\s*$/.test(lines[i])) { buf.push(lines[i]); i++; }
|
|
19
|
+
i++; // skip closing fence (tolerant of EOF — streaming-safe)
|
|
20
|
+
blocks.push({ type: 'code', lang: fence[1] || '', text: buf.join('\n') });
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const h = line.match(/^(#{1,6})\s+(.*)$/); // heading
|
|
25
|
+
if (h) { blocks.push({ type: 'h', level: h[1].length, text: h[2].trim() }); i++; continue; }
|
|
26
|
+
|
|
27
|
+
if (/^\s*([-*_])\1\1[-*_\s]*$/.test(line)) { blocks.push({ type: 'hr' }); i++; continue; } // hr
|
|
28
|
+
|
|
29
|
+
if (/^\s*>\s?/.test(line)) { // blockquote
|
|
30
|
+
const q = [];
|
|
31
|
+
while (i < lines.length && /^\s*>\s?/.test(lines[i])) { q.push(lines[i].replace(/^\s*>\s?/, '')); i++; }
|
|
32
|
+
blocks.push({ type: 'quote', text: q.join('\n') });
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (line.indexOf('|') >= 0 && isSep(lines[i + 1])) { // GFM table
|
|
37
|
+
const headers = splitRow(line);
|
|
38
|
+
i += 2;
|
|
39
|
+
const rows = [];
|
|
40
|
+
while (i < lines.length && lines[i].indexOf('|') >= 0 && !/^\s*$/.test(lines[i])) { rows.push(splitRow(lines[i])); i++; }
|
|
41
|
+
blocks.push({ type: 'table', headers, rows });
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const lu = line.match(/^\s*[-*+]\s+(.*)$/); // list
|
|
46
|
+
const lo = line.match(/^\s*\d+[.)]\s+(.*)$/);
|
|
47
|
+
if (lu || lo) {
|
|
48
|
+
const ordered = !!lo; const items = [];
|
|
49
|
+
while (i < lines.length) {
|
|
50
|
+
const mu = lines[i].match(/^\s*[-*+]\s+(.*)$/);
|
|
51
|
+
const mo = lines[i].match(/^\s*\d+[.)]\s+(.*)$/);
|
|
52
|
+
if (ordered && mo) { items.push(mo[1]); i++; }
|
|
53
|
+
else if (!ordered && mu) { items.push(mu[1]); i++; }
|
|
54
|
+
else break;
|
|
55
|
+
}
|
|
56
|
+
blocks.push({ type: ordered ? 'ol' : 'ul', items });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const p = []; // paragraph
|
|
61
|
+
while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^```/.test(lines[i]) &&
|
|
62
|
+
!/^#{1,6}\s/.test(lines[i]) && !/^\s*>/.test(lines[i]) &&
|
|
63
|
+
!/^\s*([-*+]|\d+[.)])\s+/.test(lines[i]) &&
|
|
64
|
+
!(lines[i].indexOf('|') >= 0 && isSep(lines[i + 1]))) {
|
|
65
|
+
p.push(lines[i]); i++;
|
|
66
|
+
}
|
|
67
|
+
blocks.push({ type: 'p', text: p.join('\n') });
|
|
68
|
+
}
|
|
69
|
+
return blocks;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function splitRow(line) {
|
|
73
|
+
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---- inline tokenizer (pure) ----
|
|
77
|
+
const SAFE_HREF = /^(https?:|mailto:|\/|\.|#)/i;
|
|
78
|
+
export function tokenizeInline(text) {
|
|
79
|
+
const out = [];
|
|
80
|
+
let s = String(text == null ? '' : text);
|
|
81
|
+
const rules = [
|
|
82
|
+
{ type: 'code', re: /`([^`]+)`/ },
|
|
83
|
+
{ type: 'bold', re: /\*\*([^*]+)\*\*/ },
|
|
84
|
+
{ type: 'strike', re: /~~([^~]+)~~/ },
|
|
85
|
+
{ type: 'link', re: /\[([^\]]+)\]\(([^)\s]+)\)/ },
|
|
86
|
+
{ type: 'em', re: /\*([^*\n]+)\*|_([^_\n]+)_/ },
|
|
87
|
+
];
|
|
88
|
+
while (s.length) {
|
|
89
|
+
let best = null;
|
|
90
|
+
for (const r of rules) {
|
|
91
|
+
const m = r.re.exec(s);
|
|
92
|
+
if (m && (best === null || m.index < best.m.index)) best = { r, m };
|
|
93
|
+
}
|
|
94
|
+
if (!best) { out.push({ type: 'text', value: s }); break; }
|
|
95
|
+
if (best.m.index > 0) out.push({ type: 'text', value: s.slice(0, best.m.index) });
|
|
96
|
+
const m = best.m;
|
|
97
|
+
if (best.r.type === 'link') out.push({ type: 'link', value: m[1], href: m[2] });
|
|
98
|
+
else if (best.r.type === 'em') out.push({ type: 'em', value: m[1] || m[2] });
|
|
99
|
+
else out.push({ type: best.r.type, value: m[1] });
|
|
100
|
+
s = s.slice(m.index + m[0].length);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- DOM renderer (browser only) ----
|
|
106
|
+
export function renderMarkdown(md) {
|
|
107
|
+
const frag = document.createDocumentFragment();
|
|
108
|
+
for (const b of parseMarkdown(md)) {
|
|
109
|
+
if (b.type === 'h') frag.appendChild(inlineInto(document.createElement('h' + Math.min(b.level, 6)), b.text, 'sp-md-h'));
|
|
110
|
+
else if (b.type === 'p') frag.appendChild(inlineInto(document.createElement('p'), b.text, 'sp-md-p'));
|
|
111
|
+
else if (b.type === 'ul' || b.type === 'ol') frag.appendChild(listEl(b));
|
|
112
|
+
else if (b.type === 'code') frag.appendChild(codeBlock(b.text));
|
|
113
|
+
else if (b.type === 'table') frag.appendChild(tableEl(b));
|
|
114
|
+
else if (b.type === 'quote') frag.appendChild(inlineInto(document.createElement('blockquote'), b.text, 'sp-md-quote'));
|
|
115
|
+
else if (b.type === 'hr') { const hr = document.createElement('hr'); hr.className = 'sp-md-hr'; frag.appendChild(hr); }
|
|
116
|
+
}
|
|
117
|
+
return frag;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function inlineInto(el, text, cls) {
|
|
121
|
+
if (cls) el.className = cls;
|
|
122
|
+
for (const t of tokenizeInline(text)) {
|
|
123
|
+
if (t.type === 'text') el.appendChild(document.createTextNode(t.value));
|
|
124
|
+
else if (t.type === 'link') {
|
|
125
|
+
const a = document.createElement('a');
|
|
126
|
+
a.textContent = t.value;
|
|
127
|
+
a.href = SAFE_HREF.test(t.href) ? t.href : '#'; // reject javascript: etc.
|
|
128
|
+
a.target = '_blank'; a.rel = 'noopener noreferrer';
|
|
129
|
+
el.appendChild(a);
|
|
130
|
+
} else {
|
|
131
|
+
const tag = t.type === 'bold' ? 'strong' : t.type === 'em' ? 'em' : t.type === 'strike' ? 'del' : 'code';
|
|
132
|
+
const e = document.createElement(tag);
|
|
133
|
+
if (t.type === 'code') e.className = 'sp-md-code';
|
|
134
|
+
e.textContent = t.value;
|
|
135
|
+
el.appendChild(e);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return el;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function listEl(b) {
|
|
142
|
+
const l = document.createElement(b.type); l.className = 'sp-md-list';
|
|
143
|
+
b.items.forEach((it) => l.appendChild(inlineInto(document.createElement('li'), it)));
|
|
144
|
+
return l;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function codeBlock(text) {
|
|
148
|
+
const wrap = document.createElement('div'); wrap.className = 'sp-md-pre';
|
|
149
|
+
const copy = document.createElement('button'); copy.type = 'button'; copy.className = 'sp-md-copy'; copy.textContent = 'Copy';
|
|
150
|
+
copy.addEventListener('click', function () {
|
|
151
|
+
if (navigator.clipboard) navigator.clipboard.writeText(text).then(function () { copy.textContent = 'Copied'; setTimeout(function () { copy.textContent = 'Copy'; }, 1200); });
|
|
152
|
+
});
|
|
153
|
+
const pre = document.createElement('pre'); const code = document.createElement('code');
|
|
154
|
+
code.textContent = text; pre.appendChild(code);
|
|
155
|
+
wrap.appendChild(copy); wrap.appendChild(pre);
|
|
156
|
+
return wrap;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function tableEl(b) {
|
|
160
|
+
const t = document.createElement('table'); t.className = 'sp-md-table';
|
|
161
|
+
const thead = document.createElement('thead'); const htr = document.createElement('tr');
|
|
162
|
+
b.headers.forEach((c) => htr.appendChild(inlineInto(document.createElement('th'), c)));
|
|
163
|
+
thead.appendChild(htr); t.appendChild(thead);
|
|
164
|
+
const tb = document.createElement('tbody');
|
|
165
|
+
b.rows.forEach((row) => {
|
|
166
|
+
const tr = document.createElement('tr');
|
|
167
|
+
row.forEach((c) => tr.appendChild(inlineInto(document.createElement('td'), c)));
|
|
168
|
+
tb.appendChild(tr);
|
|
169
|
+
});
|
|
170
|
+
t.appendChild(tb);
|
|
171
|
+
return t;
|
|
172
|
+
}
|