@ampless/plugin-youtube 1.0.0-alpha.1 → 1.0.0-alpha.11

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/editor.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Node } from '@tiptap/core';
2
+ import { TiptapNodeHtmlAdapters, TiptapNodeMarkdownAdapters } from 'ampless';
2
3
 
3
4
  declare module '@tiptap/core' {
4
5
  interface Commands<ReturnType> {
@@ -26,5 +27,43 @@ declare const AmplessYoutubeNode: Node<any, any>;
26
27
  declare const youtubeEditor: {
27
28
  extension: Node<any, any>;
28
29
  };
30
+ /**
31
+ * Canonical named export consumed by the codegen'd
32
+ * `_editor-bootstrap.tsx`. Plugins MUST export this symbol from
33
+ * their `./editor` module (= the subpath declared in
34
+ * `package.json#amplessPlugin.editorExports`) for the
35
+ * auto-wiring to find them.
36
+ */
37
+ declare const editorExtension: Node<any, any>;
38
+
39
+ /**
40
+ * tiptap → markdown adapter map. Serialises `amplessYoutube` nodes back
41
+ * to a bare `https://youtu.be/<videoId>` URL line so the admin's
42
+ * "format: tiptap → markdown" body switch is lossless. The reverse
43
+ * direction (URL → embed node on paste) is handled by the existing paste
44
+ * rule + `extractSingleUrl` path in the runtime.
45
+ *
46
+ * `update-ampless` reads this export (via namespace import `* as`) and
47
+ * wires it into `installAdminTiptapNodeMarkdown`.
48
+ */
49
+ declare const tiptapNodeToMarkdown: TiptapNodeMarkdownAdapters;
50
+
51
+ /**
52
+ * tiptap → html adapter map. Serialises `amplessYoutube` nodes to the
53
+ * canonical placeholder div `<div data-ampless-youtube data-video-id="..."
54
+ * class="ampless-youtube-placeholder">…</div>` so the admin's
55
+ * "format: tiptap → html" body switch is lossless. The div is what
56
+ * `Node.parseHTML`'s `tag: 'div[data-ampless-youtube]'` rule restores
57
+ * from. It is an admin format-switch interchange form; public rendering
58
+ * expands YouTube embeds from the `tiptap` / `markdown` walkers, while
59
+ * `format: 'html'` preserves the div literally.
60
+ *
61
+ * `markdown → html` is a 2-hop via `generateJSON` (in admin format-switch);
62
+ * this adapter is reused by that path — no duplicate logic needed.
63
+ *
64
+ * `update-ampless` reads this export (via namespace import `* as`) and
65
+ * wires it into `installAdminTiptapNodeHtml`.
66
+ */
67
+ declare const tiptapNodeToHtml: TiptapNodeHtmlAdapters;
29
68
 
30
- export { AmplessYoutubeNode, youtubeEditor };
69
+ export { AmplessYoutubeNode, editorExtension, tiptapNodeToHtml, tiptapNodeToMarkdown, youtubeEditor };
package/dist/editor.js CHANGED
@@ -6,6 +6,40 @@ import {
6
6
 
7
7
  // src/editor.tsx
8
8
  import { Node, mergeAttributes } from "@tiptap/core";
9
+ function getBareUrlLinkHref(el) {
10
+ if (el.tagName.toLowerCase() === "a") {
11
+ return el.getAttribute("href");
12
+ }
13
+ if (el.tagName.toLowerCase() !== "p") return null;
14
+ const children = Array.from(el.children);
15
+ if (children.length !== 1) return null;
16
+ const link = children[0];
17
+ if (!(link instanceof HTMLElement)) return null;
18
+ if (link.tagName.toLowerCase() !== "a") return null;
19
+ const href = link.getAttribute("href")?.trim();
20
+ if (!href) return null;
21
+ const linkText = link.textContent?.trim();
22
+ if (linkText !== href) return null;
23
+ if (el.textContent?.trim() !== linkText) return null;
24
+ return href;
25
+ }
26
+ function placeholderAttrs(attrs) {
27
+ const out = {
28
+ "data-ampless-youtube": "",
29
+ "data-video-id": String(attrs.videoId ?? ""),
30
+ class: "ampless-youtube-placeholder"
31
+ };
32
+ if (attrs.start != null && Number.isFinite(Number(attrs.start))) {
33
+ out["data-start"] = String(attrs.start);
34
+ }
35
+ return out;
36
+ }
37
+ function escapeAttr(v) {
38
+ return v.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
39
+ }
40
+ function attrsToHtmlString(attrs) {
41
+ return Object.entries(attrs).map(([k, v]) => v === "" ? k : `${k}="${escapeAttr(v)}"`).join(" ");
42
+ }
9
43
  var AmplessYoutubeNode = Node.create({
10
44
  name: "amplessYoutube",
11
45
  group: "block",
@@ -16,7 +50,12 @@ var AmplessYoutubeNode = Node.create({
16
50
  return {
17
51
  videoId: {
18
52
  default: "",
19
- parseHTML: (el) => el.getAttribute("data-video-id") ?? "",
53
+ parseHTML: (el) => {
54
+ const dataVideoId = el.getAttribute("data-video-id");
55
+ if (dataVideoId != null) return dataVideoId;
56
+ const href = getBareUrlLinkHref(el);
57
+ return href ? parseYoutubeUrl(href) ?? "" : "";
58
+ },
20
59
  renderHTML: (attrs) => ({
21
60
  "data-video-id": String(attrs.videoId ?? "")
22
61
  })
@@ -37,16 +76,59 @@ var AmplessYoutubeNode = Node.create({
37
76
  return [
38
77
  {
39
78
  tag: "div[data-ampless-youtube]"
79
+ },
80
+ {
81
+ tag: "p",
82
+ priority: 100,
83
+ getAttrs: (el) => {
84
+ const href = getBareUrlLinkHref(el) ?? "";
85
+ const videoId = parseYoutubeUrl(href);
86
+ if (!videoId) return false;
87
+ return { videoId, start: null };
88
+ }
89
+ },
90
+ {
91
+ // Defensive fallback for genuinely top-level bare-URL `<a>` tags
92
+ // (no parent block, or directly under `<body>` — = HTML fragments
93
+ // without a paragraph wrapper at all). The primary case is the
94
+ // markdown bare URL line `<p><a href=URL>URL</a></p>`, which is
95
+ // already handled by the `tag: 'p'` rule above (priority 100,
96
+ // `getBareUrlLinkHref`). This rule covers the rare standalone-link
97
+ // shape that bypasses that rule entirely.
98
+ //
99
+ // Scope is deliberately narrow:
100
+ // 1. Link text equals href — `<a href="URL">caption</a>` stays
101
+ // a captioned Link, not silently swallowed into an embed.
102
+ // 2. Parent is null or `<body>` only. Inside any content block
103
+ // (`<p>`, `<li>`, `<blockquote>`, `<div>`, …) the autolink
104
+ // stays an inline Link mark to preserve the surrounding
105
+ // structure. Promoting it would split `<li>` into an empty
106
+ // paragraph item plus a list-external embed, break
107
+ // blockquotes, etc.
108
+ tag: "a[href]",
109
+ priority: 100,
110
+ getAttrs: (el) => {
111
+ const link = el;
112
+ const parent = link.parentElement;
113
+ if (parent && parent.tagName.toLowerCase() !== "body") return false;
114
+ const href = link.getAttribute("href")?.trim() ?? "";
115
+ if (!href) return false;
116
+ const linkText = link.textContent?.trim() ?? "";
117
+ if (linkText !== href) return false;
118
+ const videoId = parseYoutubeUrl(href);
119
+ if (!videoId) return false;
120
+ return { videoId, start: null };
121
+ }
40
122
  }
41
123
  ];
42
124
  },
43
125
  renderHTML({ HTMLAttributes }) {
44
126
  return [
45
127
  "div",
46
- mergeAttributes(HTMLAttributes, {
47
- "data-ampless-youtube": "",
48
- class: "ampless-youtube-placeholder"
49
- }),
128
+ mergeAttributes(HTMLAttributes, placeholderAttrs({
129
+ videoId: HTMLAttributes["data-video-id"],
130
+ start: HTMLAttributes["data-start"]
131
+ })),
50
132
  ["span", {}, `YouTube: ${HTMLAttributes["data-video-id"] ?? ""}`]
51
133
  ];
52
134
  },
@@ -70,7 +152,15 @@ var AmplessYoutubeNode = Node.create({
70
152
  return [
71
153
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
72
154
  {
73
- find: YOUTUBE_URL,
155
+ // tiptap's paste rule internally calls `String.prototype.matchAll`
156
+ // which throws when the regex doesn't carry the `g` flag. Clone
157
+ // the source regex (which is anchored `^...$` for use in the
158
+ // renderer / markdown extractor) into a global form here. Anchored
159
+ // global is fine — without the `m` flag, `^...$` only anchor at the
160
+ // start / end of the **entire input**, so this matches at most
161
+ // once for the pasted input as a whole (= exactly the single-URL
162
+ // paste case we care about).
163
+ find: new RegExp(YOUTUBE_URL.source, "g"),
74
164
  handler: ({ range, match, commands }) => {
75
165
  const videoId = match[1] ?? match[2];
76
166
  if (!videoId) return;
@@ -87,7 +177,27 @@ var AmplessYoutubeNode = Node.create({
87
177
  var youtubeEditor = {
88
178
  extension: AmplessYoutubeNode
89
179
  };
180
+ var editorExtension = AmplessYoutubeNode;
181
+ var tiptapNodeToMarkdown = {
182
+ amplessYoutube: (node) => {
183
+ const videoId = String(node.attrs?.videoId ?? "").trim();
184
+ if (!videoId) return null;
185
+ return `https://youtu.be/${videoId}`;
186
+ }
187
+ };
188
+ var tiptapNodeToHtml = {
189
+ amplessYoutube: (node) => {
190
+ const videoId = String(node.attrs?.videoId ?? "").trim();
191
+ if (!videoId) return null;
192
+ const attrs = placeholderAttrs(node.attrs ?? {});
193
+ const url = `https://youtu.be/${videoId}`;
194
+ return `<div ${attrsToHtmlString(attrs)}><a href="${escapeAttr(url)}">${escapeAttr(url)}</a></div>`;
195
+ }
196
+ };
90
197
  export {
91
198
  AmplessYoutubeNode,
199
+ editorExtension,
200
+ tiptapNodeToHtml,
201
+ tiptapNodeToMarkdown,
92
202
  youtubeEditor
93
203
  };
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { definePlugin } from "ampless";
9
9
  import { jsx } from "react/jsx-runtime";
10
10
  function youtubePlugin(opts = {}) {
11
11
  return definePlugin({
12
- name: opts.name ?? "@ampless/plugin-youtube",
12
+ name: opts.name ?? "youtube",
13
13
  apiVersion: 1,
14
14
  packageName: "@ampless/plugin-youtube",
15
15
  trust_level: "trusted",
@@ -24,6 +24,21 @@ function youtubePlugin(opts = {}) {
24
24
  const startRaw = node.attrs?.start;
25
25
  const start = typeof startRaw === "number" && Number.isFinite(startRaw) ? startRaw : void 0;
26
26
  return /* @__PURE__ */ jsx(YouTubeEmbed, { videoId, start });
27
+ },
28
+ // Opt into the public html walker so `format: 'html'` posts expand
29
+ // the canonical placeholder div (emitted by the admin's tiptap→html
30
+ // switch) into the same `<YouTubeEmbed>` the tiptap / markdown
31
+ // walkers render. Attribute names match `placeholderAttrs()` in
32
+ // ./editor.tsx (the canonical definition site).
33
+ htmlPlaceholder: {
34
+ flagAttr: "data-ampless-youtube",
35
+ attrsFromElement: (attribs) => {
36
+ const start = Number(attribs["data-start"]);
37
+ return {
38
+ videoId: attribs["data-video-id"] ?? "",
39
+ start: Number.isFinite(start) ? start : void 0
40
+ };
41
+ }
27
42
  }
28
43
  },
29
44
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/plugin-youtube",
3
- "version": "1.0.0-alpha.1",
3
+ "version": "1.0.0-alpha.11",
4
4
  "description": "YouTube embed plugin for ampless — renders youtube.com / youtu.be URLs inline as iframes in tiptap + markdown posts",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,7 +32,7 @@
32
32
  "peerDependencies": {
33
33
  "@tiptap/core": "^3",
34
34
  "react": "^18 || ^19",
35
- "ampless": "1.0.0-alpha.42"
35
+ "ampless": "1.0.0-alpha.49"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@tiptap/core": "^3.23.6",
@@ -51,7 +51,7 @@
51
51
  ],
52
52
  "amplessPlugin": {
53
53
  "apiVersion": 1,
54
- "name": "@ampless/plugin-youtube",
54
+ "name": "youtube",
55
55
  "trustLevel": "trusted",
56
56
  "capabilities": [
57
57
  "contentFields"
@@ -59,7 +59,8 @@
59
59
  "displayName": {
60
60
  "en": "YouTube embeds",
61
61
  "ja": "YouTube 埋め込み"
62
- }
62
+ },
63
+ "editorExports": "./editor"
63
64
  },
64
65
  "scripts": {
65
66
  "build": "tsup",