@ampless/plugin-youtube 1.0.0-alpha.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ampless contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.ja.md ADDED
@@ -0,0 +1,53 @@
1
+ # @ampless/plugin-youtube
2
+
3
+ > English: [README.md](./README.md)
4
+
5
+ ampless の投稿に YouTube 埋め込みをインラインで表示するプラグイン。`https://youtu.be/<id>` および `https://www.youtube.com/watch?v=<id>` 形式の URL を、プライバシー強化された `youtube-nocookie.com` 上の `<iframe>` 要素として描画する(再生開始まで cookie を設定しない)。
6
+
7
+ ## インストール
8
+
9
+ ```sh
10
+ npm i @ampless/plugin-youtube
11
+ ```
12
+
13
+ ## 配線
14
+
15
+ `cms.config.ts`:
16
+
17
+ ```ts
18
+ import { defineConfig } from 'ampless'
19
+ import youtubePlugin from '@ampless/plugin-youtube'
20
+
21
+ export default defineConfig({
22
+ plugins: [youtubePlugin()],
23
+ })
24
+ ```
25
+
26
+ tiptap エディタ側は `templates/_shared/app/(admin)/admin/_editor-bootstrap.tsx` で Node を登録する:
27
+
28
+ ```tsx
29
+ 'use client'
30
+ import { installAdminEditorExtensions } from '@ampless/admin/editor'
31
+ import { youtubeEditor } from '@ampless/plugin-youtube/editor'
32
+
33
+ export function EditorBootstrap({ children }: { children: React.ReactNode }) {
34
+ installAdminEditorExtensions([youtubeEditor.extension])
35
+ return <>{children}</>
36
+ }
37
+ ```
38
+
39
+ その上で `createAdminLayout(admin, { editorBootstrap: EditorBootstrap })` に渡す。
40
+
41
+ ## 動作
42
+
43
+ - `amplessYoutube` 型の tiptap ノード(エディタのペーストルールまたは `setYoutube({ videoId })` コマンドで挿入)は YouTube の iframe としてレンダリングされる。
44
+ - markdown 投稿で単独行が YouTube URL の段落(例: 段落の中身が `https://youtu.be/dQw4w9WgXcQ` だけ)も iframe としてレンダリングされる。
45
+ - 段落内インラインの URL はそのまま残る。単独行の URL のみ置換対象。
46
+
47
+ ## CSP
48
+
49
+ 本プラグインは `<iframe src="https://www.youtube-nocookie.com/...">` を出力する。サイト側の CSP(`next.config.ts` / middleware で設定)の `frame-src` に `youtube-nocookie.com` を含める必要がある。
50
+
51
+ ## License
52
+
53
+ MIT
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # @ampless/plugin-youtube
2
+
3
+ > 日本語版: [README.ja.md](./README.ja.md)
4
+
5
+ Inline YouTube embeds for ampless posts. Renders `https://youtu.be/<id>` and `https://www.youtube.com/watch?v=<id>` URLs as `<iframe>` elements pointing at `youtube-nocookie.com` (the privacy-enhanced embed host that does not set cookies before playback starts).
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm i @ampless/plugin-youtube
11
+ ```
12
+
13
+ ## Wire up
14
+
15
+ `cms.config.ts`:
16
+
17
+ ```ts
18
+ import { defineConfig } from 'ampless'
19
+ import youtubePlugin from '@ampless/plugin-youtube'
20
+
21
+ export default defineConfig({
22
+ plugins: [youtubePlugin()],
23
+ })
24
+ ```
25
+
26
+ For the tiptap editor side, register the Node in `templates/_shared/app/(admin)/admin/_editor-bootstrap.tsx`:
27
+
28
+ ```tsx
29
+ 'use client'
30
+ import { installAdminEditorExtensions } from '@ampless/admin/editor'
31
+ import { youtubeEditor } from '@ampless/plugin-youtube/editor'
32
+
33
+ export function EditorBootstrap({ children }: { children: React.ReactNode }) {
34
+ installAdminEditorExtensions([youtubeEditor.extension])
35
+ return <>{children}</>
36
+ }
37
+ ```
38
+
39
+ Then thread it into `createAdminLayout(admin, { editorBootstrap: EditorBootstrap })`.
40
+
41
+ ## Behaviour
42
+
43
+ - Tiptap nodes typed `amplessYoutube` (inserted via the editor's paste rule or the `setYoutube({ videoId })` command) render as a YouTube iframe.
44
+ - Markdown posts with a single-line YouTube URL (e.g. a paragraph whose entire content is `https://youtu.be/dQw4w9WgXcQ`) also render as an iframe.
45
+ - Inline URLs in the middle of a paragraph are left as-is — only single-line URLs convert.
46
+
47
+ ## CSP
48
+
49
+ The plugin emits `<iframe src="https://www.youtube-nocookie.com/...">`. Your site's CSP (configured in `next.config.ts` / middleware) needs to include `youtube-nocookie.com` in `frame-src` for the embed to load.
50
+
51
+ ## License
52
+
53
+ MIT
@@ -0,0 +1,39 @@
1
+ // src/shared.tsx
2
+ import { jsx } from "react/jsx-runtime";
3
+ var YOUTUBE_URL = /^https:\/\/(?:www\.)?(?:youtube\.com\/watch\?v=([\w-]{11})|youtu\.be\/([\w-]{11}))(?:[?&#]\S*)?$/;
4
+ var VIDEO_ID = /^[\w-]{11}$/;
5
+ function YouTubeEmbed({
6
+ videoId,
7
+ start,
8
+ title
9
+ }) {
10
+ if (typeof videoId !== "string" || !VIDEO_ID.test(videoId)) return null;
11
+ const query = typeof start === "number" && Number.isFinite(start) && start > 0 ? `?start=${Math.floor(start)}` : "";
12
+ const src = `https://www.youtube-nocookie.com/embed/${videoId}${query}`;
13
+ return /* @__PURE__ */ jsx(
14
+ "iframe",
15
+ {
16
+ src,
17
+ title: title ?? `YouTube video ${videoId}`,
18
+ loading: "lazy",
19
+ allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
20
+ referrerPolicy: "strict-origin-when-cross-origin",
21
+ allowFullScreen: true,
22
+ width: 560,
23
+ height: 315,
24
+ style: { width: "100%", aspectRatio: "16 / 9", height: "auto", border: 0 }
25
+ }
26
+ );
27
+ }
28
+ function parseYoutubeUrl(url) {
29
+ const trimmed = url.trim();
30
+ const m = trimmed.match(YOUTUBE_URL);
31
+ if (!m) return null;
32
+ return m[1] ?? m[2] ?? null;
33
+ }
34
+
35
+ export {
36
+ YOUTUBE_URL,
37
+ YouTubeEmbed,
38
+ parseYoutubeUrl
39
+ };
@@ -0,0 +1,30 @@
1
+ import { Node } from '@tiptap/core';
2
+
3
+ declare module '@tiptap/core' {
4
+ interface Commands<ReturnType> {
5
+ amplessYoutube: {
6
+ /** Insert a YouTube embed node with the given video id (+ optional start). */
7
+ setYoutube: (opts: {
8
+ videoId: string;
9
+ start?: number;
10
+ }) => ReturnType;
11
+ /**
12
+ * Insert a YouTube embed from a URL. Returns false (and does
13
+ * nothing) if the URL doesn't match the canonical / short form.
14
+ */
15
+ insertYoutubeFromUrl: (url: string) => ReturnType;
16
+ };
17
+ }
18
+ }
19
+ declare const AmplessYoutubeNode: Node<any, any>;
20
+ /**
21
+ * Named export consumed by templates' `_editor-bootstrap.tsx`. The
22
+ * `.extension` field is the tiptap Node — the wrapper object exists so
23
+ * future per-instance configuration can be added without breaking the
24
+ * call site.
25
+ */
26
+ declare const youtubeEditor: {
27
+ extension: Node<any, any>;
28
+ };
29
+
30
+ export { AmplessYoutubeNode, youtubeEditor };
package/dist/editor.js ADDED
@@ -0,0 +1,93 @@
1
+ "use client";
2
+ import {
3
+ YOUTUBE_URL,
4
+ parseYoutubeUrl
5
+ } from "./chunk-67ETNQMN.js";
6
+
7
+ // src/editor.tsx
8
+ import { Node, mergeAttributes } from "@tiptap/core";
9
+ var AmplessYoutubeNode = Node.create({
10
+ name: "amplessYoutube",
11
+ group: "block",
12
+ atom: true,
13
+ selectable: true,
14
+ draggable: true,
15
+ addAttributes() {
16
+ return {
17
+ videoId: {
18
+ default: "",
19
+ parseHTML: (el) => el.getAttribute("data-video-id") ?? "",
20
+ renderHTML: (attrs) => ({
21
+ "data-video-id": String(attrs.videoId ?? "")
22
+ })
23
+ },
24
+ start: {
25
+ default: null,
26
+ parseHTML: (el) => {
27
+ const v = el.getAttribute("data-start");
28
+ if (v === null) return null;
29
+ const n = Number(v);
30
+ return Number.isFinite(n) ? n : null;
31
+ },
32
+ renderHTML: (attrs) => attrs.start != null ? { "data-start": String(attrs.start) } : {}
33
+ }
34
+ };
35
+ },
36
+ parseHTML() {
37
+ return [
38
+ {
39
+ tag: "div[data-ampless-youtube]"
40
+ }
41
+ ];
42
+ },
43
+ renderHTML({ HTMLAttributes }) {
44
+ return [
45
+ "div",
46
+ mergeAttributes(HTMLAttributes, {
47
+ "data-ampless-youtube": "",
48
+ class: "ampless-youtube-placeholder"
49
+ }),
50
+ ["span", {}, `YouTube: ${HTMLAttributes["data-video-id"] ?? ""}`]
51
+ ];
52
+ },
53
+ addCommands() {
54
+ return {
55
+ setYoutube: (opts) => ({ commands }) => commands.insertContent({
56
+ type: this.name,
57
+ attrs: { videoId: opts.videoId, start: opts.start ?? null }
58
+ }),
59
+ insertYoutubeFromUrl: (url) => ({ commands }) => {
60
+ const videoId = parseYoutubeUrl(url);
61
+ if (!videoId) return false;
62
+ return commands.insertContent({
63
+ type: this.name,
64
+ attrs: { videoId, start: null }
65
+ });
66
+ }
67
+ };
68
+ },
69
+ addPasteRules() {
70
+ return [
71
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
72
+ {
73
+ find: YOUTUBE_URL,
74
+ handler: ({ range, match, commands }) => {
75
+ const videoId = match[1] ?? match[2];
76
+ if (!videoId) return;
77
+ commands.deleteRange(range);
78
+ commands.insertContent({
79
+ type: this.name,
80
+ attrs: { videoId, start: null }
81
+ });
82
+ }
83
+ }
84
+ ];
85
+ }
86
+ });
87
+ var youtubeEditor = {
88
+ extension: AmplessYoutubeNode
89
+ };
90
+ export {
91
+ AmplessYoutubeNode,
92
+ youtubeEditor
93
+ };
@@ -0,0 +1,49 @@
1
+ import { AmplessPlugin } from 'ampless';
2
+ import { ReactElement } from 'react';
3
+
4
+ /**
5
+ * YouTube canonical / short URL pattern. Anchored with `^...$` so the
6
+ * markdown walker only intercepts paragraphs whose entire content is a
7
+ * single YouTube URL.
8
+ *
9
+ * Captures:
10
+ * - Group 1: video id for `youtube.com/watch?v=<id>`
11
+ * - Group 2: video id for `youtu.be/<id>`
12
+ *
13
+ * The id grammar `[\w-]{11}` matches the canonical 11-character
14
+ * YouTube video id format. Anything else fails the regex.
15
+ */
16
+ declare const YOUTUBE_URL: RegExp;
17
+ interface YouTubeEmbedProps {
18
+ videoId: string;
19
+ /** Start position in seconds. Forwarded to the embed URL as `?start=`. */
20
+ start?: number;
21
+ /** Embed title for screen readers / iframe `title`. */
22
+ title?: string;
23
+ }
24
+ /**
25
+ * Render a YouTube embed as a single `<iframe>` pointing at
26
+ * youtube-nocookie.com (the privacy-enhanced embed host that does NOT
27
+ * set cookies until the user starts playback). Defensive: an invalid
28
+ * `videoId` (not matching the 11-char pattern) renders nothing.
29
+ */
30
+ declare function YouTubeEmbed({ videoId, start, title, }: YouTubeEmbedProps): ReactElement | null;
31
+ /**
32
+ * Extract the 11-char video id from a URL string. Returns `null` if the
33
+ * URL doesn't match the canonical / short form. Used by the tiptap
34
+ * editor's paste rule + the `contentFields` markdown walker.
35
+ */
36
+ declare function parseYoutubeUrl(url: string): string | null;
37
+
38
+ interface YoutubePluginOptions {
39
+ /**
40
+ * Override the plugin's `name` (and therefore admin-side namespace).
41
+ * Useful if a site wants to install two instances or rename the
42
+ * default — though multi-instance is not currently supported by the
43
+ * contentFields registry.
44
+ */
45
+ name?: string;
46
+ }
47
+ declare function youtubePlugin(opts?: YoutubePluginOptions): AmplessPlugin;
48
+
49
+ export { YOUTUBE_URL, YouTubeEmbed, type YoutubePluginOptions, youtubePlugin as default, parseYoutubeUrl };
package/dist/index.js ADDED
@@ -0,0 +1,45 @@
1
+ import {
2
+ YOUTUBE_URL,
3
+ YouTubeEmbed,
4
+ parseYoutubeUrl
5
+ } from "./chunk-67ETNQMN.js";
6
+
7
+ // src/index.tsx
8
+ import { definePlugin } from "ampless";
9
+ import { jsx } from "react/jsx-runtime";
10
+ function youtubePlugin(opts = {}) {
11
+ return definePlugin({
12
+ name: opts.name ?? "@ampless/plugin-youtube",
13
+ apiVersion: 1,
14
+ packageName: "@ampless/plugin-youtube",
15
+ trust_level: "trusted",
16
+ capabilities: ["contentFields"],
17
+ displayName: { en: "YouTube embeds", ja: "YouTube \u57CB\u3081\u8FBC\u307F" },
18
+ contentFields: [
19
+ {
20
+ kind: "tiptap",
21
+ nodeType: "amplessYoutube",
22
+ render: (node) => {
23
+ const videoId = String(node.attrs?.videoId ?? "");
24
+ const startRaw = node.attrs?.start;
25
+ const start = typeof startRaw === "number" && Number.isFinite(startRaw) ? startRaw : void 0;
26
+ return /* @__PURE__ */ jsx(YouTubeEmbed, { videoId, start });
27
+ }
28
+ },
29
+ {
30
+ kind: "markdown-url",
31
+ pattern: YOUTUBE_URL,
32
+ render: ({ match }) => {
33
+ const videoId = match[1] ?? match[2] ?? "";
34
+ return /* @__PURE__ */ jsx(YouTubeEmbed, { videoId });
35
+ }
36
+ }
37
+ ]
38
+ });
39
+ }
40
+ export {
41
+ YOUTUBE_URL,
42
+ YouTubeEmbed,
43
+ youtubePlugin as default,
44
+ parseYoutubeUrl
45
+ };
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@ampless/plugin-youtube",
3
+ "version": "1.0.0-alpha.1",
4
+ "description": "YouTube embed plugin for ampless — renders youtube.com / youtu.be URLs inline as iframes in tiptap + markdown posts",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.js",
10
+ "types": "./dist/index.d.ts"
11
+ },
12
+ "./editor": {
13
+ "import": "./dist/editor.js",
14
+ "types": "./dist/editor.d.ts"
15
+ },
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/heavymoons/ampless.git",
28
+ "directory": "packages/plugin-youtube"
29
+ },
30
+ "homepage": "https://github.com/heavymoons/ampless/tree/main/packages/plugin-youtube#readme",
31
+ "bugs": "https://github.com/heavymoons/ampless/issues",
32
+ "peerDependencies": {
33
+ "@tiptap/core": "^3",
34
+ "react": "^18 || ^19",
35
+ "ampless": "1.0.0-alpha.42"
36
+ },
37
+ "devDependencies": {
38
+ "@tiptap/core": "^3.23.6",
39
+ "@types/react": "^19.2.15",
40
+ "@types/react-dom": "^19.2.3",
41
+ "react": "^19.2.6",
42
+ "react-dom": "^19.2.6"
43
+ },
44
+ "keywords": [
45
+ "ampless",
46
+ "plugin",
47
+ "ampless-plugin",
48
+ "youtube",
49
+ "embed",
50
+ "cms"
51
+ ],
52
+ "amplessPlugin": {
53
+ "apiVersion": 1,
54
+ "name": "@ampless/plugin-youtube",
55
+ "trustLevel": "trusted",
56
+ "capabilities": [
57
+ "contentFields"
58
+ ],
59
+ "displayName": {
60
+ "en": "YouTube embeds",
61
+ "ja": "YouTube 埋め込み"
62
+ }
63
+ },
64
+ "scripts": {
65
+ "build": "tsup",
66
+ "dev": "tsup --watch",
67
+ "lint": "tsc --noEmit",
68
+ "test": "vitest run --passWithNoTests"
69
+ }
70
+ }