ampless 1.0.0-alpha.47 → 1.0.0-alpha.49
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 +99 -1
- package/dist/index.js +31 -0
- package/docs/plugin-author-guide.ja.md +54 -9
- package/docs/plugin-author-guide.md +107 -40
- package/package.json +1 -1
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;
|
|
@@ -1639,6 +1681,34 @@ interface PostRevisionConnection {
|
|
|
1639
1681
|
items: PostRevision[];
|
|
1640
1682
|
nextToken?: string;
|
|
1641
1683
|
}
|
|
1684
|
+
/**
|
|
1685
|
+
* Lightweight row for admin list views — excludes body / metadata.
|
|
1686
|
+
* Body fields (tiptap JSON, markdown source, etc.) can be tens of KB per post;
|
|
1687
|
+
* projecting them out cuts per-post transfer size by 90%+ at scale.
|
|
1688
|
+
*
|
|
1689
|
+
* For list views that only need title / slug / status / dates / tags,
|
|
1690
|
+
* use `listPostSummaries` instead of `listPosts`.
|
|
1691
|
+
*
|
|
1692
|
+
* Scale note: for single-site blogs (hundreds to low thousands of posts),
|
|
1693
|
+
* full client-side fetch + sort/search is fast and eliminates GSI complexity.
|
|
1694
|
+
* If a site grows to many thousands of posts, revisit with a
|
|
1695
|
+
* (status, updatedAt) GSI + server-side pagination.
|
|
1696
|
+
*/
|
|
1697
|
+
interface PostSummary {
|
|
1698
|
+
postId: string;
|
|
1699
|
+
slug: string;
|
|
1700
|
+
title: string;
|
|
1701
|
+
excerpt?: string;
|
|
1702
|
+
status: 'draft' | 'published';
|
|
1703
|
+
publishedAt?: string;
|
|
1704
|
+
updatedAt?: string;
|
|
1705
|
+
tags: string[];
|
|
1706
|
+
}
|
|
1707
|
+
/** Options for `listPostSummaries`. */
|
|
1708
|
+
interface SummaryListOptions {
|
|
1709
|
+
/** default 'all' */
|
|
1710
|
+
status?: 'draft' | 'published' | 'all';
|
|
1711
|
+
}
|
|
1642
1712
|
interface PostsProvider {
|
|
1643
1713
|
list(opts?: ListOptions): Promise<Post[]>;
|
|
1644
1714
|
get(slug: string): Promise<Post | null>;
|
|
@@ -1647,6 +1717,17 @@ interface PostsProvider {
|
|
|
1647
1717
|
update(postId: string, data: Partial<Post>): Promise<Post>;
|
|
1648
1718
|
remove(postId: string): Promise<void>;
|
|
1649
1719
|
listPostHistory(postId: string, options?: ListPostHistoryOptions): Promise<PostRevisionConnection>;
|
|
1720
|
+
/**
|
|
1721
|
+
* Return lightweight summaries for all posts (no body / metadata).
|
|
1722
|
+
* Implementations MUST page through all nextToken values to return the
|
|
1723
|
+
* complete list — the admin list view depends on this for accurate search
|
|
1724
|
+
* and sort.
|
|
1725
|
+
*
|
|
1726
|
+
* Optional: providers that do not implement this fall back to a best-effort
|
|
1727
|
+
* single-page result via `list()` (see `listPostSummaries` fallback path).
|
|
1728
|
+
* The admin provider always implements this.
|
|
1729
|
+
*/
|
|
1730
|
+
listSummaries?(opts?: SummaryListOptions): Promise<PostSummary[]>;
|
|
1650
1731
|
}
|
|
1651
1732
|
declare function setPostsProvider(p: PostsProvider): void;
|
|
1652
1733
|
declare function hasPostsProvider(): boolean;
|
|
@@ -1663,6 +1744,23 @@ declare function deletePost(postId: string): Promise<void>;
|
|
|
1663
1744
|
* callers (e.g. the admin history panel) degrade gracefully.
|
|
1664
1745
|
*/
|
|
1665
1746
|
declare function listPostHistory(postId: string, options?: ListPostHistoryOptions): Promise<PostRevisionConnection>;
|
|
1747
|
+
/**
|
|
1748
|
+
* Return lightweight summaries for all posts (no body / metadata).
|
|
1749
|
+
*
|
|
1750
|
+
* Delegates to `provider.listSummaries()` when available — the admin
|
|
1751
|
+
* provider implements this with a full nextToken loop and selectionSet
|
|
1752
|
+
* projection, ensuring all posts are returned without fetching large body
|
|
1753
|
+
* fields.
|
|
1754
|
+
*
|
|
1755
|
+
* **Fallback**: if the configured provider does not implement `listSummaries`,
|
|
1756
|
+
* falls back to a single page from `provider.list({ status })`. This is
|
|
1757
|
+
* best-effort only — it returns at most one page of results (no pagination).
|
|
1758
|
+
* Providers that need complete summary lists MUST implement `listSummaries`.
|
|
1759
|
+
* A `console.warn` is emitted once to make this visible.
|
|
1760
|
+
*
|
|
1761
|
+
* If no provider is configured, maps DUMMY_POSTS to summaries.
|
|
1762
|
+
*/
|
|
1763
|
+
declare function listPostSummaries(opts?: SummaryListOptions): Promise<PostSummary[]>;
|
|
1666
1764
|
|
|
1667
1765
|
interface KvItem<T = unknown> {
|
|
1668
1766
|
pk: string;
|
|
@@ -1925,4 +2023,4 @@ declare function pickDefaultEntrypoint(files: readonly {
|
|
|
1925
2023
|
|
|
1926
2024
|
declare const VERSION = "0.0.1";
|
|
1927
2025
|
|
|
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 };
|
|
2026
|
+
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 PostSummary, 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, type SummaryListOptions, 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, listPostSummaries, listPosts, listSiteSettings, mimeTypeFor, parseColorPair, parseLinkList, pickDefaultEntrypoint, resolveLocalized, resolvePluginSettings, resolveThemeValues, setKvStore, setPostsProvider, setSiteSetting, stringifyLinkList, stripCommonPrefix, themeSettingKey, unflattenSettings, updatePost, validateBundle, validateBundlePath, validatePluginSettingValue, validatePublicAssetKey, validateThemeValue };
|
package/dist/index.js
CHANGED
|
@@ -47,6 +47,7 @@ var DUMMY_POSTS = [
|
|
|
47
47
|
|
|
48
48
|
// src/core.ts
|
|
49
49
|
var provider = null;
|
|
50
|
+
var warnedSummaryFallback = false;
|
|
50
51
|
function setPostsProvider(p) {
|
|
51
52
|
provider = p;
|
|
52
53
|
}
|
|
@@ -87,6 +88,35 @@ async function listPostHistory(postId, options) {
|
|
|
87
88
|
if (!provider) return { items: [] };
|
|
88
89
|
return provider.listPostHistory(postId, options);
|
|
89
90
|
}
|
|
91
|
+
async function listPostSummaries(opts) {
|
|
92
|
+
const status = opts?.status ?? "all";
|
|
93
|
+
if (!provider) {
|
|
94
|
+
return dummyList({ status }).map(postToSummary);
|
|
95
|
+
}
|
|
96
|
+
if (provider.listSummaries) {
|
|
97
|
+
return provider.listSummaries(opts);
|
|
98
|
+
}
|
|
99
|
+
if (!warnedSummaryFallback) {
|
|
100
|
+
warnedSummaryFallback = true;
|
|
101
|
+
console.warn(
|
|
102
|
+
"[ampless] listPostSummaries: provider does not implement listSummaries; falling back to a single-page best-effort via list(). Complete listings require the provider to implement listSummaries."
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const posts = await provider.list({ status });
|
|
106
|
+
return posts.map(postToSummary);
|
|
107
|
+
}
|
|
108
|
+
function postToSummary(p) {
|
|
109
|
+
return {
|
|
110
|
+
postId: p.postId,
|
|
111
|
+
slug: p.slug,
|
|
112
|
+
title: p.title,
|
|
113
|
+
excerpt: p.excerpt,
|
|
114
|
+
status: p.status,
|
|
115
|
+
publishedAt: p.publishedAt,
|
|
116
|
+
updatedAt: p.updatedAt,
|
|
117
|
+
tags: p.tags ?? []
|
|
118
|
+
};
|
|
119
|
+
}
|
|
90
120
|
|
|
91
121
|
// src/kv.ts
|
|
92
122
|
var store = null;
|
|
@@ -799,6 +829,7 @@ export {
|
|
|
799
829
|
isTagListUrl,
|
|
800
830
|
isValidPluginKey,
|
|
801
831
|
listPostHistory,
|
|
832
|
+
listPostSummaries,
|
|
802
833
|
listPosts,
|
|
803
834
|
listSiteSettings,
|
|
804
835
|
mimeTypeFor,
|
|
@@ -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'`
|
|
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
|
|
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
|
-
|
|
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
|
-
####
|
|
957
|
+
#### Public html walker: `htmlPlaceholder`
|
|
950
958
|
|
|
951
|
-
`format: 'html'`
|
|
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
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
-
|
|
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
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
`
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
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
|
-
|
|
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
|
-
####
|
|
1246
|
-
|
|
1247
|
-
To make `format: 'html'` posts render placeholder divs as real
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
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
|
|