@ampless/runtime 1.0.0-alpha.55 → 1.0.0-alpha.57

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.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Post, MediaMetadata, AmplessPlugin, ContentFieldRenderer, PluginPublicRenderContext, TiptapNodeMarkdownAdapters, Config, ThemeModule, ThemeManifest, PluginPackageManifest } from 'ampless';
1
+ import { Post, MediaMetadata, AmplessPlugin, ContentFieldRenderer, PluginPublicRenderContext, TiptapNodeHtmlAdapters, TiptapNodeMarkdownAdapters, Config, ThemeModule, ThemeManifest, PluginPackageManifest } from 'ampless';
2
2
  export { Config, Post, ThemeManifest } from 'ampless';
3
3
  import { Metadata } from 'next';
4
4
  import { ReactNode } from 'react';
@@ -165,6 +165,21 @@ interface ContentFieldRegistry {
165
165
  kind: 'markdown-url';
166
166
  }>;
167
167
  }>;
168
+ /**
169
+ * Plugins that declared `htmlPlaceholder` on their `kind: 'tiptap'`
170
+ * entry, keyed by the **lowercased** `flagAttr` (htmlparser2 lowercases
171
+ * HTML attribute names while parsing, so the public html walker looks
172
+ * up `attribs[flagAttr.toLowerCase()]` and compares against these keys
173
+ * case-insensitively). Used by `renderHtmlNode` to expand canonical
174
+ * placeholder divs in `format: 'html'` bodies into the plugin's
175
+ * existing tiptap renderer output.
176
+ */
177
+ htmlPlaceholder: ReadonlyMap<string, {
178
+ plugin: AmplessPlugin;
179
+ renderer: Extract<ContentFieldRenderer, {
180
+ kind: 'tiptap';
181
+ }>;
182
+ }>;
168
183
  }
169
184
  /**
170
185
  * Build a `ContentFieldRegistry` from a list of plugins. Eagerly errors
@@ -210,8 +225,17 @@ declare function renderBodyHtmlString(post: Post): string;
210
225
  * markdown without editing) can still hand us a raw HTML string
211
226
  * here. In that case, return it as-is rather than walking it as a
212
227
  * malformed tiptap node and producing empty output.
228
+ *
229
+ * `opts.nodeAdapters` lets callers supply per-nodeType serialisers —
230
+ * the admin post-form passes the registry populated by
231
+ * `installAdminTiptapNodeHtml` so that plugin-registered embed nodes
232
+ * (e.g. `amplessYoutube`) are serialised to placeholder div strings
233
+ * instead of falling through with empty children. Callers that omit
234
+ * `opts` get the original behaviour unchanged.
213
235
  */
214
- declare function tiptapToHtml(doc: unknown): string;
236
+ declare function tiptapToHtml(doc: unknown, opts?: {
237
+ nodeAdapters?: TiptapNodeHtmlAdapters;
238
+ }): string;
215
239
  /** Convert markdown to HTML using marked + GFM. */
216
240
  declare function markdownToHtml(md: string): string;
217
241
  /**
package/dist/index.js CHANGED
@@ -254,6 +254,7 @@ import {
254
254
  createElement
255
255
  } from "react";
256
256
  import { marked } from "marked";
257
+ import { parseDocument, ElementType } from "htmlparser2";
257
258
  function escape(s) {
258
259
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
259
260
  }
@@ -264,7 +265,12 @@ function textAlignStyle(attrs) {
264
265
  }
265
266
  return "";
266
267
  }
267
- function renderTiptapString(node) {
268
+ function renderTiptapString(node, opts) {
269
+ const adapter = opts?.nodeAdapters?.[node.type];
270
+ if (adapter) {
271
+ const out = adapter(node);
272
+ if (typeof out === "string") return out;
273
+ }
268
274
  if (node.type === "text") {
269
275
  let html = escape(node.text ?? "");
270
276
  for (const mark of node.marks ?? []) {
@@ -281,7 +287,7 @@ function renderTiptapString(node) {
281
287
  }
282
288
  return html;
283
289
  }
284
- const children = (node.content ?? []).map(renderTiptapString).join("");
290
+ const children = (node.content ?? []).map((c) => renderTiptapString(c, opts)).join("");
285
291
  switch (node.type) {
286
292
  case "doc":
287
293
  return children;
@@ -350,6 +356,7 @@ function renderMarkdownString(md) {
350
356
  }
351
357
  function buildContentFieldRegistry(plugins) {
352
358
  const tiptap = /* @__PURE__ */ new Map();
