@wdprlib/render 1.2.0 → 1.2.3

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,12 +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
+ createSettings: () => import_ast4.createSettings,
48
+ DEFAULT_SETTINGS: () => import_ast4.DEFAULT_SETTINGS,
49
49
  DEFAULT_EMBED_ALLOWLIST: () => DEFAULT_EMBED_ALLOWLIST
50
50
  });
51
51
  module.exports = __toCommonJS(exports_src);
52
52
 
53
+ // packages/render/src/render.ts
54
+ var import_ast3 = require("@wdprlib/ast");
55
+
53
56
  // packages/render/src/context.ts
54
57
  var import_ast = require("@wdprlib/ast");
55
58
 
@@ -324,11 +327,17 @@ function isValidCssColor(color) {
324
327
  if (/^#[0-9a-f]{3}([0-9a-f])?$/.test(trimmed) || /^#[0-9a-f]{6}([0-9a-f]{2})?$/.test(trimmed)) {
325
328
  return true;
326
329
  }
327
- if (/^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0|1|0?\.\d+))?\s*\)$/.test(trimmed)) {
328
- return true;
329
- }
330
- if (/^hsla?\(\s*\d{1,3}\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*(,\s*(0|1|0?\.\d+))?\s*\)$/.test(trimmed)) {
331
- return true;
330
+ const fnMatch = trimmed.match(/^(rgba?|hsla?)\(([^)]*)\)$/);
331
+ if (fnMatch) {
332
+ const fn = fnMatch[1];
333
+ const args = fnMatch[2].split(",").map((s) => s.trim()).join(",");
334
+ if (fn.startsWith("rgb")) {
335
+ if (/^\d{1,3},\d{1,3},\d{1,3}(,(0|1|0?\.\d+))?$/.test(args))
336
+ return true;
337
+ } else {
338
+ if (/^\d{1,3},\d{1,3}%,\d{1,3}%(,(0|1|0?\.\d+))?$/.test(args))
339
+ return true;
340
+ }
332
341
  }
333
342
  return false;
334
343
  }
