ampless 1.0.0-alpha.47 → 1.0.0-alpha.48

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
@@ -1072,6 +1072,48 @@ type ContentFieldRenderer = {
1072
1072
  kind: 'tiptap';
1073
1073
  nodeType: string;
1074
1074
  render(node: TiptapRenderNode, ctx: PluginPublicRenderContext): ReactNode;
1075
+ /**
1076
+ * Opt-in: expand this node's canonical placeholder div when it
1077
+ * appears in a `format: 'html'` post body on public render.
1078
+ *
1079
+ * The admin's `tiptap → html` format switch serialises an embed
1080
+ * node (e.g. `amplessYoutube`) to its canonical placeholder div
1081
+ * (`<div data-ampless-youtube data-video-id="…"><a href="…">…</a></div>`,
1082
+ * via the `tiptapNodeToHtml` adapter). Without `htmlPlaceholder`,
1083
+ * the public renderer of a `format: 'html'` post emits that div
1084
+ * literally — the placeholder shows as a bare div + link instead
1085
+ * of the live embed. (This was the documented limitation from the
1086
+ * canonical-html-embed work; declaring `htmlPlaceholder` resolves
1087
+ * it.)
1088
+ *
1089
+ * When declared, the runtime's public html walker finds each
1090
+ * **top-level** element carrying `flagAttr`, builds a
1091
+ * `TiptapRenderNode { type: nodeType, attrs: attrsFromElement(attribs) }`,
1092
+ * and calls this same `render` — so the `tiptap`, `markdown`, and
1093
+ * `html` formats all reach one renderer (no per-format divergence).
1094
+ *
1095
+ * - `flagAttr` is the marker attribute (e.g.
1096
+ * `'data-ampless-youtube'`). It is **matched case-insensitively**:
1097
+ * the runtime lowercases it at registration because htmlparser2
1098
+ * lowercases HTML attribute names while parsing, so declaring
1099
+ * `'data-My-Embed'` still matches `<div data-my-embed>` /
1100
+ * `<div DATA-MY-EMBED>`.
1101
+ * - `attrsFromElement` converts the div's HTML attributes (already
1102
+ * lowercased by the parser) into the tiptap node `attrs` that
1103
+ * `render` expects, with the correct types — e.g. a
1104
+ * `data-start="30"` string must become the `number 30` that
1105
+ * `render` reads.
1106
+ *
1107
+ * Only top-level placeholders expand; divs nested inside
1108
+ * `<blockquote>` / `<li>` / etc. stay literal. If `attrsFromElement`
1109
+ * or `render` throws, the runtime warns and falls back to the raw
1110
+ * placeholder slice (the embedded link stays clickable) rather than
1111
+ * dropping the engineer-authored content.
1112
+ */
1113
+ htmlPlaceholder?: {
1114
+ flagAttr: string;
1115
+ attrsFromElement(attribs: Readonly<Record<string, string>>): Record<string, unknown>;
1116
+ };
1075
1117
  } | {
1076
1118
  kind: 'markdown-url';
1077
1119
  pattern: RegExp;
@@ -896,9 +896,9 @@ operator が admin UI でポストのフォーマットを切り替える場合
896
896
 
897
897
  プレースホルダー div は **admin format-switch 相互運用専用**の正規 HTML 形式: `Node.renderHTML` が emit し、`Node.parseHTML` の `tag: 'div[data-ampless-*]'` rule が復元することで、admin での `tiptap ↔ markdown ↔ html` 切替が embed を保ったまま round-trip できる。
898
898
 
899
- **`format: 'html'` の公開描画ではプレースホルダー展開を行わない。** 公開描画はフォーマット別に分岐: tiptap 投稿は React walker が `contentFields.tiptap` registry を参照(= `amplessYoutube` Node を実際の iframe に変換)、markdown 投稿は markdown walker が `contentFields.markdownUrl` を参照(= bare URL paragraph を実際の iframe に変換)、html 投稿は `htmlPassthroughBlock` raw HTML をそのまま出力する。**プレースホルダー div を含む html-format 投稿は公開ページでリテラル div として表示される。** `publicHtmlForPost` `beforeContent` / `afterContent` slot のみ提供する capability であり、body を変換しない。
899
+ **`format: 'html'` の公開描画は、plugin `htmlPlaceholder` を宣言していればプレースホルダーを展開する。** 公開描画の 3 経路すべてが同じ `contentFields.tiptap` renderer に到達する: tiptap 投稿は React walker が `contentFields.tiptap` registry を参照(= `amplessYoutube` Node を実際の iframe に変換)、markdown 投稿は markdown walker が `contentFields.markdownUrl` を参照(= bare URL paragraph を実際の iframe に変換)、html 投稿は **public html walker** が `contentFields.htmlPlaceholder` を参照(= top-level `<div data-ampless-youtube …>` プレースホルダーを実際の iframe に変換)。上の `tiptapNodeToHtml` アダプターは **admin format-switch** の交換形式のみを司り、その形式を公開描画時に展開させるのは `htmlPlaceholder` の宣言である(契約は下記の `htmlPlaceholder` セクションを参照)。`publicHtmlForPost` は従来どおり `beforeContent` / `afterContent` slot のみ提供する capability であり、body を変換しない。
900
900
 
901
- embed を公開ページで実際に描画させたい場合は、投稿を `tiptap` または `markdown` 形式で保存する(= format-switch round-trip で embed Node / bare URL line に戻り、対応する公開 walker が iframe を emit する)。`html` 形式は **engineer が完全制御で書く raw HTML** として扱われる前提で、プレースホルダーは format-switch round-trip artefact であって描画可能な embed reference ではない。`data-ampless-*` プレースホルダーを public html walker で iframe に展開する将来 capability は本セクション末尾の follow-up note を参照。
901
+ embed node を持つが `htmlPlaceholder` を**宣言しない** plugin は従来挙動を維持する: `format: 'html'` 投稿の公開描画ではプレースホルダー div がリテラルにそのまま出力される(内側の正規 URL link clickable なので graceful degrade する)。その場合に iframe を得るには `tiptap` または `markdown` 形式で保存する。
902
902
 
903
903
  #### アダプターの契約
904
904
 
@@ -927,7 +927,15 @@ export const tiptapNodeToHtml: TiptapNodeHtmlAdapters = {
927
927
  const videoId = String(node.attrs?.videoId ?? '').trim()
928
928
  if (!videoId) return null // fallthrough
929
929
  const attrs = placeholderAttrs(node.attrs ?? {})
930
- return `<div ${attrsToHtmlString(attrs)}><span>YouTube: ${escapeAttr(videoId)}</span></div>`
930
+ // 内側のコンテンツは editor 用視覚 label (`<span>YouTube: id</span>`、
931
+ // Node.renderHTML 専用) ではなく URL link にする。`format: 'html'` の
932
+ // 公開描画ではこの body をリテラル表示するため editor 用 label が漏れる
933
+ // のを防ぐ。viewer 側で iframe 展開されない場合でもクリック可能な link が
934
+ // 残り graceful degradation する。markdown 正規形 (bare URL line) と
935
+ // 対応する形式。parseHTML は `data-video-id` 属性を読むため内側コンテンツは
936
+ // round-trip と無関係。
937
+ const url = `https://youtu.be/${videoId}`
938
+ return `<div ${attrsToHtmlString(attrs)}><a href="${escapeAttr(url)}">${escapeAttr(url)}</a></div>`
931
939
  },
932
940
  }
