mnfst 0.5.81 → 0.5.83

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.
@@ -17,11 +17,8 @@ if (typeof window !== 'undefined') {
17
17
  });
18
18
  }
19
19
 
20
- // Cache for DOMPurify loading (only fetched when the .safe modifier is used)
21
- let purifyPromise = null;
22
-
23
20
  // DOMPurify config tuned for Manifest's markdown output. The markdown
24
- // extensions emit <x-code>, <x-icon>, and other x-* custom elements that must
21
+ // extensions emit <x-icon> custom elements and `x-*` directive attributes that must
25
22
  // survive sanitization, so custom-element handling is enabled with a
26
23
  // tag-name allowlist (x-*) and an attribute filter that rejects event
27
24
  // handlers (on*). DOMPurify's defaults handle <script>, javascript: URLs,
@@ -34,29 +31,36 @@ const MARKDOWN_PURIFY_CONFIG = {
34
31
  }
35
32
  };
36
33
 
37
- async function loadDOMPurify() {
38
- if (typeof window.DOMPurify !== 'undefined') return window.DOMPurify;
39
- if (purifyPromise) return purifyPromise;
40
- purifyPromise = new Promise((resolve, reject) => {
41
- const script = document.createElement('script');
42
- script.src = 'https://cdn.jsdelivr.net/npm/dompurify@latest/dist/purify.min.js';
43
- script.onload = () => {
44
- if (typeof window.DOMPurify !== 'undefined') {
45
- resolve(window.DOMPurify);
46
- } else {
47
- console.error('[Manifest Markdown] DOMPurify failed to load — DOMPurify is undefined');
48
- purifyPromise = null;
49
- reject(new Error('DOMPurify failed to load'));
50
- }
51
- };
52
- script.onerror = (err) => {
53
- console.error('[Manifest Markdown] DOMPurify script failed to load:', err);
54
- purifyPromise = null;
55
- reject(err);
56
- };
57
- document.head.appendChild(script);
58
- });
59
- return purifyPromise;
34
+ // DOMPurify loader is defined on window by whichever of svg/markdown loads
35
+ // first (see manifest.svg.js). Declaring `let purifyPromise` here at top
36
+ // level collides with svg.js's identical declaration in the realm's shared
37
+ // global lexical environment, so we use the shared loader instead.
38
+ if (!window.ManifestDOMPurify) {
39
+ window.ManifestDOMPurify = {
40
+ _promise: null,
41
+ load() {
42
+ if (typeof window.DOMPurify !== 'undefined') return Promise.resolve(window.DOMPurify);
43
+ if (this._promise) return this._promise;
44
+ this._promise = new Promise((resolve, reject) => {
45
+ const script = document.createElement('script');
46
+ script.src = 'https://cdn.jsdelivr.net/npm/dompurify@latest/dist/purify.min.js';
47
+ script.onload = () => {
48
+ if (typeof window.DOMPurify !== 'undefined') {
49
+ resolve(window.DOMPurify);
50
+ } else {
51
+ this._promise = null;
52
+ reject(new Error('DOMPurify failed to load'));
53
+ }
54
+ };
55
+ script.onerror = (err) => {
56
+ this._promise = null;
57
+ reject(err);
58
+ };
59
+ document.head.appendChild(script);
60
+ });
61
+ return this._promise;
62
+ }
63
+ };
60
64
  }
61
65
 
62
66
  // Sanitize HTML if the .safe modifier was used; pass-through otherwise.
@@ -67,7 +71,7 @@ async function loadDOMPurify() {
67
71
  async function maybeSanitizeMarkdownHtml(html, safe) {
68
72
  if (!safe) return html;
69
73
  try {
70
- const DOMPurify = await loadDOMPurify();
74
+ const DOMPurify = await window.ManifestDOMPurify.load();
71
75
  return DOMPurify.sanitize(html, MARKDOWN_PURIFY_CONFIG);
72
76
  } catch {
73
77
  // Loader failure — fall back to escaping rather than silently emitting
@@ -117,7 +121,7 @@ async function loadMarkedJS() {
117
121
  // HTML-escape a string for safe interpolation inside an attribute value.
118
122
  // Used by the code-fence renderer below — title/language strings come from
119
123
  // the markdown source, so without escaping a fence like ```js " onclick=alert(1) x="
120
- // could inject arbitrary attributes onto the <x-code> element.
124
+ // could inject arbitrary attributes onto the <pre> element.
121
125
  function escapeForAttribute(s) {
122
126
  return String(s)
123
127
  .replace(/&/g, '&amp;')
@@ -126,45 +130,49 @@ function escapeForAttribute(s) {
126
130
  .replace(/>/g, '&gt;');
127
131
  }
128
132
 
133
+ // Escape a literal HTML fragment so it displays as source text when placed
134
+ // inside a <code> element. Used for the ::: frame demo modifier, where the
135
+ // same content is rendered live AND shown below as its own source.
136
+ function escapeForText(s) {
137
+ return String(s)
138
+ .replace(/&/g, '&amp;')
139
+ .replace(/</g, '&lt;')
140
+ .replace(/>/g, '&gt;');
141
+ }
142
+
129
143
  // Configure marked to preserve full language strings
130
144
  async function configureMarked(marked) {
131
145
  marked.use({
132
146
  renderer: {
147
+ // Render fenced code blocks as <pre x-code="…"><code>…</code></pre>.
148
+ // The code plugin's directive then handles highlighting, copy
149
+ // buttons, collapse, line numbers, etc. — same code path whether
150
+ // the block was authored in HTML or markdown.
133
151
  code(token) {
134
152
  const lang = token.lang || '';
135
153
  const text = token.text || '';
136
- const escaped = token.escaped || false;
137
-
138
- // Parse the language string to extract attributes
139
- const attributes = parseLanguageString(lang);
140
154
 
141
- // Build attributes for the x-code element
142
- let xCodeAttributes = '';
143
- if (attributes.title) {
144
- xCodeAttributes += ` name="${escapeForAttribute(attributes.title)}"`;
145
- }
146
- if (attributes.language) {
147
- xCodeAttributes += ` language="${escapeForAttribute(attributes.language)}"`;
148
- }
149
- if (attributes.numbers) {
150
- xCodeAttributes += ' numbers';
151
- }
152
- if (attributes.copy) {
153
- xCodeAttributes += ' copy';
155
+ const attrs = parseLanguageString(lang);
156
+
157
+ let preAttrs = '';
158
+ // x-code carries the language as its value (empty string for
159
+ // "no explicit language; auto-detect")
160
+ preAttrs += ` x-code="${attrs.language ? escapeForAttribute(attrs.language) : ''}"`;
161
+ if (attrs.title) preAttrs += ` name="${escapeForAttribute(attrs.title)}"`;
162
+ if (attrs.lines) preAttrs += ' lines';
163
+ if (attrs.copy) preAttrs += ' copy';
164
+ if (attrs.edit) preAttrs += ' edit';
165
+ if (attrs.collapse !== null) {
166
+ preAttrs += attrs.collapse === ''
167
+ ? ' collapse'
168
+ : ` collapse="${escapeForAttribute(attrs.collapse)}"`;
154
169
  }
170
+ if (attrs.from) preAttrs += ` from="${escapeForAttribute(attrs.from)}"`;
155
171
 
156
- // For x-code elements, use the raw text to preserve formatting
157
- let code = text;
158
- let preserveOriginal = '';
159
-
160
- // For HTML language code blocks, preserve the original raw text to maintain indentation
161
- if (attributes.language === 'html' || text.includes('<!DOCTYPE') || (text.includes('<html') && text.includes('<head') && text.includes('<body'))) {
162
- // Store the original content in a data attribute to preserve indentation
163
- preserveOriginal = ` data-original-content="${text.replace(/"/g, '&quot;')}"`;
164
- }
165
-
166
- // Always create an x-code element, with or without attributes
167
- return `<x-code${xCodeAttributes}${preserveOriginal}>${code}</x-code>\n`;
172
+ // marked has already HTML-escaped `text`. It's safe to drop
173
+ // it inside <code> and let the code plugin read .textContent
174
+ // as the highlight source.
175
+ return `<pre${preAttrs}><code>${text}</code></pre>\n`;
168
176
  }
169
177
  },
170
178
  // Configure marked to allow custom HTML tags
@@ -219,9 +227,17 @@ async function configureMarked(marked) {
219
227
  }
220
228
  },
221
229
  renderer(token) {
222
- const classes = token.classes || '';
230
+ let classes = token.classes || '';
223
231
  const iconValue = token.iconValue || '';
224
232
 
233
+ // `::: frame demo` — render the frame contents live AND emit
234
+ // a sibling <pre x-code="html"> showing the same source. Lets
235
+ // authors write the example once and have it both rendered
236
+ // and documented. Strip `demo` from the class list so the
237
+ // resulting <aside> has just `frame`.
238
+ const isDemo = /\bframe\b/.test(classes) && /\bdemo\b/.test(classes);
239
+ if (isDemo) classes = classes.replace(/\bdemo\b/, '').replace(/\s+/g, ' ').trim();
240
+
225
241
  // For frame callouts, don't parse as markdown to avoid wrapping HTML in <p> tags
226
242
  let parsedContent;
227
243
  if (classes.includes('frame')) {
@@ -247,7 +263,11 @@ async function configureMarked(marked) {
247
263
  `<div>${parsedContent}</div>` :
248
264
  parsedContent;
249
265
 
250
- return `<aside${classes ? ` class="${classes}"` : ''}>${iconHtml}${wrappedContent}</aside>\n`;
266
+ const aside = `<aside${classes ? ` class="${classes}"` : ''}>${iconHtml}${wrappedContent}</aside>`;
267
+ if (isDemo) {
268
+ return `${aside}\n<pre x-code="html" copy><code>${escapeForText(token.text.trim())}</code></pre>\n`;
269
+ }
270
+ return `${aside}\n`;
251
271
  }
252
272
  }]
253
273
  });
@@ -259,17 +279,16 @@ async function configureMarked(marked) {
259
279
  });
260
280
  }
261
281
 
262
- // Custom renderer for x-code-group to handle line breaks properly
282
+ // Markdown preprocessor: ensure that block-level HTML containers (the
283
+ // wrappers authors use to group fenced code blocks into tabs, frames, etc.)
284
+ // have a blank line after their opening tag so marked treats the contents
285
+ // as block-level markdown rather than raw inline HTML. Without this, a
286
+ // fenced ```js immediately after `<div x-code-group>` is treated as text.
263
287
  function renderXCodeGroup(markdown) {
264
- // Find x-code-group blocks and process them specially
265
- const xCodeGroupRegex = /<x-code-group[^>]*>([\s\S]*?)<\/x-code-group>/g;
266
-
267
- return markdown.replace(xCodeGroupRegex, (match, content) => {
268
- // Ensure there's a line break after the opening tag if there isn't one
269
- const processedContent = content.replace(/^(?!\s*\n)/, '\n');
270
-
271
- return `<x-code-group>${processedContent}</x-code-group>`;
272
- });
288
+ return markdown.replace(
289
+ /(<(?:div|section|article|aside)[^>]*\bx-code-group\b[^>]*>)(?!\s*\n)/g,
290
+ '$1\n'
291
+ );
273
292
  }
274
293
 
275
294
  // Post-process HTML to enable checkboxes by removing disabled attribute
@@ -296,68 +315,69 @@ function isHighlightJsAvailable() {
296
315
 
297
316
 
298
317
 
299
- // Parse language string to extract title and attributes
318
+ // Parse a fence's info-string into an attributes bag. Supported tokens:
319
+ // javascript language (first non-flag bareword)
320
+ // "Tab name" quoted name → name attribute (tabs / title bar)
321
+ // lines line numbers gutter
322
+ // copy copy button
323
+ // edit CodeJar editor
324
+ // collapse collapse with default threshold (20 lines)
325
+ // collapse=10 collapse to first 10 lines
326
+ // from=#demo pull source from referenced element
300
327
  function parseLanguageString(languageString) {
301
- if (!languageString || languageString.trim() === '') {
302
- return { title: null, language: null, numbers: false, copy: false };
303
- }
304
-
305
- const parts = languageString.split(/\s+/);
306
-
307
328
  const attributes = {
308
329
  title: null,
309
330
  language: null,
310
- numbers: false,
311
- copy: false
331
+ lines: false,
332
+ copy: false,
333
+ edit: false,
334
+ collapse: null, // null = not collapsible; '' = default threshold; '10' = explicit
335
+ from: null
312
336
  };
337
+ if (!languageString || languageString.trim() === '') return attributes;
313
338
 
339
+ const parts = languageString.split(/\s+/);
314
340
  let i = 0;
315
341
  while (i < parts.length) {
316
342
  const part = parts[i];
317
343
 
318
- // Check for attributes
319
- if (part === 'numbers') {
320
- attributes.numbers = true;
321
- i++;
322
- continue;
344
+ if (part === 'lines') { attributes.lines = true; i++; continue; }
345
+ if (part === 'copy') { attributes.copy = true; i++; continue; }
346
+ if (part === 'edit') { attributes.edit = true; i++; continue; }
347
+ if (part === 'collapse') { attributes.collapse = ''; i++; continue; }
348
+ if (part.startsWith('collapse=')) {
349
+ attributes.collapse = part.slice('collapse='.length).replace(/^"|"$/g, '');
350
+ i++; continue;
323
351
  }
324
-
325
- if (part === 'copy') {
326
- attributes.copy = true;
327
- i++;
328
- continue;
352
+ if (part.startsWith('from=')) {
353
+ attributes.from = part.slice('from='.length).replace(/^"|"$/g, '');
354
+ i++; continue;
329
355
  }
330
356
 
331
- // Check for quoted names (e.g., "Example")
332
- if (part.startsWith('"') && part.endsWith('"')) {
333
- // Single word quoted name
357
+ // Quoted name handling single-word "Foo" or multi-word "Foo Bar Baz"
358
+ if (part.startsWith('"') && part.endsWith('"') && part.length > 1) {
334
359
  attributes.title = part.slice(1, -1);
335
- i++;
336
- continue;
337
- } else if (part.startsWith('"')) {
338
- // Multi-word quoted name
360
+ i++; continue;
361
+ }
362
+ if (part.startsWith('"')) {
339
363
  let fullName = part.slice(1);
340
364
  i++;
341
365
  while (i < parts.length) {
342
- const nextPart = parts[i];
343
- if (nextPart.endsWith('"')) {
344
- fullName += ' ' + nextPart.slice(0, -1);
366
+ const next = parts[i];
367
+ if (next.endsWith('"')) {
368
+ fullName += ' ' + next.slice(0, -1);
345
369
  attributes.title = fullName;
346
370
  i++;
347
371
  break;
348
- } else {
349
- fullName += ' ' + nextPart;
350
- i++;
351
372
  }
373
+ fullName += ' ' + next;
374
+ i++;
352
375
  }
353
376
  continue;
354
377
  }
355
378
 
356
- // Store language identifiers (e.g., "css", "javascript", etc.)
357
- // Use the first language identifier found
358
- if (!attributes.language) {
359
- attributes.language = part;
360
- }
379
+ // Unrecognized bareword → treat as language (first one wins)
380
+ if (!attributes.language) attributes.language = part;
361
381
  i++;
362
382
  }
363
383
 
@@ -415,7 +435,8 @@ async function initializeMarkdownPlugin() {
415
435
  // (`x-markdown.safe="$x.user.bio"`), parsed HTML is run through
416
436
  // DOMPurify before injection. Default is unsanitized — Manifest's
417
437
  // design lets authors render raw HTML and custom-element extensions
418
- // (x-code, x-icon, callouts) freely. Use .safe when the markdown
438
+ // (x-icon, callouts) and directive attributes (x-code, etc.) freely.
439
+ // Use .safe when the markdown
419
440
  // source can contain content from untrusted parties (Appwrite
420
441
  // collections, API responses, crowdsourced translations, etc.).
421
442
  const safe = Array.isArray(modifiers) && modifiers.includes('safe');