dsh-plugin-onenote 0.0.0-stage → 0.2.1

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,274 @@
1
+ /**
2
+ * Microsoft Graph client for OneNote.
3
+ *
4
+ * Three rules keep this thin and predictable:
5
+ * - every call re-resolves the access token, so a refresh reaches the next call;
6
+ * - a 401 becomes one actionable sign-in message instead of a bare status;
7
+ * - pagination follows `@odata.nextLink` but stops at a cap, so one huge
8
+ * notebook cannot stall a turn.
9
+ */
10
+ import { OneNoteAuthError, requireAccessToken } from './auth.js';
11
+
12
+ const GRAPH_ORIGIN = 'https://graph.microsoft.com/v1.0';
13
+ const REQUEST_TIMEOUT_MS = 30_000;
14
+ const MAX_PAGES = 5;
15
+
16
+ /** A Graph failure carrying the status and Graph's own error code. */
17
+ export class GraphError extends Error {
18
+ constructor(message, { status, code } = {}) {
19
+ super(message);
20
+ this.name = 'GraphError';
21
+ this.status = status;
22
+ this.code = code;
23
+ }
24
+ }
25
+
26
+ function buildUrl(path, params) {
27
+ const url = new URL(`${GRAPH_ORIGIN}${path}`);
28
+ for (const [key, value] of Object.entries(params ?? {})) {
29
+ if (value === undefined || value === null || value === '') continue;
30
+ url.searchParams.set(key, String(value));
31
+ }
32
+ return url;
33
+ }
34
+
35
+ async function request(url, config, { accept = 'application/json', signal } = {}) {
36
+ const token = await requireAccessToken(config);
37
+ const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
38
+ const combined = signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
39
+
40
+ let response;
41
+ try {
42
+ response = await fetch(url, {
43
+ method: 'GET',
44
+ headers: { authorization: `Bearer ${token}`, accept },
45
+ signal: combined,
46
+ });
47
+ } catch (error) {
48
+ if (error instanceof OneNoteAuthError) throw error;
49
+ if (signal?.aborted === true) throw new GraphError('The OneNote request was cancelled.', { code: 'CANCELLED' });
50
+ const detail = error instanceof Error ? error.message : String(error);
51
+ throw new GraphError(`Could not reach Microsoft Graph (${detail}). Check the network and try again.`, { code: 'NETWORK' });
52
+ }
53
+
54
+ if (response.status === 401) {
55
+ throw new OneNoteAuthError(
56
+ 'Microsoft Graph rejected the OneNote token. Open Settings → Plugins → OneNote and sign in again.',
57
+ 'SIGN_IN_REQUIRED',
58
+ );
59
+ }
60
+
61
+ if (!response.ok) {
62
+ const body = await response.text().catch(() => '');
63
+ let code;
64
+ let detail = body;
65
+ try {
66
+ const parsed = JSON.parse(body);
67
+ code = parsed?.error?.code;
68
+ detail = parsed?.error?.message ?? body;
69
+ } catch {
70
+ /* a non-JSON error body is still worth showing, trimmed below */
71
+ }
72
+ // Graph's own message already ends with a period; the templates below add
73
+ // one, so trim it first. A doubled period ("expression..") read as a
74
+ // different kind of failure and hid the real cause from the user.
75
+ const reason = typeof detail === 'string'
76
+ ? detail.split('\n')[0].trim().replace(/\.+$/, '').slice(0, 400)
77
+ : '';
78
+ // A rejected OData expression is a defect in the query this plugin built,
79
+ // never a permission or network problem. Saying so stops the next reader
80
+ // from re-authenticating, which cannot help.
81
+ if (response.status === 400 && /Parsing OData Select and Expand/i.test(reason)) {
82
+ throw new GraphError(
83
+ `Microsoft Graph rejected the query this plugin generated: ${reason}. This is a defect in the plugin, not a permission or network problem: update the plugin, then restart DeepSeek Harness.`,
84
+ { status: 400, code: code ?? 'BAD_REQUEST' },
85
+ );
86
+ }
87
+ if (response.status === 403) {
88
+ throw new GraphError(
89
+ `Microsoft Graph denied access${reason.length > 0 ? `: ${reason}` : ''}. The signed-in account may lack permission for this notebook.`,
90
+ { status: 403, code: code ?? 'FORBIDDEN' },
91
+ );
92
+ }
93
+ if (response.status === 404) {
94
+ throw new GraphError('That OneNote item no longer exists. List the notebooks again to get current ids.', { status: 404, code: code ?? 'NOT_FOUND' });
95
+ }
96
+ if (response.status === 429) {
97
+ const retry = response.headers.get('retry-after');
98
+ throw new GraphError(
99
+ `Microsoft Graph rate-limited the request${retry === null ? '' : `; retry after ${retry}s`}. Wait and try again.`,
100
+ { status: 429, code: code ?? 'TOO_MANY_REQUESTS' },
101
+ );
102
+ }
103
+ throw new GraphError(
104
+ `Microsoft Graph request failed (${response.status})${reason.length > 0 ? `: ${reason}` : ''}.`,
105
+ { status: response.status, code: code ?? 'GRAPH_ERROR' },
106
+ );
107
+ }
108
+
109
+ if (accept === 'text/html') return response.text();
110
+ return response.json();
111
+ }
112
+
113
+ async function requestCollection(path, params, config, signal) {
114
+ const items = [];
115
+ let next = buildUrl(path, params);
116
+ for (let page = 0; page < MAX_PAGES; page += 1) {
117
+ const payload = await request(next, config, { signal });
118
+ if (Array.isArray(payload?.value)) items.push(...payload.value);
119
+ const nextLink = payload?.['@odata.nextLink'];
120
+ if (typeof nextLink !== 'string' || nextLink.length === 0) return items;
121
+ next = new URL(nextLink);
122
+ }
123
+ return items;
124
+ }
125
+
126
+ /**
127
+ * The expanded parent selectors shared by every page query.
128
+ *
129
+ * OneNote implements only a subset of OData: a nested `select` inside `$expand`
130
+ * is rejected with `Parsing OData Select and Expand failed`, which failed every
131
+ * page and section read. Expanding the navigation property bare is accepted and
132
+ * still returns the parent's `id` and `displayName`, which is all the consent
133
+ * check needs. Verified against Graph v1.0; do not reintroduce `(select=...)`.
134
+ */
135
+ const PAGE_EXPAND = 'parentSection,parentNotebook';
136
+ const PAGE_SELECT = 'id,title,createdDateTime,lastModifiedDateTime,links';
137
+
138
+ function toNotebook(raw) {
139
+ const notebook = {
140
+ id: typeof raw?.id === 'string' ? raw.id : '',
141
+ name: typeof raw?.displayName === 'string' && raw.displayName.length > 0 ? raw.displayName : String(raw?.id ?? ''),
142
+ isDefault: raw?.isDefault === true,
143
+ isShared: raw?.isShared === true,
144
+ };
145
+ const modified = raw?.lastModifiedDateTime ?? raw?.createdDateTime;
146
+ if (typeof modified === 'string') notebook.lastModified = modified;
147
+ return notebook;
148
+ }
149
+
150
+ function toSection(raw) {
151
+ const section = {
152
+ id: typeof raw?.id === 'string' ? raw.id : '',
153
+ name: typeof raw?.displayName === 'string' && raw.displayName.length > 0 ? raw.displayName : String(raw?.id ?? ''),
154
+ };
155
+ if (typeof raw?.parentNotebook?.id === 'string') section.notebookId = raw.parentNotebook.id;
156
+ return section;
157
+ }
158
+
159
+ function toPage(raw) {
160
+ const page = {
161
+ id: typeof raw?.id === 'string' ? raw.id : '',
162
+ title: typeof raw?.title === 'string' && raw.title.trim().length > 0 ? raw.title.trim() : '(untitled page)',
163
+ };
164
+ if (typeof raw?.lastModifiedDateTime === 'string') page.lastModified = raw.lastModifiedDateTime;
165
+ if (typeof raw?.parentSection?.displayName === 'string' && raw.parentSection.displayName.length > 0) {
166
+ page.sectionName = raw.parentSection.displayName;
167
+ }
168
+ if (typeof raw?.parentNotebook?.displayName === 'string' && raw.parentNotebook.displayName.length > 0) {
169
+ page.notebookName = raw.parentNotebook.displayName;
170
+ }
171
+ if (typeof raw?.parentNotebook?.id === 'string' && raw.parentNotebook.id.length > 0) {
172
+ page.notebookId = raw.parentNotebook.id;
173
+ }
174
+ const webUrl = raw?.links?.oneNoteWebUrl?.href;
175
+ if (typeof webUrl === 'string' && webUrl.length > 0) page.webUrl = webUrl;
176
+ return page;
177
+ }
178
+
179
+ /** The signed-in user, used to name the account in sign-in reports. */
180
+ export async function getMe(config, signal) {
181
+ const raw = await request(buildUrl('/me', { $select: 'id,displayName,userPrincipalName,mail' }), config, { signal });
182
+ const me = {};
183
+ for (const key of ['id', 'displayName', 'userPrincipalName', 'mail']) {
184
+ if (typeof raw?.[key] === 'string' && raw[key].length > 0) me[key] = raw[key];
185
+ }
186
+ return me;
187
+ }
188
+
189
+ /** Every notebook the account can see, including shared ones. */
190
+ export async function listNotebooks(config, signal) {
191
+ const raw = await requestCollection(
192
+ '/me/onenote/notebooks',
193
+ { $select: 'id,displayName,isDefault,isShared,lastModifiedDateTime,createdDateTime', $top: 100 },
194
+ config,
195
+ signal,
196
+ );
197
+ return raw.map(toNotebook);
198
+ }
199
+
200
+ /** Sections directly inside one notebook. */
201
+ export async function listSections(config, notebookId, signal) {
202
+ const raw = await requestCollection(
203
+ `/me/onenote/notebooks/${encodeURIComponent(notebookId)}/sections`,
204
+ { $select: 'id,displayName,lastModifiedDateTime', $top: 100 },
205
+ config,
206
+ signal,
207
+ );
208
+ return raw.map(toSection);
209
+ }
210
+
211
+ /**
212
+ * Pages inside one notebook.
213
+ *
214
+ * `$filter=parentNotebook/id eq …` is the only server-side notebook filter
215
+ * OneNote offers, and it needs the expanded parent in the same request, so the
216
+ * page projection is pinned rather than left to the service default.
217
+ */
218
+ export async function listPagesInNotebook(config, notebookId, signal, limit = 200) {
219
+ const raw = await requestCollection(
220
+ '/me/onenote/pages',
221
+ {
222
+ $filter: `parentNotebook/id eq '${notebookId}'`,
223
+ $select: PAGE_SELECT,
224
+ $expand: PAGE_EXPAND,
225
+ $top: Math.min(100, limit),
226
+ },
227
+ config,
228
+ signal,
229
+ );
230
+ return raw.slice(0, limit).map(toPage);
231
+ }
232
+
233
+ /** Pages inside one section. */
234
+ export async function listPagesInSection(config, sectionId, signal, limit = 200) {
235
+ const raw = await requestCollection(
236
+ `/me/onenote/sections/${encodeURIComponent(sectionId)}/pages`,
237
+ { $select: PAGE_SELECT, $expand: PAGE_EXPAND, $top: Math.min(100, limit) },
238
+ config,
239
+ signal,
240
+ );
241
+ return raw.slice(0, limit).map(toPage);
242
+ }
243
+
244
+ /** One section's metadata, including the notebook that owns it. */
245
+ export async function getSectionMeta(config, sectionId, signal) {
246
+ const raw = await request(
247
+ buildUrl(`/me/onenote/sections/${encodeURIComponent(sectionId)}`, {
248
+ $select: 'id,displayName',
249
+ $expand: 'parentNotebook',
250
+ }),
251
+ config,
252
+ { signal },
253
+ );
254
+ return toSection(raw);
255
+ }
256
+
257
+ /** One page's metadata, including the notebook that owns it. */
258
+ export async function getPageMeta(config, pageId, signal) {
259
+ const raw = await request(
260
+ buildUrl(`/me/onenote/pages/${encodeURIComponent(pageId)}`, { $select: PAGE_SELECT, $expand: PAGE_EXPAND }),
261
+ config,
262
+ { signal },
263
+ );
264
+ return toPage(raw);
265
+ }
266
+
267
+ /** One page's HTML content. */
268
+ export async function getPageContent(config, pageId, signal) {
269
+ const html = await request(buildUrl(`/me/onenote/pages/${encodeURIComponent(pageId)}/content`), config, {
270
+ accept: 'text/html',
271
+ signal,
272
+ });
273
+ return typeof html === 'string' ? html : '';
274
+ }
package/src/parser.js ADDED
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Convert the HTML Microsoft Graph returns for a OneNote page into compact Markdown.
3
+ *
4
+ * OneNote emits deeply nested `div`/`span` soup with `data-*` attributes. This
5
+ * module is a small, dependency-free tokenizer plus a block writer: it keeps the
6
+ * document structure a reader cares about (headings, paragraphs, lists, tables,
7
+ * emphasis, links, code) and drops everything else. Media is never fetched, so
8
+ * an image contributes only its alt text.
9
+ */
10
+
11
+ const BLOCK_TAGS = new Set(['p', 'div', 'section', 'article', 'header', 'footer', 'main', 'body', 'html', 'li', 'tr', 'table', 'ul', 'ol', 'pre', 'blockquote']);
12
+ const SKIP_TAGS = new Set(['script', 'style', 'head', 'title', 'meta', 'link', 'noscript']);
13
+ /**
14
+ * HTML void elements: they never emit a closing tag, so opening one must never
15
+ * raise the skip depth. OneNote wraps every page in a `<head>` containing
16
+ * `<meta … />`, and treating that as an unclosed skip scope discarded the whole
17
+ * page body — every page rendered as empty text.
18
+ */
19
+ const VOID_TAGS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
20
+ const INLINE_MARKS = {
21
+ strong: ['**', '**'],
22
+ b: ['**', '**'],
23
+ em: ['_', '_'],
24
+ i: ['_', '_'],
25
+ del: ['~~', '~~'],
26
+ s: ['~~', '~~'],
27
+ strike: ['~~', '~~'],
28
+ code: ['`', '`'],
29
+ u: ['', ''],
30
+ sub: ['', ''],
31
+ sup: ['', ''],
32
+ span: ['', ''],
33
+ font: ['', ''],
34
+ small: ['', ''],
35
+ mark: ['', ''],
36
+ };
37
+
38
+ const NAMED_ENTITIES = {
39
+ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ',
40
+ ndash: '–', mdash: '—', hellip: '…', copy: '©', reg: '®', trade: '™',
41
+ lsquo: '‘', rsquo: '’', ldquo: '“', rdquo: '”', middot: '·', bull: '•',
42
+ times: '×', divide: '÷', deg: '°', plusmn: '±', frac12: '½',
43
+ };
44
+
45
+ /** Decode the character references that actually appear in OneNote content. */
46
+ export function decodeEntities(text) {
47
+ if (!text.includes('&')) return text;
48
+ return text.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z][a-zA-Z0-9]*);/g, (match, body) => {
49
+ if (body.startsWith('#')) {
50
+ const hex = body[1] === 'x' || body[1] === 'X';
51
+ const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
52
+ if (Number.isFinite(code) && code > 0 && code <= 0x10ffff) return String.fromCodePoint(code);
53
+ return '';
54
+ }
55
+ const named = NAMED_ENTITIES[body.toLowerCase()];
56
+ return named === undefined ? match : named;
57
+ });
58
+ }
59
+
60
+ const TAG_PATTERN = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<![^>]*>|<\s*(\/?)\s*([a-zA-Z][a-zA-Z0-9:-]*)((?:"[^"]*"|'[^']*'|[^'">])*)>/g;
61
+ const ATTR_PATTERN = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
62
+
63
+ function parseAttributes(raw) {
64
+ const attrs = {};
65
+ if (!raw) return attrs;
66
+ ATTR_PATTERN.lastIndex = 0;
67
+ let match;
68
+ while ((match = ATTR_PATTERN.exec(raw)) !== null) {
69
+ const value = match[2] ?? match[3] ?? match[4] ?? '';
70
+ attrs[match[1].toLowerCase()] = decodeEntities(value);
71
+ }
72
+ return attrs;
73
+ }
74
+
75
+ /** Collapse runs of whitespace the way a rendered paragraph would. */
76
+ function collapse(text) {
77
+ return text.replace(/[\t\r\n ]+/g, ' ').trim();
78
+ }
79
+
80
+ /**
81
+ * Render one page's HTML as Markdown.
82
+ * @param {string} html - the page content from Graph.
83
+ * @param {number} maxChars - a hard ceiling on the produced text.
84
+ * @returns {string} Markdown text, ending with a truncation note when clipped.
85
+ */
86
+ export function parseOneNoteHtml(html, maxChars = 60000) {
87
+ return renderPage(html, maxChars).content;
88
+ }
89
+
90
+ /**
91
+ * Render one page and report whether the ceiling clipped it.
92
+ * @param {string} html - the page content from Graph.
93
+ * @param {number} maxChars - a hard ceiling on the produced text.
94
+ * @returns {{ content: string, truncated: boolean }} Markdown plus the clip flag.
95
+ */
96
+ export function renderPage(html, maxChars = 60000) {
97
+ if (typeof html !== 'string' || html.length === 0) return { content: '', truncated: false };
98
+
99
+ const lines = [];
100
+ let used = 0;
101
+ let truncated = false;
102
+ let buffer = '';
103
+ /** The list marker for the item being collected, kept out of the collapsed text. */
104
+ let pendingPrefix = '';
105
+ let headingLevel = 0;
106
+ let preDepth = 0;
107
+ let preBuffer = '';
108
+ let skipDepth = 0;
109
+ let table = null;
110
+ const frames = [];
111
+ const lists = [];
112
+
113
+ const emit = (line) => {
114
+ if (truncated) return;
115
+ const text = line.replace(/[ \t]+$/u, '');
116
+ if (text.length === 0) {
117
+ if (lines.length === 0 || lines[lines.length - 1] === '') return;
118
+ lines.push('');
119
+ used += 1;
120
+ return;
121
+ }
122
+ if (used + text.length > maxChars) {
123
+ const room = maxChars - used;
124
+ if (room > 80) lines.push(text.slice(0, room));
125
+ truncated = true;
126
+ return;
127
+ }
128
+ lines.push(text);
129
+ used += text.length + 1;
130
+ };
131
+
132
+ const flush = () => {
133
+ const text = collapse(buffer);
134
+ buffer = '';
135
+ if (text.length === 0) return;
136
+ // The list marker is kept out of `buffer` so collapsing text cannot eat its indent.
137
+ const prefix = pendingPrefix;
138
+ pendingPrefix = '';
139
+ if (headingLevel > 0) emit(`${'#'.repeat(headingLevel)} ${text}`);
140
+ else emit(prefix + text);
141
+ };
142
+
143
+ const listPrefix = () => {
144
+ const depth = Math.max(0, lists.length - 1);
145
+ const indent = ' '.repeat(depth);
146
+ const current = lists[lists.length - 1];
147
+ if (current === undefined) return '';
148
+ if (current.ordered) {
149
+ current.index += 1;
150
+ return `${indent}${current.index}. `;
151
+ }
152
+ return `${indent}- `;
153
+ };
154
+
155
+ const renderTable = () => {
156
+ if (table === null || table.rows.length === 0) {
157
+ table = null;
158
+ return;
159
+ }
160
+ const rows = table.rows;
161
+ const width = Math.max(...rows.map((row) => row.length));
162
+ const padded = rows.map((row) => [...row, ...Array(width - row.length).fill('')]);
163
+ const header = padded[0];
164
+ const body = padded.slice(1);
165
+ emit(`| ${header.join(' | ')} |`);
166
+ emit(`| ${header.map(() => '---').join(' | ')} |`);
167
+ for (const row of body) emit(`| ${row.join(' | ')} |`);
168
+ emit('');
169
+ table = null;
170
+ };
171
+
172
+ const closeFrame = (name) => {
173
+ for (let index = frames.length - 1; index >= 0; index -= 1) {
174
+ if (frames[index].name !== name) continue;
175
+ const frame = frames.splice(index, 1)[0];
176
+ const inner = buffer.slice(frame.start);
177
+ buffer = buffer.slice(0, frame.start) + frame.wrap(inner);
178
+ return;
179
+ }
180
+ };
181
+
182
+ const openInline = (name, attrs) => {
183
+ if (name === 'a') {
184
+ const href = attrs.href ?? '';
185
+ const start = buffer.length;
186
+ frames.push({
187
+ name,
188
+ start,
189
+ wrap: (inner) => {
190
+ const text = collapse(inner);
191
+ if (text.length === 0) return '';
192
+ if (href.length === 0 || href.startsWith('javascript:')) return text;
193
+ return `[${text}](${href})`;
194
+ },
195
+ });
196
+ return;
197
+ }
198
+ const mark = INLINE_MARKS[name];
199
+ if (mark === undefined) return;
200
+ if (mark[0].length === 0) {
201
+ frames.push({ name, start: buffer.length, wrap: (inner) => inner });
202
+ return;
203
+ }
204
+ const start = buffer.length;
205
+ frames.push({ name, start, wrap: (inner) => (collapse(inner).length === 0 ? inner : `${mark[0]}${inner}${mark[1]}`) });
206
+ };
207
+
208
+ const handleOpen = (name, attrs) => {
209
+ if (SKIP_TAGS.has(name)) {
210
+ // A void element inside a skipped scope (OneNote's `<meta>` inside
211
+ // `<head>`) has no closing tag, so it must not increment the depth.
212
+ if (!VOID_TAGS.has(name)) skipDepth += 1;
213
+ return;
214
+ }
215
+ if (skipDepth > 0) return;
216
+
217
+ if (name === 'pre') {
218
+ flush();
219
+ preDepth += 1;
220
+ preBuffer = '';
221
+ return;
222
+ }
223
+ if (preDepth > 0) return;
224
+
225
+ if (table !== null) {
226
+ if (name === 'tr') table.row = [];
227
+ else if (name === 'td' || name === 'th') table.cell = '';
228
+ else if (name === 'br' && table.cell !== null) table.cell += ' ';
229
+ return;
230
+ }
231
+
232
+ if (name === 'table') {
233
+ flush();
234
+ table = { rows: [], row: null, cell: null };
235
+ return;
236
+ }
237
+
238
+ if (name === 'br') {
239
+ flush();
240
+ return;
241
+ }
242
+ if (name === 'hr') {
243
+ flush();
244
+ emit('---');
245
+ emit('');
246
+ return;
247
+ }
248
+ if (name === 'img') {
249
+ const alt = collapse(attrs.alt ?? attrs.title ?? '');
250
+ if (alt.length > 0) buffer += `🖼️ ${alt} `;
251
+ return;
252
+ }
253
+ if (/^h[1-6]$/u.test(name)) {
254
+ flush();
255
+ headingLevel = Number(name.slice(1));
256
+ return;
257
+ }
258
+ if (name === 'ul' || name === 'ol') {
259
+ flush();
260
+ lists.push({ ordered: name === 'ol', index: 0 });
261
+ return;
262
+ }
263
+ if (name === 'li') {
264
+ flush();
265
+ pendingPrefix = listPrefix();
266
+ return;
267
+ }
268
+ if (BLOCK_TAGS.has(name)) {
269
+ flush();
270
+ return;
271
+ }
272
+ openInline(name, attrs);
273
+ };
274
+
275
+ const handleClose = (name) => {
276
+ if (SKIP_TAGS.has(name)) {
277
+ if (skipDepth > 0) skipDepth -= 1;
278
+ return;
279
+ }
280
+ if (skipDepth > 0) return;
281
+
282
+ if (name === 'pre') {
283
+ if (preDepth > 0) preDepth -= 1;
284
+ if (preDepth === 0) {
285
+ const body = preBuffer.replace(/\r\n?/gu, '\n').replace(/^\n+|\n+$/gu, '');
286
+ preBuffer = '';
287
+ if (body.length > 0) {
288
+ emit('```');
289
+ for (const line of body.split('\n').slice(0, 60)) emit(line);
290
+ emit('```');
291
+ emit('');
292
+ }
293
+ }
294
+ return;
295
+ }
296
+ if (preDepth > 0) return;
297
+
298
+ if (table !== null) {
299
+ if (name === 'td' || name === 'th') {
300
+ if (table.row !== null) table.row.push(collapse(table.cell ?? ''));
301
+ table.cell = null;
302
+ } else if (name === 'tr') {
303
+ if (table.row !== null && table.row.length > 0) table.rows.push(table.row);
304
+ table.row = null;
305
+ } else if (name === 'table') {
306
+ renderTable();
307
+ }
308
+ return;
309
+ }
310
+
311
+ if (name === 'ul' || name === 'ol') {
312
+ flush();
313
+ lists.pop();
314
+ emit('');
315
+ return;
316
+ }
317
+ if (name === 'li') {
318
+ flush();
319
+ return;
320
+ }
321
+ if (/^h[1-6]$/u.test(name)) {
322
+ flush();
323
+ headingLevel = 0;
324
+ emit('');
325
+ return;
326
+ }
327
+ if (name === 'p' || name === 'div' || name === 'blockquote' || BLOCK_TAGS.has(name)) {
328
+ flush();
329
+ return;
330
+ }
331
+ closeFrame(name);
332
+ };
333
+
334
+ TAG_PATTERN.lastIndex = 0;
335
+ let cursor = 0;
336
+ let match;
337
+ while ((match = TAG_PATTERN.exec(html)) !== null) {
338
+ const text = html.slice(cursor, match.index);
339
+ cursor = match.index + match[0].length;
340
+ if (text.length > 0) {
341
+ const decoded = decodeEntities(text);
342
+ if (preDepth > 0) preBuffer += decoded;
343
+ else if (skipDepth === 0) {
344
+ if (table !== null && table.cell !== null) table.cell += decoded;
345
+ else buffer += decoded;
346
+ }
347
+ }
348
+ if (match[2] === undefined) continue; // comment, doctype, or CDATA
349
+ const name = match[2].toLowerCase();
350
+ if (match[1] === '/') handleClose(name);
351
+ else handleOpen(name, parseAttributes(match[3]));
352
+ }
353
+ if (cursor < html.length) buffer += decodeEntities(html.slice(cursor));
354
+
355
+ flush();
356
+ if (table !== null) renderTable();
357
+
358
+ const content = lines
359
+ .join('\n')
360
+ .replace(/\n{3,}/gu, '\n\n')
361
+ .trim();
362
+ return { content, truncated };
363
+ }
364
+
365
+ /**
366
+ * Reduce HTML to one whitespace-collapsed string, for keyword scanning.
367
+ * @param {string} html - page HTML.
368
+ * @param {number} maxChars - ceiling on the returned text.
369
+ * @returns {string} plain text.
370
+ */
371
+ export function htmlToPlainText(html, maxChars = 20000) {
372
+ if (typeof html !== 'string' || html.length === 0) return '';
373
+ let skipDepth = 0;
374
+ let out = '';
375
+ TAG_PATTERN.lastIndex = 0;
376
+ let cursor = 0;
377
+ let match;
378
+ while ((match = TAG_PATTERN.exec(html)) !== null && out.length < maxChars) {
379
+ const text = html.slice(cursor, match.index);
380
+ cursor = match.index + match[0].length;
381
+ if (text.length > 0 && skipDepth === 0) out += decodeEntities(text);
382
+ if (match[2] === undefined) continue;
383
+ const name = match[2].toLowerCase();
384
+ if (SKIP_TAGS.has(name)) {
385
+ if (match[1] === '/') skipDepth = Math.max(0, skipDepth - 1);
386
+ else if (!VOID_TAGS.has(name)) skipDepth += 1;
387
+ } else if (skipDepth === 0) {
388
+ out += ' ';
389
+ }
390
+ }
391
+ if (cursor < html.length && skipDepth === 0) out += decodeEntities(html.slice(cursor));
392
+ return collapse(out).slice(0, maxChars);
393
+ }