jsmdcui 0.5.0 → 0.7.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.
@@ -0,0 +1,151 @@
1
+ function markdownHeadingDeclarations(markdown) {
2
+ const lines = String(markdown).split(/\r?\n/);
3
+ const declarations = [];
4
+ let fence = null;
5
+
6
+ for (let index = 0; index < lines.length; index++) {
7
+ const line = lines[index];
8
+ const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
9
+ if (fenceMatch) {
10
+ const marker = fenceMatch[1][0];
11
+ const length = fenceMatch[1].length;
12
+ if (!fence) fence = { marker, length };
13
+ else if (marker === fence.marker && length >= fence.length) fence = null;
14
+ continue;
15
+ }
16
+ if (fence) continue;
17
+
18
+ const atx = line.match(/^ {0,3}(#{1,6})(?:[ \t]+(.*?)\s*#*\s*|[ \t]*)$/);
19
+ if (atx) {
20
+ declarations.push({
21
+ line: index + 1,
22
+ source: line.trim(),
23
+ markdown: line,
24
+ });
25
+ continue;
26
+ }
27
+
28
+ if (index + 1 < lines.length && line.trim() && /^ {0,3}(?:=+|-+)\s*$/.test(lines[index + 1])) {
29
+ declarations.push({
30
+ line: index + 1,
31
+ source: `${line.trim()} / ${lines[index + 1].trim()}`,
32
+ markdown: `${line}\n${lines[index + 1]}`,
33
+ });
34
+ index++;
35
+ }
36
+ }
37
+
38
+ if (!declarations.length) return [];
39
+ return declarations.map((item) => {
40
+ // Render headings independently so Bun cannot hide a source-level
41
+ // collision by suffixing later duplicates as "-1", "-2", and so on.
42
+ const html = String(Bun.markdown.html(item.markdown, { headings: { ids: true } }));
43
+ const id = html.match(/<h[1-6]\b[^>]*\bid="([^"]*)"[^>]*>/i)?.[1] ?? "";
44
+ return {
45
+ id,
46
+ kind: "heading",
47
+ line: item.line,
48
+ source: item.source,
49
+ };
50
+ }).filter((item) => item.id);
51
+ }
52
+
53
+ function fencedBlockDeclarations(markdown) {
54
+ const lines = String(markdown).split(/\r?\n/);
55
+ const declarations = [];
56
+ let fence = null;
57
+
58
+ for (let index = 0; index < lines.length; index++) {
59
+ const line = lines[index];
60
+ if (!fence) {
61
+ const opening = line.match(/^ {0,3}(`{3,}|~{3,})\s*(\S+)?\s*$/);
62
+ if (!opening) continue;
63
+ fence = { marker: opening[1][0], length: opening[1].length };
64
+ const identity = String(opening[2] ?? "").match(/^([A-Za-z_][\w:-]*)(?:#([A-Za-z_][\w:-]*))?(?:\.[A-Za-z_][\w:-]*)*$/);
65
+ if (identity?.[2]) {
66
+ declarations.push({
67
+ id: identity[2],
68
+ kind: `${identity[1]} fenced block`,
69
+ line: index + 1,
70
+ source: line.trim(),
71
+ });
72
+ }
73
+ continue;
74
+ }
75
+
76
+ const closing = line.match(/^ {0,3}(`{3,}|~{3,})\s*$/);
77
+ if (closing && closing[1][0] === fence.marker && closing[1].length >= fence.length)
78
+ fence = null;
79
+ }
80
+
81
+ return declarations;
82
+ }
83
+
84
+ export function checkMarkdownIdCollisions(markdown) {
85
+ const declarations = [
86
+ ...markdownHeadingDeclarations(markdown),
87
+ ...fencedBlockDeclarations(markdown),
88
+ ].sort((a, b) => a.line - b.line);
89
+ const byId = new Map();
90
+ for (const declaration of declarations) {
91
+ if (!byId.has(declaration.id)) byId.set(declaration.id, []);
92
+ byId.get(declaration.id).push(declaration);
93
+ }
94
+ const collisions = [...byId.entries()]
95
+ .filter(([, items]) => items.length > 1)
96
+ .map(([id, items]) => ({ id, declarations: items }));
97
+ return { declarations, collisions };
98
+ }
99
+
100
+ function inlineCode(value) {
101
+ const text = String(value);
102
+ const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map((match) => match[0].length));
103
+ const delimiter = "`".repeat(longest + 1);
104
+ const padding = text.startsWith("`") || text.endsWith("`") ? " " : "";
105
+ return `${delimiter}${padding}${text}${padding}${delimiter}`;
106
+ }
107
+
108
+ export function formatMarkdownIdCheck(path, result) {
109
+ const headings = result.declarations.filter((item) => item.kind === "heading").length;
110
+ const fencedBlocks = result.declarations.length - headings;
111
+ const lines = [
112
+ "# Markdown UI ID Check",
113
+ "",
114
+ `- File: ${inlineCode(path)}`,
115
+ `- Selectable IDs: **${result.declarations.length}**`,
116
+ ` * Headings: **${headings}**`,
117
+ ` * Fenced blocks: **${fencedBlocks}**`,
118
+ ];
119
+
120
+ if (!result.collisions.length) {
121
+ lines.push("", "## No ID collisions found");
122
+ return lines.join("\n");
123
+ }
124
+
125
+ lines.push("", `## FAIL — Found ${result.collisions.length} colliding ID(s)`);
126
+ for (const collision of result.collisions) {
127
+ lines.push("", `### ID ${inlineCode(`#${collision.id}`)}`);
128
+ lines.push("", `- Declarations: **${collision.declarations.length}**`);
129
+ for (const declaration of collision.declarations) {
130
+ lines.push(` * Line **${declaration.line}**`);
131
+ lines.push(` - Type: ${declaration.kind}`);
132
+ lines.push(` - Source: ${inlineCode(declaration.source)}`);
133
+ }
134
+ }
135
+ lines.push("", "## Suggested fix", "", "- Rename the heading or fenced block so every selectable ID is unique.");
136
+ return lines.join("\n");
137
+ }
138
+
139
+ function statusBanner(label, color, fallbackColor) {
140
+ const foreground = Bun.color?.(color, "ansi-16m") || fallbackColor;
141
+ return `${foreground}\x1b[1m${label}\x1b[0m\n${foreground}\x1b[2m${"═".repeat(label.length)}\x1b[0m\n`;
142
+ }
143
+
144
+ export function formatMarkdownIdCheckAnsi(path, result) {
145
+ let output = String(Bun.markdown.ansi(formatMarkdownIdCheck(path, result)));
146
+ if (!output.endsWith("\n")) output += "\n";
147
+ output += "\n" + (result.collisions.length
148
+ ? statusBanner("FAILED", "#ff3030", "\x1b[31m")
149
+ : statusBanner("PASSED", "#00d75f", "\x1b[32m"));
150
+ return output;
151
+ }
@@ -0,0 +1,25 @@
1
+ import { appendFileSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ export const KITTY_DEBUG_LOG = resolve(import.meta.dir, "../..", "kitty-placement.log");
5
+ export const KITTY_DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(
6
+ String(process.env.JSMDCUI_KITTY_DEBUG ?? ""),
7
+ );
8
+
9
+ if (KITTY_DEBUG_ENABLED) {
10
+ try {
11
+ writeFileSync(KITTY_DEBUG_LOG, "");
12
+ } catch {}
13
+ }
14
+
15
+ export function logKittyPlacement(stage, details = {}) {
16
+ if (!KITTY_DEBUG_ENABLED) return;
17
+ try {
18
+ appendFileSync(KITTY_DEBUG_LOG, JSON.stringify({
19
+ time: new Date().toISOString(),
20
+ pid: process.pid,
21
+ stage,
22
+ ...details,
23
+ }) + "\n");
24
+ } catch {}
25
+ }
@@ -0,0 +1,190 @@
1
+ import { dirname, resolve } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { logKittyPlacement } from "./kitty-debug.mjs";
4
+ import { fetchHttpBytes as defaultFetchHttpBytes } from "../platform/commands.js";
5
+
6
+ const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
7
+
8
+ function imageSize(buffer) {
9
+ if (buffer.length >= 24 && buffer.subarray(0, 8).equals(PNG)) {
10
+ return { mime: "image/png", width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
11
+ }
12
+ const gif = buffer.toString("ascii", 0, 6);
13
+ if (buffer.length >= 10 && (gif === "GIF87a" || gif === "GIF89a")) {
14
+ return { mime: "image/gif", width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) };
15
+ }
16
+ if (buffer.length >= 26 && buffer.toString("ascii", 0, 2) === "BM") {
17
+ return { mime: "image/bmp", width: Math.abs(buffer.readInt32LE(18)), height: Math.abs(buffer.readInt32LE(22)) };
18
+ }
19
+ if (buffer.length >= 16 && buffer.toString("ascii", 0, 4) === "RIFF" && buffer.toString("ascii", 8, 12) === "WEBP") {
20
+ const kind = buffer.toString("ascii", 12, 16);
21
+ if (kind === "VP8 " && buffer.length >= 30) {
22
+ return {
23
+ mime: "image/webp",
24
+ width: buffer.readUInt16LE(26) & 0x3fff,
25
+ height: buffer.readUInt16LE(28) & 0x3fff,
26
+ };
27
+ }
28
+ if (kind === "VP8L" && buffer.length >= 25) {
29
+ const bits = buffer[21] | (buffer[22] << 8) | (buffer[23] << 16) | (buffer[24] << 24);
30
+ return {
31
+ mime: "image/webp",
32
+ width: (bits & 0x3fff) + 1,
33
+ height: ((bits >> 14) & 0x3fff) + 1,
34
+ };
35
+ }
36
+ if (kind === "VP8X" && buffer.length >= 30) {
37
+ return {
38
+ mime: "image/webp",
39
+ width: 1 + buffer.readUIntLE(24, 3),
40
+ height: 1 + buffer.readUIntLE(27, 3),
41
+ };
42
+ }
43
+ }
44
+ if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8) {
45
+ let offset = 2;
46
+ while (offset + 8 < buffer.length) {
47
+ if (buffer[offset] !== 0xff) { offset++; continue; }
48
+ const marker = buffer[offset + 1];
49
+ offset += 2;
50
+ if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) continue;
51
+ if (offset + 2 > buffer.length) break;
52
+ const length = buffer.readUInt16BE(offset);
53
+ if (length < 2 || offset + length > buffer.length) break;
54
+ const sof = (marker >= 0xc0 && marker <= 0xc3) ||
55
+ (marker >= 0xc5 && marker <= 0xc7) ||
56
+ (marker >= 0xc9 && marker <= 0xcb) ||
57
+ (marker >= 0xcd && marker <= 0xcf);
58
+ if (sof && length >= 7) {
59
+ return { mime: "image/jpeg", width: buffer.readUInt16BE(offset + 5), height: buffer.readUInt16BE(offset + 3) };
60
+ }
61
+ offset += length;
62
+ }
63
+ }
64
+ const svg = buffer.subarray(0, Math.min(buffer.length, 8192)).toString("utf8");
65
+ if (svg.includes("<svg")) {
66
+ const width = svg.match(/\bwidth=["']([0-9.]+)(?:px)?["']/i);
67
+ const height = svg.match(/\bheight=["']([0-9.]+)(?:px)?["']/i);
68
+ if (width && height) {
69
+ return { mime: "image/svg+xml", width: Math.round(Number(width[1])), height: Math.round(Number(height[1])) };
70
+ }
71
+ const viewBox = svg.match(/\bviewBox=["'][^"']*?([0-9.]+)[ ,]+([0-9.]+)\s*["']/i);
72
+ if (viewBox) {
73
+ return { mime: "image/svg+xml", width: Math.round(Number(viewBox[1])), height: Math.round(Number(viewBox[2])) };
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+
79
+ function isHttpUrl(value) {
80
+ return /^https?:\/\//i.test(String(value ?? ""));
81
+ }
82
+
83
+ function imageSource(href, markdownPath, allowUrl) {
84
+ try {
85
+ if (href.startsWith("file:")) return { kind: "local", value: fileURLToPath(href) };
86
+ if (process.platform === "win32" && /^[A-Za-z]:[\\/]/.test(href)) {
87
+ return { kind: "local", value: resolve(href) };
88
+ }
89
+ if (isHttpUrl(href)) {
90
+ return allowUrl ? { kind: "remote", value: new URL(href).href } : null;
91
+ }
92
+ if (href.startsWith("//")) {
93
+ if (!allowUrl) return null;
94
+ const protocol = isHttpUrl(markdownPath) ? new URL(markdownPath).protocol : "https:";
95
+ return { kind: "remote", value: new URL(`${protocol}${href}`).href };
96
+ }
97
+ if (/^[A-Za-z][A-Za-z\d+.-]*:/.test(href)) return null;
98
+ if (isHttpUrl(markdownPath)) {
99
+ return allowUrl ? { kind: "remote", value: new URL(href, markdownPath).href } : null;
100
+ }
101
+ const decoded = decodeURIComponent(href.replace(/[?#].*$/, ""));
102
+ return {
103
+ kind: "local",
104
+ value: resolve(markdownPath ? dirname(markdownPath) : process.cwd(), decoded),
105
+ };
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ function stableImageId(pathname, line, data) {
112
+ let hash = 2166136261;
113
+ for (const byte of Buffer.concat([Buffer.from(`${pathname}\0${line}\0`), data])) {
114
+ hash ^= byte;
115
+ hash = Math.imul(hash, 16777619);
116
+ }
117
+ return (hash >>> 0) % 2147483646 + 1;
118
+ }
119
+
120
+ export function fitKittyImageToWidth(image, maxCols) {
121
+ const originalCols = Math.max(1, Math.trunc(Number(image?.cols) || 1));
122
+ const originalRows = Math.max(1, Math.trunc(Number(image?.rows) || 1));
123
+ const availableCols = Math.max(1, Math.trunc(Number(maxCols) || 1));
124
+ const cols = Math.min(originalCols, availableCols);
125
+ const rows = Math.max(1, Math.round(originalRows * cols / originalCols));
126
+ return { cols, rows };
127
+ }
128
+
129
+ export async function prepareKittyImages(ansiText, markdownPath, terminalCols = 80, options = {}) {
130
+ const inputLines = String(ansiText).split("\n");
131
+ const outputLines = [];
132
+ const images = [];
133
+ const maxCols = Math.max(1, Math.trunc(Number(terminalCols) || 80));
134
+ const oscImage = /\x1b\]8;;([^\x1b]*)\x1b\\(?=[^\n]*📷)/;
135
+
136
+ for (let sourceLine = 0; sourceLine < inputLines.length; sourceLine++) {
137
+ const line = inputLines[sourceLine];
138
+ const match = line.match(oscImage);
139
+ const source = match ? imageSource(match[1], markdownPath, options.allowUrl === true) : null;
140
+ if (!source) {
141
+ outputLines.push(line);
142
+ continue;
143
+ }
144
+ try {
145
+ let data;
146
+ if (source.kind === "remote") {
147
+ const fetchBytes = options.fetchHttpBytes ?? defaultFetchHttpBytes;
148
+ data = Buffer.from(await fetchBytes(source.value));
149
+ } else {
150
+ const file = Bun.file(source.value);
151
+ if (!(await file.exists())) throw new Error("missing image");
152
+ data = Buffer.from(await file.arrayBuffer());
153
+ }
154
+ const size = imageSize(data);
155
+ if (!size?.width || !size?.height) throw new Error("unsupported image");
156
+ const estimatedCols = Math.max(1, Math.round(size.width / 8));
157
+ const cols = Math.min(maxCols, estimatedCols);
158
+ const rows = Math.max(1, Math.round((cols * size.height) / size.width / 2));
159
+ const lineIndex = outputLines.length;
160
+ outputLines.push(line, ...Array(Math.max(0, rows - 1)).fill(""));
161
+ images.push({
162
+ id: stableImageId(source.value, sourceLine, data),
163
+ line: lineIndex,
164
+ cols,
165
+ rows,
166
+ pixelWidth: size.width,
167
+ pixelHeight: size.height,
168
+ mime: size.mime,
169
+ data,
170
+ path: source.value,
171
+ });
172
+ logKittyPlacement("image-prepared", {
173
+ path: source.value,
174
+ sourceKind: source.kind,
175
+ sourceLine,
176
+ renderedLine: lineIndex,
177
+ imageId: images.at(-1).id,
178
+ intrinsicWidth: size.width,
179
+ intrinsicHeight: size.height,
180
+ cols,
181
+ rows,
182
+ terminalCols: maxCols + 1,
183
+ });
184
+ } catch {
185
+ // An unavailable or unsupported image remains the normal Markdown link.
186
+ outputLines.push(line);
187
+ }
188
+ }
189
+ return { rendered: outputLines.join("\n"), images };
190
+ }
package/src/cui/rpc.mjs CHANGED
@@ -66,6 +66,169 @@ function webDollarValue(element)
66
66
  return String(element?.textContent ?? "").replace(/\n$/, "");
67
67
  }
68
68
 
69
+ function isWebHeading(element)
70
+ {
71
+ return /^h[1-6]$/i.test(String(element?.tagName ?? ""));
72
+ }
73
+
74
+ function firstHeadingList(heading)
75
+ {
76
+ for (let sibling = heading?.nextElementSibling; sibling; sibling = sibling.nextElementSibling) {
77
+ if (isWebHeading(sibling) || String(sibling.tagName ?? "").toLowerCase() === "section")
78
+ break;
79
+ if (sibling.matches?.("ul, ol")) return sibling;
80
+ const list = sibling.querySelector?.("ul, ol");
81
+ if (list) return list;
82
+ }
83
+ return null;
84
+ }
85
+
86
+ function directTaskCheckbox(item)
87
+ {
88
+ for (const checkbox of item?.querySelectorAll?.('input[type="checkbox"]') ?? []) {
89
+ if (checkbox.closest?.("li.task-list-item") === item) return checkbox;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ function webTaskItemValue(item, checkbox)
95
+ {
96
+ const label = checkbox?.closest?.("label");
97
+ if (label && label.closest?.("li.task-list-item") === item)
98
+ return String(label.textContent ?? "").trim();
99
+
100
+ const copy = item.cloneNode?.(true);
101
+ for (const nested of copy?.querySelectorAll?.("ul, ol") ?? []) nested.remove?.();
102
+ for (const input of copy?.querySelectorAll?.('input[type="checkbox"]') ?? []) input.remove?.();
103
+ return String(copy?.textContent ?? "").trim();
104
+ }
105
+
106
+ function webHeadingValue(heading)
107
+ {
108
+ const single = String(heading?.id ?? "").startsWith("select");
109
+ const list = firstHeadingList(heading);
110
+ if (!list) return single ? null : [];
111
+
112
+ const selected = [];
113
+ for (const item of list.children ?? []) {
114
+ if (!item.matches?.("li.task-list-item")) continue;
115
+ const checkbox = directTaskCheckbox(item);
116
+ if (!checkbox?.checked) continue;
117
+ const value = webTaskItemValue(item, checkbox);
118
+ if (single) return value;
119
+ selected.push(value);
120
+ }
121
+ return single ? null : selected;
122
+ }
123
+
124
+ function directWebTaskItems(list)
125
+ {
126
+ const items = [];
127
+ for (const item of list?.children ?? []) {
128
+ if (!item.matches?.("li.task-list-item")) continue;
129
+ if (directTaskCheckbox(item)) items.push(item);
130
+ }
131
+ return items;
132
+ }
133
+
134
+ function webTaskItemSnapshot(item)
135
+ {
136
+ const checkbox = directTaskCheckbox(item);
137
+ return {
138
+ value: webTaskItemValue(item, checkbox),
139
+ checked: Boolean(checkbox?.checked),
140
+ };
141
+ }
142
+
143
+ function normalizedTaskItem(input)
144
+ {
145
+ if (input && typeof input === "object") {
146
+ return {
147
+ value: String(input.value ?? input.label ?? "").replace(/\r?\n/g, " "),
148
+ checked: Boolean(input.checked),
149
+ };
150
+ }
151
+ return {
152
+ value: String(input ?? "").replace(/\r?\n/g, " "),
153
+ checked: false,
154
+ };
155
+ }
156
+
157
+ function normalizedSpliceRange(length, argumentCount, start, deleteCount)
158
+ {
159
+ if (argumentCount === 0) return { start: 0, deleteCount: 0 };
160
+ let relativeStart = Number(start);
161
+ if (Number.isNaN(relativeStart)) relativeStart = 0;
162
+ relativeStart = Math.trunc(relativeStart);
163
+ const actualStart = relativeStart < 0
164
+ ? Math.max(length + relativeStart, 0)
165
+ : Math.min(relativeStart, length);
166
+ if (argumentCount === 1) return { start: actualStart, deleteCount: length - actualStart };
167
+ let requestedDelete = Number(deleteCount);
168
+ if (Number.isNaN(requestedDelete)) requestedDelete = 0;
169
+ requestedDelete = Math.max(0, Math.trunc(requestedDelete));
170
+ return {
171
+ start: actualStart,
172
+ deleteCount: Math.min(requestedDelete, length - actualStart),
173
+ };
174
+ }
175
+
176
+ function appendWebTaskItem(list, input, before = null)
177
+ {
178
+ const documentObject = list?.ownerDocument;
179
+ if (!documentObject?.createElement || !documentObject?.createTextNode) return false;
180
+
181
+ const itemValue = normalizedTaskItem(input);
182
+ const item = documentObject.createElement("li");
183
+ item.classList?.add("task-list-item");
184
+ const label = documentObject.createElement("label");
185
+ const checkbox = documentObject.createElement("input");
186
+ checkbox.type = "checkbox";
187
+ checkbox.classList?.add("task-list-item-checkbox");
188
+ checkbox.checked = itemValue.checked;
189
+ label.append(checkbox, documentObject.createTextNode(itemValue.value));
190
+ item.append(label);
191
+ list.insertBefore(item, before);
192
+ return true;
193
+ }
194
+
195
+ function mutateWebHeadingList(heading, method, args)
196
+ {
197
+ const list = firstHeadingList(heading);
198
+ if (!list) {
199
+ if (method === "push" || method === "unshift") return 0;
200
+ if (method === "splice") return [];
201
+ return undefined;
202
+ }
203
+
204
+ const items = directWebTaskItems(list);
205
+ if (method === "splice") {
206
+ const range = normalizedSpliceRange(items.length, args.length, args[0], args[1]);
207
+ const removed = items
208
+ .slice(range.start, range.start + range.deleteCount)
209
+ .map((item) => webTaskItemValue(item, directTaskCheckbox(item)));
210
+ const before = items[range.start] ?? null;
211
+ for (const input of args.slice(2)) appendWebTaskItem(list, input, before);
212
+ for (const item of items.slice(range.start, range.start + range.deleteCount)) item.remove?.();
213
+ return removed;
214
+ }
215
+ if (method === "pop" || method === "shift") {
216
+ const item = method === "pop" ? items.at(-1) : items[0];
217
+ if (!item) return undefined;
218
+ const value = webTaskItemValue(item, directTaskCheckbox(item));
219
+ item.remove?.();
220
+ return value;
221
+ }
222
+
223
+ if (method === "push") {
224
+ for (const input of args) appendWebTaskItem(list, input);
225
+ } else {
226
+ const before = items[0] ?? list.firstChild ?? null;
227
+ for (const input of args) appendWebTaskItem(list, input, before);
228
+ }
229
+ return directWebTaskItems(list).length;
230
+ }
231
+
69
232
  function resizeWebTextarea(element)
70
233
  {
71
234
  if (!element || String(element.tagName ?? "").toLowerCase() !== "textarea") return;
@@ -105,11 +268,23 @@ export function createWebDollar(documentObject = globalThis.document)
105
268
  return function $(selectorText) {
106
269
  const selector = parseDollarIdentity(selectorText, { selector: true });
107
270
  const selection = {
271
+ html() {
272
+ try {
273
+ const element = findWebDollarElement(documentObject, selectorText, selector);
274
+ return element ? String(element.innerHTML ?? "") : "";
275
+ } catch {
276
+ return "";
277
+ }
278
+ },
108
279
  val(...args) {
109
280
  try {
110
- if (!selector) return args.length > 0 ? selection : "";
111
281
  const element = findWebDollarElement(documentObject, selectorText, selector);
112
282
  if (!element) return args.length > 0 ? selection : "";
283
+ if (isWebHeading(element)) {
284
+ if (args.length > 0) return selection;
285
+ return webHeadingValue(element);
286
+ }
287
+ if (!selector) return args.length > 0 ? selection : "";
113
288
  if (args.length > 0) {
114
289
  const value = String(args[0] ?? "");
115
290
  if ("value" in element) {
@@ -124,6 +299,57 @@ export function createWebDollar(documentObject = globalThis.document)
124
299
  return args.length > 0 ? selection : "";
125
300
  }
126
301
  },
302
+ push(...items) {
303
+ try {
304
+ const element = findWebDollarElement(documentObject, selectorText, selector);
305
+ return isWebHeading(element) ? mutateWebHeadingList(element, "push", items) : 0;
306
+ } catch {
307
+ return 0;
308
+ }
309
+ },
310
+ pop() {
311
+ try {
312
+ const element = findWebDollarElement(documentObject, selectorText, selector);
313
+ return isWebHeading(element) ? mutateWebHeadingList(element, "pop", []) : undefined;
314
+ } catch {
315
+ return undefined;
316
+ }
317
+ },
318
+ shift() {
319
+ try {
320
+ const element = findWebDollarElement(documentObject, selectorText, selector);
321
+ return isWebHeading(element) ? mutateWebHeadingList(element, "shift", []) : undefined;
322
+ } catch {
323
+ return undefined;
324
+ }
325
+ },
326
+ unshift(...items) {
327
+ try {
328
+ const element = findWebDollarElement(documentObject, selectorText, selector);
329
+ return isWebHeading(element) ? mutateWebHeadingList(element, "unshift", items) : 0;
330
+ } catch {
331
+ return 0;
332
+ }
333
+ },
334
+ splice(...args) {
335
+ try {
336
+ const element = findWebDollarElement(documentObject, selectorText, selector);
337
+ return isWebHeading(element) ? mutateWebHeadingList(element, "splice", args) : [];
338
+ } catch {
339
+ return [];
340
+ }
341
+ },
342
+ slice(...args) {
343
+ try {
344
+ const element = findWebDollarElement(documentObject, selectorText, selector);
345
+ if (!isWebHeading(element)) return [];
346
+ const list = firstHeadingList(element);
347
+ if (!list) return [];
348
+ return directWebTaskItems(list).map(webTaskItemSnapshot).slice(...args);
349
+ } catch {
350
+ return [];
351
+ }
352
+ },
127
353
  };
128
354
  return selection;
129
355
  };
@@ -103,7 +103,7 @@ const server = Bun.serve({
103
103
  },
104
104
  });
105
105
 
106
- console.log(mda("- Bun RPC server listening on"));
106
+ console.error(mda("- Bun RPC server listening on"));
107
107
  console.log(`http://localhost:${server.port}${pathname}`);
108
108
 
109
109
 
@@ -0,0 +1,60 @@
1
+ export function toggleTaskCheckboxBeforeColumn(line, column) {
2
+ const text = String(line ?? "");
3
+ const end = Math.max(0, Math.min(text.length, Math.trunc(Number(column) || 0) + 1));
4
+ const prefix = text.slice(0, end);
5
+ const uncheckedAt = prefix.lastIndexOf("☐");
6
+ const checkedAt = prefix.lastIndexOf("☒");
7
+ const checkboxAt = Math.max(uncheckedAt, checkedAt);
8
+
9
+ if (checkboxAt < 0) return { line: text, toggled: false };
10
+
11
+ const replacement = text[checkboxAt] === "☐" ? "☒" : "☐";
12
+ return {
13
+ line: text.slice(0, checkboxAt) + replacement + text.slice(checkboxAt + 1),
14
+ toggled: true,
15
+ checkboxAt,
16
+ checked: replacement === "☒",
17
+ };
18
+ }
19
+
20
+ export function updateAnsiTaskCheckbox(ansiLine, checkboxAt, checked) {
21
+ const input = String(ansiLine ?? "");
22
+ const target = checked ? "☒" : "☐";
23
+ const sgr = `\x1b[${checked ? "32" : "2"}m`;
24
+ let plainIndex = 0;
25
+
26
+ for (let i = 0; i < input.length;) {
27
+ if (input[i] === "\x1b" && input[i + 1] === "[") {
28
+ let end = i + 2;
29
+ while (end < input.length) {
30
+ const code = input.charCodeAt(end);
31
+ if (code >= 0x40 && code <= 0x7e) { end++; break; }
32
+ end++;
33
+ }
34
+ i = end;
35
+ continue;
36
+ }
37
+ if (input[i] === "\x1b" && input[i + 1] === "]") {
38
+ const bel = input.indexOf("\x07", i + 2);
39
+ const st = input.indexOf("\x1b\\", i + 2);
40
+ if (bel < 0 && st < 0) break;
41
+ i = bel >= 0 && (st < 0 || bel < st) ? bel + 1 : st + 2;
42
+ continue;
43
+ }
44
+
45
+ const codePoint = input.codePointAt(i);
46
+ const charLength = codePoint > 0xffff ? 2 : 1;
47
+ if (plainIndex === checkboxAt && (input[i] === "☐" || input[i] === "☒")) {
48
+ const prefix = input.slice(0, i);
49
+ const immediateSgr = prefix.match(/\x1b\[[0-9;]*m$/);
50
+ const beforeStyle = immediateSgr
51
+ ? prefix.slice(0, prefix.length - immediateSgr[0].length) + sgr
52
+ : prefix;
53
+ return beforeStyle + target + input.slice(i + charLength);
54
+ }
55
+ plainIndex += charLength;
56
+ i += charLength;
57
+ }
58
+
59
+ return input;
60
+ }