ampless 1.0.0-alpha.46 → 1.0.0-alpha.47
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 +25 -1
- package/docs/plugin-author-guide.ja.md +108 -0
- package/docs/plugin-author-guide.md +159 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1014,6 +1014,30 @@ interface TiptapRenderNode {
|
|
|
1014
1014
|
*/
|
|
1015
1015
|
type TiptapNodeToMarkdown = (node: TiptapRenderNode) => string | null;
|
|
1016
1016
|
type TiptapNodeMarkdownAdapters = Readonly<Record<string, TiptapNodeToMarkdown>>;
|
|
1017
|
+
/**
|
|
1018
|
+
* Adapter that converts a single tiptap node to its canonical HTML placeholder
|
|
1019
|
+
* div form. Plugins that ship an embed node (e.g. amplessYoutube →
|
|
1020
|
+
* `<div data-ampless-youtube data-video-id="...">…</div>`) export a map from
|
|
1021
|
+
* nodeType to adapter from their `./editor` module (named export
|
|
1022
|
+
* `tiptapNodeToHtml`); `update-ampless` wires the map into
|
|
1023
|
+
* `installAdminTiptapNodeHtml` so the admin's `tiptap → html` format switch
|
|
1024
|
+
* can losslessly serialise the embed to the canonical placeholder div form.
|
|
1025
|
+
*
|
|
1026
|
+
* The canonical div is what tiptap's `Node.renderHTML` emits and what the
|
|
1027
|
+
* `Node.parseHTML` `tag: 'div[data-ampless-*]'` rule restores from. It is an
|
|
1028
|
+
* admin format-switch interchange form; public rendering expands embeds from
|
|
1029
|
+
* the `tiptap` / `markdown` walkers, while `format: 'html'` preserves the div
|
|
1030
|
+
* literally.
|
|
1031
|
+
*
|
|
1032
|
+
* The `markdown → html` direction is a 2-hop via `generateJSON(...)` so
|
|
1033
|
+
* plugins only need to export the `tiptap → html` adapter; the markdown side
|
|
1034
|
+
* reuses it via tiptap's parseHTML rules (no duplicate logic).
|
|
1035
|
+
*
|
|
1036
|
+
* Return `null` (or simply omit the nodeType from the map) to fall through to
|
|
1037
|
+
* the runtime's default HTML switch.
|
|
1038
|
+
*/
|
|
1039
|
+
type TiptapNodeToHtml = (node: TiptapRenderNode) => string | null;
|
|
1040
|
+
type TiptapNodeHtmlAdapters = Readonly<Record<string, TiptapNodeToHtml>>;
|
|
1017
1041
|
/**
|
|
1018
1042
|
* Match object passed to a `contentFields` `markdown-url` renderer. The
|
|
1019
1043
|
* runtime walks the markdown body via `marked.lexer`, tests the trimmed
|
|
@@ -1901,4 +1925,4 @@ declare function pickDefaultEntrypoint(files: readonly {
|
|
|
1901
1925
|
|
|
1902
1926
|
declare const VERSION = "0.0.1";
|
|
1903
1927
|
|
|
1904
|
-
export { type AmplessEvent, type AmplessPlugin, type AuthContext, type BundleExtractResult, type CacheConfig, type CacheStrategy, type Config, type ContentEventPayload, type ContentEventType, type ContentFieldRenderer, type ContentFormat, type ContentTypeDefinition, type CreatePostInput, DEFAULT_ENTRYPOINT, type DateFormat, type EventPayloadOf, type EventType, type ExtractedFile, type FieldDefinition, type FieldType, type HistoryConfig, type ImageDisplay, type KvItem, type KvStore, type LinkListItem, type ListOptions, type ListPostHistoryOptions, type LocalizedString, MAX_BUNDLE_BYTES, type MarkdownEmbedMatch, type Media, type MediaEventPayload, type MediaEventType, type MediaMetadata, type MediaProcessingDefaults, type OgImageConfig, type OgImageFont, type OgImageRenderContext, PLUGIN_KEY_PATTERN, type Page, type PluginBooleanField, type PluginCapability, type PluginCodeField, type PluginEventHandler, type PluginHookResult, type PluginJsonField, type PluginMetadata, type PluginNumberField, type PluginPackageManifest, type PluginPublicRenderContext, type PluginRepeatableField, type PluginRepeatableSubField, type PluginRuntimeContext, type PluginSecretField, type PluginSelectField, type PluginSettingField, type PluginSettingsManifest, type PluginTextField, type PluginTextareaField, type PluginUninstallContext, type PluginUrlField, type Post, type PostIndexEventPayload, type PostIndexEventType, type PostMetadata, type PostRevision, type PostRevisionConnection, type PostStatus, type PostsProvider, type PublicBodyDescriptor, type PublicHeadDescriptor, type PublicPostBodyDescriptor, type PublicPostHtmlDescriptor, type PublicPostHtmlPosition, type PublicPostScriptDescriptor, type Role, SITE_CONFIG_PK, type ScriptStrategy, type SiteSettingsEventPayload, type SiteSettingsEventType, type StaticPostBody, type StaticPostFileMeta, type StreamEventName, TEXT_EXTENSIONS, type ThemeColorField, type ThemeField, type ThemeFieldType, type ThemeFontFamilyField, type ThemeImageField, type ThemeLengthField, type ThemeLinkListField, type ThemeManifest, type ThemeModule, type ThemeRouteContext, type ThemeSelectField, type ThemeTextField, type TiptapNodeMarkdownAdapters, type TiptapNodeToMarkdown, type TiptapRenderNode, type TrustLevel, type TrustedPluginRuntimeContext, VERSION, type ValidationIssue, bundlePrefix, createPost, decodeAwsJson, defineConfig, definePlugin, defineSchema, defineTheme, defineThemeModule, deletePost, deleteSiteSetting, detectContentEvents, encodeAwsJson, escapeXml, extractFirstImageUrl, findAbsolutePathRefs, flattenSettings, formatColorPair, formatDate, formatPublicAssetUrl, getKvStore, getPost, getPostById, getSiteSetting, hasKvStore, hasPostsProvider, isTagListUrl, isValidPluginKey, listPostHistory, listPosts, listSiteSettings, mimeTypeFor, parseColorPair, parseLinkList, pickDefaultEntrypoint, resolveLocalized, resolvePluginSettings, resolveThemeValues, setKvStore, setPostsProvider, setSiteSetting, stringifyLinkList, stripCommonPrefix, themeSettingKey, unflattenSettings, updatePost, validateBundle, validateBundlePath, validatePluginSettingValue, validatePublicAssetKey, validateThemeValue };
|
|
1928
|
+
export { type AmplessEvent, type AmplessPlugin, type AuthContext, type BundleExtractResult, type CacheConfig, type CacheStrategy, type Config, type ContentEventPayload, type ContentEventType, type ContentFieldRenderer, type ContentFormat, type ContentTypeDefinition, type CreatePostInput, DEFAULT_ENTRYPOINT, type DateFormat, type EventPayloadOf, type EventType, type ExtractedFile, type FieldDefinition, type FieldType, type HistoryConfig, type ImageDisplay, type KvItem, type KvStore, type LinkListItem, type ListOptions, type ListPostHistoryOptions, type LocalizedString, MAX_BUNDLE_BYTES, type MarkdownEmbedMatch, type Media, type MediaEventPayload, type MediaEventType, type MediaMetadata, type MediaProcessingDefaults, type OgImageConfig, type OgImageFont, type OgImageRenderContext, PLUGIN_KEY_PATTERN, type Page, type PluginBooleanField, type PluginCapability, type PluginCodeField, type PluginEventHandler, type PluginHookResult, type PluginJsonField, type PluginMetadata, type PluginNumberField, type PluginPackageManifest, type PluginPublicRenderContext, type PluginRepeatableField, type PluginRepeatableSubField, type PluginRuntimeContext, type PluginSecretField, type PluginSelectField, type PluginSettingField, type PluginSettingsManifest, type PluginTextField, type PluginTextareaField, type PluginUninstallContext, type PluginUrlField, type Post, type PostIndexEventPayload, type PostIndexEventType, type PostMetadata, type PostRevision, type PostRevisionConnection, type PostStatus, type PostsProvider, type PublicBodyDescriptor, type PublicHeadDescriptor, type PublicPostBodyDescriptor, type PublicPostHtmlDescriptor, type PublicPostHtmlPosition, type PublicPostScriptDescriptor, type Role, SITE_CONFIG_PK, type ScriptStrategy, type SiteSettingsEventPayload, type SiteSettingsEventType, type StaticPostBody, type StaticPostFileMeta, type StreamEventName, TEXT_EXTENSIONS, type ThemeColorField, type ThemeField, type ThemeFieldType, type ThemeFontFamilyField, type ThemeImageField, type ThemeLengthField, type ThemeLinkListField, type ThemeManifest, type ThemeModule, type ThemeRouteContext, type ThemeSelectField, type ThemeTextField, type TiptapNodeHtmlAdapters, type TiptapNodeMarkdownAdapters, type TiptapNodeToHtml, type TiptapNodeToMarkdown, type TiptapRenderNode, type TrustLevel, type TrustedPluginRuntimeContext, VERSION, type ValidationIssue, bundlePrefix, createPost, decodeAwsJson, defineConfig, definePlugin, defineSchema, defineTheme, defineThemeModule, deletePost, deleteSiteSetting, detectContentEvents, encodeAwsJson, escapeXml, extractFirstImageUrl, findAbsolutePathRefs, flattenSettings, formatColorPair, formatDate, formatPublicAssetUrl, getKvStore, getPost, getPostById, getSiteSetting, hasKvStore, hasPostsProvider, isTagListUrl, isValidPluginKey, listPostHistory, listPosts, listSiteSettings, mimeTypeFor, parseColorPair, parseLinkList, pickDefaultEntrypoint, resolveLocalized, resolvePluginSettings, resolveThemeValues, setKvStore, setPostsProvider, setSiteSetting, stringifyLinkList, stripCommonPrefix, themeSettingKey, unflattenSettings, updatePost, validateBundle, validateBundlePath, validatePluginSettingValue, validatePublicAssetKey, validateThemeValue };
|
|
@@ -848,6 +848,114 @@ 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'` の公開描画ではプレースホルダー展開を行わない。** 公開描画はフォーマット別に分岐: 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 を変換しない。
|
|
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 を参照。
|
|
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
|
+
// packages/plugin-youtube/src/editor.tsx
|
|
915
|
+
import type { TiptapNodeMarkdownAdapters, TiptapNodeHtmlAdapters } from 'ampless'
|
|
916
|
+
|
|
917
|
+
export const tiptapNodeToMarkdown: TiptapNodeMarkdownAdapters = {
|
|
918
|
+
amplessYoutube: (node) => {
|
|
919
|
+
const videoId = String(node.attrs?.videoId ?? '').trim()
|
|
920
|
+
if (!videoId) return null // fallthrough
|
|
921
|
+
return `https://youtu.be/${videoId}`
|
|
922
|
+
},
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
export const tiptapNodeToHtml: TiptapNodeHtmlAdapters = {
|
|
926
|
+
amplessYoutube: (node) => {
|
|
927
|
+
const videoId = String(node.attrs?.videoId ?? '').trim()
|
|
928
|
+
if (!videoId) return null // fallthrough
|
|
929
|
+
const attrs = placeholderAttrs(node.attrs ?? {})
|
|
930
|
+
return `<div ${attrsToHtmlString(attrs)}><span>YouTube: ${escapeAttr(videoId)}</span></div>`
|
|
931
|
+
},
|
|
932
|
+
}
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
#### 配線
|
|
936
|
+
|
|
937
|
+
`update-ampless` が各プラグインの `./editor` モジュールから `tiptapNodeToMarkdown` と `tiptapNodeToHtml` の named export を読み取り(namespace import `* as` 経由)、両方の install に自動で配線する。**手動での配線は不要。** プラグインがいずれかのマップを export しない場合、生成ファイルの `?? {}` fallback が no-op になる。
|
|
938
|
+
|
|
939
|
+
#### `markdown → html` 2-hop
|
|
940
|
+
|
|
941
|
+
`markdown → html` の方向は `tiptapNodeToHtml` アダプターを 2-hop 経由で再利用する:
|
|
942
|
+
|
|
943
|
+
1. `markdownToHtml(body)` — marked が markdown を HTML に変換。bare URL 行は `<p><a href="URL">URL</a></p>` になる。
|
|
944
|
+
2. `generateJSON(html, extensions)` — tiptap が HTML を parse。プラグインの `Node.parseHTML` `tag: 'p'` rule が bare URL の paragraph を embed Node に昇格する。
|
|
945
|
+
3. `tiptapToHtml(doc, { nodeAdapters })` — html アダプターが embed Node をプレースホルダー div に serialize する。
|
|
946
|
+
|
|
947
|
+
つまりプラグインは `tiptap → html` アダプターを 1 つ export するだけでよく、`markdown → html` の方向は tiptap の parse rule を通して自動的に再利用される。**重複ロジックは不要。**
|
|
948
|
+
|
|
949
|
+
#### Follow-up: public html walker(未実装)
|
|
950
|
+
|
|
951
|
+
`format: 'html'` 投稿で、保存されたプレースホルダー div を公開ページで実 iframe として描画するためには、将来の capability で保存された html body を render 時に walk し、`data-ampless-*` flag を見て登録済プラグインの React renderer(= tiptap 投稿で `contentFields.tiptap` が使うのと同じ renderer)に置換する仕組みが必要。スケッチ:
|
|
952
|
+
|
|
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` 等の小さなパーサーを選定する必要あり)。
|
|
956
|
+
|
|
957
|
+
本 PR の scope 外(別 proposal として track)。当面は html-format 投稿のプレースホルダーは raw HTML として扱われ、公開ページで iframe 表示したい著者は `tiptap` か `markdown` で保存する。
|
|
958
|
+
|
|
851
959
|
### markdown → tiptap の復元
|
|
852
960
|
|
|
853
961
|
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,165 @@ 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 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.
|
|
1183
|
+
|
|
1184
|
+
#### Adapter contract
|
|
1185
|
+
|
|
1186
|
+
Both adapters have the same signature:
|
|
1187
|
+
|
|
1188
|
+
```ts
|
|
1189
|
+
(node: TiptapRenderNode) => string | null
|
|
1190
|
+
```
|
|
1191
|
+
|
|
1192
|
+
Return a **string** (including empty string `''`) to use your output.
|
|
1193
|
+
Return **`null`** to fall through to the default switch (useful for nodes
|
|
1194
|
+
you don't handle or for degenerate inputs like a missing video id).
|
|
1195
|
+
|
|
1196
|
+
```ts
|
|
1197
|
+
// packages/plugin-youtube/src/editor.tsx
|
|
1198
|
+
import type { TiptapNodeMarkdownAdapters, TiptapNodeHtmlAdapters } from 'ampless'
|
|
1199
|
+
|
|
1200
|
+
export const tiptapNodeToMarkdown: TiptapNodeMarkdownAdapters = {
|
|
1201
|
+
amplessYoutube: (node) => {
|
|
1202
|
+
const videoId = String(node.attrs?.videoId ?? '').trim()
|
|
1203
|
+
if (!videoId) return null // fall through
|
|
1204
|
+
return `https://youtu.be/${videoId}`
|
|
1205
|
+
},
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
export const tiptapNodeToHtml: TiptapNodeHtmlAdapters = {
|
|
1209
|
+
amplessYoutube: (node) => {
|
|
1210
|
+
const videoId = String(node.attrs?.videoId ?? '').trim()
|
|
1211
|
+
if (!videoId) return null // fall through
|
|
1212
|
+
// placeholderAttrs() is a plugin-local helper that returns the same
|
|
1213
|
+
// attribute dict used by Node.renderHTML — single source of truth.
|
|
1214
|
+
const attrs = placeholderAttrs(node.attrs ?? {})
|
|
1215
|
+
return `<div ${attrsToHtmlString(attrs)}><span>YouTube: ${escapeAttr(videoId)}</span></div>`
|
|
1216
|
+
},
|
|
1217
|
+
}
|
|
1218
|
+
```
|
|
1219
|
+
|
|
1220
|
+
#### Wiring
|
|
1221
|
+
|
|
1222
|
+
`update-ampless` reads the `tiptapNodeToMarkdown` and `tiptapNodeToHtml`
|
|
1223
|
+
named exports from each plugin's `./editor` module (via namespace import `*
|
|
1224
|
+
as`) and wires them into both installs automatically — **no hand-wiring
|
|
1225
|
+
required**. If your plugin does not export one of the maps, the `?? {}`
|
|
1226
|
+
fallback in the generated file is a no-op.
|
|
1227
|
+
|
|
1228
|
+
#### `markdown → html` 2-hop
|
|
1229
|
+
|
|
1230
|
+
The `markdown → html` direction reuses the `tiptapNodeToHtml` adapter via
|
|
1231
|
+
a 2-hop:
|
|
1232
|
+
|
|
1233
|
+
1. `markdownToHtml(body)` — marked converts the markdown body to HTML. Bare
|
|
1234
|
+
URL lines become `<p><a href="URL">URL</a></p>`.
|
|
1235
|
+
2. `generateJSON(html, extensions)` — tiptap parses the HTML. Your plugin's
|
|
1236
|
+
`Node.parseHTML` `tag: 'p'` rule promotes the bare-URL paragraph to the
|
|
1237
|
+
embed Node.
|
|
1238
|
+
3. `tiptapToHtml(doc, { nodeAdapters })` — the html adapter serialises the
|
|
1239
|
+
embed Node to the placeholder div.
|
|
1240
|
+
|
|
1241
|
+
This means your plugin only needs to export the `tiptap → html` adapter once;
|
|
1242
|
+
the `markdown → html` direction reuses it automatically through tiptap's parse
|
|
1243
|
+
rules. **No duplicate logic is needed.**
|
|
1244
|
+
|
|
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`.
|
|
1265
|
+
|
|
1107
1266
|
### Markdown to tiptap restoration
|
|
1108
1267
|
|
|
1109
1268
|
If an editor Node serializes to markdown as a bare URL line, the reverse
|