933
941
  ```
@@ -946,15 +954,52 @@ export const tiptapNodeToHtml: TiptapNodeHtmlAdapters = {
946
954
 
947
955
  つまりプラグインは `tiptap → html` アダプターを 1 つ export するだけでよく、`markdown → html` の方向は tiptap の parse rule を通して自動的に再利用される。**重複ロジックは不要。**
948
956
 
949
- #### Follow-up: public html walker(未実装)
957
+ #### Public html walker: `htmlPlaceholder`
950
958
 
951
- `format: 'html'` 投稿で、保存されたプレースホルダー div を公開ページで実 iframe として描画するためには、将来の capability で保存された html body render 時に walk し、`data-ampless-*` flag を見て登録済プラグインの React renderer(= tiptap 投稿で `contentFields.tiptap` が使うのと同じ renderer)に置換する仕組みが必要。スケッチ:
959
+ `format: 'html'` 投稿のプレースホルダー div を公開ページで実 embed として描画するには、既存の `contentFields` `tiptap` entry **`htmlPlaceholder`** 宣言を追加する。**新しい renderer は書かない** walker tiptap entry が既に使う `render(node, ctx)` をそのまま呼ぶので、3 形式(tiptap / markdown / html)すべてが 1 個の renderer に到達し、描画が divergence しない。
952
960
 
953
- - 新 plugin capability `contentFields.html`: プレースホルダー flag attribute(例: `data-ampless-youtube`)を key にし、parse 済 div の attribute set から React node を返す。
954
- - Runtime: `renderBody` の `format: 'html'` ブランチが body を parse、`[data-ampless-*]` div を walk して登録済 renderer の output に置換、残りは raw HTML として emit。
955
- - Runtime 側の server-side HTML parser(現状なし、`parse5` / `htmlparser2` 等の小さなパーサーを選定する必要あり)。
961
+ ```ts
962
+ // packages/plugin-youtube/src/index.tsx
963
+ contentFields: [
964
+ {
965
+ kind: 'tiptap',
966
+ nodeType: 'amplessYoutube',
967
+ render: (node) => {
968
+ const videoId = String(node.attrs?.videoId ?? '')
969
+ const startRaw = node.attrs?.start
970
+ const start =
971
+ typeof startRaw === 'number' && Number.isFinite(startRaw) ? startRaw : undefined
972
+ return <YouTubeEmbed videoId={videoId} start={start} />
973
+ },
974
+ htmlPlaceholder: {
975
+ // top-level プレースホルダー div を識別する marker attribute。
976
+ flagAttr: 'data-ampless-youtube',
977
+ // div の HTML 属性を render が期待する tiptap node attrs に変換する
978
+ // — 型も正しく。walker は型を知らないのでここで変換する
979
+ // (例: data-start string → number)。
980
+ attrsFromElement: (attribs) => {
981
+ const start = Number(attribs['data-start'])
982
+ return {
983
+ videoId: attribs['data-video-id'] ?? '',
984
+ start: Number.isFinite(start) ? start : undefined,
985
+ }
986
+ },
987
+ },
988
+ },
989
+ // …markdown-url entry は変更なし…
990
+ ]
991
+ ```
992
+
993
+ `format: 'html'` 投稿の公開描画時、runtime は body を server-side で parse し(`htmlparser2`)、`flagAttr` を持つ **top-level** element ごとに `{ type: nodeType, attrs: attrsFromElement(attribs) }` node を組んで `render(node, ctx)` を呼ぶ。それ以外はすべて **元文字列の slice**(byte 単位で完全保存、DOM の再シリアライズなし)として passthrough する。
994
+
995
+ 把握すべき制約:
996
+
997
+ - **top-level のみ。** `<blockquote>` / `<li>` 等の内側に nest したプレースホルダー div はリテラルのまま — editor の body-level-only な `parseHTML` fallback と整合。展開されるのは depth-0 の element のみ。
998
+ - **`flagAttr` は case-insensitive で照合される。** htmlparser2 は parse 時に HTML 属性名を小文字化し、runtime は登録時に `flagAttr` を小文字化するため、`<div DATA-AMPLESS-YOUTUBE …>` も `<div data-ampless-youtube …>` も展開される。`flagAttr` は任意の属性名でよい — site-local plugin は `data-my-embed` を使える。固定の `data-ampless` prefix 要件はない。
999
+ - **graceful degradation。** `attrsFromElement` または `render` が throw した場合、runtime は `console.warn` を出力し **元のプレースホルダー slice**(内側の正規 URL link は clickable のまま)に fallback する。engineer が書いた本文を消さない。
1000
+ - **page-level script。** embed が third-party script を要する場合(例: x.com の `widgets.js`)、`publicPostScript` / 検知ヘルパーもプレースホルダー形式を認識する必要がある。`plugin-x-embed` の `hasTweetIn` は `format: 'html'` body 内で `twitter-tweet` と `data-ampless-tweet` の両方を match させ、展開された blockquote を hydrate するため widgets.js を注入する。
956
1001
 
957
- PR scope 外(別 proposal として track)。当面は html-format 投稿のプレースホルダーは raw HTML として扱われ、公開ページで iframe 表示したい著者は `tiptap` `markdown` で保存する。
1002
+ **wrapper 境界の変化。** プレースホルダーを含まない `format: 'html'` 投稿は単一の wrapper `<div>` として出力される(fast path — 従来の raw passthrough markup 完全一致)。プレースホルダーを**含む**投稿は **複数の wrapper div と React embed の兄弟列が交互に並ぶ**構造になる: 各 raw chunk 内の bytes は正確に保存されるが、wrapper 境界は移動する。body 全体を 1 個の wrapper で囲む前提の direct-child / adjacent-sibling CSS selector は従来と同じには効かない。これは embed を in-place 展開するための許容トレードオフ。
958
1003
 
959
1004
  ### markdown → tiptap の復元
960
1005
 
@@ -1161,25 +1161,27 @@ interop only**: it is what `Node.renderHTML` emits, and what
1161
1161
  that switching `tiptap ↔ markdown ↔ html` in the admin preserves embeds
1162
1162
  losslessly.
1163
1163
 
1164
- **Public render of `format: 'html'` posts does NOT expand the
1165
- placeholder.** Public render paths are format-specific: tiptap posts go
1166
- through the React walker that consults the `contentFields.tiptap`
1167
- registry (= real iframe for `amplessYoutube` Nodes); markdown posts go
1168
- through the markdown walker that consults `contentFields.markdownUrl`
1169
- (= real iframe for bare URL paragraphs); html posts pass through as raw
1170
- HTML (`htmlPassthroughBlock`). For an html-format post that contains a
1171
- placeholder div, the public page shows the literal placeholder, not an
1172
- iframe `publicHtmlForPost` only emits `beforeContent` / `afterContent`
1173
- slots and does not transform the body.
1174
-
1175
- If you want the embed to render publicly, save the post as `tiptap` or
1176
- `markdown` (= the format-switch round-trip restores the embed Node /
1177
- bare URL line, then the matching public walker emits the iframe). The
1178
- `html` format is treated as **raw HTML written by an engineer who wants
1179
- full control** — the placeholder is a format-switch round-trip artefact,
1180
- not a renderable embed reference. A future capability could add a public
1181
- html walker that expands `data-ampless-*` placeholders to iframes; see
1182
- the follow-up note at the end of this section.
1164
+ **Public render of `format: 'html'` posts expands the placeholder when
1165
+ the plugin declares `htmlPlaceholder`.** All three public render paths
1166
+ reach the same `contentFields.tiptap` renderer: tiptap posts go through
1167
+ the React walker that consults the `contentFields.tiptap` registry
1168
+ (= real iframe for `amplessYoutube` Nodes); markdown posts go through the
1169
+ markdown walker that consults `contentFields.markdownUrl` (= real iframe
1170
+ for bare URL paragraphs); html posts go through the **public html walker**
1171
+ that consults `contentFields.htmlPlaceholder` (= real iframe for
1172
+ top-level `<div data-ampless-youtube …>` placeholders). The
1173
+ `tiptapNodeToHtml` adapter above only governs the **admin format-switch**
1174
+ interchange form — declaring `htmlPlaceholder` is what makes the public
1175
+ html walker expand that form on render. (See the dedicated
1176
+ `htmlPlaceholder` section below for the contract.) `publicHtmlForPost`
1177
+ still only emits `beforeContent` / `afterContent` slots and does not
1178
+ transform the body.
1179
+
1180
+ A plugin that ships an embed node but does **not** declare
1181
+ `htmlPlaceholder` keeps the old behaviour: the placeholder div is shipped
1182
+ literally on public render of `format: 'html'` posts (the inner canonical
1183
+ URL link is still clickable, so it degrades gracefully). Save as `tiptap`
1184
+ or `markdown` to get the iframe in that case.
1183
1185
 
1184
1186
  #### Adapter contract
1185
1187
 
@@ -1212,7 +1214,16 @@ export const tiptapNodeToHtml: TiptapNodeHtmlAdapters = {
1212
1214
  // placeholderAttrs() is a plugin-local helper that returns the same
1213
1215
  // attribute dict used by Node.renderHTML — single source of truth.
1214
1216
  const attrs = placeholderAttrs(node.attrs ?? {})
1215
- return `<div ${attrsToHtmlString(attrs)}><span>YouTube: ${escapeAttr(videoId)}</span></div>`
1217
+ // Inner content is the canonical URL as a clickable link, not the
1218
+ // editor's visual label `<span>YouTube: id</span>`. Public render of
1219
+ // `format: 'html'` posts shows this content literally; an editor
1220
+ // label would leak. The URL link gracefully degrades — viewers
1221
+ // without iframe expansion still get a clickable link, and it
1222
+ // mirrors the markdown canonical form. parseHTML reads the
1223
+ // `data-video-id` attribute, so the inner content is irrelevant
1224
+ // for round-trip.
1225
+ const url = `https://youtu.be/${videoId}`
1226
+ return `<div ${attrsToHtmlString(attrs)}><a href="${escapeAttr(url)}">${escapeAttr(url)}</a></div>`
1216
1227
  },