359
+ const htmlPlaceholder = /* @__PURE__ */ new Map();
353
360
  const seenMarkdownPatterns = /* @__PURE__ */ new Set();
354
361
  const markdownUrl = [];
355
362
  for (const plugin of plugins) {
@@ -363,6 +370,15 @@ function buildContentFieldRegistry(plugins) {
363
370
  );
364
371
  }
365
372
  tiptap.set(field.nodeType, { plugin, renderer: field });
373
+ if (field.htmlPlaceholder) {
374
+ const flagAttr = field.htmlPlaceholder.flagAttr.toLowerCase();
375
+ if (htmlPlaceholder.has(flagAttr)) {
376
+ throw new Error(
377
+ `[ampless contentFields] duplicate htmlPlaceholder flagAttr "${flagAttr}" \u2014 already registered by another plugin. Each flagAttr may be claimed by at most one plugin.`
378
+ );
379
+ }
380
+ htmlPlaceholder.set(flagAttr, { plugin, renderer: field });
381
+ }
366
382
  } else if (field.kind === "markdown-url") {
367
383
  const key = field.pattern.source;
368
384
  if (seenMarkdownPatterns.has(key)) {
@@ -375,7 +391,7 @@ function buildContentFieldRegistry(plugins) {
375
391
  }
376
392
  }
377
393
  }
378
- return { tiptap, markdownUrl };
394
+ return { tiptap, markdownUrl, htmlPlaceholder };
379
395
  }
380
396
  function htmlPassthrough(html) {
381
397
  return createElement("span", { dangerouslySetInnerHTML: { __html: html } });
@@ -575,9 +591,90 @@ function extractSingleUrl(para) {
575
591
  }
576
592
  return null;
577
593
  }
