create-ampless 1.0.0-alpha.137 → 1.0.0-alpha.141
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.js
CHANGED
|
@@ -2230,7 +2230,7 @@ async function regenerateEditorBootstrap(destDir, projectPkg) {
|
|
|
2230
2230
|
lines.push("// editor extension, add / remove the corresponding @ampless/plugin-...");
|
|
2231
2231
|
lines.push("// entry in your project's package.json (and register it in cms.config.ts");
|
|
2232
2232
|
lines.push("// for runtime).", "");
|
|
2233
|
-
lines.push("import { installAdminEditorExtensions, installAdminTiptapNodeMarkdown } from '@ampless/admin/editor'");
|
|
2233
|
+
lines.push("import { installAdminEditorExtensions, installAdminTiptapNodeMarkdown, installAdminTiptapNodeHtml } from '@ampless/admin/editor'");
|
|
2234
2234
|
const identifiers = [];
|
|
2235
2235
|
const seen = /* @__PURE__ */ new Map();
|
|
2236
2236
|
for (const p3 of installedPlugins) {
|
|
@@ -2251,6 +2251,7 @@ async function regenerateEditorBootstrap(destDir, projectPkg) {
|
|
|
2251
2251
|
if (identifiers.length === 0) {
|
|
2252
2252
|
lines.push(" installAdminEditorExtensions([])");
|
|
2253
2253
|
lines.push(" installAdminTiptapNodeMarkdown([])");
|
|
2254
|
+
lines.push(" installAdminTiptapNodeHtml([])");
|
|
2254
2255
|
} else {
|
|
2255
2256
|
lines.push(" installAdminEditorExtensions([");
|
|
2256
2257
|
for (const id of identifiers) lines.push(` ${id}.editorExtension,`);
|
|
@@ -2258,6 +2259,9 @@ async function regenerateEditorBootstrap(destDir, projectPkg) {
|
|
|
2258
2259
|
lines.push(" installAdminTiptapNodeMarkdown([");
|
|
2259
2260
|
for (const id of identifiers) lines.push(` ${id}.tiptapNodeToMarkdown ?? {},`);
|
|
2260
2261
|
lines.push(" ])");
|
|
2262
|
+
lines.push(" installAdminTiptapNodeHtml([");
|
|
2263
|
+
for (const id of identifiers) lines.push(` ${id}.tiptapNodeToHtml ?? {},`);
|
|
2264
|
+
lines.push(" ])");
|
|
2261
2265
|
}
|
|
2262
2266
|
lines.push(" return <>{children}</>");
|
|
2263
2267
|
lines.push("}");
|
|
@@ -848,6 +848,156 @@ export default createAdminLayout(admin, { editorBootstrap: EditorBootstrap })
|
|
|
848
848
|
|
|
849
849
|
`installAdminEditorExtensions` は idempotent で、render 時に client component 内で呼ばれる。admin の `<TiptapEditor>` は毎回 render 時に登録済みリストを built-in extensions の末尾に spread する。
|
|
850
850
|
|
|
851
|
+
`update-ampless` が生成する `_editor-bootstrap.tsx` は extensions、markdown アダプター、html アダプターの3つの install 呼び出しを含む:
|
|
852
|
+
|
|
853
|
+
```tsx
|
|
854
|
+
// app/(admin)/admin/_editor-bootstrap.tsx (AUTO-GENERATED — 編集不可)
|
|
855
|
+
'use client'
|
|
856
|
+
// AUTO-GENERATED by `npm run update-ampless`. Do not edit ...
|
|
857
|
+
import { installAdminEditorExtensions, installAdminTiptapNodeMarkdown, installAdminTiptapNodeHtml } from '@ampless/admin/editor'
|
|
858
|
+
import * as __ampless_plugin_x_embed_editor from '@ampless/plugin-x-embed/editor'
|
|
859
|
+
import * as __ampless_plugin_youtube_editor from '@ampless/plugin-youtube/editor'
|
|
860
|
+
|
|
861
|
+
export function EditorBootstrap({ children }: { children: React.ReactNode }) {
|
|
862
|
+
installAdminEditorExtensions([
|
|
863
|
+
__ampless_plugin_x_embed_editor.editorExtension,
|
|
864
|
+
__ampless_plugin_youtube_editor.editorExtension,
|
|
865
|
+
])
|
|
866
|
+
installAdminTiptapNodeMarkdown([
|
|
867
|
+
__ampless_plugin_x_embed_editor.tiptapNodeToMarkdown ?? {},
|
|
868
|
+
__ampless_plugin_youtube_editor.tiptapNodeToMarkdown ?? {},
|
|
869
|
+
])
|
|
870
|
+
installAdminTiptapNodeHtml([
|
|
871
|
+
__ampless_plugin_x_embed_editor.tiptapNodeToHtml ?? {},
|
|
872
|
+
__ampless_plugin_youtube_editor.tiptapNodeToHtml ?? {},
|
|
873
|
+
])
|
|
874
|
+
return <>{children}</>
|
|
875
|
+
}
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
### フォーマット切り替えの可逆アダプター(`tiptapNodeToMarkdown` + `tiptapNodeToHtml`)
|
|
879
|
+
|
|
880
|
+
operator が admin UI でポストのフォーマットを切り替える場合(例: `tiptap → markdown`、`tiptap → html`)、admin はボディコンテンツを変換する必要がある。通常の prose node は tiptap の built-in レンダラーで処理されるが、**atom node**(`amplessYoutube` のような embed ブロック)は子要素を持たず、children が空のまま fallthrough し、embed が無音で消えてしまう。
|
|
881
|
+
|
|
882
|
+
2 つのアダプターで両方向を修正する:
|
|
883
|
+
|
|
884
|
+
| アダプター | 方向 | 出力 |
|
|
885
|
+
| --------------------- | --------------------------------- | ---- |
|
|
886
|
+
| `tiptapNodeToMarkdown`| `tiptap → markdown` | bare URL 行(例: `https://youtu.be/<id>`) |
|
|
887
|
+
| `tiptapNodeToHtml` | `tiptap → html`、`markdown → html`| 正規プレースホルダー div |
|
|
888
|
+
|
|
889
|
+
フォーマット切替で扱う 3 種類の正規 body 表現:
|
|
890
|
+
|
|
891
|
+
| フォーマット | 正規形 |
|
|
892
|
+
| ------------ | ------ |
|
|
893
|
+
| tiptap | `{ type: 'amplessYoutube', attrs: { videoId, start } }` Node |
|
|
894
|
+
| markdown | bare `https://youtu.be/<id>` URL 行 |
|
|
895
|
+
| html | `<div data-ampless-youtube data-video-id="<id>" …>…</div>` |
|
|
896
|
+
|
|
897
|
+
プレースホルダー div は **admin format-switch 相互運用専用**の正規 HTML 形式: `Node.renderHTML` が emit し、`Node.parseHTML` の `tag: 'div[data-ampless-*]'` rule が復元することで、admin での `tiptap ↔ markdown ↔ html` 切替が embed を保ったまま round-trip できる。
|
|
898
|
+
|
|
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
|
+
|
|
901
|
+
embed node を持つが `htmlPlaceholder` を**宣言しない** plugin は従来挙動を維持する: `format: 'html'` 投稿の公開描画ではプレースホルダー div がリテラルにそのまま出力される(内側の正規 URL link は clickable なので graceful に degrade する)。その場合に iframe を得るには `tiptap` または `markdown` 形式で保存する。
|
|
902
|
+
|
|
903
|
+
#### アダプターの契約
|
|
904
|
+
|
|
905
|
+
両アダプターは同じシグネチャを持つ:
|
|
906
|
+
|
|
907
|
+
```ts
|
|
908
|
+
(node: TiptapRenderNode) => string | null
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
**文字列**(空文字 `''` も含む)を返すと、その出力を使う。**`null`** を返すとデフォルトの switch へ fallthrough する(対応しない node や video id が欠損している場合などに使う)。
|
|
912
|
+
|
|
913
|
+
```ts
|
|
914
|
+
import type { TiptapNodeMarkdownAdapters, TiptapNodeHtmlAdapters } from 'ampless'
|
|
915
|
+
|
|
916
|
+
export const tiptapNodeToMarkdown: TiptapNodeMarkdownAdapters = {
|
|
917
|
+
amplessYoutube: (node) => {
|
|
918
|
+
const videoId = String(node.attrs?.videoId ?? '').trim()
|
|
919
|
+
if (!videoId) return null
|
|
920
|
+
return `https://youtu.be/${videoId}`
|
|
921
|
+
},
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
export const tiptapNodeToHtml: TiptapNodeHtmlAdapters = {
|
|
925
|
+
amplessYoutube: (node) => {
|
|
926
|
+
const videoId = String(node.attrs?.videoId ?? '').trim()
|
|
927
|
+
if (!videoId) return null
|
|
928
|
+
const attrs = placeholderAttrs(node.attrs ?? {})
|
|
929
|
+
// 内側のコンテンツは editor 用視覚 label (`<span>YouTube: id</span>`、
|
|
930
|
+
// Node.renderHTML 専用) ではなく URL link にする。`format: 'html'` の
|
|
931
|
+
// 公開描画ではこの body をリテラル表示するため editor 用 label が漏れる
|
|
932
|
+
// のを防ぐ。viewer 側で iframe 展開されない場合でもクリック可能な link が
|
|
933
|
+
// 残り graceful degradation する。
|
|
934
|
+
const url = `https://youtu.be/${videoId}`
|
|
935
|
+
return `<div ${attrsToHtmlString(attrs)}><a href="${escapeAttr(url)}">${escapeAttr(url)}</a></div>`
|
|
936
|
+
},
|
|
937
|
+
}
|
|
938
|
+
```
|
|
939
|
+
|
|
940
|
+
#### 配線
|
|
941
|
+
|
|
942
|
+
`update-ampless` が各プラグインの `./editor` モジュールから `tiptapNodeToMarkdown` と `tiptapNodeToHtml` の named export を読み取り、両方の install に自動で配線する。**手動での配線は不要。** プラグインがいずれかのマップを export しない場合、生成ファイルの `?? {}` fallback が no-op になる。
|
|
943
|
+
|
|
944
|
+
#### `markdown → html` 2-hop
|
|
945
|
+
|
|
946
|
+
`markdown → html` の方向は `tiptapNodeToHtml` アダプターを 2-hop 経由で再利用する:
|
|
947
|
+
|
|
948
|
+
1. `markdownToHtml(body)` — marked が markdown を HTML に変換。bare URL 行は `<p><a href="URL">URL</a></p>` になる。
|
|
949
|
+
2. `generateJSON(html, extensions)` — tiptap が HTML を parse。プラグインの `Node.parseHTML` `tag: 'p'` rule が bare URL の paragraph を embed Node に昇格する。
|
|
950
|
+
3. `tiptapToHtml(doc, { nodeAdapters })` — html アダプターが embed Node をプレースホルダー div に serialize する。
|
|
951
|
+
|
|
952
|
+
プラグインは `tiptap → html` アダプターを 1 つ export するだけでよく、`markdown → html` の方向は tiptap の parse rule を通して自動的に再利用される。**重複ロジックは不要。**
|
|
953
|
+
|
|
954
|
+
#### Public html walker: `htmlPlaceholder`
|
|
955
|
+
|
|
956
|
+
`format: 'html'` 投稿のプレースホルダー div を公開ページで実 embed として描画するには、既存の `contentFields` の `tiptap` entry に **`htmlPlaceholder`** 宣言を追加する。**新しい renderer は書かない** — walker は tiptap entry が既に使う `render(node, ctx)` をそのまま呼ぶので、3 形式(tiptap / markdown / html)すべてが 1 個の renderer に到達し、描画が divergence しない。
|
|
957
|
+
|
|
958
|
+
```ts
|
|
959
|
+
// packages/plugin-youtube/src/index.tsx
|
|
960
|
+
contentFields: [
|
|
961
|
+
{
|
|
962
|
+
kind: 'tiptap',
|
|
963
|
+
nodeType: 'amplessYoutube',
|
|
964
|
+
render: (node) => {
|
|
965
|
+
const videoId = String(node.attrs?.videoId ?? '')
|
|
966
|
+
const startRaw = node.attrs?.start
|
|
967
|
+
const start =
|
|
968
|
+
typeof startRaw === 'number' && Number.isFinite(startRaw) ? startRaw : undefined
|
|
969
|
+
return <YouTubeEmbed videoId={videoId} start={start} />
|
|
970
|
+
},
|
|
971
|
+
htmlPlaceholder: {
|
|
972
|
+
// top-level プレースホルダー div を識別する marker attribute。
|
|
973
|
+
flagAttr: 'data-ampless-youtube',
|
|
974
|
+
// div の HTML 属性を render が期待する tiptap node attrs に変換する
|
|
975
|
+
// — 型も正しく。walker は型を知らないのでここで変換する
|
|
976
|
+
// (例: data-start string → number)。
|
|
977
|
+
attrsFromElement: (attribs) => {
|
|
978
|
+
const start = Number(attribs['data-start'])
|
|
979
|
+
return {
|
|
980
|
+
videoId: attribs['data-video-id'] ?? '',
|
|
981
|
+
start: Number.isFinite(start) ? start : undefined,
|
|
982
|
+
}
|
|
983
|
+
},
|
|
984
|
+
},
|
|
985
|
+
},
|
|
986
|
+
// …markdown-url entry は変更なし…
|
|
987
|
+
]
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
`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 する。
|
|
991
|
+
|
|
992
|
+
把握すべき制約:
|
|
993
|
+
|
|
994
|
+
- **top-level のみ。** `<blockquote>` / `<li>` 等の内側に nest したプレースホルダー div はリテラルのまま — editor の body-level-only な `parseHTML` fallback と整合。展開されるのは depth-0 の element のみ。
|
|
995
|
+
- **`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 要件はない。
|
|
996
|
+
- **graceful degradation。** `attrsFromElement` または `render` が throw した場合、runtime は `console.warn` を出力し **元のプレースホルダー slice**(内側の正規 URL link は clickable のまま)に fallback する。engineer が書いた本文を消さない。
|
|
997
|
+
- **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 を注入する。
|
|
998
|
+
|
|
999
|
+
**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 展開するための許容トレードオフ。
|
|
1000
|
+
|
|
851
1001
|
### markdown → tiptap の復元
|
|
852
1002
|
|
|
853
1003
|
editor Node が markdown へ bare URL line として serialise される場合、逆方向の復元は paste rule ではなく `Node.parseHTML()` で扱う必要がある。admin の `markdown → tiptap` format switch は、まず markdown を HTML に変換する。GFM autolink により bare URL line は `<p><a href="https://...">https://...</a></p>` になり、その HTML を tiptap が document として parse する。paste rule は user paste / typing event 用なので、この HTML parse 経路では発火しない。
|
|
@@ -1104,6 +1104,230 @@ export default createAdminLayout(admin, { editorBootstrap: EditorBootstrap })
|
|
|
1104
1104
|
inside a client component. The admin's `<TiptapEditor>` spreads the
|
|
1105
1105
|
registered list onto its built-in extensions on every render.
|
|
1106
1106
|
|
|
1107
|
+
The `_editor-bootstrap.tsx` generated by `update-ampless` wires three
|
|
1108
|
+
install calls — extensions, markdown adapters, and html adapters:
|
|
1109
|
+
|
|
1110
|
+
```tsx
|
|
1111
|
+
// app/(admin)/admin/_editor-bootstrap.tsx (AUTO-GENERATED — do not edit)
|
|
1112
|
+
'use client'
|
|
1113
|
+
// AUTO-GENERATED by `npm run update-ampless`. Do not edit ...
|
|
1114
|
+
import { installAdminEditorExtensions, installAdminTiptapNodeMarkdown, installAdminTiptapNodeHtml } from '@ampless/admin/editor'
|
|
1115
|
+
import * as __ampless_plugin_x_embed_editor from '@ampless/plugin-x-embed/editor'
|
|
1116
|
+
import * as __ampless_plugin_youtube_editor from '@ampless/plugin-youtube/editor'
|
|
1117
|
+
|
|
1118
|
+
export function EditorBootstrap({ children }: { children: React.ReactNode }) {
|
|
1119
|
+
installAdminEditorExtensions([
|
|
1120
|
+
__ampless_plugin_x_embed_editor.editorExtension,
|
|
1121
|
+
__ampless_plugin_youtube_editor.editorExtension,
|
|
1122
|
+
])
|
|
1123
|
+
installAdminTiptapNodeMarkdown([
|
|
1124
|
+
__ampless_plugin_x_embed_editor.tiptapNodeToMarkdown ?? {},
|
|
1125
|
+
__ampless_plugin_youtube_editor.tiptapNodeToMarkdown ?? {},
|
|
1126
|
+
])
|
|
1127
|
+
installAdminTiptapNodeHtml([
|
|
1128
|
+
__ampless_plugin_x_embed_editor.tiptapNodeToHtml ?? {},
|
|
1129
|
+
__ampless_plugin_youtube_editor.tiptapNodeToHtml ?? {},
|
|
1130
|
+
])
|
|
1131
|
+
return <>{children}</>
|
|
1132
|
+
}
|
|
1133
|
+
```
|
|
1134
|
+
|
|
1135
|
+
### Lossless format-switch adapters (`tiptapNodeToMarkdown` + `tiptapNodeToHtml`)
|
|
1136
|
+
|
|
1137
|
+
When the operator switches the post format in the admin UI (e.g. `tiptap →
|
|
1138
|
+
markdown` or `tiptap → html`), the admin must convert the body content. For
|
|
1139
|
+
standard prose nodes tiptap's built-in renderers handle this, but **atom
|
|
1140
|
+
nodes** (embed blocks like `amplessYoutube`) have no children — they fall
|
|
1141
|
+
through with empty output, silently dropping the embed.
|
|
1142
|
+
|
|
1143
|
+
Two adapters fix both directions:
|
|
1144
|
+
|
|
1145
|
+
| Adapter | Direction | Output |
|
|
1146
|
+
| --------------------- | --------------------------------- | ------ |
|
|
1147
|
+
| `tiptapNodeToMarkdown`| `tiptap → markdown` | bare URL line (e.g. `https://youtu.be/<id>`) |
|
|
1148
|
+
| `tiptapNodeToHtml` | `tiptap → html`, `markdown → html`| canonical placeholder div |
|
|
1149
|
+
|
|
1150
|
+
The three canonical body representations across format-switch are:
|
|
1151
|
+
|
|
1152
|
+
| Format | Canonical form |
|
|
1153
|
+
| -------- | -------------- |
|
|
1154
|
+
| tiptap | `{ type: 'amplessYoutube', attrs: { videoId, start } }` Node |
|
|
1155
|
+
| markdown | bare `https://youtu.be/<id>` URL line |
|
|
1156
|
+
| html | `<div data-ampless-youtube data-video-id="<id>" …>…</div>` |
|
|
1157
|
+
|
|
1158
|
+
The placeholder div is the canonical HTML form for **admin format-switch
|
|
1159
|
+
interop only**: it is what `Node.renderHTML` emits, and what
|
|
1160
|
+
`Node.parseHTML`'s `tag: 'div[data-ampless-*]'` rule restores from, so
|
|
1161
|
+
that switching `tiptap ↔ markdown ↔ html` in the admin preserves embeds
|
|
1162
|
+
losslessly.
|
|
1163
|
+
|
|
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.
|
|
1185
|
+
|
|
1186
|
+
#### Adapter contract
|
|
1187
|
+
|
|
1188
|
+
Both adapters have the same signature:
|
|
1189
|
+
|
|
1190
|
+
```ts
|
|
1191
|
+
(node: TiptapRenderNode) => string | null
|
|
1192
|
+
```
|
|
1193
|
+
|
|
1194
|
+
Return a **string** (including empty string `''`) to use your output.
|
|
1195
|
+
Return **`null`** to fall through to the default switch (useful for nodes
|
|
1196
|
+
you don't handle or for degenerate inputs like a missing video id).
|
|
1197
|
+
|
|
1198
|
+
```ts
|
|
1199
|
+
// packages/plugin-youtube/src/editor.tsx
|
|
1200
|
+
import type { TiptapNodeMarkdownAdapters, TiptapNodeHtmlAdapters } from 'ampless'
|
|
1201
|
+
|
|
1202
|
+
export const tiptapNodeToMarkdown: TiptapNodeMarkdownAdapters = {
|
|
1203
|
+
amplessYoutube: (node) => {
|
|
1204
|
+
const videoId = String(node.attrs?.videoId ?? '').trim()
|
|
1205
|
+
if (!videoId) return null // fall through
|
|
1206
|
+
return `https://youtu.be/${videoId}`
|
|
1207
|
+
},
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
export const tiptapNodeToHtml: TiptapNodeHtmlAdapters = {
|
|
1211
|
+
amplessYoutube: (node) => {
|
|
1212
|
+
const videoId = String(node.attrs?.videoId ?? '').trim()
|
|
1213
|
+
if (!videoId) return null // fall through
|
|
1214
|
+
const attrs = placeholderAttrs(node.attrs ?? {})
|
|
1215
|
+
// Inner content is the canonical URL as a clickable link, not the
|
|
1216
|
+
// editor's visual label `<span>YouTube: id</span>`. Public render of
|
|
1217
|
+
// `format: 'html'` posts shows this content literally; an editor
|
|
1218
|
+
// label would leak. The URL link gracefully degrades — viewers
|
|
1219
|
+
// without iframe expansion still get a clickable link, and it
|
|
1220
|
+
// mirrors the markdown canonical form. parseHTML reads the
|
|
1221
|
+
// `data-video-id` attribute, so the inner content is irrelevant
|
|
1222
|
+
// for round-trip.
|
|
1223
|
+
const url = `https://youtu.be/${videoId}`
|
|
1224
|
+
return `<div ${attrsToHtmlString(attrs)}><a href="${escapeAttr(url)}">${escapeAttr(url)}</a></div>`
|
|
1225
|
+
},
|
|
1226
|
+
}
|
|
1227
|
+
```
|
|
1228
|
+
|
|
1229
|
+
#### Wiring
|
|
1230
|
+
|
|
1231
|
+
`update-ampless` reads the `tiptapNodeToMarkdown` and `tiptapNodeToHtml`
|
|
1232
|
+
named exports from each plugin's `./editor` module (via namespace import `*
|
|
1233
|
+
as`) and wires them into both installs automatically — **no hand-wiring
|
|
1234
|
+
required**. If your plugin does not export one of the maps, the `?? {}`
|
|
1235
|
+
fallback in the generated file is a no-op.
|
|
1236
|
+
|
|
1237
|
+
#### `markdown → html` 2-hop
|
|
1238
|
+
|
|
1239
|
+
The `markdown → html` direction reuses the `tiptapNodeToHtml` adapter via
|
|
1240
|
+
a 2-hop:
|
|
1241
|
+
|
|
1242
|
+
1. `markdownToHtml(body)` — marked converts the markdown body to HTML. Bare
|
|
1243
|
+
URL lines become `<p><a href="URL">URL</a></p>`.
|
|
1244
|
+
2. `generateJSON(html, extensions)` — tiptap parses the HTML. Your plugin's
|
|
1245
|
+
`Node.parseHTML` `tag: 'p'` rule promotes the bare-URL paragraph to the
|
|
1246
|
+
embed Node.
|
|
1247
|
+
3. `tiptapToHtml(doc, { nodeAdapters })` — the html adapter serialises the
|
|
1248
|
+
embed Node to the placeholder div.
|
|
1249
|
+
|
|
1250
|
+
This means your plugin only needs to export the `tiptap → html` adapter once;
|
|
1251
|
+
the `markdown → html` direction reuses it automatically through tiptap's parse
|
|
1252
|
+
rules. **No duplicate logic is needed.**
|
|
1253
|
+
|
|
1254
|
+
#### Public html walker: `htmlPlaceholder`
|
|
1255
|
+
|
|
1256
|
+
To make `format: 'html'` posts render placeholder divs as real embeds on
|
|
1257
|
+
the public page, add an **`htmlPlaceholder`** declaration to your existing
|
|
1258
|
+
`contentFields` `tiptap` entry. You write **no new renderer** — the walker
|
|
1259
|
+
calls the same `render(node, ctx)` your tiptap entry already uses, so all
|
|
1260
|
+
three formats (tiptap / markdown / html) reach one renderer with no
|
|
1261
|
+
divergence.
|
|
1262
|
+
|
|
1263
|
+
```ts
|
|
1264
|
+
// packages/plugin-youtube/src/index.tsx
|
|
1265
|
+
contentFields: [
|
|
1266
|
+
{
|
|
1267
|
+
kind: 'tiptap',
|
|
1268
|
+
nodeType: 'amplessYoutube',
|
|
1269
|
+
render: (node) => {
|
|
1270
|
+
const videoId = String(node.attrs?.videoId ?? '')
|
|
1271
|
+
const startRaw = node.attrs?.start
|
|
1272
|
+
const start =
|
|
1273
|
+
typeof startRaw === 'number' && Number.isFinite(startRaw) ? startRaw : undefined
|
|
1274
|
+
return <YouTubeEmbed videoId={videoId} start={start} />
|
|
1275
|
+
},
|
|
1276
|
+
htmlPlaceholder: {
|
|
1277
|
+
// The marker attribute that flags a top-level placeholder div.
|
|
1278
|
+
flagAttr: 'data-ampless-youtube',
|
|
1279
|
+
// Convert the div's HTML attributes into the tiptap node `attrs`
|
|
1280
|
+
// your `render` expects — WITH the right types. The walker is
|
|
1281
|
+
// type-agnostic, so coerce here (e.g. data-start string → number).
|
|
1282
|
+
attrsFromElement: (attribs) => {
|
|
1283
|
+
const start = Number(attribs['data-start'])
|
|
1284
|
+
return {
|
|
1285
|
+
videoId: attribs['data-video-id'] ?? '',
|
|
1286
|
+
start: Number.isFinite(start) ? start : undefined,
|
|
1287
|
+
}
|
|
1288
|
+
},
|
|
1289
|
+
},
|
|
1290
|
+
},
|
|
1291
|
+
// …markdown-url entry unchanged…
|
|
1292
|
+
]
|
|
1293
|
+
```
|
|
1294
|
+
|
|
1295
|
+
On public render of a `format: 'html'` post, the runtime parses the body
|
|
1296
|
+
server-side (`htmlparser2`), finds each **top-level** element carrying
|
|
1297
|
+
`flagAttr`, builds a `{ type: nodeType, attrs: attrsFromElement(attribs) }`
|
|
1298
|
+
node, and calls `render(node, ctx)`. Everything else passes through as the
|
|
1299
|
+
**original-string slices** (byte-for-byte; no DOM re-serialisation).
|
|
1300
|
+
|
|
1301
|
+
Constraints worth knowing:
|
|
1302
|
+
|
|
1303
|
+
- **Top-level only.** Placeholder divs nested inside `<blockquote>`,
|
|
1304
|
+
`<li>`, etc. stay literal — consistent with the editor's body-level-only
|
|
1305
|
+
`parseHTML` fallback. Only depth-0 elements expand.
|
|
1306
|
+
- **`flagAttr` is matched case-insensitively.** htmlparser2 lowercases
|
|
1307
|
+
HTML attribute names while parsing, and the runtime lowercases your
|
|
1308
|
+
`flagAttr` at registration, so `<div DATA-AMPLESS-YOUTUBE …>` and
|
|
1309
|
+
`<div data-ampless-youtube …>` both expand. `flagAttr` can be any
|
|
1310
|
+
attribute name — a site-local plugin may use `data-my-embed`; there is
|
|
1311
|
+
no fixed `data-ampless` prefix requirement.
|
|
1312
|
+
- **Graceful degradation.** If `attrsFromElement` or `render` throws, the
|
|
1313
|
+
runtime logs a `console.warn` and falls back to the **raw placeholder
|
|
1314
|
+
slice** (the inner canonical URL link stays clickable) rather than
|
|
1315
|
+
dropping the engineer-authored content.
|
|
1316
|
+
- **Page-level scripts.** If your embed needs a third-party script (e.g.
|
|
1317
|
+
x.com's `widgets.js`), your `publicPostScript` / detection helper must
|
|
1318
|
+
also recognise the placeholder form. `plugin-x-embed`'s `hasTweetIn`
|
|
1319
|
+
matches both `twitter-tweet` and `data-ampless-tweet` in `format: 'html'`
|
|
1320
|
+
bodies so widgets.js is injected for the expanded blockquote to hydrate.
|
|
1321
|
+
|
|
1322
|
+
**Wrapper-boundary change.** A placeholder-free `format: 'html'` post is
|
|
1323
|
+
emitted as a single wrapper `<div>` (the fast path — markup-identical to
|
|
1324
|
+
the previous raw passthrough). A post **with** placeholders becomes
|
|
1325
|
+
**multiple wrapper divs interleaved with the React embed siblings**: the
|
|
1326
|
+
bytes inside each raw chunk are preserved exactly, but the wrapper
|
|
1327
|
+
boundaries shift. Direct-child / adjacent-sibling CSS selectors that
|
|
1328
|
+
assumed one wrapper around the whole body will not match the same way.
|
|
1329
|
+
This is the accepted trade-off for expanding embeds in-place.
|
|
1330
|
+
|
|
1107
1331
|
### Markdown to tiptap restoration
|
|
1108
1332
|
|
|
1109
1333
|
If an editor Node serializes to markdown as a bare URL line, the reverse
|
|
@@ -26,21 +26,21 @@
|
|
|
26
26
|
"@tiptap/pm": "^3.23.6",
|
|
27
27
|
"@tiptap/react": "^3.23.6",
|
|
28
28
|
"@tiptap/starter-kit": "^3.23.6",
|
|
29
|
-
"@ampless/plugin-analytics-ga4": "^0.2.0-alpha.
|
|
30
|
-
"@ampless/plugin-cookie-consent": "^0.1.0-alpha.
|
|
31
|
-
"@ampless/plugin-gtm": "^0.2.0-alpha.
|
|
32
|
-
"@ampless/plugin-og-image": "^0.2.0-alpha.
|
|
33
|
-
"@ampless/plugin-plausible": "^0.2.0-alpha.
|
|
34
|
-
"@ampless/plugin-reading-time": "^0.1.0-alpha.
|
|
35
|
-
"@ampless/plugin-rss": "^0.2.0-alpha.
|
|
36
|
-
"@ampless/plugin-schema-jsonld": "^0.1.1-alpha.
|
|
37
|
-
"@ampless/plugin-seo": "^0.2.0-alpha.
|
|
38
|
-
"@ampless/plugin-webhook": "^0.2.0-alpha.
|
|
39
|
-
"@ampless/admin": "^1.0.0-alpha.
|
|
40
|
-
"@ampless/backend": "^1.0.0-alpha.
|
|
41
|
-
"@ampless/runtime": "^1.0.0-alpha.
|
|
29
|
+
"@ampless/plugin-analytics-ga4": "^0.2.0-alpha.32",
|
|
30
|
+
"@ampless/plugin-cookie-consent": "^0.1.0-alpha.23",
|
|
31
|
+
"@ampless/plugin-gtm": "^0.2.0-alpha.31",
|
|
32
|
+
"@ampless/plugin-og-image": "^0.2.0-alpha.48",
|
|
33
|
+
"@ampless/plugin-plausible": "^0.2.0-alpha.31",
|
|
34
|
+
"@ampless/plugin-reading-time": "^0.1.0-alpha.21",
|
|
35
|
+
"@ampless/plugin-rss": "^0.2.0-alpha.48",
|
|
36
|
+
"@ampless/plugin-schema-jsonld": "^0.1.1-alpha.27",
|
|
37
|
+
"@ampless/plugin-seo": "^0.2.0-alpha.48",
|
|
38
|
+
"@ampless/plugin-webhook": "^0.2.0-alpha.49",
|
|
39
|
+
"@ampless/admin": "^1.0.0-alpha.81",
|
|
40
|
+
"@ampless/backend": "^1.0.0-alpha.70",
|
|
41
|
+
"@ampless/runtime": "^1.0.0-alpha.57",
|
|
42
42
|
"@digital-go-jp/tailwind-theme-plugin": "^0.3.4",
|
|
43
|
-
"ampless": "^1.0.0-alpha.
|
|
43
|
+
"ampless": "^1.0.0-alpha.48",
|
|
44
44
|
"aws-amplify": "^6.17.0",
|
|
45
45
|
"class-variance-authority": "^0.7.1",
|
|
46
46
|
"clsx": "^2.1.1",
|