@wdprlib/render 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -44,10 +44,15 @@ var __export = (target, all) => {
44
44
  var exports_src = {};
45
45
  __export(exports_src, {
46
46
  renderToHtml: () => renderToHtml,
47
+ createSettings: () => import_ast3.createSettings,
48
+ DEFAULT_SETTINGS: () => import_ast3.DEFAULT_SETTINGS,
47
49
  DEFAULT_EMBED_ALLOWLIST: () => DEFAULT_EMBED_ALLOWLIST
48
50
  });
49
51
  module.exports = __toCommonJS(exports_src);
50
52
 
53
+ // packages/render/src/context.ts
54
+ var import_ast = require("@wdprlib/ast");
55
+
51
56
  // packages/render/src/escape.ts
52
57
  function escapeHtml(text) {
53
58
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -418,6 +423,8 @@ class RenderContext {
418
423
  _equationIndex = 0;
419
424
  _htmlBlockIndex = 0;
420
425
  _bibciteCounter = 0;
426
+ _idSuffix;
427
+ settings;
421
428
  options;
422
429
  footnotes;
423
430
  styles;
@@ -426,6 +433,8 @@ class RenderContext {
426
433
  bibliographyMap;
427
434
  bibliographyEntries;
428
435
  constructor(tree, options = {}) {
436
+ this.settings = options.settings ?? import_ast.DEFAULT_SETTINGS;
437
+ this._idSuffix = this.settings.useTrueIds ? null : Math.random().toString(16).slice(2, 8);
429
438
  this.options = options;
430
439
  this.footnotes = options.footnotes ?? tree.footnotes ?? [];
431
440
  this.styles = tree.styles ?? [];
@@ -479,6 +488,18 @@ class RenderContext {
479
488
  nextBibciteCounter() {
480
489
  return ++this._bibciteCounter;
481
490
  }
491
+ generateId(prefix, index) {
492
+ if (this._idSuffix === null) {
493
+ return `${prefix}${index}`;
494
+ }
495
+ return `${prefix}${index}-${this._idSuffix}`;
496
+ }
497
+ generateFixedId(name) {
498
+ if (this._idSuffix === null) {
499
+ return name;
500
+ }
501
+ return `${name}-${this._idSuffix}`;
502
+ }
482
503
  get page() {
483
504
  return this.options.page;
484
505
  }
@@ -488,15 +509,23 @@ class RenderContext {
488
509
  case "url": {
489
510
  const url = source.data;
490
511
  if (url.startsWith("/") && !url.startsWith("//")) {
512
+ if (!this.settings.allowLocalPaths)
513
+ return null;
491
514
  return `/local--files${url}`;
492
515
  }
493
516
  return url;
494
517
  }
495
518
  case "file1":
519
+ if (!this.settings.allowLocalPaths)
520
+ return null;
496
521
  return pageName ? `/local--files/${pageName}/${source.data.file}` : `/local--files/${source.data.file}`;
497
522
  case "file2":
523
+ if (!this.settings.allowLocalPaths)
524
+ return null;
498
525
  return `/local--files/${source.data.page}/${source.data.file}`;
499
526
  case "file3":
527
+ if (!this.settings.allowLocalPaths)
528
+ return null;
500
529
  return `/local--files/${source.data.site}/${source.data.page}/${source.data.file}`;
501
530
  }
502
531
  }
@@ -548,28 +577,28 @@ class RenderContext {
548
577
  }
549
578
 
550
579
  // packages/render/src/elements/container.ts
551
- var import_ast = require("@wdprlib/ast");
580
+ var import_ast2 = require("@wdprlib/ast");
552
581
  function renderContainer(ctx, data) {
553
582
  const { type, attributes, elements } = data;
554
- if (import_ast.isHeaderType(type)) {
583
+ if (import_ast2.isHeaderType(type)) {
555
584
  renderHeader(ctx, type.header.level, type.header["has-toc"], attributes, elements);
556
585
  return;
557
586
  }
558
- if (import_ast.isAlignType(type)) {
587
+ if (import_ast2.isAlignType(type)) {
559
588
  ctx.push(`<div style="text-align: ${type.align};">`);
560
589
  renderElements(ctx, elements);
561
590
  ctx.push("</div>");
562
591
  return;
563
592
  }
564
- if (import_ast.isStringContainerType(type)) {
593
+ if (import_ast2.isStringContainerType(type)) {
565
594
  renderStringContainer(ctx, type, attributes, elements);
566
595
  }
567
596
  }
568
597
  function renderHeader(ctx, level, hasToc, attributes, elements) {
569
598
  const tag = `h${level}`;
570
599
  if (hasToc) {
571
- const tocId = ctx.nextTocIndex();
572
- ctx.push(`<${tag} id="toc${tocId}"${renderAttrs(attributes)}>`);
600
+ const tocId = ctx.generateId("toc", ctx.nextTocIndex());
601
+ ctx.push(`<${tag} id="${tocId}"${renderAttrs(attributes)}>`);
573
602
  } else {
574
603
  ctx.push(`<${tag}${renderAttrs(attributes)}>`);
575
604
  }
@@ -855,6 +884,8 @@ function renderAnchorName(ctx, name) {
855
884
  // packages/render/src/elements/image.ts
856
885
  function renderImage(ctx, data) {
857
886
  let src = ctx.resolveImageSource(data.source);
887
+ if (src === null)
888
+ return;
858
889
  if (isDangerousUrl(src)) {
859
890
  src = "#invalid-url";
860
891
  }
@@ -3939,7 +3970,7 @@ function fnv1aHash(input, hexLen) {
3939
3970
  function renderTabView(ctx, tabs) {
3940
3971
  const labelString = tabs.map((t) => t.label).join("");
3941
3972
  const hash = md5Hash(labelString);
3942
- const widgetId = `wiki-tabview-${hash}`;
3973
+ const widgetId = ctx.generateFixedId(`wiki-tabview-${hash}`);
3943
3974
  ctx.push(`<div id="${widgetId}" class="yui-navset">`);
3944
3975
  ctx.push(`<ul class="yui-nav">`);
3945
3976
  for (let i = 0;i < tabs.length; i++) {
@@ -3954,7 +3985,8 @@ function renderTabView(ctx, tabs) {
3954
3985
  for (let i = 0;i < tabs.length; i++) {
3955
3986
  const tab = tabs[i];
3956
3987
  const displayStyle = i === 0 ? "" : ` style="display:none"`;
3957
- ctx.push(`<div id="wiki-tab-0-${i}"${displayStyle}>`);
3988
+ const tabId = ctx.generateId("wiki-tab-0-", i);
3989
+ ctx.push(`<div id="${tabId}"${displayStyle}>`);
3958
3990
  renderElements(ctx, tab.elements);
3959
3991
  ctx.push("</div>");
3960
3992
  }
@@ -3967,8 +3999,9 @@ function md5Hash(input) {
3967
3999
 
3968
4000
  // packages/render/src/elements/footnote.ts
3969
4001
  function renderFootnoteRef(ctx, index) {
4002
+ const id = ctx.generateId("footnoteref-", index);
3970
4003
  ctx.push(`<sup class="footnoteref">`);
3971
- ctx.push(`<a id="footnoteref-${index}" href="javascript:;" class="footnoteref">${index}</a>`);
4004
+ ctx.push(`<a id="${id}" href="javascript:;" class="footnoteref">${index}</a>`);
3972
4005
  ctx.push("</sup>");
3973
4006
  }
3974
4007
  function renderFootnoteBlock(ctx, data) {
@@ -3980,7 +4013,8 @@ function renderFootnoteBlock(ctx, data) {
3980
4013
  for (let i = 0;i < ctx.footnotes.length; i++) {
3981
4014
  const index = i + 1;
3982
4015
  const elements = ctx.footnotes[i] ?? [];
3983
- ctx.push(`<div class="footnote-footer" id="footnote-${index}">`);
4016
+ const fnId = ctx.generateId("footnote-", index);
4017
+ ctx.push(`<div class="footnote-footer" id="${fnId}">`);
3984
4018
  ctx.push(`<a href="javascript:;">${index}</a>. `);
3985
4019
  renderElements(ctx, elements);
3986
4020
  ctx.push("</div>");
@@ -4018,7 +4052,7 @@ function renderMath(ctx, data) {
4018
4052
  const index = ctx.nextEquationIndex() + 1;
4019
4053
  const latex = data["latex-source"];
4020
4054
  const mathml = renderLatexToMathML(latex, true);
4021
- const id = data.name ? `equation-${data.name}` : `equation-${index}`;
4055
+ const id = data.name ? ctx.generateId("equation-", data.name) : ctx.generateId("equation-", index);
4022
4056
  const dataName = data.name ? ` data-name="${escapeAttr(data.name)}"` : "";
4023
4057
  ctx.push(`<div class="math-block" id="${escapeAttr(id)}"${dataName}>`);
4024
4058
  if (data.name) {
@@ -4057,7 +4091,7 @@ function renderMathInline(ctx, data) {
4057
4091
  ctx.push("</span>");
4058
4092
  }
4059
4093
  function renderEquationRef(ctx, name) {
4060
- const id = `equation-${name}`;
4094
+ const id = ctx.generateId("equation-", name);
4061
4095
  ctx.push(`<span class="eref" data-target="${escapeAttr(id)}">`);
4062
4096
  ctx.push(`<a class="eref-link" href="#${escapeAttr(id)}">`);
4063
4097
  ctx.push(escapeHtml(name));
@@ -4209,8 +4243,8 @@ function renderGitlabSnippet(ctx, snippetId) {
4209
4243
  }
4210
4244
 
4211
4245
  // packages/render/src/elements/embed-block.ts
4212
- var import_dompurify = __toESM(require("dompurify"));
4213
- var import_jsdom = require("jsdom");
4246
+ var import_htmlparser2 = require("htmlparser2");
4247
+ var import_sanitize_html = __toESM(require("sanitize-html"));
4214
4248
  var BOOLEAN_ATTRIBUTES = [
4215
4249
  "allowfullscreen",
4216
4250
  "async",
@@ -4244,21 +4278,42 @@ var DEFAULT_EMBED_ALLOWLIST = [
4244
4278
  { host: "w.soundcloud.com", pathPrefix: "/player/" },
4245
4279
  { host: "codepen.io" }
4246
4280
  ];
4247
- var window = new import_jsdom.JSDOM("").window;
4248
- var purify = import_dompurify.default(window);
4249
- purify.addHook("uponSanitizeAttribute", (_node, data) => {
4250
- if (data.attrName === "src" && data.attrValue) {
4251
- if (!data.attrValue.toLowerCase().startsWith("https://")) {
4252
- data.attrValue = "";
4253
- data.forceKeepAttr = false;
4281
+ var SANITIZE_CONFIG = {
4282
+ allowedTags: ["iframe"],
4283
+ allowedAttributes: {
4284
+ iframe: [
4285
+ "src",
4286
+ "allow",
4287
+ "allowfullscreen",
4288
+ "frameborder",
4289
+ "height",
4290
+ "loading",
4291
+ "referrerpolicy",
4292
+ "sandbox",
4293
+ "title",
4294
+ "width"
4295
+ ]
4296
+ },
4297
+ allowedSchemes: ["https"]
4298
+ };
4299
+ function findIframes(html) {
4300
+ const doc = import_htmlparser2.parseDocument(html);
4301
+ const iframes = [];
4302
+ function walk(nodes) {
4303
+ for (const node of nodes) {
4304
+ if (node.type === "tag") {
4305
+ if (node.name === "iframe") {
4306
+ iframes.push(node);
4307
+ }
4308
+ if (node.children) {
4309
+ walk(node.children);
4310
+ }
4311
+ }
4254
4312
  }
4255
4313
  }
4256
- });
4257
- var DOMPURIFY_CONFIG = {
4258
- ALLOWED_TAGS: ["iframe"],
4259
- ADD_ATTR: ["allow", "allowfullscreen", "frameborder", "loading", "referrerpolicy", "sandbox"],
4260
- FORBID_ATTR: ["srcdoc", "onload", "onerror", "onclick"]
4261
- };
4314
+ walk(doc.children);
4315
+ return iframes;
4316
+ }
4262
4317
  function matchesHostPattern(hostname, pattern) {
4263
4318
  const lowerHostname = hostname.toLowerCase();
4264
4319
  const lowerPattern = pattern.toLowerCase();
@@ -4288,20 +4343,16 @@ function matchesAllowlistEntry(url, entry) {
4288
4343
  return true;
4289
4344
  }
4290
4345
  function validateAndSanitizeEmbed(content, allowlist) {
4291
- const sanitized = purify.sanitize(content.trim(), {
4292
- ...DOMPURIFY_CONFIG,
4293
- RETURN_TRUSTED_TYPE: false
4294
- });
4346
+ const sanitized = import_sanitize_html.default(content.trim(), SANITIZE_CONFIG);
4295
4347
  if (!sanitized.trim()) {
4296
4348
  return null;
4297
4349
  }
4298
- const dom = new import_jsdom.JSDOM(sanitized);
4299
- const iframes = dom.window.document.querySelectorAll("iframe");
4350
+ const iframes = findIframes(sanitized);
4300
4351
  if (iframes.length !== 1) {
4301
4352
  return null;
4302
4353
  }
4303
4354
  const iframe = iframes[0];
4304
- const src = iframe.getAttribute("src")?.trim();
4355
+ const src = iframe.attribs.src?.trim();
4305
4356
  if (!src) {
4306
4357
  return null;
4307
4358
  }
@@ -4395,8 +4446,9 @@ function renderBibliographyCite(ctx, data) {
4395
4446
  return;
4396
4447
  }
4397
4448
  const idSuffix = generateIdSuffix(data.label, counter);
4398
- const id = `bibcite-${number}-${idSuffix}`;
4399
- const onclick = `WIKIDOT.page.utils.scrollToReference('bibitem-${number}')`;
4449
+ const id = ctx.generateId(`bibcite-${number}-`, idSuffix);
4450
+ const bibitemId = ctx.generateId("bibitem-", number);
4451
+ const onclick = `WIKIDOT.page.utils.scrollToReference('${bibitemId}')`;
4400
4452
  ctx.push(`<a href="javascript:;" class="bibcite" id="${id}" onclick="${escapeAttr(onclick)}">`);
4401
4453
  ctx.push(String(number));
4402
4454
  ctx.push("</a>");
@@ -4409,7 +4461,8 @@ function renderBibliographyBlock(ctx, data, renderElements2) {
4409
4461
  ctx.push(`<div class="title">${escapeHtml(title)}</div>`);
4410
4462
  let index = 1;
4411
4463
  for (const entry of data.entries) {
4412
- ctx.push(`<div class="bibitem" id="bibitem-${index}">`);
4464
+ const itemId = ctx.generateId("bibitem-", index);
4465
+ ctx.push(`<div class="bibitem" id="${itemId}">`);
4413
4466
  ctx.push(`${index}. `);
4414
4467
  renderElements2(ctx, entry.value);
4415
4468
  ctx.push("</div>");
@@ -4442,12 +4495,19 @@ function renderTocList(ctx, listData, depth) {
4442
4495
  renderTocItem(ctx, item, depth);
4443
4496
  }
4444
4497
  }
4498
+ function rewriteTocAnchor(ctx, href) {
4499
+ const match = /^#toc(\d+)$/.exec(href);
4500
+ if (!match)
4501
+ return href;
4502
+ return `#${ctx.generateId("toc", Number(match[1]))}`;
4503
+ }
4445
4504
  function renderTocItem(ctx, item, depth) {
4446
4505
  if (item["item-type"] === "elements") {
4447
4506
  for (const el of item.elements) {
4448
4507
  const link = extractLinkText(el);
4449
4508
  if (link) {
4450
- ctx.push(`<div style="margin-left: ${depth}em;"><a href="${escapeHtml(link.href)}">${escapeHtml(link.text)}</a></div>`);
4509
+ const href = rewriteTocAnchor(ctx, link.href);
4510
+ ctx.push(`<div style="margin-left: ${depth}em;"><a href="${escapeAttr(href)}">${escapeHtml(link.text)}</a></div>`);
4451
4511
  }
4452
4512
  }
4453
4513
  } else if (item["item-type"] === "sub-list") {
@@ -5012,7 +5072,7 @@ function formatNumber(n) {
5012
5072
  function renderToHtml(tree, options = {}) {
5013
5073
  const ctx = new RenderContext(tree, options);
5014
5074
  renderElements(ctx, tree.elements);
5015
- if (tree.styles?.length) {
5075
+ if (ctx.settings.allowStyleElements && tree.styles?.length) {
5016
5076
  for (const style of tree.styles) {
5017
5077
  ctx.push(`<style>${escapeStyleContent(style)}</style>`);
5018
5078
  }
@@ -5156,3 +5216,6 @@ function renderElement(ctx, element) {
5156
5216
  break;
5157
5217
  }
5158
5218
  }
5219
+
5220
+ // packages/render/src/index.ts
5221
+ var import_ast3 = require("@wdprlib/ast");
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { SyntaxTree as SyntaxTree2 } from "@wdprlib/ast";
2
- import { Element } from "@wdprlib/ast";
2
+ import { Element, WikitextSettings } from "@wdprlib/ast";
3
3
  /**
4
4
  * Allowlist entry for embed content validation
5
5
  * Each entry specifies a host pattern and optional path prefix
@@ -15,7 +15,7 @@ interface EmbedAllowlistEntry {
15
15
  * Only iframes with src matching these host+path patterns will be rendered.
16
16
  *
17
17
  * Note: Set to null to allow any HTTPS iframe (Wikidot's 'anyiframe' behavior).
18
- * DOMPurify still enforces HTTPS-only and blocks dangerous attributes.
18
+ * sanitize-html still enforces HTTPS-only and blocks dangerous attributes.
19
19
  */
20
20
  declare const DEFAULT_EMBED_ALLOWLIST: EmbedAllowlistEntry[] | null;
21
21
  /**
@@ -70,6 +70,8 @@ interface RenderResolvers {
70
70
  * Options for HTML rendering
71
71
  */
72
72
  interface RenderOptions {
73
+ /** Wikitext settings controlling rendering behavior */
74
+ settings?: WikitextSettings;
73
75
  /** Page context for resolving file paths, links, etc. */
74
76
  page?: PageContext;
75
77
  /** Pre-collected footnote elements from SyntaxTree.footnotes */
@@ -100,4 +102,6 @@ interface RenderOptions {
100
102
  * Render a SyntaxTree to HTML string
101
103
  */
102
104
  declare function renderToHtml(tree: SyntaxTree2, options?: RenderOptions): string;
103
- export { renderToHtml, ResolvedUser, RenderResolvers, RenderOptions, PageContext, DEFAULT_EMBED_ALLOWLIST };
105
+ import { WikitextMode, WikitextSettings as WikitextSettings3 } from "@wdprlib/ast";
106
+ import { createSettings, DEFAULT_SETTINGS } from "@wdprlib/ast";
107
+ export { renderToHtml, createSettings, WikitextSettings3 as WikitextSettings, WikitextMode, ResolvedUser, RenderResolvers, RenderOptions, PageContext, DEFAULT_SETTINGS, DEFAULT_EMBED_ALLOWLIST };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { SyntaxTree as SyntaxTree2 } from "@wdprlib/ast";
2
- import { Element } from "@wdprlib/ast";
2
+ import { Element, WikitextSettings } from "@wdprlib/ast";
3
3
  /**
4
4
  * Allowlist entry for embed content validation
5
5
  * Each entry specifies a host pattern and optional path prefix
@@ -15,7 +15,7 @@ interface EmbedAllowlistEntry {
15
15
  * Only iframes with src matching these host+path patterns will be rendered.
16
16
  *
17
17
  * Note: Set to null to allow any HTTPS iframe (Wikidot's 'anyiframe' behavior).
18
- * DOMPurify still enforces HTTPS-only and blocks dangerous attributes.
18
+ * sanitize-html still enforces HTTPS-only and blocks dangerous attributes.
19
19
  */
20
20
  declare const DEFAULT_EMBED_ALLOWLIST: EmbedAllowlistEntry[] | null;
21
21
  /**
@@ -70,6 +70,8 @@ interface RenderResolvers {
70
70
  * Options for HTML rendering
71
71
  */
72
72
  interface RenderOptions {
73
+ /** Wikitext settings controlling rendering behavior */
74
+ settings?: WikitextSettings;
73
75
  /** Page context for resolving file paths, links, etc. */
74
76
  page?: PageContext;
75
77
  /** Pre-collected footnote elements from SyntaxTree.footnotes */
@@ -100,4 +102,6 @@ interface RenderOptions {
100
102
  * Render a SyntaxTree to HTML string
101
103
  */
102
104
  declare function renderToHtml(tree: SyntaxTree2, options?: RenderOptions): string;
103
- export { renderToHtml, ResolvedUser, RenderResolvers, RenderOptions, PageContext, DEFAULT_EMBED_ALLOWLIST };
105
+ import { WikitextMode, WikitextSettings as WikitextSettings3 } from "@wdprlib/ast";
106
+ import { createSettings, DEFAULT_SETTINGS } from "@wdprlib/ast";
107
+ export { renderToHtml, createSettings, WikitextSettings3 as WikitextSettings, WikitextMode, ResolvedUser, RenderResolvers, RenderOptions, PageContext, DEFAULT_SETTINGS, DEFAULT_EMBED_ALLOWLIST };
package/dist/index.js CHANGED
@@ -1,3 +1,6 @@
1
+ // packages/render/src/context.ts
2
+ import { DEFAULT_SETTINGS } from "@wdprlib/ast";
3
+
1
4
  // packages/render/src/escape.ts
2
5
  function escapeHtml(text) {
3
6
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -368,6 +371,8 @@ class RenderContext {
368
371
  _equationIndex = 0;
369
372
  _htmlBlockIndex = 0;
370
373
  _bibciteCounter = 0;
374
+ _idSuffix;
375
+ settings;
371
376
  options;
372
377
  footnotes;
373
378
  styles;
@@ -376,6 +381,8 @@ class RenderContext {
376
381
  bibliographyMap;
377
382
  bibliographyEntries;
378
383
  constructor(tree, options = {}) {
384
+ this.settings = options.settings ?? DEFAULT_SETTINGS;
385
+ this._idSuffix = this.settings.useTrueIds ? null : Math.random().toString(16).slice(2, 8);
379
386
  this.options = options;
380
387
  this.footnotes = options.footnotes ?? tree.footnotes ?? [];
381
388
  this.styles = tree.styles ?? [];
@@ -429,6 +436,18 @@ class RenderContext {
429
436
  nextBibciteCounter() {
430
437
  return ++this._bibciteCounter;
431
438
  }
439
+ generateId(prefix, index) {
440
+ if (this._idSuffix === null) {
441
+ return `${prefix}${index}`;
442
+ }
443
+ return `${prefix}${index}-${this._idSuffix}`;
444
+ }
445
+ generateFixedId(name) {
446
+ if (this._idSuffix === null) {
447
+ return name;
448
+ }
449
+ return `${name}-${this._idSuffix}`;
450
+ }
432
451
  get page() {
433
452
  return this.options.page;
434
453
  }
@@ -438,15 +457,23 @@ class RenderContext {
438
457
  case "url": {
439
458
  const url = source.data;
440
459
  if (url.startsWith("/") && !url.startsWith("//")) {
460
+ if (!this.settings.allowLocalPaths)
461
+ return null;
441
462
  return `/local--files${url}`;
442
463
  }
443
464
  return url;
444
465
  }
445
466
  case "file1":
467
+ if (!this.settings.allowLocalPaths)
468
+ return null;
446
469
  return pageName ? `/local--files/${pageName}/${source.data.file}` : `/local--files/${source.data.file}`;
447
470
  case "file2":
471
+ if (!this.settings.allowLocalPaths)
472
+ return null;
448
473
  return `/local--files/${source.data.page}/${source.data.file}`;
449
474
  case "file3":
475
+ if (!this.settings.allowLocalPaths)
476
+ return null;
450
477
  return `/local--files/${source.data.site}/${source.data.page}/${source.data.file}`;
451
478
  }
452
479
  }
@@ -518,8 +545,8 @@ function renderContainer(ctx, data) {
518
545
  function renderHeader(ctx, level, hasToc, attributes, elements) {
519
546
  const tag = `h${level}`;
520
547
  if (hasToc) {
521
- const tocId = ctx.nextTocIndex();
522
- ctx.push(`<${tag} id="toc${tocId}"${renderAttrs(attributes)}>`);
548
+ const tocId = ctx.generateId("toc", ctx.nextTocIndex());
549
+ ctx.push(`<${tag} id="${tocId}"${renderAttrs(attributes)}>`);
523
550
  } else {
524
551
  ctx.push(`<${tag}${renderAttrs(attributes)}>`);
525
552
  }
@@ -805,6 +832,8 @@ function renderAnchorName(ctx, name) {
805
832
  // packages/render/src/elements/image.ts
806
833
  function renderImage(ctx, data) {
807
834
  let src = ctx.resolveImageSource(data.source);
835
+ if (src === null)
836
+ return;
808
837
  if (isDangerousUrl(src)) {
809
838
  src = "#invalid-url";
810
839
  }
@@ -3889,7 +3918,7 @@ function fnv1aHash(input, hexLen) {
3889
3918
  function renderTabView(ctx, tabs) {
3890
3919
  const labelString = tabs.map((t) => t.label).join("");
3891
3920
  const hash = md5Hash(labelString);
3892
- const widgetId = `wiki-tabview-${hash}`;
3921
+ const widgetId = ctx.generateFixedId(`wiki-tabview-${hash}`);
3893
3922
  ctx.push(`<div id="${widgetId}" class="yui-navset">`);
3894
3923
  ctx.push(`<ul class="yui-nav">`);
3895
3924
  for (let i = 0;i < tabs.length; i++) {
@@ -3904,7 +3933,8 @@ function renderTabView(ctx, tabs) {
3904
3933
  for (let i = 0;i < tabs.length; i++) {
3905
3934
  const tab = tabs[i];
3906
3935
  const displayStyle = i === 0 ? "" : ` style="display:none"`;
3907
- ctx.push(`<div id="wiki-tab-0-${i}"${displayStyle}>`);
3936
+ const tabId = ctx.generateId("wiki-tab-0-", i);
3937
+ ctx.push(`<div id="${tabId}"${displayStyle}>`);
3908
3938
  renderElements(ctx, tab.elements);
3909
3939
  ctx.push("</div>");
3910
3940
  }
@@ -3917,8 +3947,9 @@ function md5Hash(input) {
3917
3947
 
3918
3948
  // packages/render/src/elements/footnote.ts
3919
3949
  function renderFootnoteRef(ctx, index) {
3950
+ const id = ctx.generateId("footnoteref-", index);
3920
3951
  ctx.push(`<sup class="footnoteref">`);
3921
- ctx.push(`<a id="footnoteref-${index}" href="javascript:;" class="footnoteref">${index}</a>`);
3952
+ ctx.push(`<a id="${id}" href="javascript:;" class="footnoteref">${index}</a>`);
3922
3953
  ctx.push("</sup>");
3923
3954
  }
3924
3955
  function renderFootnoteBlock(ctx, data) {
@@ -3930,7 +3961,8 @@ function renderFootnoteBlock(ctx, data) {
3930
3961
  for (let i = 0;i < ctx.footnotes.length; i++) {
3931
3962
  const index = i + 1;
3932
3963
  const elements = ctx.footnotes[i] ?? [];
3933
- ctx.push(`<div class="footnote-footer" id="footnote-${index}">`);
3964
+ const fnId = ctx.generateId("footnote-", index);
3965
+ ctx.push(`<div class="footnote-footer" id="${fnId}">`);
3934
3966
  ctx.push(`<a href="javascript:;">${index}</a>. `);
3935
3967
  renderElements(ctx, elements);
3936
3968
  ctx.push("</div>");
@@ -3968,7 +4000,7 @@ function renderMath(ctx, data) {
3968
4000
  const index = ctx.nextEquationIndex() + 1;
3969
4001
  const latex = data["latex-source"];
3970
4002
  const mathml = renderLatexToMathML(latex, true);
3971
- const id = data.name ? `equation-${data.name}` : `equation-${index}`;
4003
+ const id = data.name ? ctx.generateId("equation-", data.name) : ctx.generateId("equation-", index);
3972
4004
  const dataName = data.name ? ` data-name="${escapeAttr(data.name)}"` : "";
3973
4005
  ctx.push(`<div class="math-block" id="${escapeAttr(id)}"${dataName}>`);
3974
4006
  if (data.name) {
@@ -4007,7 +4039,7 @@ function renderMathInline(ctx, data) {
4007
4039
  ctx.push("</span>");
4008
4040
  }
4009
4041
  function renderEquationRef(ctx, name) {
4010
- const id = `equation-${name}`;
4042
+ const id = ctx.generateId("equation-", name);
4011
4043
  ctx.push(`<span class="eref" data-target="${escapeAttr(id)}">`);
4012
4044
  ctx.push(`<a class="eref-link" href="#${escapeAttr(id)}">`);
4013
4045
  ctx.push(escapeHtml(name));
@@ -4159,8 +4191,8 @@ function renderGitlabSnippet(ctx, snippetId) {
4159
4191
  }
4160
4192
 
4161
4193
  // packages/render/src/elements/embed-block.ts
4162
- import DOMPurify from "dompurify";
4163
- import { JSDOM } from "jsdom";
4194
+ import { parseDocument } from "htmlparser2";
4195
+ import sanitizeHtml from "sanitize-html";
4164
4196
  var BOOLEAN_ATTRIBUTES = [
4165
4197
  "allowfullscreen",
4166
4198
  "async",
@@ -4194,21 +4226,42 @@ var DEFAULT_EMBED_ALLOWLIST = [
4194
4226
  { host: "w.soundcloud.com", pathPrefix: "/player/" },
4195
4227
  { host: "codepen.io" }
4196
4228
  ];
4197
- var window = new JSDOM("").window;
4198
- var purify = DOMPurify(window);
4199
- purify.addHook("uponSanitizeAttribute", (_node, data) => {
4200
- if (data.attrName === "src" && data.attrValue) {
4201
- if (!data.attrValue.toLowerCase().startsWith("https://")) {
4202
- data.attrValue = "";
4203
- data.forceKeepAttr = false;
4229
+ var SANITIZE_CONFIG = {
4230
+ allowedTags: ["iframe"],
4231
+ allowedAttributes: {
4232
+ iframe: [
4233
+ "src",
4234
+ "allow",
4235
+ "allowfullscreen",
4236
+ "frameborder",
4237
+ "height",
4238
+ "loading",
4239
+ "referrerpolicy",
4240
+ "sandbox",
4241
+ "title",
4242
+ "width"
4243
+ ]
4244
+ },
4245
+ allowedSchemes: ["https"]
4246
+ };
4247
+ function findIframes(html) {
4248
+ const doc = parseDocument(html);
4249
+ const iframes = [];
4250
+ function walk(nodes) {
4251
+ for (const node of nodes) {
4252
+ if (node.type === "tag") {
4253
+ if (node.name === "iframe") {
4254
+ iframes.push(node);
4255
+ }
4256
+ if (node.children) {
4257
+ walk(node.children);
4258
+ }
4259
+ }
4204
4260
  }
4205
4261
  }
4206
- });
4207
- var DOMPURIFY_CONFIG = {
4208
- ALLOWED_TAGS: ["iframe"],
4209
- ADD_ATTR: ["allow", "allowfullscreen", "frameborder", "loading", "referrerpolicy", "sandbox"],
4210
- FORBID_ATTR: ["srcdoc", "onload", "onerror", "onclick"]
4211
- };
4262
+ walk(doc.children);
4263
+ return iframes;
4264
+ }
4212
4265
  function matchesHostPattern(hostname, pattern) {
4213
4266
  const lowerHostname = hostname.toLowerCase();
4214
4267
  const lowerPattern = pattern.toLowerCase();
@@ -4238,20 +4291,16 @@ function matchesAllowlistEntry(url, entry) {
4238
4291
  return true;
4239
4292
  }
4240
4293
  function validateAndSanitizeEmbed(content, allowlist) {
4241
- const sanitized = purify.sanitize(content.trim(), {
4242
- ...DOMPURIFY_CONFIG,
4243
- RETURN_TRUSTED_TYPE: false
4244
- });
4294
+ const sanitized = sanitizeHtml(content.trim(), SANITIZE_CONFIG);
4245
4295
  if (!sanitized.trim()) {
4246
4296
  return null;
4247
4297
  }
4248
- const dom = new JSDOM(sanitized);
4249
- const iframes = dom.window.document.querySelectorAll("iframe");
4298
+ const iframes = findIframes(sanitized);
4250
4299
  if (iframes.length !== 1) {
4251
4300
  return null;
4252
4301
  }
4253
4302
  const iframe = iframes[0];
4254
- const src = iframe.getAttribute("src")?.trim();
4303
+ const src = iframe.attribs.src?.trim();
4255
4304
  if (!src) {
4256
4305
  return null;
4257
4306
  }
@@ -4345,8 +4394,9 @@ function renderBibliographyCite(ctx, data) {
4345
4394
  return;
4346
4395
  }
4347
4396
  const idSuffix = generateIdSuffix(data.label, counter);
4348
- const id = `bibcite-${number}-${idSuffix}`;
4349
- const onclick = `WIKIDOT.page.utils.scrollToReference('bibitem-${number}')`;
4397
+ const id = ctx.generateId(`bibcite-${number}-`, idSuffix);
4398
+ const bibitemId = ctx.generateId("bibitem-", number);
4399
+ const onclick = `WIKIDOT.page.utils.scrollToReference('${bibitemId}')`;
4350
4400
  ctx.push(`<a href="javascript:;" class="bibcite" id="${id}" onclick="${escapeAttr(onclick)}">`);
4351
4401
  ctx.push(String(number));
4352
4402
  ctx.push("</a>");
@@ -4359,7 +4409,8 @@ function renderBibliographyBlock(ctx, data, renderElements2) {
4359
4409
  ctx.push(`<div class="title">${escapeHtml(title)}</div>`);
4360
4410
  let index = 1;
4361
4411
  for (const entry of data.entries) {
4362
- ctx.push(`<div class="bibitem" id="bibitem-${index}">`);
4412
+ const itemId = ctx.generateId("bibitem-", index);
4413
+ ctx.push(`<div class="bibitem" id="${itemId}">`);
4363
4414
  ctx.push(`${index}. `);
4364
4415
  renderElements2(ctx, entry.value);
4365
4416
  ctx.push("</div>");
@@ -4392,12 +4443,19 @@ function renderTocList(ctx, listData, depth) {
4392
4443
  renderTocItem(ctx, item, depth);
4393
4444
  }
4394
4445
  }
4446
+ function rewriteTocAnchor(ctx, href) {
4447
+ const match = /^#toc(\d+)$/.exec(href);
4448
+ if (!match)
4449
+ return href;
4450
+ return `#${ctx.generateId("toc", Number(match[1]))}`;
4451
+ }
4395
4452
  function renderTocItem(ctx, item, depth) {
4396
4453
  if (item["item-type"] === "elements") {
4397
4454
  for (const el of item.elements) {
4398
4455
  const link = extractLinkText(el);
4399
4456
  if (link) {
4400
- ctx.push(`<div style="margin-left: ${depth}em;"><a href="${escapeHtml(link.href)}">${escapeHtml(link.text)}</a></div>`);
4457
+ const href = rewriteTocAnchor(ctx, link.href);
4458
+ ctx.push(`<div style="margin-left: ${depth}em;"><a href="${escapeAttr(href)}">${escapeHtml(link.text)}</a></div>`);
4401
4459
  }
4402
4460
  }
4403
4461
  } else if (item["item-type"] === "sub-list") {
@@ -4962,7 +5020,7 @@ function formatNumber(n) {
4962
5020
  function renderToHtml(tree, options = {}) {
4963
5021
  const ctx = new RenderContext(tree, options);
4964
5022
  renderElements(ctx, tree.elements);
4965
- if (tree.styles?.length) {
5023
+ if (ctx.settings.allowStyleElements && tree.styles?.length) {
4966
5024
  for (const style of tree.styles) {
4967
5025
  ctx.push(`<style>${escapeStyleContent(style)}</style>`);
4968
5026
  }
@@ -5106,7 +5164,12 @@ function renderElement(ctx, element) {
5106
5164
  break;
5107
5165
  }
5108
5166
  }
5167
+
5168
+ // packages/render/src/index.ts
5169
+ import { createSettings, DEFAULT_SETTINGS as DEFAULT_SETTINGS2 } from "@wdprlib/ast";
5109
5170
  export {
5110
5171
  renderToHtml,
5172
+ createSettings,
5173
+ DEFAULT_SETTINGS2 as DEFAULT_SETTINGS,
5111
5174
  DEFAULT_EMBED_ALLOWLIST
5112
5175
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wdprlib/render",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "HTML renderer for Wikidot markup",
5
5
  "keywords": [
6
6
  "html",
@@ -39,13 +39,13 @@
39
39
  "access": "public"
40
40
  },
41
41
  "dependencies": {
42
- "@wdprlib/ast": "1.0.0",
43
- "dompurify": "^3.3.1",
44
- "jsdom": "^28.0.0",
42
+ "@wdprlib/ast": "1.1.0",
43
+ "domhandler": "^5.0.3",
44
+ "htmlparser2": "^10.0.0",
45
+ "sanitize-html": "^2.14.0",
45
46
  "temml": "^0.13.1"
46
47
  },
47
48
  "devDependencies": {
48
- "@types/dompurify": "^3.2.0",
49
- "@types/jsdom": "^27.0.0"
49
+ "@types/sanitize-html": "^2.13.0"
50
50
  }
51
51
  }