1217
1228
  }
1218
1229
  ```
@@ -1242,26 +1253,82 @@ This means your plugin only needs to export the `tiptap → html` adapter once;
1242
1253
  the `markdown → html` direction reuses it automatically through tiptap's parse
1243
1254
  rules. **No duplicate logic is needed.**
1244
1255
 
1245
- #### Follow-up: public html walker (not implemented)
1246
-
1247
- To make `format: 'html'` posts render placeholder divs as real iframes
1248
- on the public page, a future capability would walk the saved html body
1249
- on render, look up the `data-ampless-*` flag, and replace the placeholder
1250
- with the registered plugin's React renderer (same renderer that
1251
- `contentFields.tiptap` already uses for tiptap posts). Sketch:
1252
-
1253
- - New plugin capability `contentFields.html` keyed on the placeholder
1254
- flag attribute (e.g. `data-ampless-youtube`), returning a React node
1255
- from the parsed div's attribute set.
1256
- - Runtime: `renderBody` for `format: 'html'` parses the body, walks
1257
- `[data-ampless-*]` divs, replaces each with the registered renderer's
1258
- output, and emits the rest as raw HTML.
1259
- - Server-side HTML parsing in runtime (currently absent — would need
1260
- to pick a small parser like `parse5` or `htmlparser2`).
1261
-
1262
- Out of scope for the current PR (tracked as a separate proposal). For
1263
- now, html-format posts treat placeholders as raw HTML and authors who
1264
- want public iframes should save as `tiptap` or `markdown`.
1256
+ #### Public html walker: `htmlPlaceholder`
1257
+
1258
+ To make `format: 'html'` posts render placeholder divs as real embeds on
1259
+ the public page, add an **`htmlPlaceholder`** declaration to your existing
1260
+ `contentFields` `tiptap` entry. You write **no new renderer** the walker
1261
+ calls the same `render(node, ctx)` your tiptap entry already uses, so all
1262
+ three formats (tiptap / markdown / html) reach one renderer with no
1263
+ divergence.
1264
+
1265
+ ```ts
1266
+ // packages/plugin-youtube/src/index.tsx
1267
+ contentFields: [
1268
+ {
1269
+ kind: 'tiptap',
1270
+ nodeType: 'amplessYoutube',
1271
+ render: (node) => {
1272
+ const videoId = String(node.attrs?.videoId ?? '')
1273
+ const startRaw = node.attrs?.start
1274
+ const start =
1275
+ typeof startRaw === 'number' && Number.isFinite(startRaw) ? startRaw : undefined
1276
+ return <YouTubeEmbed videoId={videoId} start={start} />
1277
+ },
1278
+ htmlPlaceholder: {
1279
+ // The marker attribute that flags a top-level placeholder div.
1280
+ flagAttr: 'data-ampless-youtube',
1281
+ // Convert the div's HTML attributes into the tiptap node `attrs`
1282
+ // your `render` expects — WITH the right types. The walker is
1283
+ // type-agnostic, so coerce here (e.g. data-start string → number).
1284
+ attrsFromElement: (attribs) => {
1285
+ const start = Number(attribs['data-start'])
1286
+ return {
1287
+ videoId: attribs['data-video-id'] ?? '',
1288
+ start: Number.isFinite(start) ? start : undefined,
1289
+ }
1290
+ },
1291
+ },
1292
+ },
1293
+ // …markdown-url entry unchanged…
1294
+ ]
1295
+ ```
1296
+
1297
+ On public render of a `format: 'html'` post, the runtime parses the body
1298
+ server-side (`htmlparser2`), finds each **top-level** element carrying
1299
+ `flagAttr`, builds a `{ type: nodeType, attrs: attrsFromElement(attribs) }`
1300
+ node, and calls `render(node, ctx)`. Everything else passes through as the
1301
+ **original-string slices** (byte-for-byte; no DOM re-serialisation).
1302
+
1303
+ Constraints worth knowing:
1304
+
1305
+ - **Top-level only.** Placeholder divs nested inside `<blockquote>`,
1306
+ `<li>`, etc. stay literal — consistent with the editor's body-level-only
1307
+ `parseHTML` fallback. Only depth-0 elements expand.
1308
+ - **`flagAttr` is matched case-insensitively.** htmlparser2 lowercases
1309
+ HTML attribute names while parsing, and the runtime lowercases your
1310
+ `flagAttr` at registration, so `<div DATA-AMPLESS-YOUTUBE …>` and
1311
+ `<div data-ampless-youtube …>` both expand. `flagAttr` can be any
1312
+ attribute name — a site-local plugin may use `data-my-embed`; there is
1313
+ no fixed `data-ampless` prefix requirement.
1314
+ - **Graceful degradation.** If `attrsFromElement` or `render` throws, the
1315
+ runtime logs a `console.warn` and falls back to the **raw placeholder
1316
+ slice** (the inner canonical URL link stays clickable) rather than
1317
+ dropping the engineer-authored content.
1318
+ - **Page-level scripts.** If your embed needs a third-party script (e.g.
1319
+ x.com's `widgets.js`), your `publicPostScript` / detection helper must
1320
+ also recognise the placeholder form. `plugin-x-embed`'s `hasTweetIn`
1321
+ matches both `twitter-tweet` and `data-ampless-tweet` in `format: 'html'`
1322
+ bodies so widgets.js is injected for the expanded blockquote to hydrate.
1323
+
1324
+ **Wrapper-boundary change.** A placeholder-free `format: 'html'` post is
1325
+ emitted as a single wrapper `<div>` (the fast path — markup-identical to
1326
+ the previous raw passthrough). A post **with** placeholders becomes
1327
+ **multiple wrapper divs interleaved with the React embed siblings**: the
1328
+ bytes inside each raw chunk are preserved exactly, but the wrapper
1329
+ boundaries shift. Direct-child / adjacent-sibling CSS selectors that
1330
+ assumed one wrapper around the whole body will not match the same way.
1331
+ This is the accepted trade-off for expanding embeds in-place.
1265
1332
 
1266
1333
  ### Markdown to tiptap restoration
1267
1334
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ampless",
3
- "version": "1.0.0-alpha.47",
3
+ "version": "1.0.0-alpha.48",
4
4
  "description": "Serverless CMS for AWS Amplify",
5
5
  "license": "MIT",
6
6
  "type": "module",