@@ -418,6 +427,9 @@ function sanitizeAttributes(attributes) {
418
427
  // packages/render/src/context.ts
419
428
  class RenderContext {
420
429
  chunks = [];
430
+ renderInlineStyles = false;
431
+ _styleSlotId = null;
432
+ _styleSlotContents = new Map;
421
433
  _tocIndex = 0;
422
434
  _footnoteIndex = 0;
423
435
  _equationIndex = 0;
@@ -473,6 +485,26 @@ class RenderContext {
473
485
  getOutput() {
474
486
  return this.chunks.join("");
475
487
  }
488
+ enterStyleSlot(slotId) {
489
+ this._styleSlotId = slotId;
490
+ if (!this._styleSlotContents.has(slotId)) {
491
+ this._styleSlotContents.set(slotId, []);
492
+ }
493
+ }
494
+ exitStyleSlot() {
495
+ this._styleSlotId = null;
496
+ }
497
+ hasActiveStyleSlot() {
498
+ return this._styleSlotId !== null;
499
+ }
500
+ pushToStyleSlot(css) {
501
+ if (this._styleSlotId !== null) {
502
+ this._styleSlotContents.get(this._styleSlotId).push(css);
503
+ }
504
+ }
505
+ getStyleSlotContents(slotId) {
506
+ return this._styleSlotContents.get(slotId) ?? [];
507
+ }
476
508
  nextTocIndex() {
477
509
  return this._tocIndex++;
478
510
  }
@@ -4117,7 +4149,7 @@ function renderJoin(ctx, data) {
4117
4149
  const buttonText = data["button-text"] ?? "Join";
4118
4150
  const attrs = data.attributes ?? {};
4119
4151
  const className = attrs.class ?? "join-box";
4120
- ctx.push(`<div class="${escapeHtml(className)}">`);
4152
+ ctx.push(`<div class="${escapeAttr(className)}">`);
4121
4153
  ctx.push(`<a href="javascript:;">${escapeHtml(buttonText)}</a>`);
4122
4154
  ctx.push("</div>");
4123
4155
  }
@@ -4610,13 +4642,20 @@ function evaluateIfTagsCondition(condition, pageTags) {
4610
4642
  const optional = [];
4611
4643
  for (const token of tokens) {
4612
4644
  if (token.startsWith("+")) {
4613
- required.push(token.slice(1).toLowerCase());
4645
+ const tag = token.slice(1).toLowerCase();
4646
+ if (tag)
4647
+ required.push(tag);
4614
4648
  } else if (token.startsWith("-")) {
4615
- excluded.push(token.slice(1).toLowerCase());
4649
+ const tag = token.slice(1).toLowerCase();
4650
+ if (tag)
4651
+ excluded.push(tag);
4616
4652
  } else {
4617
4653
  optional.push(token.toLowerCase());
4618
4654
  }
4619
4655
  }
4656
+ if (required.length === 0 && excluded.length === 0 && optional.length === 0) {
4657
+ return false;
4658
+ }
4620
4659
  for (const tag of required) {
4621
4660
  if (!pageTagSet.has(tag))
4622
4661
  return false;
@@ -4635,7 +4674,17 @@ function evaluateIfTagsCondition(condition, pageTags) {
4635
4674
  function renderIfTags(ctx, data) {
4636
4675
  const pageTags = ctx.page?.tags ?? [];
4637
4676
  if (evaluateIfTagsCondition(data.condition, pageTags)) {
4677
+ const prev = ctx.renderInlineStyles;
4678
+ ctx.renderInlineStyles = true;
4679
+ const slotId = data._styleSlot;
4680
+ if (slotId !== undefined) {
4681
+ ctx.enterStyleSlot(slotId);
4682
+ }
4638
4683
  renderElements(ctx, data.elements);
4684
+ if (slotId !== undefined) {
4685
+ ctx.exitStyleSlot();
4686
+ }
4687
+ ctx.renderInlineStyles = prev;
4639
4688
  }
4640
4689
  }
4641
4690
 
@@ -5079,7 +5128,14 @@ function renderToHtml(tree, options = {}) {
5079
5128
  renderElements(ctx, tree.elements);
5080
5129
  if (ctx.settings.allowStyleElements && tree.styles?.length) {
5081
5130
  for (const style of tree.styles) {
5082
- ctx.push(`<style>${escapeStyleContent(style)}</style>`);
5131
+ if (style.startsWith(import_ast3.STYLE_SLOT_PREFIX)) {
5132
+ const slotId = parseInt(style.slice(import_ast3.STYLE_SLOT_PREFIX.length), 10);
5133
+ for (const css of ctx.getStyleSlotContents(slotId)) {
5134
+ ctx.push(`<style>${escapeStyleContent(css)}</style>`);
5135
+ }
5136
+ } else {
5137
+ ctx.push(`<style>${escapeStyleContent(style)}</style>`);
5138
+ }
5083
5139
  }
5084
5140
  }
5085
5141
  return ctx.getOutput();
@@ -5191,6 +5247,13 @@ function renderElement(ctx, element) {
5191
5247
  renderIfTags(ctx, element.data);
5192
5248
  break;
5193
5249
  case "style":
5250
+ if (ctx.renderInlineStyles && ctx.settings.allowStyleElements) {
5251
+ if (ctx.hasActiveStyleSlot()) {
5252
+ ctx.pushToStyleSlot(element.data);
5253
+ } else {
5254
+ ctx.push(`<style>${escapeStyleContent(element.data)}</style>`);
5255
+ }
5256
+ }
5194
5257
  break;
5195
5258
  case "line-break":
5196
5259
  ctx.push("<br />");
@@ -5223,4 +5286,4 @@ function renderElement(ctx, element) {
5223
5286
  }
5224
5287
 
5225
5288
  // packages/render/src/index.ts
5226
- var import_ast3 = require("@wdprlib/ast");
5289
+ var import_ast4 = require("@wdprlib/ast");
package/dist/index.d.cts CHANGED
@@ -19,71 +19,113 @@ interface EmbedAllowlistEntry {
19
19
  */
20
20
  declare const DEFAULT_EMBED_ALLOWLIST: EmbedAllowlistEntry[] | null;
21
21
  /**
22
- * Page context for resolving links, images, etc.
22
+ * Contextual information about the wiki page being rendered.
23
+ *
24
+ * The renderer uses this to resolve relative links, local file paths,
25
+ * page-existence checks (adding a `"newpage"` CSS class to links
26
+ * targeting non-existent pages), and `[[iftags]]` evaluation.
27
+ *
28
+ * @group Render Options
23
29
  */
24
30
  interface PageContext {
25
- /** Current page's full name (e.g., "secret:test2") */
31
+ /** Full page name including category prefix (e.g. `"secret:test2"`) */
26
32
  pageName: string;
27
- /** Site slug (e.g., "scp-wiki") */
33
+ /** Site slug used to build inter-site URLs (e.g. `"scp-wiki"`) */
28
34
  site?: string;
29
- /** Site domain (e.g., "scp-wiki.wikidot.com") */
35
+ /** Site domain used for absolute URL generation (e.g. `"scp-wiki.wikidot.com"`) */
30
36
  domain?: string;
31
- /** Check if a page exists (for "newpage" class on links) */
37
+ /**
38
+ * Returns whether a page exists on the site.
39
+ * When a target page does not exist, the renderer adds `class="newpage"`
40
+ * to the link element — the standard Wikidot convention for red-links.
41
+ */
32
42
  pageExists?: (page: string) => boolean;
33
- /** Page tags for [[iftags]] conditional rendering */
43
+ /** Page tags used for client-side `[[iftags]]` evaluation during rendering */
34
44
  tags?: string[];
35
45
  }
36
46
  /**
37
- * Resolved user information for rendering
47
+ * User profile data returned by a user-resolver callback.
48
+ *
49
+ * Passed to the renderer to produce the Wikidot user-info markup
50
+ * (`[[user username]]`). When a field is omitted the corresponding
51
+ * UI element is simply not emitted.
52
+ *
53
+ * @group Render Options
38
54
  */
39
55
  interface ResolvedUser {
40
- /** User's display name (defaults to username if not provided) */
56
+ /** Display name shown in the rendered output (falls back to the raw username) */
41
57
  name?: string;
42
- /** User profile URL. If not provided, link becomes non-navigable */
58
+ /** Profile URL. When omitted the username is rendered as a non-navigable `<span>` */
43
59
  url?: string;
44
- /** Avatar image URL. If not provided, no avatar is rendered */
60
+ /** Avatar image URL. When omitted no avatar `<img>` is rendered */
45
61
  avatarUrl?: string;
46
- /** Karma image URL for avatar background (Wikidot-specific feature) */
62
+ /** Karma-badge image URL shown behind the avatar (Wikidot-specific feature) */
47
63
  karmaUrl?: string;
48
64
  }
49
65
  /**
50
- * Resolver functions for dynamic content
66
+ * Async/sync resolver callbacks for content that depends on external data.
67
+ *
68
+ * Unlike the parser's `DataProvider` (which fetches bulk data for
69
+ * module expansion), these resolvers are called per-element during the
70
+ * rendering pass.
71
+ *
72
+ * @group Render Options
51
73
  */
52
74
  interface RenderResolvers {
53
75
  /**
54
- * Resolve user information from username.
55
- * Returns user data for rendering, or null if user not found.
56
- * If not provided, user elements are rendered as plain text.
76
+ * Look up a user profile by username.
77
+ *
78
+ * @param username - The raw username from `[[user username]]`
79
+ * @returns Profile data for rendering, or `null` if the user is unknown.
80
+ * When `null` or when the resolver is omitted, the username is
81
+ * rendered as plain text.
57
82
  */
58
83
  user?: (username: string) => ResolvedUser | null;
59
84
  /**
60
- * Returns URL for htmlBlock iframe src.
61
- * Called with the index of the htmlBlock (0-based, matching tree["html-blocks"] order).
62
- * If not provided or returns empty string, uses default pattern: /{pageName}/html/{hash}-{nonce}
85
+ * Build an iframe `src` URL for an `[[html]]` block.
63
86
  *
64
- * SECURITY NOTE: The returned URL is used directly in iframe src attribute.
65
- * The application is responsible for validating the URL scheme (e.g., rejecting javascript:, data:).
87
+ * @param index - Zero-based index matching `SyntaxTree["html-blocks"]`
88
+ * @returns The URL string. When empty or when the resolver is omitted,
89
+ * the default pattern `/{pageName}/html/{hash}-{nonce}` is used.
90
+ *
91
+ * @security The returned URL is injected directly into the iframe `src`
92
+ * attribute. The caller must validate the scheme to reject `javascript:`,
93
+ * `data:`, and other dangerous protocols.
66
94
  */
67
95
  htmlBlockUrl?: (index: number) => string;
68
96
  }
69
97
  /**
70
- * Options for HTML rendering
98
+ * Full configuration for `renderToHtml()`.
99
+ *
100
+ * Every field is optional; defaults produce safe, standalone HTML output
101
+ * suitable for a full wiki page.
102
+ *
103
+ * @group Render Options
71
104
  */
72
105
  interface RenderOptions {
73
106
  /**
74
- * Base URL used to resolve protocol-relative URLs (e.g., "//example.com/path").
75
- * The protocol of this URL is inherited by protocol-relative references.
76
- * Example: "https://scp-wiki.wikidot.com" or "http://scp-jp.wikidot.com"
77
- * If not provided, protocol-relative URLs default to HTTPS.
107
+ * Base URL for resolving protocol-relative URLs (e.g. `"//example.com/path"`).
108
+ *
109
+ * The scheme of this URL (`http:` or `https:`) is prepended to
110
+ * protocol-relative references. When omitted, HTTPS is assumed.
111
+ *
112
+ * @example "https://scp-wiki.wikidot.com"
78
113
  */
79
114
  baseUrl?: string;
80
- /** Wikitext settings controlling rendering behavior */
115
+ /**
116
+ * Context-dependent feature flags.
117
+ * Defaults to page-mode settings when omitted.
118
+ */
81
119
  settings?: WikitextSettings;
82
- /** Page context for resolving file paths, links, etc. */
120
+ /** Page context for resolving relative links, local file paths, etc. */
83
121
  page?: PageContext;
84
- /** Pre-collected footnote elements from SyntaxTree.footnotes */
122
+ /**
123
+ * Pre-collected footnote element arrays from `SyntaxTree.footnotes`.
124
+ * Passed through so the renderer can emit footnote bodies in the
125
+ * `[[footnoteblock]]` section.
126
+ */
85
127
  footnotes?: Element[][];
86
- /** Resolver functions for dynamic content */
128
+ /** Callbacks for resolving users, HTML-block URLs, etc. */
87
129
  resolvers?: RenderResolvers;
88
130
  /**
89
131
  * Sandbox attribute value for htmlBlock iframes.
@@ -106,7 +148,18 @@ interface RenderOptions {
106
148
  embedAllowlist?: EmbedAllowlistEntry[] | null;
107
149
  }
108
150
  /**
109
- * Render a SyntaxTree to HTML string
151
+ * Render a {@link SyntaxTree} to an HTML string.
152
+ *
153
+ * This is the main entry point of `@wdprlib/render`. It walks the AST
154
+ * produced by `@wdprlib/parser`, serialises each element to HTML, and
155
+ * appends any collected `[[module CSS]]` styles at the end (when
156
+ * `WikitextSettings.allowStyleElements` is `true`).
157
+ *
158
+ * @param tree - Parsed AST (from `parse()` or `resolveModules()`)
159
+ * @param options - Rendering configuration
160
+ * @returns Complete HTML string
161
+ *
162
+ * @group Render
110
163
  */
111
164
  declare function renderToHtml(tree: SyntaxTree2, options?: RenderOptions): string;
112
165
  import { WikitextMode, WikitextSettings as WikitextSettings3 } from "@wdprlib/ast";
package/dist/index.d.ts CHANGED
@@ -19,71 +19,113 @@ interface EmbedAllowlistEntry {
19
19
  */
20
20
  declare const DEFAULT_EMBED_ALLOWLIST: EmbedAllowlistEntry[] | null;
21
21
  /**
22
- * Page context for resolving links, images, etc.
22
+ * Contextual information about the wiki page being rendered.
23
+ *
24
+ * The renderer uses this to resolve relative links, local file paths,
25
+ * page-existence checks (adding a `"newpage"` CSS class to links
26
+ * targeting non-existent pages), and `[[iftags]]` evaluation.
27
+ *
28
+ * @group Render Options
23
29
  */
24
30
  interface PageContext {
25
- /** Current page's full name (e.g., "secret:test2") */
31
+ /** Full page name including category prefix (e.g. `"secret:test2"`) */
26
32
  pageName: string;
27
- /** Site slug (e.g., "scp-wiki") */
33
+ /** Site slug used to build inter-site URLs (e.g. `"scp-wiki"`) */
28
34
  site?: string;
29
- /** Site domain (e.g., "scp-wiki.wikidot.com") */
35
+ /** Site domain used for absolute URL generation (e.g. `"scp-wiki.wikidot.com"`) */
30
36
  domain?: string;
31
- /** Check if a page exists (for "newpage" class on links) */
37
+ /**
38
+ * Returns whether a page exists on the site.
39
+ * When a target page does not exist, the renderer adds `class="newpage"`
40
+ * to the link element — the standard Wikidot convention for red-links.
41
+ */
32
42
  pageExists?: (page: string) => boolean;
33
- /** Page tags for [[iftags]] conditional rendering */
43
+ /** Page tags used for client-side `[[iftags]]` evaluation during rendering */
34
44
  tags?: string[];
35
45
  }
36
46
  /**
37
- * Resolved user information for rendering
47
+ * User profile data returned by a user-resolver callback.
48
+ *
49
+ * Passed to the renderer to produce the Wikidot user-info markup
50
+ * (`[[user username]]`). When a field is omitted the corresponding
51
+ * UI element is simply not emitted.
52
+ *
53
+ * @group Render Options
38
54
  */
39
55
  interface ResolvedUser {
40
- /** User's display name (defaults to username if not provided) */
56
+ /** Display name shown in the rendered output (falls back to the raw username) */
41
57
  name?: string;
42
- /** User profile URL. If not provided, link becomes non-navigable */
58
+ /** Profile URL. When omitted the username is rendered as a non-navigable `<span>` */
43
59
  url?: string;
44
- /** Avatar image URL. If not provided, no avatar is rendered */
60
+ /** Avatar image URL. When omitted no avatar `<img>` is rendered */
45
61
  avatarUrl?: string;
46
- /** Karma image URL for avatar background (Wikidot-specific feature) */
62
+ /** Karma-badge image URL shown behind the avatar (Wikidot-specific feature) */
47
63
  karmaUrl?: string;
48
64
  }
49
65
  /**
50
- * Resolver functions for dynamic content
66
+ * Async/sync resolver callbacks for content that depends on external data.
67
+ *
68
+ * Unlike the parser's `DataProvider` (which fetches bulk data for
69
+ * module expansion), these resolvers are called per-element during the
70
+ * rendering pass.
71
+ *
72
+ * @group Render Options
51
73
  */
52
74
  interface RenderResolvers {
53
75
  /**
54
- * Resolve user information from username.
55
- * Returns user data for rendering, or null if user not found.
56
- * If not provided, user elements are rendered as plain text.
76
+ * Look up a user profile by username.
77
+ *
78
+ * @param username - The raw username from `[[user username]]`
79
+ * @returns Profile data for rendering, or `null` if the user is unknown.
80
+ * When `null` or when the resolver is omitted, the username is
81
+ * rendered as plain text.
57
82
  */
58
83
  user?: (username: string) => ResolvedUser | null;
59
84
  /**
60
- * Returns URL for htmlBlock iframe src.
61
- * Called with the index of the htmlBlock (0-based, matching tree["html-blocks"] order).
62
- * If not provided or returns empty string, uses default pattern: /{pageName}/html/{hash}-{nonce}
85
+ * Build an iframe `src` URL for an `[[html]]` block.
63
86
  *
64
- * SECURITY NOTE: The returned URL is used directly in iframe src attribute.
65
- * The application is responsible for validating the URL scheme (e.g., rejecting javascript:, data:).
87
+ * @param index - Zero-based index matching `SyntaxTree["html-blocks"]`
88
+ * @returns The URL string. When empty or when the resolver is omitted,
89
+ * the default pattern `/{pageName}/html/{hash}-{nonce}` is used.
90
+ *
91
+ * @security The returned URL is injected directly into the iframe `src`
92
+ * attribute. The caller must validate the scheme to reject `javascript:`,
93
+ * `data:`, and other dangerous protocols.
66
94
  */
67
95
  htmlBlockUrl?: (index: number) => string;
68
96
  }
69
97
  /**
70
- * Options for HTML rendering
98
+ * Full configuration for `renderToHtml()`.
99
+ *
100
+ * Every field is optional; defaults produce safe, standalone HTML output
101
+ * suitable for a full wiki page.
102
+ *
103
+ * @group Render Options
71
104
  */
72
105
  interface RenderOptions {
73
106
  /**
74
- * Base URL used to resolve protocol-relative URLs (e.g., "//example.com/path").
75
- * The protocol of this URL is inherited by protocol-relative references.
76
- * Example: "https://scp-wiki.wikidot.com" or "http://scp-jp.wikidot.com"
77
- * If not provided, protocol-relative URLs default to HTTPS.
107
+ * Base URL for resolving protocol-relative URLs (e.g. `"//example.com/path"`).
108
+ *
109
+ * The scheme of this URL (`http:` or `https:`) is prepended to
110
+ * protocol-relative references. When omitted, HTTPS is assumed.
111
+ *
112
+ * @example "https://scp-wiki.wikidot.com"
78
113
  */
79
114
  baseUrl?: string;
80
- /** Wikitext settings controlling rendering behavior */
115
+ /**
116
+ * Context-dependent feature flags.
117
+ * Defaults to page-mode settings when omitted.
118
+ */
81
119
  settings?: WikitextSettings;
82
- /** Page context for resolving file paths, links, etc. */
120
+ /** Page context for resolving relative links, local file paths, etc. */
83
121
  page?: PageContext;
84
- /** Pre-collected footnote elements from SyntaxTree.footnotes */
122
+ /**
123
+ * Pre-collected footnote element arrays from `SyntaxTree.footnotes`.
124
+ * Passed through so the renderer can emit footnote bodies in the
125
+ * `[[footnoteblock]]` section.
126
+ */
85
127
  footnotes?: Element[][];
86
- /** Resolver functions for dynamic content */
128
+ /** Callbacks for resolving users, HTML-block URLs, etc. */
87
129
  resolvers?: RenderResolvers;
88
130
  /**
89
131
  * Sandbox attribute value for htmlBlock iframes.
@@ -106,7 +148,18 @@ interface RenderOptions {
106
148
  embedAllowlist?: EmbedAllowlistEntry[] | null;
107
149
  }
108
150
  /**
109
- * Render a SyntaxTree to HTML string
151
+ * Render a {@link SyntaxTree} to an HTML string.
152
+ *
153
+ * This is the main entry point of `@wdprlib/render`. It walks the AST
154
+ * produced by `@wdprlib/parser`, serialises each element to HTML, and
155
+ * appends any collected `[[module CSS]]` styles at the end (when
156
+ * `WikitextSettings.allowStyleElements` is `true`).
157
+ *
158
+ * @param tree - Parsed AST (from `parse()` or `resolveModules()`)
159
+ * @param options - Rendering configuration
160
+ * @returns Complete HTML string
161
+ *
162
+ * @group Render
110
163
  */
111
164
  declare function renderToHtml(tree: SyntaxTree2, options?: RenderOptions): string;
112
165
  import { WikitextMode, WikitextSettings as WikitextSettings3 } from "@wdprlib/ast";
package/dist/index.js CHANGED
@@ -1,3 +1,6 @@
1
+ // packages/render/src/render.ts
2
+ import { STYLE_SLOT_PREFIX } from "@wdprlib/ast";
3
+
1
4
  // packages/render/src/context.ts
2
5
  import { DEFAULT_SETTINGS } from "@wdprlib/ast";
3
6
 
@@ -272,11 +275,17 @@ function isValidCssColor(color) {
272
275
  if (/^#[0-9a-f]{3}([0-9a-f])?$/.test(trimmed) || /^#[0-9a-f]{6}([0-9a-f]{2})?$/.test(trimmed)) {
273
276
  return true;
274
277
  }
275
- if (/^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0|1|0?\.\d+))?\s*\)$/.test(trimmed)) {
276
- return true;
277
- }
278
- if (/^hsla?\(\s*\d{1,3}\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*(,\s*(0|1|0?\.\d+))?\s*\)$/.test(trimmed)) {
279
- return true;
278
+ const fnMatch = trimmed.match(/^(rgba?|hsla?)\(([^)]*)\)$/);
279
+ if (fnMatch) {
280
+ const fn = fnMatch[1];
281
+ const args = fnMatch[2].split(",").map((s) => s.trim()).join(",");
282
+ if (fn.startsWith("rgb")) {
283
+ if (/^\d{1,3},\d{1,3},\d{1,3}(,(0|1|0?\.\d+))?$/.test(args))
284
+ return true;
285
+ } else {
286
+ if (/^\d{1,3},\d{1,3}%,\d{1,3}%(,(0|1|0?\.\d+))?$/.test(args))
287
+ return true;
288
+ }
280
289
  }
281
290
  return false;
282
291
  }
@@ -366,6 +375,9 @@ function sanitizeAttributes(attributes) {
366
375
  // packages/render/src/context.ts
367
376
  class RenderContext {
368
377
  chunks = [];
378
+ renderInlineStyles = false;
379
+ _styleSlotId = null;
380
+ _styleSlotContents = new Map;
369
381
  _tocIndex = 0;
370
382
  _footnoteIndex = 0;
371
383
  _equationIndex = 0;
@@ -421,6 +433,26 @@ class RenderContext {
421
433
  getOutput() {
422
434
  return this.chunks.join("");
423
435
  }
436
+ enterStyleSlot(slotId) {
437
+ this._styleSlotId = slotId;
438
+ if (!this._styleSlotContents.has(slotId)) {
439
+ this._styleSlotContents.set(slotId, []);
440
+ }
441
+ }
442
+ exitStyleSlot() {
443
+ this._styleSlotId = null;
444
+ }
445
+ hasActiveStyleSlot() {
446
+ return this._styleSlotId !== null;
447
+ }
448
+ pushToStyleSlot(css) {
449
+ if (this._styleSlotId !== null) {
450
+ this._styleSlotContents.get(this._styleSlotId).push(css);
451
+ }
452
+ }
453
+ getStyleSlotContents(slotId) {
454
+ return this._styleSlotContents.get(slotId) ?? [];
455
+ }
424
456
  nextTocIndex() {
425
457
  return this._tocIndex++;
426
458
  }
@@ -4065,7 +4097,7 @@ function renderJoin(ctx, data) {
4065
4097
  const buttonText = data["button-text"] ?? "Join";
4066
4098
  const attrs = data.attributes ?? {};
4067
4099
  const className = attrs.class ?? "join-box";
4068
- ctx.push(`<div class="${escapeHtml(className)}">`);
4100
+ ctx.push(`<div class="${escapeAttr(className)}">`);
4069
4101
  ctx.push(`<a href="javascript:;">${escapeHtml(buttonText)}</a>`);
4070
4102
  ctx.push("</div>");
4071
4103
  }
@@ -4558,13 +4590,20 @@ function evaluateIfTagsCondition(condition, pageTags) {
4558
4590
  const optional = [];
4559
4591
  for (const token of tokens) {
4560
4592
  if (token.startsWith("+")) {
4561
- required.push(token.slice(1).toLowerCase());
4593
+ const tag = token.slice(1).toLowerCase();
4594
+ if (tag)
4595
+ required.push(tag);
4562
4596
  } else if (token.startsWith("-")) {
4563
- excluded.push(token.slice(1).toLowerCase());
4597
+ const tag = token.slice(1).toLowerCase();
4598
+ if (tag)
4599
+ excluded.push(tag);
4564
4600
  } else {
4565
4601
  optional.push(token.toLowerCase());
4566
4602
  }
4567
4603
  }
4604
+ if (required.length === 0 && excluded.length === 0 && optional.length === 0) {
4605
+ return false;
4606
+ }
4568
4607
  for (const tag of required) {
4569
4608
  if (!pageTagSet.has(tag))
4570
4609
  return false;
@@ -4583,7 +4622,17 @@ function evaluateIfTagsCondition(condition, pageTags) {
4583
4622
  function renderIfTags(ctx, data) {
4584
4623
  const pageTags = ctx.page?.tags ?? [];
4585
4624
  if (evaluateIfTagsCondition(data.condition, pageTags)) {
4625
+ const prev = ctx.renderInlineStyles;
4626
+ ctx.renderInlineStyles = true;
4627
+ const slotId = data._styleSlot;
4628
+ if (slotId !== undefined) {
4629
+ ctx.enterStyleSlot(slotId);
4630
+ }
4586
4631
  renderElements(ctx, data.elements);
4632
+ if (slotId !== undefined) {
4633
+ ctx.exitStyleSlot();
4634
+ }
4635
+ ctx.renderInlineStyles = prev;
4587
4636
  }
4588
4637
  }
4589
4638
 
@@ -5027,7 +5076,14 @@ function renderToHtml(tree, options = {}) {
5027
5076
  renderElements(ctx, tree.elements);
5028
5077
  if (ctx.settings.allowStyleElements && tree.styles?.length) {
5029
5078
  for (const style of tree.styles) {
5030
- ctx.push(`<style>${escapeStyleContent(style)}</style>`);
5079
+ if (style.startsWith(STYLE_SLOT_PREFIX)) {
5080
+ const slotId = parseInt(style.slice(STYLE_SLOT_PREFIX.length), 10);
5081
+ for (const css of ctx.getStyleSlotContents(slotId)) {
5082
+ ctx.push(`<style>${escapeStyleContent(css)}</style>`);
5083
+ }
5084
+ } else {
5085
+ ctx.push(`<style>${escapeStyleContent(style)}</style>`);
5086
+ }
5031
5087
  }
5032
5088
  }
5033
5089
  return ctx.getOutput();
@@ -5139,6 +5195,13 @@ function renderElement(ctx, element) {
5139
5195
  renderIfTags(ctx, element.data);
5140
5196
  break;
5141
5197
  case "style":
5198
+ if (ctx.renderInlineStyles && ctx.settings.allowStyleElements) {
5199
+ if (ctx.hasActiveStyleSlot()) {
5200
+ ctx.pushToStyleSlot(element.data);
5201
+ } else {
5202
+ ctx.push(`<style>${escapeStyleContent(element.data)}</style>`);
5203
+ }
5204
+ }
5142
5205
  break;
5143
5206
  case "line-break":
5144
5207
  ctx.push("<br />");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wdprlib/render",
3
- "version": "1.2.0",
3
+ "version": "1.2.3",
4
4
  "description": "HTML renderer for Wikidot markup",
5
5
  "keywords": [
6
6
  "html",
@@ -39,7 +39,7 @@
39
39
  "access": "public"
40
40
  },
41
41
  "dependencies": {
42
- "@wdprlib/ast": "1.1.0",
42
+ "@wdprlib/ast": "1.1.1",
43
43
  "domhandler": "^5.0.3",
44
44
  "htmlparser2": "^10.0.0",
45
45
  "sanitize-html": "^2.14.0",