594
+ function isParsedElement(node) {
595
+ return ElementType.isTag(node);
596
+ }
597
+ function renderHtmlNode(html, opts) {
598
+ const registry = opts.contentFields?.htmlPlaceholder;
599
+ if (!registry || registry.size === 0) {
600
+ return htmlPassthroughBlock(html);
601
+ }
602
+ const lower = html.toLowerCase();
603
+ let anyFlagPresent = false;
604
+ for (const flagAttr of registry.keys()) {
605
+ if (lower.includes(flagAttr)) {
606
+ anyFlagPresent = true;
607
+ break;
608
+ }
609
+ }
610
+ if (!anyFlagPresent) {
611
+ return htmlPassthroughBlock(html);
612
+ }
613
+ let doc;
614
+ try {
615
+ doc = parseDocument(html, {
616
+ withStartIndices: true,
617
+ withEndIndices: true
618
+ });
619
+ } catch (err) {
620
+ console.warn(
621
+ `[ampless renderBody] htmlparser2 failed to parse a format:'html' body; falling back to raw passthrough: ${err instanceof Error ? err.message : String(err)}`
622
+ );
623
+ return htmlPassthroughBlock(html);
624
+ }
625
+ const children = [];
626
+ let chunk = 0;
627
+ let cursor = 0;
628
+ const flushRawUntil = (end) => {
629
+ if (end <= cursor) return;
630
+ const slice = html.slice(cursor, end);
631
+ children.push(htmlPassthroughBlock(slice, `html-raw-${chunk++}`));
632
+ cursor = end;
633
+ };
634
+ for (const node of doc.children) {
635
+ if (!isParsedElement(node)) continue;
636
+ if (node.startIndex == null || node.endIndex == null) continue;
637
+ const reg = matchHtmlPlaceholder(node.attribs, registry);
638
+ if (!reg) continue;
639
+ const start = node.startIndex;
640
+ const endExclusive = node.endIndex + 1;
641
+ flushRawUntil(start);
642
+ const placeholderSlice = html.slice(start, endExclusive);
643
+ const ctx = opts.ctxForPlugin?.(reg.plugin);
644
+ if (!ctx) {
645
+ children.push(htmlPassthroughBlock(placeholderSlice, `html-raw-${chunk++}`));
646
+ cursor = endExclusive;
647
+ continue;
648
+ }
649
+ try {
650
+ const renderNode = {
651
+ type: reg.renderer.nodeType,
652
+ attrs: reg.renderer.htmlPlaceholder.attrsFromElement(node.attribs)
653
+ };
654
+ const out = reg.renderer.render(renderNode, ctx);
655
+ children.push(createElement(Fragment, { key: `html-embed-${start}` }, out));
656
+ } catch (err) {
657
+ console.warn(
658
+ `[ampless renderBody] plugin "${reg.plugin.instanceId ?? reg.plugin.name}" threw inside contentFields tiptap renderer for nodeType "${reg.renderer.nodeType}": ${err instanceof Error ? err.message : String(err)}`
659
+ );
660
+ children.push(htmlPassthroughBlock(placeholderSlice, `html-raw-${chunk++}`));
661
+ }
662
+ cursor = endExclusive;
663
+ }
664
+ flushRawUntil(html.length);
665
+ return createElement(Fragment, null, ...children);
666
+ }
667
+ function matchHtmlPlaceholder(attribs, registry) {
668
+ for (const flagAttr of registry.keys()) {
669
+ if (Object.prototype.hasOwnProperty.call(attribs, flagAttr)) {
670
+ return registry.get(flagAttr) ?? null;
671
+ }
672
+ }
673
+ return null;
674
+ }
578
675
  function renderBody(post, opts = {}) {
579
676
  if (post.format === "html") {
580
- return htmlPassthroughBlock(String(post.body));
677
+ return renderHtmlNode(String(post.body), opts);
581
678
  }
582
679
  if (post.format === "markdown") {
583
680
  return renderMarkdownNode(String(post.body), opts);
@@ -599,9 +696,9 @@ function renderBodyHtmlString(post) {
599
696
  }
600
697
  return "";
601
698
  }
602
- function tiptapToHtml(doc) {
699
+ function tiptapToHtml(doc, opts) {
603
700
  if (typeof doc === "string") return doc;
604
- return renderTiptapString(doc);
701
+ return renderTiptapString(doc, opts);
605
702
  }
606
703
  function markdownToHtml(md) {
607
704
  return renderMarkdownString(md);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/runtime",
3
- "version": "1.0.0-alpha.55",
3
+ "version": "1.0.0-alpha.57",
4
4
  "description": "Public-side runtime for ampless: post fetching, theme dispatch, middleware, public route handlers",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -46,12 +46,13 @@
46
46
  "@radix-ui/react-slot": "^1.2.4",
47
47
  "class-variance-authority": "^0.7.1",
48
48
  "clsx": "^2.1.1",
49
+ "htmlparser2": "^10.1.0",
49
50
  "lucide-react": "^1.16.0",
50
51
  "marked": "^18.0.4",
51
52
  "sanitize-html": "^2.17.4",
52
53
  "tailwind-merge": "^3.6.0",
53
- "@ampless/plugin-og-image": "0.2.0-alpha.46",
54
- "ampless": "1.0.0-alpha.46"
54
+ "@ampless/plugin-og-image": "0.2.0-alpha.48",
55
+ "ampless": "1.0.0-alpha.48"
55
56
  },
56
57
  "peerDependencies": {
57
58
  "@aws-amplify/adapter-nextjs": "^1",