quikchat 1.2.4 → 1.2.7
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/README.md +9 -4
- package/dist/build-manifest.json +73 -80
- package/dist/quikchat-md-full.cjs.js +630 -256
- package/dist/quikchat-md-full.cjs.js.map +1 -1
- package/dist/quikchat-md-full.cjs.min.js +3 -3
- package/dist/quikchat-md-full.cjs.min.js.map +1 -1
- package/dist/quikchat-md-full.esm.js +630 -256
- package/dist/quikchat-md-full.esm.js.map +1 -1
- package/dist/quikchat-md-full.esm.min.js +3 -3
- package/dist/quikchat-md-full.esm.min.js.map +1 -1
- package/dist/quikchat-md-full.umd.js +630 -256
- package/dist/quikchat-md-full.umd.js.map +1 -1
- package/dist/quikchat-md-full.umd.min.js +3 -3
- package/dist/quikchat-md-full.umd.min.js.map +1 -1
- package/dist/quikchat-md.cjs.js +614 -246
- package/dist/quikchat-md.cjs.js.map +1 -1
- package/dist/quikchat-md.cjs.min.js +3 -3
- package/dist/quikchat-md.cjs.min.js.map +1 -1
- package/dist/quikchat-md.esm.js +614 -246
- package/dist/quikchat-md.esm.js.map +1 -1
- package/dist/quikchat-md.esm.min.js +3 -3
- package/dist/quikchat-md.esm.min.js.map +1 -1
- package/dist/quikchat-md.umd.js +614 -246
- package/dist/quikchat-md.umd.js.map +1 -1
- package/dist/quikchat-md.umd.min.js +3 -3
- package/dist/quikchat-md.umd.min.js.map +1 -1
- package/dist/quikchat.cjs.js +3 -3
- package/dist/quikchat.cjs.js.map +1 -1
- package/dist/quikchat.cjs.min.js +1 -1
- package/dist/quikchat.cjs.min.js.map +1 -1
- package/dist/quikchat.css +351 -120
- package/dist/quikchat.esm.js +3 -3
- package/dist/quikchat.esm.js.map +1 -1
- package/dist/quikchat.esm.min.js +1 -1
- package/dist/quikchat.esm.min.js.map +1 -1
- package/dist/quikchat.min.css +1 -1
- package/dist/quikchat.umd.js +3 -3
- package/dist/quikchat.umd.js.map +1 -1
- package/dist/quikchat.umd.min.js +1 -1
- package/dist/quikchat.umd.min.js.map +1 -1
- package/package.json +6 -5
|
@@ -600,7 +600,7 @@
|
|
|
600
600
|
value: function _autoGrowTextarea() {
|
|
601
601
|
var el = this._textEntry;
|
|
602
602
|
el.style.height = 'auto';
|
|
603
|
-
var maxHeight =
|
|
603
|
+
var maxHeight = 120;
|
|
604
604
|
el.style.height = Math.min(el.scrollHeight, maxHeight) + 'px';
|
|
605
605
|
el.style.overflowY = el.scrollHeight > maxHeight ? 'auto' : 'hidden';
|
|
606
606
|
}
|
|
@@ -1068,9 +1068,9 @@
|
|
|
1068
1068
|
key: "version",
|
|
1069
1069
|
value: function version() {
|
|
1070
1070
|
return {
|
|
1071
|
-
"version": "1.2.
|
|
1071
|
+
"version": "1.2.7",
|
|
1072
1072
|
"license": "BSD-2",
|
|
1073
|
-
"url": "https://github/deftio/quikchat"
|
|
1073
|
+
"url": "https://github.com/deftio/quikchat"
|
|
1074
1074
|
};
|
|
1075
1075
|
}
|
|
1076
1076
|
|
|
@@ -1128,35 +1128,144 @@
|
|
|
1128
1128
|
|
|
1129
1129
|
/**
|
|
1130
1130
|
* quikdown_bd - Bidirectional Markdown Parser
|
|
1131
|
-
* @version 1.
|
|
1131
|
+
* @version 1.2.12
|
|
1132
1132
|
* @license BSD-2-Clause
|
|
1133
1133
|
* @copyright DeftIO 2025
|
|
1134
1134
|
*/
|
|
1135
1135
|
/**
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1138
|
-
*
|
|
1139
|
-
*
|
|
1140
|
-
*
|
|
1141
|
-
*
|
|
1142
|
-
*
|
|
1143
|
-
*
|
|
1144
|
-
*
|
|
1145
|
-
*
|
|
1136
|
+
* quikdown_classify — Shared line-classification utilities
|
|
1137
|
+
* ═════════════════════════════════════════════════════════
|
|
1138
|
+
*
|
|
1139
|
+
* Pure functions for classifying markdown lines. Used by both the main
|
|
1140
|
+
* parser (quikdown.js) and the editor (quikdown_edit.js) so the logic
|
|
1141
|
+
* lives in one place.
|
|
1142
|
+
*
|
|
1143
|
+
* All functions operate on a **trimmed** line (caller must trim).
|
|
1144
|
+
* None use regexes with nested quantifiers — every check is either a
|
|
1145
|
+
* simple regex or a linear scan, so there is zero ReDoS risk.
|
|
1146
1146
|
*/
|
|
1147
1147
|
|
|
1148
|
-
// Version will be injected at build time
|
|
1149
|
-
const quikdownVersion = '1.1.1';
|
|
1150
1148
|
|
|
1151
|
-
|
|
1149
|
+
/**
|
|
1150
|
+
* Dash-only HR check — exact parity with the main parser's original
|
|
1151
|
+
* regex `/^---+\s*$/`. Only matches lines of three or more dashes
|
|
1152
|
+
* with optional trailing whitespace (no interspersed spaces).
|
|
1153
|
+
*
|
|
1154
|
+
* @param {string} trimmed The line, already trimmed
|
|
1155
|
+
* @returns {boolean}
|
|
1156
|
+
*/
|
|
1157
|
+
function isDashHRLine(trimmed) {
|
|
1158
|
+
if (trimmed.length < 3) return false;
|
|
1159
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
1160
|
+
const ch = trimmed[i];
|
|
1161
|
+
if (ch === '-') continue;
|
|
1162
|
+
// Allow trailing whitespace only
|
|
1163
|
+
if (ch === ' ' || ch === '\t') {
|
|
1164
|
+
for (let j = i + 1; j < trimmed.length; j++) {
|
|
1165
|
+
if (trimmed[j] !== ' ' && trimmed[j] !== '\t') return false;
|
|
1166
|
+
}
|
|
1167
|
+
return i >= 3; // at least 3 dashes before whitespace
|
|
1168
|
+
}
|
|
1169
|
+
return false;
|
|
1170
|
+
}
|
|
1171
|
+
return true; // all dashes
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* quikdown — A compact, scanner-based markdown parser
|
|
1176
|
+
* ════════════════════════════════════════════════════
|
|
1177
|
+
*
|
|
1178
|
+
* Architecture overview (v1.2.8 — lexer rewrite)
|
|
1179
|
+
* ───────────────────────────────────────────────
|
|
1180
|
+
* Prior to v1.2.8, quikdown used a multi-pass regex pipeline: each block
|
|
1181
|
+
* type (headings, blockquotes, HR, lists, tables) and each inline format
|
|
1182
|
+
* (bold, italic, links, …) was handled by its own global regex applied
|
|
1183
|
+
* sequentially to the full document string. That worked but made the code
|
|
1184
|
+
* hard to extend and debug — a new construct meant adding another regex
|
|
1185
|
+
* pass, and ordering bugs between passes were subtle.
|
|
1186
|
+
*
|
|
1187
|
+
* Starting in v1.2.8 the parser uses a **line-scanning** approach for
|
|
1188
|
+
* block detection and a **per-block inline pass** for formatting:
|
|
1189
|
+
*
|
|
1190
|
+
* ┌─────────────────────────────────────────────────────────┐
|
|
1191
|
+
* │ Phase 1 — Code Extraction │
|
|
1192
|
+
* │ Scan for fenced code blocks (``` / ~~~) and inline │
|
|
1193
|
+
* │ code spans (`…`). Replace with §CB§ / §IC§ place- │
|
|
1194
|
+
* │ holders so code content is never touched by later │
|
|
1195
|
+
* │ phases. │
|
|
1196
|
+
* ├─────────────────────────────────────────────────────────┤
|
|
1197
|
+
* │ Phase 2 — HTML Escaping │
|
|
1198
|
+
* │ Escape &, <, >, ", ' in the remaining text to prevent │
|
|
1199
|
+
* │ XSS. (Skipped when allow_unsafe_html is true.) │
|
|
1200
|
+
* ├─────────────────────────────────────────────────────────┤
|
|
1201
|
+
* │ Phase 3 — Block Scanning │
|
|
1202
|
+
* │ Walk the text **line by line**. At each line, the │
|
|
1203
|
+
* │ scanner checks (in order): │
|
|
1204
|
+
* │ • table rows (|) │
|
|
1205
|
+
* │ • headings (#) │
|
|
1206
|
+
* │ • HR (---) │
|
|
1207
|
+
* │ • blockquotes (>) │
|
|
1208
|
+
* │ • list items (-, *, +, 1.) │
|
|
1209
|
+
* │ • code-block placeholder (§CB…§) │
|
|
1210
|
+
* │ • paragraph text (everything else) │
|
|
1211
|
+
* │ │
|
|
1212
|
+
* │ Block text is run through the **inline formatter** │
|
|
1213
|
+
* │ which handles bold, italic, strikethrough, links, │
|
|
1214
|
+
* │ images, and autolinks. │
|
|
1215
|
+
* │ │
|
|
1216
|
+
* │ Paragraphs are wrapped in <p> tags. Lazy linefeeds │
|
|
1217
|
+
* │ (single \n → <br>) are handled here too. │
|
|
1218
|
+
* ├─────────────────────────────────────────────────────────┤
|
|
1219
|
+
* │ Phase 4 — Code Restoration │
|
|
1220
|
+
* │ Replace §CB§ / §IC§ placeholders with rendered <pre> │
|
|
1221
|
+
* │ / <code> HTML, applying the fence_plugin if present. │
|
|
1222
|
+
* └─────────────────────────────────────────────────────────┘
|
|
1223
|
+
*
|
|
1224
|
+
* Why this design?
|
|
1225
|
+
* • Single pass over lines for block identification — no re-scanning.
|
|
1226
|
+
* • Each block type is a clearly separated branch, easy to add new ones.
|
|
1227
|
+
* • Inline formatting is confined to block text — can't accidentally
|
|
1228
|
+
* match across block boundaries or inside HTML tags.
|
|
1229
|
+
* • Code extraction still uses a simple regex (it's one pattern, not a
|
|
1230
|
+
* chain) because the §-placeholder approach is proven and simple.
|
|
1231
|
+
*
|
|
1232
|
+
* @param {string} markdown The markdown source text
|
|
1233
|
+
* @param {Object} options Configuration (see below)
|
|
1234
|
+
* @returns {string} Rendered HTML
|
|
1235
|
+
*/
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1239
|
+
// Constants
|
|
1240
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1241
|
+
|
|
1242
|
+
/** Build-time version stamp (injected by tools/updateVersion) */
|
|
1243
|
+
const quikdownVersion = '1.2.12';
|
|
1244
|
+
|
|
1245
|
+
/** CSS class prefix used for all generated elements */
|
|
1152
1246
|
const CLASS_PREFIX = 'quikdown-';
|
|
1153
|
-
const PLACEHOLDER_CB = '§CB';
|
|
1154
|
-
const PLACEHOLDER_IC = '§IC';
|
|
1155
1247
|
|
|
1156
|
-
|
|
1248
|
+
/** Placeholder sigils — chosen to be extremely unlikely in real text */
|
|
1249
|
+
const PLACEHOLDER_CB = '§CB'; // fenced code blocks
|
|
1250
|
+
const PLACEHOLDER_IC = '§IC'; // inline code spans
|
|
1251
|
+
const PLACEHOLDER_HT = '§HT'; // safe HTML tags (limited mode)
|
|
1252
|
+
|
|
1253
|
+
/** Attributes whose values need URL sanitization */
|
|
1254
|
+
const URL_ATTRIBUTES = { href:1, src:1, action:1, formaction:1 };
|
|
1255
|
+
|
|
1256
|
+
/** HTML entity escape map */
|
|
1157
1257
|
const ESC_MAP = {'&':'&','<':'<','>':'>','"':'"',"'":'''};
|
|
1158
1258
|
|
|
1159
|
-
//
|
|
1259
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1260
|
+
// Style definitions
|
|
1261
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* Inline styles for every element quikdown can emit.
|
|
1265
|
+
* When `inline_styles: true` these are injected as style="…" attributes.
|
|
1266
|
+
* When `inline_styles: false` (default) we use class="quikdown-<tag>"
|
|
1267
|
+
* and these same values are emitted by `quikdown.emitStyles()`.
|
|
1268
|
+
*/
|
|
1160
1269
|
const QUIKDOWN_STYLES = {
|
|
1161
1270
|
h1: 'font-size:2em;font-weight:600;margin:.67em 0;text-align:left',
|
|
1162
1271
|
h2: 'font-size:1.5em;font-weight:600;margin:.83em 0',
|
|
@@ -1179,30 +1288,41 @@
|
|
|
1179
1288
|
ul: 'margin:.5em 0;padding-left:2em',
|
|
1180
1289
|
ol: 'margin:.5em 0;padding-left:2em',
|
|
1181
1290
|
li: 'margin:.25em 0',
|
|
1182
|
-
// Task list specific styles
|
|
1183
1291
|
'task-item': 'list-style:none',
|
|
1184
1292
|
'task-checkbox': 'margin-right:.5em'
|
|
1185
1293
|
};
|
|
1186
1294
|
|
|
1187
|
-
//
|
|
1295
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1296
|
+
// Attribute factory
|
|
1297
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* Creates a `getAttr(tag, additionalStyle?)` helper that returns
|
|
1301
|
+
* either a class="…" or style="…" attribute string depending on mode.
|
|
1302
|
+
*
|
|
1303
|
+
* @param {boolean} inline_styles True → emit style="…"; false → class="…"
|
|
1304
|
+
* @param {Object} styles The QUIKDOWN_STYLES map
|
|
1305
|
+
* @returns {Function}
|
|
1306
|
+
*/
|
|
1188
1307
|
function createGetAttr(inline_styles, styles) {
|
|
1189
1308
|
return function(tag, additionalStyle = '') {
|
|
1190
1309
|
if (inline_styles) {
|
|
1191
1310
|
let style = styles[tag];
|
|
1192
1311
|
if (!style && !additionalStyle) return '';
|
|
1193
|
-
|
|
1194
|
-
//
|
|
1312
|
+
|
|
1313
|
+
// When adding alignment that conflicts with the tag's default,
|
|
1314
|
+
// strip the default text-align first.
|
|
1195
1315
|
if (additionalStyle && additionalStyle.includes('text-align') && style && style.includes('text-align')) {
|
|
1196
1316
|
style = style.replace(/text-align:[^;]+;?/, '').trim();
|
|
1317
|
+
/* istanbul ignore next */
|
|
1197
1318
|
if (style && !style.endsWith(';')) style += ';';
|
|
1198
1319
|
}
|
|
1199
|
-
|
|
1320
|
+
|
|
1200
1321
|
/* istanbul ignore next - defensive: additionalStyle without style doesn't occur with current tags */
|
|
1201
1322
|
const fullStyle = additionalStyle ? (style ? `${style}${additionalStyle}` : additionalStyle) : style;
|
|
1202
1323
|
return ` style="${fullStyle}"`;
|
|
1203
1324
|
} else {
|
|
1204
1325
|
const classAttr = ` class="${CLASS_PREFIX}${tag}"`;
|
|
1205
|
-
// Apply inline styles for alignment even when using CSS classes
|
|
1206
1326
|
if (additionalStyle) {
|
|
1207
1327
|
return `${classAttr} style="${additionalStyle}"`;
|
|
1208
1328
|
}
|
|
@@ -1211,69 +1331,124 @@
|
|
|
1211
1331
|
};
|
|
1212
1332
|
}
|
|
1213
1333
|
|
|
1334
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1335
|
+
// Main parser function
|
|
1336
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1337
|
+
|
|
1214
1338
|
function quikdown(markdown, options = {}) {
|
|
1339
|
+
// ── Guard: only process non-empty strings ──
|
|
1215
1340
|
if (!markdown || typeof markdown !== 'string') {
|
|
1216
1341
|
return '';
|
|
1217
1342
|
}
|
|
1218
|
-
|
|
1219
|
-
const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false } = options;
|
|
1220
|
-
const styles = QUIKDOWN_STYLES; // Use module-level styles
|
|
1221
|
-
const getAttr = createGetAttr(inline_styles, styles); // Create getAttr once
|
|
1222
1343
|
|
|
1223
|
-
//
|
|
1344
|
+
// ── Unpack options ──
|
|
1345
|
+
const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false, allow_unsafe_html = false } = options;
|
|
1346
|
+
const styles = QUIKDOWN_STYLES;
|
|
1347
|
+
const getAttr = createGetAttr(inline_styles, styles);
|
|
1348
|
+
|
|
1349
|
+
// ── Helpers (closed over options) ──
|
|
1350
|
+
|
|
1351
|
+
/** Escape the five HTML-special characters. */
|
|
1224
1352
|
function escapeHtml(text) {
|
|
1225
1353
|
return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
|
|
1226
1354
|
}
|
|
1227
|
-
|
|
1228
|
-
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* Bidirectional marker helper.
|
|
1358
|
+
* When bidirectional mode is on, returns ` data-qd="…"`.
|
|
1359
|
+
* The non-bidirectional branch is a trivial no-op arrow; it is
|
|
1360
|
+
* exercised in the core bundle but never in quikdown_bd.
|
|
1361
|
+
*/
|
|
1362
|
+
/* istanbul ignore next - trivial no-op fallback */
|
|
1229
1363
|
const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
|
|
1230
|
-
|
|
1231
|
-
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* Sanitize a URL to block javascript:, vbscript:, and non-image data: URIs.
|
|
1367
|
+
* Returns '#' for blocked URLs.
|
|
1368
|
+
*/
|
|
1232
1369
|
function sanitizeUrl(url, allowUnsafe = false) {
|
|
1233
1370
|
/* istanbul ignore next - defensive programming, regex ensures url is never empty */
|
|
1234
1371
|
if (!url) return '';
|
|
1235
|
-
|
|
1236
|
-
// If unsafe URLs are explicitly allowed, return as-is
|
|
1237
1372
|
if (allowUnsafe) return url;
|
|
1238
|
-
|
|
1373
|
+
|
|
1239
1374
|
const trimmedUrl = url.trim();
|
|
1240
1375
|
const lowerUrl = trimmedUrl.toLowerCase();
|
|
1241
|
-
|
|
1242
|
-
// Block dangerous protocols
|
|
1243
1376
|
const dangerousProtocols = ['javascript:', 'vbscript:', 'data:'];
|
|
1244
|
-
|
|
1377
|
+
|
|
1245
1378
|
for (const protocol of dangerousProtocols) {
|
|
1246
1379
|
if (lowerUrl.startsWith(protocol)) {
|
|
1247
|
-
// Exception: Allow data:image/* for images
|
|
1248
1380
|
if (protocol === 'data:' && lowerUrl.startsWith('data:image/')) {
|
|
1249
1381
|
return trimmedUrl;
|
|
1250
1382
|
}
|
|
1251
|
-
// Return safe empty link for dangerous protocols
|
|
1252
1383
|
return '#';
|
|
1253
1384
|
}
|
|
1254
1385
|
}
|
|
1255
|
-
|
|
1256
1386
|
return trimmedUrl;
|
|
1257
1387
|
}
|
|
1258
1388
|
|
|
1259
|
-
|
|
1389
|
+
/**
|
|
1390
|
+
* Sanitize attributes on an HTML tag string for limited mode.
|
|
1391
|
+
* Strips on* event handlers (case-insensitive) and runs sanitizeUrl()
|
|
1392
|
+
* on href/src/action/formaction values.
|
|
1393
|
+
*/
|
|
1394
|
+
function sanitizeHtmlTagAttrs(tagStr) {
|
|
1395
|
+
// Self-closing or void tag without attributes — pass through
|
|
1396
|
+
if (!/\s/.test(tagStr.replace(/<\/?[a-zA-Z][a-zA-Z0-9]*/, '').replace(/\/?>$/, ''))) {
|
|
1397
|
+
return tagStr;
|
|
1398
|
+
}
|
|
1399
|
+
// Parse: <tagname ...attrs... > or <tagname ...attrs... />
|
|
1400
|
+
const m = tagStr.match(/^(<\/?[a-zA-Z][a-zA-Z0-9]*)([\s\S]*?)(\/?>)$/);
|
|
1401
|
+
/* istanbul ignore next - defensive: Phase 1.5 regex guarantees valid tag shape */
|
|
1402
|
+
if (!m) return tagStr;
|
|
1403
|
+
|
|
1404
|
+
const [, open, attrStr, close] = m;
|
|
1405
|
+
// Match individual attributes: name="value", name='value', name=value, or bare name
|
|
1406
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- linear: no nested quantifiers
|
|
1407
|
+
const attrRe = /([a-zA-Z_][\w\-.:]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
|
|
1408
|
+
const attrs = [];
|
|
1409
|
+
let am;
|
|
1410
|
+
while ((am = attrRe.exec(attrStr)) !== null) {
|
|
1411
|
+
const name = am[1];
|
|
1412
|
+
const value = am[2] !== undefined ? am[2] : am[3] !== undefined ? am[3] : am[4];
|
|
1413
|
+
// Strip event handlers (on*)
|
|
1414
|
+
if (/^on/i.test(name)) continue;
|
|
1415
|
+
if (value === undefined) {
|
|
1416
|
+
// Boolean attribute (e.g. disabled, checked)
|
|
1417
|
+
attrs.push(name);
|
|
1418
|
+
} else {
|
|
1419
|
+
let sanitized = value;
|
|
1420
|
+
if (name.toLowerCase() in URL_ATTRIBUTES) {
|
|
1421
|
+
sanitized = sanitizeUrl(value);
|
|
1422
|
+
}
|
|
1423
|
+
attrs.push(`${name}="${sanitized}"`);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
return open + (attrs.length ? ' ' + attrs.join(' ') : '') + close;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// ────────────────────────────────────────────────────────────────
|
|
1430
|
+
// Phase 1 — Code Extraction
|
|
1431
|
+
// ────────────────────────────────────────────────────────────────
|
|
1432
|
+
// Why extract code first? Fenced blocks and inline code spans can
|
|
1433
|
+
// contain markdown-like characters (*, _, #, |, etc.) that must NOT
|
|
1434
|
+
// be interpreted as formatting. By pulling them out and replacing
|
|
1435
|
+
// with unique placeholders, the rest of the pipeline never sees them.
|
|
1436
|
+
|
|
1260
1437
|
let html = markdown;
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
//
|
|
1267
|
-
//
|
|
1268
|
-
// Fence must be at start of line
|
|
1438
|
+
const codeBlocks = []; // Array of {lang, code, custom, fence, hasReverse}
|
|
1439
|
+
const inlineCodes = []; // Array of escaped-HTML strings
|
|
1440
|
+
|
|
1441
|
+
// ── Fenced code blocks ──
|
|
1442
|
+
// Matches paired fences: ``` with ``` and ~~~ with ~~~.
|
|
1443
|
+
// The fence must start at column 0 of a line (^ with /m flag).
|
|
1444
|
+
// Group 1 = fence marker, Group 2 = language hint, Group 3 = code body.
|
|
1269
1445
|
html = html.replace(/^(```|~~~)([^\n]*)\n([\s\S]*?)^\1$/gm, (match, fence, lang, code) => {
|
|
1270
1446
|
const placeholder = `${PLACEHOLDER_CB}${codeBlocks.length}§`;
|
|
1271
|
-
|
|
1272
|
-
// Trim the language specification
|
|
1273
1447
|
const langTrimmed = lang ? lang.trim() : '';
|
|
1274
|
-
|
|
1275
|
-
// If custom fence plugin is provided, use it (v1.1.0: object format required)
|
|
1448
|
+
|
|
1276
1449
|
if (fence_plugin && fence_plugin.render && typeof fence_plugin.render === 'function') {
|
|
1450
|
+
// Custom plugin — store raw code (un-escaped) so the plugin
|
|
1451
|
+
// receives the original source.
|
|
1277
1452
|
codeBlocks.push({
|
|
1278
1453
|
lang: langTrimmed,
|
|
1279
1454
|
code: code.trimEnd(),
|
|
@@ -1282,6 +1457,7 @@
|
|
|
1282
1457
|
hasReverse: !!fence_plugin.reverse
|
|
1283
1458
|
});
|
|
1284
1459
|
} else {
|
|
1460
|
+
// Default — pre-escape the code for safe HTML output.
|
|
1285
1461
|
codeBlocks.push({
|
|
1286
1462
|
lang: langTrimmed,
|
|
1287
1463
|
code: escapeHtml(code.trimEnd()),
|
|
@@ -1291,66 +1467,137 @@
|
|
|
1291
1467
|
}
|
|
1292
1468
|
return placeholder;
|
|
1293
1469
|
});
|
|
1294
|
-
|
|
1295
|
-
//
|
|
1470
|
+
|
|
1471
|
+
// ── Inline code spans ──
|
|
1472
|
+
// Matches a single backtick pair: `content`.
|
|
1473
|
+
// Content is captured and HTML-escaped immediately.
|
|
1296
1474
|
html = html.replace(/`([^`]+)`/g, (match, code) => {
|
|
1297
1475
|
const placeholder = `${PLACEHOLDER_IC}${inlineCodes.length}§`;
|
|
1298
1476
|
inlineCodes.push(escapeHtml(code));
|
|
1299
1477
|
return placeholder;
|
|
1300
1478
|
});
|
|
1301
|
-
|
|
1302
|
-
//
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
//
|
|
1306
|
-
|
|
1307
|
-
//
|
|
1479
|
+
|
|
1480
|
+
// ────────────────────────────────────────────────────────────────
|
|
1481
|
+
// Phase 1.5 — Safe HTML Extraction (whitelist mode)
|
|
1482
|
+
// ────────────────────────────────────────────────────────────────
|
|
1483
|
+
// When allow_unsafe_html is an object or array, extract whitelisted
|
|
1484
|
+
// HTML tags, sanitize their attributes, and replace with placeholders.
|
|
1485
|
+
// Non-whitelisted tags stay in text so Phase 2 will escape them.
|
|
1486
|
+
|
|
1487
|
+
const safeTags = [];
|
|
1488
|
+
// Normalize: array → object for O(1) lookup; object used as-is
|
|
1489
|
+
const htmlAllow = Array.isArray(allow_unsafe_html)
|
|
1490
|
+
? Object.fromEntries(allow_unsafe_html.map(t => [t, 1]))
|
|
1491
|
+
: (allow_unsafe_html && typeof allow_unsafe_html === 'object') ? allow_unsafe_html : null;
|
|
1492
|
+
|
|
1493
|
+
if (htmlAllow) {
|
|
1494
|
+
// Pass through HTML comments — browsers render them as nothing
|
|
1495
|
+
html = html.replace(/<!--[\s\S]*?-->/g, (match) => {
|
|
1496
|
+
const idx = safeTags.length;
|
|
1497
|
+
safeTags.push(match);
|
|
1498
|
+
return `${PLACEHOLDER_HT}${idx}§`;
|
|
1499
|
+
});
|
|
1500
|
+
html = html.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*\/?>/g, (match, tagName) => {
|
|
1501
|
+
if (tagName.toLowerCase() in htmlAllow) {
|
|
1502
|
+
const sanitized = sanitizeHtmlTagAttrs(match);
|
|
1503
|
+
const idx = safeTags.length;
|
|
1504
|
+
safeTags.push(sanitized);
|
|
1505
|
+
return `${PLACEHOLDER_HT}${idx}§`;
|
|
1506
|
+
}
|
|
1507
|
+
// Not whitelisted — leave in text for Phase 2 to escape
|
|
1508
|
+
return match;
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// ────────────────────────────────────────────────────────────────
|
|
1513
|
+
// Phase 2 — HTML Escaping
|
|
1514
|
+
// ────────────────────────────────────────────────────────────────
|
|
1515
|
+
// All remaining text (everything except code placeholders) is escaped
|
|
1516
|
+
// to prevent XSS. The `allow_unsafe_html` option skips this for
|
|
1517
|
+
// trusted pipelines that intentionally embed raw HTML.
|
|
1518
|
+
// For whitelist mode, escaping still runs (only `true` bypasses it).
|
|
1519
|
+
|
|
1520
|
+
if (allow_unsafe_html !== true) {
|
|
1521
|
+
html = escapeHtml(html);
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// Restore safe HTML tag placeholders after escaping
|
|
1525
|
+
if (htmlAllow) {
|
|
1526
|
+
safeTags.forEach((tag, i) => {
|
|
1527
|
+
html = html.replace(`${PLACEHOLDER_HT}${i}§`, tag);
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// ────────────────────────────────────────────────────────────────
|
|
1532
|
+
// Phase 3 — Block Scanning + Inline Formatting + Paragraphs
|
|
1533
|
+
// ────────────────────────────────────────────────────────────────
|
|
1534
|
+
// This is the heart of the lexer rewrite. Instead of applying
|
|
1535
|
+
// 10+ global regex passes, we:
|
|
1536
|
+
// 1. Process tables (line walker — tables need multi-line lookahead)
|
|
1537
|
+
// 2. Scan remaining lines for headings, HR, blockquotes
|
|
1538
|
+
// 3. Process lists (line walker — lists need indent tracking)
|
|
1539
|
+
// 4. Apply inline formatting to all text content
|
|
1540
|
+
// 5. Wrap remaining text in <p> tags
|
|
1541
|
+
//
|
|
1542
|
+
// Steps 1 and 3 are line-walkers that process the full text in a
|
|
1543
|
+
// single pass each. Step 2 replaces global regex with a per-line
|
|
1544
|
+
// scanner. Steps 4-5 are applied to the result.
|
|
1545
|
+
//
|
|
1546
|
+
// Total: 3 structured passes instead of 10+ regex passes.
|
|
1547
|
+
|
|
1548
|
+
// ── Step 1: Tables ──
|
|
1549
|
+
// Tables need multi-line lookahead (header → separator → body rows)
|
|
1550
|
+
// so they're handled by a dedicated line-walker first.
|
|
1308
1551
|
html = processTable(html, getAttr);
|
|
1309
|
-
|
|
1310
|
-
//
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
//
|
|
1317
|
-
|
|
1318
|
-
// Merge consecutive blockquotes
|
|
1319
|
-
html = html.replace(/<\/blockquote>\n<blockquote>/g, '\n');
|
|
1320
|
-
|
|
1321
|
-
// Process horizontal rules (allow trailing spaces)
|
|
1322
|
-
html = html.replace(/^---+\s*$/gm, `<hr${getAttr('hr')}>`);
|
|
1323
|
-
|
|
1324
|
-
// Process lists
|
|
1552
|
+
|
|
1553
|
+
// ── Step 2: Headings, HR, Blockquotes ──
|
|
1554
|
+
// These are simple line-level constructs. We scan each line once
|
|
1555
|
+
// and replace matching lines with their HTML representation.
|
|
1556
|
+
html = scanLineBlocks(html, getAttr, dataQd);
|
|
1557
|
+
|
|
1558
|
+
// ── Step 3: Lists ──
|
|
1559
|
+
// Lists need indent-level tracking across lines, so they get their
|
|
1560
|
+
// own line-walker.
|
|
1325
1561
|
html = processLists(html, getAttr, inline_styles, bidirectional);
|
|
1326
|
-
|
|
1327
|
-
//
|
|
1328
|
-
|
|
1329
|
-
//
|
|
1562
|
+
|
|
1563
|
+
// ── Step 4: Inline formatting ──
|
|
1564
|
+
// Apply bold, italic, strikethrough, images, links, and autolinks
|
|
1565
|
+
// to all text content. This runs on the output of steps 1-3, so
|
|
1566
|
+
// it sees text inside headings, blockquotes, table cells, list
|
|
1567
|
+
// items, and paragraph text.
|
|
1568
|
+
|
|
1569
|
+
// Images (must come before links —  vs [text](url))
|
|
1330
1570
|
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
|
|
1331
1571
|
const sanitizedSrc = sanitizeUrl(src, options.allow_unsafe_urls);
|
|
1572
|
+
/* istanbul ignore next - bd-only branch */
|
|
1332
1573
|
const altAttr = bidirectional && alt ? ` data-qd-alt="${escapeHtml(alt)}"` : '';
|
|
1574
|
+
/* istanbul ignore next - bd-only branch */
|
|
1333
1575
|
const srcAttr = bidirectional ? ` data-qd-src="${escapeHtml(src)}"` : '';
|
|
1334
1576
|
return `<img${getAttr('img')} src="${sanitizedSrc}" alt="${alt}"${altAttr}${srcAttr}${dataQd('!')}>`;
|
|
1335
1577
|
});
|
|
1336
|
-
|
|
1337
|
-
// Links
|
|
1578
|
+
|
|
1579
|
+
// Links
|
|
1338
1580
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, href) => {
|
|
1339
|
-
// Sanitize URL to prevent XSS
|
|
1340
1581
|
const sanitizedHref = sanitizeUrl(href, options.allow_unsafe_urls);
|
|
1341
1582
|
const isExternal = /^https?:\/\//i.test(sanitizedHref);
|
|
1342
1583
|
const rel = isExternal ? ' rel="noopener noreferrer"' : '';
|
|
1584
|
+
/* istanbul ignore next - bd-only branch */
|
|
1343
1585
|
const textAttr = bidirectional ? ` data-qd-text="${escapeHtml(text)}"` : '';
|
|
1344
1586
|
return `<a${getAttr('a')} href="${sanitizedHref}"${rel}${textAttr}${dataQd('[')}>${text}</a>`;
|
|
1345
1587
|
});
|
|
1346
|
-
|
|
1347
|
-
// Autolinks
|
|
1588
|
+
|
|
1589
|
+
// Autolinks — bare https?:// URLs become clickable <a> tags
|
|
1348
1590
|
html = html.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, (match, prefix, url) => {
|
|
1349
1591
|
const sanitizedUrl = sanitizeUrl(url, options.allow_unsafe_urls);
|
|
1350
1592
|
return `${prefix}<a${getAttr('a')} href="${sanitizedUrl}" rel="noopener noreferrer">${url}</a>`;
|
|
1351
1593
|
});
|
|
1352
|
-
|
|
1353
|
-
//
|
|
1594
|
+
|
|
1595
|
+
// Protect rendered tags so emphasis regexes don't see attribute
|
|
1596
|
+
// values — fixes #3 (underscores in URLs interpreted as emphasis).
|
|
1597
|
+
const savedTags = [];
|
|
1598
|
+
html = html.replace(/<[^>]+>/g, m => { savedTags.push(m); return `%%T${savedTags.length - 1}%%`; });
|
|
1599
|
+
|
|
1600
|
+
// Bold, italic, strikethrough
|
|
1354
1601
|
const inlinePatterns = [
|
|
1355
1602
|
[/\*\*(.+?)\*\*/g, 'strong', '**'],
|
|
1356
1603
|
[/__(.+?)__/g, 'strong', '__'],
|
|
@@ -1358,60 +1605,66 @@
|
|
|
1358
1605
|
[/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em', '_'],
|
|
1359
1606
|
[/~~(.+?)~~/g, 'del', '~~']
|
|
1360
1607
|
];
|
|
1361
|
-
|
|
1362
1608
|
inlinePatterns.forEach(([pattern, tag, marker]) => {
|
|
1363
1609
|
html = html.replace(pattern, `<${tag}${getAttr(tag)}${dataQd(marker)}>$1</${tag}>`);
|
|
1364
1610
|
});
|
|
1365
|
-
|
|
1366
|
-
//
|
|
1611
|
+
|
|
1612
|
+
// Restore protected tags
|
|
1613
|
+
html = html.replace(/%%T(\d+)%%/g, (_, i) => savedTags[i]);
|
|
1614
|
+
|
|
1615
|
+
// ── Step 5: Line breaks + paragraph wrapping ──
|
|
1367
1616
|
if (lazy_linefeeds) {
|
|
1368
|
-
// Lazy linefeeds: single
|
|
1617
|
+
// Lazy linefeeds mode: every single \n becomes <br> EXCEPT:
|
|
1618
|
+
// • Double newlines → paragraph break
|
|
1619
|
+
// • Newlines adjacent to block elements (h, blockquote, pre, hr, table, list)
|
|
1620
|
+
//
|
|
1621
|
+
// Strategy: protect block-adjacent newlines with §N§, convert
|
|
1622
|
+
// the rest, then restore.
|
|
1623
|
+
|
|
1369
1624
|
const blocks = [];
|
|
1370
1625
|
let bi = 0;
|
|
1371
|
-
|
|
1372
|
-
// Protect tables and lists
|
|
1626
|
+
|
|
1627
|
+
// Protect tables and lists from <br> injection
|
|
1373
1628
|
html = html.replace(/<(table|[uo]l)[^>]*>[\s\S]*?<\/\1>/g, m => {
|
|
1374
1629
|
blocks[bi] = m;
|
|
1375
1630
|
return `§B${bi++}§`;
|
|
1376
1631
|
});
|
|
1377
|
-
|
|
1378
|
-
// Handle paragraphs and block elements
|
|
1632
|
+
|
|
1379
1633
|
html = html.replace(/\n\n+/g, '§P§')
|
|
1380
|
-
// After block
|
|
1634
|
+
// After block-level closing tags
|
|
1381
1635
|
.replace(/(<\/(?:h[1-6]|blockquote|pre)>)\n/g, '$1§N§')
|
|
1382
1636
|
.replace(/(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)\n/g, '$1§N§')
|
|
1383
|
-
// Before block
|
|
1637
|
+
// Before block-level opening tags
|
|
1384
1638
|
.replace(/\n(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)/g, '§N§$1')
|
|
1385
1639
|
.replace(/\n(§B\d+§)/g, '§N§$1')
|
|
1386
1640
|
.replace(/(§B\d+§)\n/g, '$1§N§')
|
|
1387
|
-
// Convert
|
|
1641
|
+
// Convert surviving newlines to <br>
|
|
1388
1642
|
.replace(/\n/g, `<br${getAttr('br')}>`)
|
|
1389
1643
|
// Restore
|
|
1390
1644
|
.replace(/§N§/g, '\n')
|
|
1391
1645
|
.replace(/§P§/g, '</p><p>');
|
|
1392
|
-
|
|
1646
|
+
|
|
1393
1647
|
// Restore protected blocks
|
|
1394
1648
|
blocks.forEach((b, i) => html = html.replace(`§B${i}§`, b));
|
|
1395
|
-
|
|
1649
|
+
|
|
1396
1650
|
html = '<p>' + html + '</p>';
|
|
1397
1651
|
} else {
|
|
1398
|
-
// Standard: two spaces
|
|
1399
|
-
html = html.replace(/
|
|
1400
|
-
|
|
1401
|
-
// Paragraphs (double newlines)
|
|
1402
|
-
// Don't add </p> after block elements (they're not in paragraphs)
|
|
1652
|
+
// Standard mode: two trailing spaces → <br>, double newline → new paragraph
|
|
1653
|
+
html = html.replace(/ {2}$/gm, `<br${getAttr('br')}>`);
|
|
1654
|
+
|
|
1403
1655
|
html = html.replace(/\n\n+/g, (match, offset) => {
|
|
1404
|
-
// Check if we're after a block element closing tag
|
|
1405
1656
|
const before = html.substring(0, offset);
|
|
1406
1657
|
if (before.match(/<\/(h[1-6]|blockquote|ul|ol|table|pre|hr)>$/)) {
|
|
1407
|
-
return '<p>';
|
|
1658
|
+
return '<p>';
|
|
1408
1659
|
}
|
|
1409
|
-
return '</p><p>';
|
|
1660
|
+
return '</p><p>';
|
|
1410
1661
|
});
|
|
1411
1662
|
html = '<p>' + html + '</p>';
|
|
1412
1663
|
}
|
|
1413
|
-
|
|
1414
|
-
//
|
|
1664
|
+
|
|
1665
|
+
// ── Step 6: Cleanup ──
|
|
1666
|
+
// Remove <p> wrappers that accidentally enclose block elements.
|
|
1667
|
+
// This is simpler than trying to prevent them during wrapping.
|
|
1415
1668
|
const cleanupPatterns = [
|
|
1416
1669
|
[/<p><\/p>/g, ''],
|
|
1417
1670
|
[/<p>(<h[1-6][^>]*>)/g, '$1'],
|
|
@@ -1425,67 +1678,162 @@
|
|
|
1425
1678
|
[/(<\/table>)<\/p>/g, '$1'],
|
|
1426
1679
|
[/<p>(<pre[^>]*>)/g, '$1'],
|
|
1427
1680
|
[/(<\/pre>)<\/p>/g, '$1'],
|
|
1428
|
-
[new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)
|
|
1681
|
+
[new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)</p>`, 'g'), '$1']
|
|
1429
1682
|
];
|
|
1430
|
-
|
|
1431
1683
|
cleanupPatterns.forEach(([pattern, replacement]) => {
|
|
1432
1684
|
html = html.replace(pattern, replacement);
|
|
1433
1685
|
});
|
|
1434
|
-
|
|
1435
|
-
//
|
|
1436
|
-
// When a paragraph follows a block element, ensure it has opening <p>
|
|
1686
|
+
|
|
1687
|
+
// When a block element is followed by a newline and then text, open a <p>.
|
|
1437
1688
|
html = html.replace(/(<\/(?:h[1-6]|blockquote|ul|ol|table|pre|hr)>)\n([^<])/g, '$1\n<p>$2');
|
|
1438
|
-
|
|
1439
|
-
//
|
|
1440
|
-
|
|
1441
|
-
//
|
|
1689
|
+
|
|
1690
|
+
// ────────────────────────────────────────────────────────────────
|
|
1691
|
+
// Phase 4 — Code Restoration
|
|
1692
|
+
// ────────────────────────────────────────────────────────────────
|
|
1693
|
+
// Replace placeholders with rendered HTML. For fenced blocks this
|
|
1694
|
+
// means wrapping in <pre><code>…</code></pre> (or calling the
|
|
1695
|
+
// fence_plugin). For inline code it means <code>…</code>.
|
|
1696
|
+
|
|
1442
1697
|
codeBlocks.forEach((block, i) => {
|
|
1443
1698
|
let replacement;
|
|
1444
|
-
|
|
1699
|
+
|
|
1445
1700
|
if (block.custom && fence_plugin && fence_plugin.render) {
|
|
1446
|
-
//
|
|
1701
|
+
// Delegate to the user-provided fence plugin.
|
|
1447
1702
|
replacement = fence_plugin.render(block.code, block.lang);
|
|
1448
|
-
|
|
1449
|
-
// If plugin returns undefined, fall back to default rendering
|
|
1703
|
+
|
|
1450
1704
|
if (replacement === undefined) {
|
|
1705
|
+
// Plugin declined — fall back to default rendering.
|
|
1451
1706
|
const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
|
|
1452
1707
|
const codeAttr = inline_styles ? getAttr('code') : langClass;
|
|
1708
|
+
/* istanbul ignore next - bd-only branch */
|
|
1453
1709
|
const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
|
|
1710
|
+
/* istanbul ignore next - bd-only branch */
|
|
1454
1711
|
const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
|
|
1455
1712
|
replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${escapeHtml(block.code)}</code></pre>`;
|
|
1456
|
-
} else if (bidirectional) {
|
|
1457
|
-
//
|
|
1458
|
-
replacement = replacement.replace(/^<(\w+)/,
|
|
1713
|
+
} else /* istanbul ignore next - bd-only branch */ if (bidirectional) {
|
|
1714
|
+
// Plugin returned HTML — inject data attributes for roundtrip.
|
|
1715
|
+
replacement = replacement.replace(/^<(\w+)/,
|
|
1459
1716
|
`<$1 data-qd-fence="${escapeHtml(block.fence)}" data-qd-lang="${escapeHtml(block.lang)}" data-qd-source="${escapeHtml(block.code)}"`);
|
|
1460
1717
|
}
|
|
1461
1718
|
} else {
|
|
1462
|
-
// Default rendering
|
|
1719
|
+
// Default rendering — wrap in <pre><code>.
|
|
1463
1720
|
const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
|
|
1464
1721
|
const codeAttr = inline_styles ? getAttr('code') : langClass;
|
|
1722
|
+
/* istanbul ignore next - bd-only branch */
|
|
1465
1723
|
const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
|
|
1724
|
+
/* istanbul ignore next - bd-only branch */
|
|
1466
1725
|
const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
|
|
1467
1726
|
replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${block.code}</code></pre>`;
|
|
1468
1727
|
}
|
|
1469
|
-
|
|
1728
|
+
|
|
1470
1729
|
const placeholder = `${PLACEHOLDER_CB}${i}§`;
|
|
1471
1730
|
html = html.replace(placeholder, replacement);
|
|
1472
1731
|
});
|
|
1473
|
-
|
|
1474
|
-
// Restore inline code
|
|
1732
|
+
|
|
1733
|
+
// Restore inline code spans
|
|
1475
1734
|
inlineCodes.forEach((code, i) => {
|
|
1476
1735
|
const placeholder = `${PLACEHOLDER_IC}${i}§`;
|
|
1477
1736
|
html = html.replace(placeholder, `<code${getAttr('code')}${dataQd('`')}>${code}</code>`);
|
|
1478
1737
|
});
|
|
1479
|
-
|
|
1738
|
+
|
|
1480
1739
|
return html.trim();
|
|
1481
1740
|
}
|
|
1482
1741
|
|
|
1742
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1743
|
+
// Block-level line scanner
|
|
1744
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1745
|
+
|
|
1746
|
+
/**
|
|
1747
|
+
* scanLineBlocks — single-pass line scanner for headings, HR, blockquotes
|
|
1748
|
+
*
|
|
1749
|
+
* Walks the text line by line. For each line it checks (in order):
|
|
1750
|
+
* 1. Heading — starts with 1-6 '#' followed by a space
|
|
1751
|
+
* 2. HR — line is entirely '---…' (3+ dashes, optional trailing space)
|
|
1752
|
+
* 3. Blockquote — starts with '> ' (the > was already HTML-escaped)
|
|
1753
|
+
*
|
|
1754
|
+
* Lines that don't match any block pattern are passed through unchanged.
|
|
1755
|
+
*
|
|
1756
|
+
* This replaces three separate global regex passes from the pre-1.2.8
|
|
1757
|
+
* architecture with one structured scan.
|
|
1758
|
+
*
|
|
1759
|
+
* @param {string} text The document text (HTML-escaped, code extracted)
|
|
1760
|
+
* @param {Function} getAttr Attribute factory (class or style)
|
|
1761
|
+
* @param {Function} dataQd Bidirectional marker factory
|
|
1762
|
+
* @returns {string} Text with block-level elements rendered
|
|
1763
|
+
*/
|
|
1764
|
+
function scanLineBlocks(text, getAttr, dataQd) {
|
|
1765
|
+
const lines = text.split('\n');
|
|
1766
|
+
const result = [];
|
|
1767
|
+
let i = 0;
|
|
1768
|
+
|
|
1769
|
+
while (i < lines.length) {
|
|
1770
|
+
const line = lines[i];
|
|
1771
|
+
|
|
1772
|
+
// ── Markdown comment (reference-link hack) ──
|
|
1773
|
+
// [//]: # (comment) or [//]: # "comment" or [//]: #
|
|
1774
|
+
// These produce no output — standard markdown comment convention.
|
|
1775
|
+
if (/^\[\/\/\]: #/.test(line)) {
|
|
1776
|
+
i++;
|
|
1777
|
+
continue;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// ── Heading ──
|
|
1781
|
+
// Count leading '#' characters. Valid heading: 1-6 hashes then a space.
|
|
1782
|
+
// Example: "## Hello World ##" → <h2>Hello World</h2>
|
|
1783
|
+
let hashCount = 0;
|
|
1784
|
+
while (hashCount < line.length && hashCount < 7 && line[hashCount] === '#') {
|
|
1785
|
+
hashCount++;
|
|
1786
|
+
}
|
|
1787
|
+
if (hashCount >= 1 && hashCount <= 6 && line[hashCount] === ' ') {
|
|
1788
|
+
// Extract content after "# " and strip trailing hashes
|
|
1789
|
+
const content = line.slice(hashCount + 1).replace(/\s*#+\s*$/, '');
|
|
1790
|
+
const tag = 'h' + hashCount;
|
|
1791
|
+
result.push(`<${tag}${getAttr(tag)}${dataQd('#'.repeat(hashCount))}>${content}</${tag}>`);
|
|
1792
|
+
i++;
|
|
1793
|
+
continue;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
// ── Horizontal Rule ──
|
|
1797
|
+
// Three or more dashes, optional trailing whitespace, nothing else.
|
|
1798
|
+
if (isDashHRLine(line)) {
|
|
1799
|
+
result.push(`<hr${getAttr('hr')}>`);
|
|
1800
|
+
i++;
|
|
1801
|
+
continue;
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// ── Blockquote ──
|
|
1805
|
+
// After Phase 2, the '>' character has been escaped to '>'.
|
|
1806
|
+
// Pattern: "> content" or merged consecutive blockquotes.
|
|
1807
|
+
if (/^>\s+/.test(line)) {
|
|
1808
|
+
result.push(`<blockquote${getAttr('blockquote')}>${line.replace(/^>\s+/, '')}</blockquote>`);
|
|
1809
|
+
i++;
|
|
1810
|
+
continue;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
// ── Pass-through ──
|
|
1814
|
+
result.push(line);
|
|
1815
|
+
i++;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// Merge consecutive blockquotes into a single element.
|
|
1819
|
+
// <blockquote>A</blockquote>\n<blockquote>B</blockquote>
|
|
1820
|
+
// → <blockquote>A\nB</blockquote>
|
|
1821
|
+
let joined = result.join('\n');
|
|
1822
|
+
joined = joined.replace(/<\/blockquote>\n<blockquote>/g, '\n');
|
|
1823
|
+
return joined;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1827
|
+
// Table processing (line walker)
|
|
1828
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1829
|
+
|
|
1483
1830
|
/**
|
|
1484
|
-
*
|
|
1831
|
+
* Inline markdown formatter for table cells.
|
|
1832
|
+
* Handles bold, italic, strikethrough, and code within cell text.
|
|
1833
|
+
* Links / images / autolinks are handled by the global inline pass
|
|
1834
|
+
* (Phase 3 Step 4) which runs after table processing.
|
|
1485
1835
|
*/
|
|
1486
1836
|
function processInlineMarkdown(text, getAttr) {
|
|
1487
|
-
|
|
1488
|
-
// Process inline formatting patterns
|
|
1489
1837
|
const patterns = [
|
|
1490
1838
|
[/\*\*(.+?)\*\*/g, 'strong'],
|
|
1491
1839
|
[/__(.+?)__/g, 'strong'],
|
|
@@ -1494,27 +1842,32 @@
|
|
|
1494
1842
|
[/~~(.+?)~~/g, 'del'],
|
|
1495
1843
|
[/`([^`]+)`/g, 'code']
|
|
1496
1844
|
];
|
|
1497
|
-
|
|
1498
1845
|
patterns.forEach(([pattern, tag]) => {
|
|
1499
1846
|
text = text.replace(pattern, `<${tag}${getAttr(tag)}>$1</${tag}>`);
|
|
1500
1847
|
});
|
|
1501
|
-
|
|
1502
1848
|
return text;
|
|
1503
1849
|
}
|
|
1504
1850
|
|
|
1505
1851
|
/**
|
|
1506
|
-
*
|
|
1852
|
+
* processTable — line walker for markdown tables
|
|
1853
|
+
*
|
|
1854
|
+
* Walks through lines looking for runs of pipe-containing lines.
|
|
1855
|
+
* Each run is validated (must contain a separator row: |---|---|)
|
|
1856
|
+
* and rendered as an HTML <table>. Invalid runs are restored as-is.
|
|
1857
|
+
*
|
|
1858
|
+
* @param {string} text Full document text
|
|
1859
|
+
* @param {Function} getAttr Attribute factory
|
|
1860
|
+
* @returns {string} Text with tables rendered
|
|
1507
1861
|
*/
|
|
1508
1862
|
function processTable(text, getAttr) {
|
|
1509
1863
|
const lines = text.split('\n');
|
|
1510
1864
|
const result = [];
|
|
1511
1865
|
let inTable = false;
|
|
1512
1866
|
let tableLines = [];
|
|
1513
|
-
|
|
1867
|
+
|
|
1514
1868
|
for (let i = 0; i < lines.length; i++) {
|
|
1515
1869
|
const line = lines[i].trim();
|
|
1516
|
-
|
|
1517
|
-
// Check if this line looks like a table row (with or without trailing |)
|
|
1870
|
+
|
|
1518
1871
|
if (line.includes('|') && (line.startsWith('|') || /[^\\|]/.test(line))) {
|
|
1519
1872
|
if (!inTable) {
|
|
1520
1873
|
inTable = true;
|
|
@@ -1522,14 +1875,11 @@
|
|
|
1522
1875
|
}
|
|
1523
1876
|
tableLines.push(line);
|
|
1524
1877
|
} else {
|
|
1525
|
-
// Not a table line
|
|
1526
1878
|
if (inTable) {
|
|
1527
|
-
// Process the accumulated table
|
|
1528
1879
|
const tableHtml = buildTable(tableLines, getAttr);
|
|
1529
1880
|
if (tableHtml) {
|
|
1530
1881
|
result.push(tableHtml);
|
|
1531
1882
|
} else {
|
|
1532
|
-
// Not a valid table, restore original lines
|
|
1533
1883
|
result.push(...tableLines);
|
|
1534
1884
|
}
|
|
1535
1885
|
inTable = false;
|
|
@@ -1538,8 +1888,8 @@
|
|
|
1538
1888
|
result.push(lines[i]);
|
|
1539
1889
|
}
|
|
1540
1890
|
}
|
|
1541
|
-
|
|
1542
|
-
// Handle table at end of
|
|
1891
|
+
|
|
1892
|
+
// Handle table at end of document
|
|
1543
1893
|
if (inTable && tableLines.length > 0) {
|
|
1544
1894
|
const tableHtml = buildTable(tableLines, getAttr);
|
|
1545
1895
|
if (tableHtml) {
|
|
@@ -1548,35 +1898,35 @@
|
|
|
1548
1898
|
result.push(...tableLines);
|
|
1549
1899
|
}
|
|
1550
1900
|
}
|
|
1551
|
-
|
|
1901
|
+
|
|
1552
1902
|
return result.join('\n');
|
|
1553
1903
|
}
|
|
1554
1904
|
|
|
1555
1905
|
/**
|
|
1556
|
-
*
|
|
1906
|
+
* buildTable — validate and render a table from accumulated lines
|
|
1907
|
+
*
|
|
1908
|
+
* @param {string[]} lines Array of pipe-containing lines
|
|
1909
|
+
* @param {Function} getAttr Attribute factory
|
|
1910
|
+
* @returns {string|null} HTML table string, or null if invalid
|
|
1557
1911
|
*/
|
|
1558
1912
|
function buildTable(lines, getAttr) {
|
|
1559
|
-
|
|
1560
1913
|
if (lines.length < 2) return null;
|
|
1561
|
-
|
|
1562
|
-
//
|
|
1914
|
+
|
|
1915
|
+
// Find the separator row (---|---|)
|
|
1563
1916
|
let separatorIndex = -1;
|
|
1564
1917
|
for (let i = 1; i < lines.length; i++) {
|
|
1565
|
-
// Support separator with or without leading/trailing pipes
|
|
1566
1918
|
if (/^\|?[\s\-:|]+\|?$/.test(lines[i]) && lines[i].includes('-')) {
|
|
1567
1919
|
separatorIndex = i;
|
|
1568
1920
|
break;
|
|
1569
1921
|
}
|
|
1570
1922
|
}
|
|
1571
|
-
|
|
1572
1923
|
if (separatorIndex === -1) return null;
|
|
1573
|
-
|
|
1924
|
+
|
|
1574
1925
|
const headerLines = lines.slice(0, separatorIndex);
|
|
1575
1926
|
const bodyLines = lines.slice(separatorIndex + 1);
|
|
1576
|
-
|
|
1577
|
-
// Parse alignment from separator
|
|
1927
|
+
|
|
1928
|
+
// Parse alignment from separator cells (:--- = left, :---: = center, ---: = right)
|
|
1578
1929
|
const separator = lines[separatorIndex];
|
|
1579
|
-
// Handle pipes at start/end or not
|
|
1580
1930
|
const separatorCells = separator.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
1581
1931
|
const alignments = separatorCells.map(cell => {
|
|
1582
1932
|
const trimmed = cell.trim();
|
|
@@ -1584,31 +1934,28 @@
|
|
|
1584
1934
|
if (trimmed.endsWith(':')) return 'right';
|
|
1585
1935
|
return 'left';
|
|
1586
1936
|
});
|
|
1587
|
-
|
|
1937
|
+
|
|
1588
1938
|
let html = `<table${getAttr('table')}>\n`;
|
|
1589
|
-
|
|
1590
|
-
//
|
|
1591
|
-
// Note: headerLines will always have length > 0 since separatorIndex starts from 1
|
|
1939
|
+
|
|
1940
|
+
// Header
|
|
1592
1941
|
html += `<thead${getAttr('thead')}>\n`;
|
|
1593
1942
|
headerLines.forEach(line => {
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
html += '</tr>\n';
|
|
1943
|
+
html += `<tr${getAttr('tr')}>\n`;
|
|
1944
|
+
const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
1945
|
+
cells.forEach((cell, i) => {
|
|
1946
|
+
const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
|
|
1947
|
+
const processedCell = processInlineMarkdown(cell.trim(), getAttr);
|
|
1948
|
+
html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
|
|
1949
|
+
});
|
|
1950
|
+
html += '</tr>\n';
|
|
1603
1951
|
});
|
|
1604
1952
|
html += '</thead>\n';
|
|
1605
|
-
|
|
1606
|
-
//
|
|
1953
|
+
|
|
1954
|
+
// Body
|
|
1607
1955
|
if (bodyLines.length > 0) {
|
|
1608
1956
|
html += `<tbody${getAttr('tbody')}>\n`;
|
|
1609
1957
|
bodyLines.forEach(line => {
|
|
1610
1958
|
html += `<tr${getAttr('tr')}>\n`;
|
|
1611
|
-
// Handle pipes at start/end or not
|
|
1612
1959
|
const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
1613
1960
|
cells.forEach((cell, i) => {
|
|
1614
1961
|
const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
|
|
@@ -1619,61 +1966,81 @@
|
|
|
1619
1966
|
});
|
|
1620
1967
|
html += '</tbody>\n';
|
|
1621
1968
|
}
|
|
1622
|
-
|
|
1969
|
+
|
|
1623
1970
|
html += '</table>';
|
|
1624
1971
|
return html;
|
|
1625
1972
|
}
|
|
1626
1973
|
|
|
1974
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1975
|
+
// List processing (line walker)
|
|
1976
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1977
|
+
|
|
1627
1978
|
/**
|
|
1628
|
-
*
|
|
1979
|
+
* processLists — line walker for ordered, unordered, and task lists
|
|
1980
|
+
*
|
|
1981
|
+
* Scans each line for list markers (-, *, +, 1., 2., etc.) with
|
|
1982
|
+
* optional leading indentation for nesting. Non-list lines close
|
|
1983
|
+
* any open lists and pass through unchanged.
|
|
1984
|
+
*
|
|
1985
|
+
* Task lists (- [ ] / - [x]) are detected and rendered with
|
|
1986
|
+
* checkbox inputs.
|
|
1987
|
+
*
|
|
1988
|
+
* @param {string} text Full document text
|
|
1989
|
+
* @param {Function} getAttr Attribute factory
|
|
1990
|
+
* @param {boolean} inline_styles Whether to use inline styles
|
|
1991
|
+
* @param {boolean} bidirectional Whether to add data-qd markers
|
|
1992
|
+
* @returns {string} Text with lists rendered
|
|
1629
1993
|
*/
|
|
1630
1994
|
function processLists(text, getAttr, inline_styles, bidirectional) {
|
|
1631
|
-
|
|
1632
1995
|
const lines = text.split('\n');
|
|
1633
1996
|
const result = [];
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
// Helper to escape HTML for data-qd attributes
|
|
1637
|
-
|
|
1997
|
+
const listStack = []; // tracks nesting: [{type:'ul', level:0}, …]
|
|
1998
|
+
|
|
1999
|
+
// Helper to escape HTML for data-qd attributes. List markers (`-`, `*`,
|
|
2000
|
+
// `+`, `1.`, etc.) never contain HTML-special chars, so the replace
|
|
2001
|
+
// callback is defensive-only and never actually fires in practice.
|
|
2002
|
+
/* istanbul ignore next - defensive: list markers never trigger escaping */
|
|
2003
|
+
const escapeHtml = (text) => text.replace(/[&<>"']/g,
|
|
2004
|
+
/* istanbul ignore next - defensive: list markers never contain HTML specials */
|
|
2005
|
+
m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]);
|
|
2006
|
+
/* istanbul ignore next - trivial no-op fallback; not exercised via bd bundle */
|
|
1638
2007
|
const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
|
|
1639
|
-
|
|
2008
|
+
|
|
1640
2009
|
for (let i = 0; i < lines.length; i++) {
|
|
1641
2010
|
const line = lines[i];
|
|
1642
2011
|
const match = line.match(/^(\s*)([*\-+]|\d+\.)\s+(.+)$/);
|
|
1643
|
-
|
|
2012
|
+
|
|
1644
2013
|
if (match) {
|
|
1645
2014
|
const [, indent, marker, content] = match;
|
|
1646
2015
|
const level = Math.floor(indent.length / 2);
|
|
1647
2016
|
const isOrdered = /^\d+\./.test(marker);
|
|
1648
2017
|
const listType = isOrdered ? 'ol' : 'ul';
|
|
1649
|
-
|
|
1650
|
-
//
|
|
2018
|
+
|
|
2019
|
+
// Task list detection (only in unordered lists)
|
|
1651
2020
|
let listItemContent = content;
|
|
1652
2021
|
let taskListClass = '';
|
|
1653
2022
|
const taskMatch = content.match(/^\[([x ])\]\s+(.*)$/i);
|
|
1654
2023
|
if (taskMatch && !isOrdered) {
|
|
1655
2024
|
const [, checked, taskContent] = taskMatch;
|
|
1656
2025
|
const isChecked = checked.toLowerCase() === 'x';
|
|
1657
|
-
const checkboxAttr = inline_styles
|
|
1658
|
-
? ' style="margin-right:.5em"'
|
|
2026
|
+
const checkboxAttr = inline_styles
|
|
2027
|
+
? ' style="margin-right:.5em"'
|
|
1659
2028
|
: ` class="${CLASS_PREFIX}task-checkbox"`;
|
|
1660
2029
|
listItemContent = `<input type="checkbox"${checkboxAttr}${isChecked ? ' checked' : ''} disabled> ${taskContent}`;
|
|
1661
2030
|
taskListClass = inline_styles ? ' style="list-style:none"' : ` class="${CLASS_PREFIX}task-item"`;
|
|
1662
2031
|
}
|
|
1663
|
-
|
|
1664
|
-
// Close deeper levels
|
|
2032
|
+
|
|
2033
|
+
// Close deeper nesting levels
|
|
1665
2034
|
while (listStack.length > level + 1) {
|
|
1666
2035
|
const list = listStack.pop();
|
|
1667
2036
|
result.push(`</${list.type}>`);
|
|
1668
2037
|
}
|
|
1669
|
-
|
|
1670
|
-
// Open new
|
|
2038
|
+
|
|
2039
|
+
// Open new list or switch type at current level
|
|
1671
2040
|
if (listStack.length === level) {
|
|
1672
|
-
// Need to open a new list
|
|
1673
2041
|
listStack.push({ type: listType, level });
|
|
1674
2042
|
result.push(`<${listType}${getAttr(listType)}>`);
|
|
1675
2043
|
} else if (listStack.length === level + 1) {
|
|
1676
|
-
// Check if we need to switch list type
|
|
1677
2044
|
const currentList = listStack[listStack.length - 1];
|
|
1678
2045
|
if (currentList.type !== listType) {
|
|
1679
2046
|
result.push(`</${currentList.type}>`);
|
|
@@ -1682,11 +2049,11 @@
|
|
|
1682
2049
|
result.push(`<${listType}${getAttr(listType)}>`);
|
|
1683
2050
|
}
|
|
1684
2051
|
}
|
|
1685
|
-
|
|
2052
|
+
|
|
1686
2053
|
const liAttr = taskListClass || getAttr('li');
|
|
1687
2054
|
result.push(`<li${liAttr}${dataQd(marker)}>${listItemContent}</li>`);
|
|
1688
2055
|
} else {
|
|
1689
|
-
// Not a list item
|
|
2056
|
+
// Not a list item — close all open lists
|
|
1690
2057
|
while (listStack.length > 0) {
|
|
1691
2058
|
const list = listStack.pop();
|
|
1692
2059
|
result.push(`</${list.type}>`);
|
|
@@ -1694,76 +2061,76 @@
|
|
|
1694
2061
|
result.push(line);
|
|
1695
2062
|
}
|
|
1696
2063
|
}
|
|
1697
|
-
|
|
1698
|
-
// Close any remaining lists
|
|
2064
|
+
|
|
2065
|
+
// Close any remaining open lists
|
|
1699
2066
|
while (listStack.length > 0) {
|
|
1700
2067
|
const list = listStack.pop();
|
|
1701
2068
|
result.push(`</${list.type}>`);
|
|
1702
2069
|
}
|
|
1703
|
-
|
|
2070
|
+
|
|
1704
2071
|
return result.join('\n');
|
|
1705
2072
|
}
|
|
1706
2073
|
|
|
2074
|
+
// ════════════════════════════════════════════════════════════════════
|
|
2075
|
+
// Static API
|
|
2076
|
+
// ════════════════════════════════════════════════════════════════════
|
|
2077
|
+
|
|
1707
2078
|
/**
|
|
1708
|
-
* Emit CSS
|
|
1709
|
-
*
|
|
1710
|
-
* @param {string}
|
|
1711
|
-
* @
|
|
2079
|
+
* Emit CSS rules for all quikdown elements.
|
|
2080
|
+
*
|
|
2081
|
+
* @param {string} prefix Class prefix (default: 'quikdown-')
|
|
2082
|
+
* @param {string} theme 'light' (default) or 'dark'
|
|
2083
|
+
* @returns {string} CSS text
|
|
1712
2084
|
*/
|
|
1713
2085
|
quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
|
|
1714
2086
|
const styles = QUIKDOWN_STYLES;
|
|
1715
|
-
|
|
1716
|
-
// Define theme color overrides
|
|
2087
|
+
|
|
1717
2088
|
const themeOverrides = {
|
|
1718
2089
|
dark: {
|
|
1719
|
-
'#f4f4f4': '#2a2a2a',
|
|
1720
|
-
'#f0f0f0': '#2a2a2a',
|
|
1721
|
-
'#f2f2f2': '#2a2a2a',
|
|
1722
|
-
'#ddd': '#3a3a3a',
|
|
1723
|
-
'#06c': '#6db3f2',
|
|
2090
|
+
'#f4f4f4': '#2a2a2a', // pre background
|
|
2091
|
+
'#f0f0f0': '#2a2a2a', // code background
|
|
2092
|
+
'#f2f2f2': '#2a2a2a', // th background
|
|
2093
|
+
'#ddd': '#3a3a3a', // borders
|
|
2094
|
+
'#06c': '#6db3f2', // links
|
|
1724
2095
|
_textColor: '#e0e0e0'
|
|
1725
2096
|
},
|
|
1726
2097
|
light: {
|
|
1727
|
-
_textColor: '#333'
|
|
2098
|
+
_textColor: '#333'
|
|
1728
2099
|
}
|
|
1729
2100
|
};
|
|
1730
|
-
|
|
2101
|
+
|
|
1731
2102
|
let css = '';
|
|
1732
2103
|
for (const [tag, style] of Object.entries(styles)) {
|
|
1733
2104
|
let themedStyle = style;
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
if (!oldColor.startsWith('_')) {
|
|
1740
|
-
themedStyle = themedStyle.replace(new RegExp(oldColor, 'g'), newColor);
|
|
1741
|
-
}
|
|
1742
|
-
}
|
|
1743
|
-
|
|
1744
|
-
// Add text color for certain elements in dark theme
|
|
1745
|
-
const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
|
|
1746
|
-
if (needsTextColor.includes(tag)) {
|
|
1747
|
-
themedStyle += `;color:${themeOverrides.dark._textColor}`;
|
|
1748
|
-
}
|
|
1749
|
-
} else if (theme === 'light' && themeOverrides.light) {
|
|
1750
|
-
// Add explicit text color for light theme elements too
|
|
1751
|
-
const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
|
|
1752
|
-
if (needsTextColor.includes(tag)) {
|
|
1753
|
-
themedStyle += `;color:${themeOverrides.light._textColor}`;
|
|
2105
|
+
|
|
2106
|
+
if (theme === 'dark' && themeOverrides.dark) {
|
|
2107
|
+
for (const [oldColor, newColor] of Object.entries(themeOverrides.dark)) {
|
|
2108
|
+
if (!oldColor.startsWith('_')) {
|
|
2109
|
+
themedStyle = themedStyle.replaceAll(oldColor, newColor);
|
|
1754
2110
|
}
|
|
1755
2111
|
}
|
|
1756
|
-
|
|
2112
|
+
const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
|
|
2113
|
+
if (needsTextColor.includes(tag)) {
|
|
2114
|
+
themedStyle += `;color:${themeOverrides.dark._textColor}`;
|
|
2115
|
+
}
|
|
2116
|
+
} else if (theme === 'light' && themeOverrides.light) {
|
|
2117
|
+
const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
|
|
2118
|
+
if (needsTextColor.includes(tag)) {
|
|
2119
|
+
themedStyle += `;color:${themeOverrides.light._textColor}`;
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
|
|
1757
2123
|
css += `.${prefix}${tag} { ${themedStyle} }\n`;
|
|
1758
2124
|
}
|
|
1759
|
-
|
|
2125
|
+
|
|
1760
2126
|
return css;
|
|
1761
2127
|
};
|
|
1762
2128
|
|
|
1763
2129
|
/**
|
|
1764
|
-
*
|
|
1765
|
-
*
|
|
1766
|
-
* @
|
|
2130
|
+
* Create a pre-configured parser with baked-in options.
|
|
2131
|
+
*
|
|
2132
|
+
* @param {Object} options Options to bake in
|
|
2133
|
+
* @returns {Function} Configured quikdown(markdown) function
|
|
1767
2134
|
*/
|
|
1768
2135
|
quikdown.configure = function(options) {
|
|
1769
2136
|
return function(markdown) {
|
|
@@ -1771,18 +2138,19 @@
|
|
|
1771
2138
|
};
|
|
1772
2139
|
};
|
|
1773
2140
|
|
|
1774
|
-
/**
|
|
1775
|
-
* Version information
|
|
1776
|
-
*/
|
|
2141
|
+
/** Semantic version (injected at build time) */
|
|
1777
2142
|
quikdown.version = quikdownVersion;
|
|
1778
2143
|
|
|
1779
|
-
|
|
2144
|
+
|
|
2145
|
+
// ════════════════════════════════════════════════════════════════════
|
|
2146
|
+
// Exports
|
|
2147
|
+
// ════════════════════════════════════════════════════════════════════
|
|
2148
|
+
|
|
1780
2149
|
/* istanbul ignore next */
|
|
1781
2150
|
if (typeof module !== 'undefined' && module.exports) {
|
|
1782
2151
|
module.exports = quikdown;
|
|
1783
2152
|
}
|
|
1784
2153
|
|
|
1785
|
-
// For browser global
|
|
1786
2154
|
/* istanbul ignore next */
|
|
1787
2155
|
if (typeof window !== 'undefined') {
|
|
1788
2156
|
window.quikdown = quikdown;
|
|
@@ -1806,8 +2174,11 @@
|
|
|
1806
2174
|
return quikdown(markdown, { ...options, bidirectional: true });
|
|
1807
2175
|
}
|
|
1808
2176
|
|
|
1809
|
-
// Copy all properties and methods from quikdown (including version)
|
|
2177
|
+
// Copy all properties and methods from quikdown (including version).
|
|
2178
|
+
// Skip `configure` — quikdown_bd provides its own override below, so the
|
|
2179
|
+
// inner quikdown.configure is dead code in this bundle.
|
|
1810
2180
|
Object.keys(quikdown).forEach(key => {
|
|
2181
|
+
if (key === 'configure') return;
|
|
1811
2182
|
quikdown_bd[key] = quikdown[key];
|
|
1812
2183
|
});
|
|
1813
2184
|
|
|
@@ -1841,7 +2212,7 @@
|
|
|
1841
2212
|
|
|
1842
2213
|
// Process children with context
|
|
1843
2214
|
let childContent = '';
|
|
1844
|
-
for (
|
|
2215
|
+
for (const child of node.childNodes) {
|
|
1845
2216
|
childContent += walkNode(child, { parentTag: tag, ...parentContext });
|
|
1846
2217
|
}
|
|
1847
2218
|
|
|
@@ -2075,7 +2446,7 @@
|
|
|
2075
2446
|
let index = 1;
|
|
2076
2447
|
const indent = ' '.repeat(depth);
|
|
2077
2448
|
|
|
2078
|
-
for (
|
|
2449
|
+
for (const child of listNode.children) {
|
|
2079
2450
|
if (child.tagName !== 'LI') continue;
|
|
2080
2451
|
|
|
2081
2452
|
const dataQd = child.getAttribute('data-qd');
|
|
@@ -2088,7 +2459,7 @@
|
|
|
2088
2459
|
marker = '-';
|
|
2089
2460
|
// Get text without the checkbox
|
|
2090
2461
|
let text = '';
|
|
2091
|
-
for (
|
|
2462
|
+
for (const node of child.childNodes) {
|
|
2092
2463
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
2093
2464
|
text += node.textContent;
|
|
2094
2465
|
} else if (node.tagName && node.tagName !== 'INPUT') {
|
|
@@ -2099,7 +2470,7 @@
|
|
|
2099
2470
|
} else {
|
|
2100
2471
|
let itemContent = '';
|
|
2101
2472
|
|
|
2102
|
-
for (
|
|
2473
|
+
for (const node of child.childNodes) {
|
|
2103
2474
|
if (node.tagName === 'UL' || node.tagName === 'OL') {
|
|
2104
2475
|
itemContent += walkList(node, node.tagName === 'OL', depth + 1);
|
|
2105
2476
|
} else {
|
|
@@ -2128,7 +2499,7 @@
|
|
|
2128
2499
|
const headerRow = thead.querySelector('tr');
|
|
2129
2500
|
if (headerRow) {
|
|
2130
2501
|
const headers = [];
|
|
2131
|
-
for (
|
|
2502
|
+
for (const th of headerRow.querySelectorAll('th')) {
|
|
2132
2503
|
headers.push(th.textContent.trim());
|
|
2133
2504
|
}
|
|
2134
2505
|
result += '| ' + headers.join(' | ') + ' |\n';
|
|
@@ -2147,9 +2518,9 @@
|
|
|
2147
2518
|
// Process body
|
|
2148
2519
|
const tbody = table.querySelector('tbody');
|
|
2149
2520
|
if (tbody) {
|
|
2150
|
-
for (
|
|
2521
|
+
for (const row of tbody.querySelectorAll('tr')) {
|
|
2151
2522
|
const cells = [];
|
|
2152
|
-
for (
|
|
2523
|
+
for (const td of row.querySelectorAll('td')) {
|
|
2153
2524
|
cells.push(td.textContent.trim());
|
|
2154
2525
|
}
|
|
2155
2526
|
if (cells.length > 0) {
|
|
@@ -2171,10 +2542,13 @@
|
|
|
2171
2542
|
return markdown;
|
|
2172
2543
|
};
|
|
2173
2544
|
|
|
2174
|
-
// Override the configure method to return a bidirectional version
|
|
2545
|
+
// Override the configure method to return a bidirectional version.
|
|
2546
|
+
// We delegate to the inner quikdown.configure so the shared closure
|
|
2547
|
+
// machinery is exercised in both bundles (no dead code).
|
|
2175
2548
|
quikdown_bd.configure = function(options) {
|
|
2549
|
+
const innerParser = quikdown.configure({ ...options, bidirectional: true });
|
|
2176
2550
|
return function(markdown) {
|
|
2177
|
-
return
|
|
2551
|
+
return innerParser(markdown);
|
|
2178
2552
|
};
|
|
2179
2553
|
};
|
|
2180
2554
|
|