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