Files
conorbronsdon__avoid-ai-wri…/scripts/markdown-prose.js

284 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Shared Markdown protection for check-style.js and normalize-quotes.js. */
'use strict';
const TITLE = '"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\'|\\((?:\\\\.|[^)\\\\])*\\)';
const REF_DEF = new RegExp(`^ {0,3}\\[((?:\\\\.|[^\\]\\n])+)\\]:[ \\t]*(?:\\r?\\n[ \\t]*)?(?:<[^>\\n]*>|\\S+)(?:[ \\t]*(?:\\r?\\n[ \\t]*)?(?:${TITLE}))?[ \\t]*\\r?$`, 'gm');
const TAG = /<\/?[a-zA-Z][a-zA-Z0-9-]*(?:\s+[a-zA-Z_:][\w:.-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*\s*\/?>/g;
const labelKey = (s) => s.replace(/\\([!"#$%&'()*+,\-./:;<=>?@[\]^_`{|}~\\])/g, '$1').trim().replace(/\s+/g, ' ').toLowerCase();
// Precompute suffix boundaries so even failed, nested candidates take O(1) each.
// A bare destination has balanced parentheses and no whitespace. Whitespace may
// separate it from a title, but cannot turn arbitrary following prose into a title.
function inlineLinkEnds(s) {
const n = s.length;
const escaped = new Uint8Array(n);
for (let i = 0; i < n; i += 1) {
if (s[i] === '\\' && /[!"#$%&'()*+,\-./:;<=>?@[\]^_`{|}~\\]/.test(s[i + 1] || '')) {
escaped[++i] = 1;
}
}
const bare = new Int32Array(n + 1), space = new Int32Array(n + 1);
const lines = new Int32Array(n + 1);
const delimiters = ['"', "'", ')', '>'];
const closing = Object.fromEntries(delimiters.map((d) => [d, new Int32Array(n + 1).fill(-1)]));
bare[n] = space[n] = n;
for (let i = n - 1; i >= 0; i -= 1) {
const ch = s[i];
const ws = /[ \t\r\n]/.test(ch);
space[i] = ws ? space[i + 1] : i;
lines[i] = ws ? lines[i + 1] + (ch === '\n' ? 1 : 0) : 0;
for (const d of delimiters) {
const blocked = ch === '\0' || (ch === '\n' && lines[i] > 1)
|| (d === '>' && /[<>\r\n]/.test(ch) && ch !== '>')
|| (d === ')' && ch === '(' && !escaped[i]);
closing[d][i] = blocked ? -1 : ch === d && !escaped[i] ? i : closing[d][i + 1];
}
if (ch === '\\' && escaped[i + 1]) bare[i] = bare[i + 2];
else if (ch === '(' && !escaped[i]) {
const end = bare[i + 1];
bare[i] = s[end] === ')' && !escaped[end] ? bare[end + 1] : i;
} else bare[i] = /[\s\x00-\x1f<>]/.test(ch) || (ch === ')' && !escaped[i]) ? i : bare[i + 1];
}
const skipSpace = (i) => lines[i] > 1 ? -1 : space[i];
return (start) => {
let k = skipSpace(start);
if (k < 0) return -1;
if (s[k] === '<') {
const end = closing['>'][k + 1];
if (end < 0) return -1;
k = end + 1;
} else k = bare[k];
if (s[k] === ')') return k;
const end = skipSpace(k);
if (end < 0 || end === k) return -1;
if (s[end] === ')') return end;
const delimiter = s[end] === '(' ? ')' : s[end];
if (delimiter !== '"' && delimiter !== "'" && s[end] !== '(') return -1;
const titleEnd = closing[delimiter][end + 1];
if (titleEnd < 0) return -1;
k = skipSpace(titleEnd + 1);
return k >= 0 && s[k] === ')' ? k : -1;
};
}
/**
* Return an offset-stable mask and the checker's prose lines / paragraph breaks.
* A protected character becomes NUL; newlines remain in place. Consumers can remove
* masked characters for checks or use word-shaped filler for quote adjacency, then
* restore from the source. No Markdown is serialized or whitespace reconstructed.
*/
function markdownProse(text) {
const chars = text.split('');
const transparent = new Set();
const protect = (a, b, tag = false) => {
for (let i = a; i < b; i += 1) {
if (text[i] === '\n' || text[i] === '\r') continue;
chars[i] = '\0';
if (tag) transparent.add(i);
}
};
const lines = text.split('\n');
const bare = (s) => s.replace(/\r$/, '');
let fmEnd = -1;
// A leading thematic break followed by a blank is not frontmatter.
if (/^---[ \t]*$/.test(bare(lines[0]).replace(/^\uFEFF/, '')) && lines.length > 1 && lines[1].trim()) {
for (let k = 1; k < lines.length; k += 1) {
if (/^(?:---|\.\.\.)[ \t]*$/.test(bare(lines[k]))) { fmEnd = k; break; }
}
}
let fence = null, inIndent = false, prevBlank = true, offset = 0, quoteDepth = 0;
const listIndents = [];
const paraBreak = [];
const blockStarts = new Set();
let rawHtml = null;
lines.forEach((line, i) => {
const start = offset;
offset += line.length + 1;
if (i <= fmEnd) { protect(start, offset - 1); paraBreak.push(false); return; }
let b = bare(line).replace(/^\uFEFF/, '');
// Strip container prefixes for block recognition, retaining source offsets.
const quote = fence
? (quoteDepth ? b.match(new RegExp(`^(?: {0,3}>[ \\t]?){${quoteDepth}}`)) : null)
: b.match(/^(?: {0,3}>[ \t]?)+/);
const depth = quote ? (quote[0].match(/>/g) || []).length : 0;
if (depth !== quoteDepth) {
fence = null; rawHtml = null; inIndent = false; prevBlank = true; listIndents.length = 0;
blockStarts.add(start);
quoteDepth = depth;
}
if (quote) b = b.slice(quote[0].length);
const blank = !b.trim();
const indent = (b.match(/^[ \t]*/) || [''])[0].replace(/\t/g, ' ').length;
// A fenced block belongs to its list item; dedenting leaves that container.
if (fence && fence.listIndent && !blank && indent < fence.listIndent) {
fence = null; prevBlank = true; blockStarts.add(start);
}
if (rawHtml && rawHtml.listIndent && !blank && indent < rawHtml.listIndent) rawHtml = null;
if (!blank && !fence) {
while (listIndents.length && indent < listIndents[listIndents.length - 1]) listIndents.pop();
}
const base = listIndents[listIndents.length - 1] || 0;
const marker = !fence && b.match(/^( *)(?:[-*+]|\d{1,9}[.)])([ \t]+|$)/);
if (marker && indent - base < 4) {
// More than four spaces after a marker starts code at the item's content indent.
const padding = marker[2].length > 4 ? 1 : Math.max(1, marker[2].length);
const contentIndent = marker[0].length - marker[2].length + padding;
listIndents.push(contentIndent);
blockStarts.add(start);
b = b.slice(Math.min(contentIndent, b.length));
prevBlank = true; inIndent = false;
} else if (base) {
let consumed = 0, columns = 0;
while (consumed < b.length && /[ \t]/.test(b[consumed]) && columns < base) {
columns += b[consumed] === '\t' ? 4 - columns % 4 : 1;
consumed += 1;
}
b = b.slice(consumed);
}
const fm = b.match(/^( {0,3})(`{3,}|~{3,})(.*)$/);
if (fence) {
if (fm && fm[2][0] === fence.char && fm[2].length >= fence.length && /^\s*$/.test(fm[3])) fence = null;
protect(start, offset - 1); paraBreak.push(false); prevBlank = false; return;
}
if (fm && !(fm[2][0] === '`' && fm[3].includes('`'))) {
fence = { char: fm[2][0], length: fm[2].length, listIndent: listIndents[listIndents.length - 1] || 0 };
protect(start, offset - 1); paraBreak.push(false); inIndent = false; prevBlank = false; return;
}
const rawStart = b.match(/^ {0,3}<(script|style|pre|textarea)(?:[ \t>]|$)/i);
if (!rawHtml && rawStart) rawHtml = { name: rawStart[1], listIndent: listIndents[listIndents.length - 1] || 0 };
if (rawHtml) {
if (new RegExp('</' + rawHtml.name + '[ \t]*>', 'i').test(b)) rawHtml = null;
protect(start, offset - 1); paraBreak.push(false); prevBlank = false; return;
}
// Inline spans cannot consume headings, thematic breaks, or a following block.
if (/^ {0,3}#{1,6}(?:[ \t]|$)/.test(b) || /^ {0,3}(?:=+|-+)[ \t]*$/.test(b)
|| /^ {0,3}(?:(?:\*[ \t]*){3,}|(?:_[ \t]*){3,}|(?:-[ \t]*){3,})$/.test(b)) {
blockStarts.add(start); blockStarts.add(offset);
}
if (blank) { blockStarts.add(start); blockStarts.add(offset); }
const ind4 = /^(?: {4}| *\t)/.test(b);
if (!blank) {
if (ind4 && (prevBlank || inIndent)) inIndent = true;
else inIndent = false;
}
if (inIndent && !blank) protect(start, offset - 1);
paraBreak.push(blank);
prevBlank = blank;
});
let s = chars.join('');
let m;
// Reference definitions must precede link masking: [Docs](url): "Note" is prose,
// not a definition manufactured by removing the destination. Protect labels too;
// changing an apostrophe in an identifier can disconnect its references.
s = chars.join('');
const labels = new Set(), definitions = new Map();
REF_DEF.lastIndex = 0;
while ((m = REF_DEF.exec(s)) !== null) {
if (m[0].includes('\0') || /\n[ \t\r]*\n/.test(m[0])) continue;
definitions.set(m.index, { end: m.index + m[0].length, label: labelKey(m[1]) });
}
s = chars.join('');
// Consume competing inline constructs in source order. HTML before a backtick
// protects its attributes; a backtick before HTML protects the whole code span.
// The same ordering keeps code-like text inside link titles literal.
const linkEnd = inlineLinkEnds(s);
const html = [new RegExp(TAG.source, 'y'), /<!--[\s\S]*?(?:-->|$)/y,
/<\?[\s\S]*?(?:\?>|$)/y, /<!\[CDATA\[[\s\S]*?(?:\]\]>|$)/y,
/<![A-Z][^>]*>/y,
/<(?:[a-zA-Z][a-zA-Z0-9+.-]{1,31}:[^<>\s]*|[^<>\s@]+@[^<>\s@]+)>/y];
const nextBlock = new Int32Array(s.length + 1);
let boundary = s.length;
for (let i = s.length; i >= 0; i -= 1) {
nextBlock[i] = boundary;
if (blockStarts.has(i) || s[i] === '\0') boundary = i;
}
const tickRun = /`+/y, closeTicks = /`+/g;
const linkOpeners = [];
for (let i = 0; i < s.length; i += 1) {
const definition = definitions.get(i);
if (definition) {
labels.add(definition.label); protect(i, definition.end); i = definition.end - 1; continue;
}
if (s[i] === '\\' && /[!"#$%&'()*+,\-./:;<=>?@[\]^_`{|}~\\\u201c\u201d\u2018\u2019]/.test(s[i + 1] || '')) {
protect(i, i + 2); i += 1; continue;
}
if (s[i] === '`') {
tickRun.lastIndex = i;
const run = tickRun.exec(s)[0];
closeTicks.lastIndex = i + run.length;
let end, found = false;
while ((end = closeTicks.exec(s)) !== null && end.index < nextBlock[i]) {
if (end[0].length !== run.length) continue;
protect(i, closeTicks.lastIndex); i = closeTicks.lastIndex - 1; found = true; break;
}
if (!found) i += run.length - 1;
continue;
}
if (s[i] === '<') {
let matched = false;
for (const re of html) {
re.lastIndex = i;
m = re.exec(s);
if (!m || m[0].includes('\0')) continue;
protect(i, re.lastIndex, re === html[0]); i = re.lastIndex - 1; matched = true; break;
}
if (matched) continue;
}
if (s[i] === '[') linkOpeners.push(nextBlock[i]);
if (s[i] === ']') {
const openerBoundary = linkOpeners.pop();
if (openerBoundary === undefined || i >= openerBoundary || s[i + 1] !== '(') continue;
const end = linkEnd(i + 2);
if (end >= 0 && end < nextBlock[i]) { protect(i + 1, end + 1); i = end; }
}
}
s = chars.join('');
const refs = /\[((?:\\.|[^\]\\\n])*)\](?:[ \t]*\[((?:\\.|[^\]\\\n])*)\])?/g;
while ((m = refs.exec(s)) !== null) {
if (m[0].includes('\0')) continue;
if (m[2] !== undefined) {
if (!m[2]) { if (labels.has(labelKey(m[1]))) protect(m.index, refs.lastIndex); }
else if (labels.has(labelKey(m[2]))) protect(refs.lastIndex - m[2].length - 2, refs.lastIndex);
} else if (s[refs.lastIndex] !== '\0' && labels.has(labelKey(m[1]))) protect(m.index, refs.lastIndex);
}
s = chars.join('');
// Bare URLs exclude prose quotes and terminal punctuation. Retain balanced
// parentheses inside a URL, but leave an enclosing prose parenthesis visible.
const urls = /\bhttps?:\/\/[^\s<>"\u201c\u201d\x00]+/g;
while ((m = urls.exec(s)) !== null) {
const url = m[0];
let end = url.length;
let surroundingQuote = /[‘']/.test(s[m.index - 1] || '');
// An explicit prose quote wins over URL parenthesis balancing when its
// closing mark is followed only by terminal punctuation. Otherwise an
// unmatched URL '(' can consume the prose ')' and hide the closing quote.
const quotedEnd = surroundingQuote && /[’'][).,;:!?]*$/.exec(url);
if (quotedEnd) end = quotedEnd.index + 1;
const bounded = url.slice(0, end);
let extra = (bounded.match(/\)/g) || []).length - (bounded.match(/\(/g) || []).length;
// Peel adjacent prose delimiters in any order. Each iteration removes one
// character, retaining balanced URL parentheses and internal apostrophes.
while (end > 0) {
const last = url[end - 1];
if (/[.,;:!?]/.test(last)) end -= 1;
else if (surroundingQuote && /[’']/.test(last)) {
end -= 1; surroundingQuote = false;
} else if (extra > 0 && last === ')') {
end -= 1; extra -= 1;
} else break;
}
protect(m.index, m.index + end);
}
if (text[0] === '\uFEFF') protect(0, 1);
const masked = chars.join('');
const context = chars.map((ch, i) => ch === '\0' ? (transparent.has(i) ? '*' : 'a') : ch).join('');
const prose = masked.split('\n').map((l) => bare(l).replace(/\0/g, ''));
return { masked, context, prose, paraBreak };
}
module.exports = { markdownProse };