create-ampless 1.0.0-alpha.71 → 1.0.0-alpha.76

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
@@ -1755,6 +1755,7 @@ var AMPLESS_PACKAGES = /* @__PURE__ */ new Set([
1755
1755
  "@ampless/runtime",
1756
1756
  "@ampless/plugin-seo",
1757
1757
  "@ampless/plugin-rss",
1758
+ "@ampless/plugin-schema-jsonld",
1758
1759
  "@ampless/plugin-webhook",
1759
1760
  "@ampless/plugin-og-image",
1760
1761
  "@ampless/plugin-analytics-ga4",
@@ -1781,13 +1782,32 @@ var PROTECTED_PATTERNS = [
1781
1782
  /^\.amplify(\/|$)/,
1782
1783
  /^amplify_outputs\.json$/,
1783
1784
  /^next-env\.d\.ts$/,
1785
+ // `tsconfig.json` is auto-managed by Next.js during `next build` /
1786
+ // `next dev` — it rewrites `jsx: "preserve"` → `"react-jsx"` for
1787
+ // the React automatic runtime and appends `.next/dev/types/**/*.ts`
1788
+ // to `include`. Treating the file as `replace` here would overwrite
1789
+ // those mutations on every `update-ampless`, only for Next.js to
1790
+ // re-apply them on the next build — a churn cycle that produces a
1791
+ // dirty diff after every upgrade. Protect the file; users hand-
1792
+ // merge new compiler options in the rare case the template
1793
+ // tsconfig changes meaningfully.
1794
+ /^tsconfig\.json$/,
1784
1795
  /^tsconfig\.tsbuildinfo$/,
1785
1796
  /^pnpm-lock\.yaml$/,
1786
1797
  /^package-lock\.json$/,
1787
1798
  // Anything under themes/ is handled by syncThemes; the file-walk
1788
1799
  // classifier must not double-process it.
1789
1800
  /^themes(\/|$)/,
1790
- /^themes-registry\.ts$/
1801
+ /^themes-registry\.ts$/,
1802
+ // `plugins/` is the site-local plugin directory (user-owned, mirrors
1803
+ // the `themes/my-*` convention for user-customised themes). The
1804
+ // initial scaffold seeds a README into this directory; everything
1805
+ // afterwards is the user's territory. We never overwrite or delete
1806
+ // files here — even the seeded README is frozen at scaffold time,
1807
+ // because plugin authors will sometimes edit it to document the
1808
+ // site's own conventions. The kept-up-to-date "how to write a
1809
+ // plugin" doc lives in `packages/ampless/docs/plugin-author-guide.md`.
1810
+ /^plugins(\/|$)/
1791
1811
  ];
1792
1812
  var SEED_IF_MISSING_PATTERN = /\.custom\.ts$/;
1793
1813
  var TEXT_EXTENSIONS2 = /* @__PURE__ */ new Set([
@@ -1,6 +1,7 @@
1
1
  import { defineConfig } from 'ampless'
2
2
  import seoPlugin from '@ampless/plugin-seo'
3
3
  import rssPlugin from '@ampless/plugin-rss'
4
+ // import schemaJsonLdPlugin from '@ampless/plugin-schema-jsonld'
4
5
  // import analyticsGa4Plugin from '@ampless/plugin-analytics-ga4'
5
6
  // import gtmPlugin from '@ampless/plugin-gtm'
6
7
  // import plausiblePlugin from '@ampless/plugin-plausible'
@@ -95,6 +96,17 @@ export default defineConfig({
95
96
  // domain: '', // 'example.com' to enable
96
97
  // }),
97
98
  //
99
+ // JSON-LD structured data (Schema.org). Injects <script type="application/ld+json">
100
+ // into post pages for Article / BlogPosting markup. articleType, authorName and
101
+ // publisherName fall back to the site name when left empty.
102
+ //
103
+ // schemaJsonLdPlugin({
104
+ // articleType: 'Article', // 'Article' | 'BlogPosting' | 'NewsArticle'
105
+ // authorName: '', // empty falls back to site.name
106
+ // publisherName: '', // empty falls back to site.name
107
+ // publisherLogo: '', // optional absolute URL to publisher logo
108
+ // }),
109
+ //
98
110
  // Per-post OG images: SNS crawlers hit `/og/<slug>` and we render
99
111
  // a JSX card → PNG via Next.js `ImageResponse`. Requires at least one
100
112
  // font — ship a .ttf from your CDN or `/public` directory.
@@ -3,14 +3,18 @@ import Link from 'next/link'
3
3
  interface TagListProps {
4
4
  tags?: string[] | null
5
5
  className?: string
6
+ /** Cap the number of tags shown; the rest collapse into a "+N" indicator. */
7
+ max?: number
6
8
  }
7
9
 
8
10
  // Renders post tags as chip-style links to /tag/[tag]. Pure-server, no JS.
9
- export function TagList({ tags, className }: TagListProps) {
11
+ export function TagList({ tags, className, max }: TagListProps) {
10
12
  if (!tags?.length) return null
13
+ const shown = max != null ? tags.slice(0, max) : tags
14
+ const overflow = tags.length - shown.length
11
15
  return (
12
16
  <ul className={`flex flex-wrap gap-2 ${className ?? ''}`}>
13
- {tags.map((tag) => (
17
+ {shown.map((tag) => (
14
18
  <li key={tag}>
15
19
  <Link
16
20
  href={`/tag/${encodeURIComponent(tag)}`}
@@ -20,6 +24,9 @@ export function TagList({ tags, className }: TagListProps) {
20
24
  </Link>
21
25
  </li>
22
26
  ))}
27
+ {overflow > 0 && (
28
+ <li className="inline-block px-2 py-0.5 text-xs text-muted-foreground">+{overflow}</li>
29
+ )}
23
30
  </ul>
24
31
  )
25
32
  }
@@ -9,7 +9,7 @@
9
9
 
10
10
  # ampless プラグインの書き方
11
11
 
12
- このガイドは、初めての `definePlugin()` 呼び出しから admin 編集可能な設定パネル、そして npm 公開に至るまで、ampless プラグインを ship するために必要な手順を一通りカバーします。Phase 1 + Phase 2 の機能を網羅 — descriptor ベースの `<head>` / `<body>` 注入、非同期イベントフック、admin 管理の `settings.public` 値です。
12
+ このガイドは、初めての `definePlugin()` 呼び出しから admin 編集可能な設定パネル、そして npm 公開に至るまで、ampless プラグインを ship するために必要な手順を一通りカバーします。Phase 1〜4 の機能を網羅 — descriptor ベースの `<head>` / `<body>` / 投稿単位 body 注入、非同期イベントフック、admin 管理の `settings.public` 値です。
13
13
 
14
14
  設計の経緯と背景は [`docs/architecture/08-plugin-architecture.md`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md) に集約。本ページはその実装ハンドブック側です。
15
15
 
@@ -25,6 +25,7 @@ ampless プラグインは `AmplessPlugin` オブジェクトを返す TypeScrip
25
25
  | `siteMetadata(site)` | root layout の `generateMetadata()` | 同期 | 既存 |
26
26
  | `publicHead(ctx)` | root layout の `<head>` | 同期 (async layout から呼ばれる) | 1 |
27
27
  | `publicBodyEnd(ctx)` | root layout の `<body>` 末尾 | 同期 | 1 |
28
+ | `publicBodyForPost(post, ctx)` | テーマの post ページテンプレート(投稿単位) | 同期 | 4 |
28
29
  | `ogImage` | `/og/[slug]` ルート | リクエスト時、公開 Lambda 内 | 既存 |
29
30
  | `hooks` | trust_level に応じた processor Lambda | 非同期、SQS イベントで起動 | 既存 |
30
31
  | `settings.public` | `/admin/plugins` フォーム | 宣言的なマニフェスト | 2 |
@@ -102,6 +103,7 @@ interface AmplessPlugin {
102
103
  siteMetadata?(site): PluginMetadata
103
104
  publicHead?(ctx): readonly PublicHeadDescriptor[]
104
105
  publicBodyEnd?(ctx): readonly PublicBodyDescriptor[]
106
+ publicBodyForPost?(post: Post, ctx): readonly PublicPostBodyDescriptor[]
105
107
  ogImage?: OgImageConfig
106
108
  settings?: { public?: readonly PluginSettingField[] }
107
109
  }
@@ -162,6 +164,7 @@ trust level がズレているプラグインは「権限不足で sliently fail
162
164
  | `siteMetadata(site)` | `PluginMetadata` | サイト全体の `<title>` / favicon / RSS `<link rel="alternate">` |
163
165
  | `publicHead(ctx)` | `PublicHeadDescriptor[]` | 解析ローダー、フォント、jsonld、hreflang |
164
166
  | `publicBodyEnd(ctx)` | `PublicBodyDescriptor[]` | GTM no-script フレーム、チャットウィジェット、末尾スニペット |
167
+ | `publicBodyForPost(post, ctx)` | `PublicPostBodyDescriptor[]` | 投稿単位の body 注入 — JSON-LD 構造化データ。テーマの post ページテンプレートが render する |
165
168
 
166
169
  `ctx` オブジェクトの中身:
167
170
 
@@ -202,6 +205,15 @@ trust level がズレているプラグインは「権限不足で sliently fail
202
205
  strategy: 'afterInteractive',
203
206
  }
204
207
 
208
+ // inline script — JSON-LD variant (publicHead / publicBodyEnd / publicBodyForPost で使用可能)
209
+ // runtime が body を自動 escape するので、生の JSON 文字列を返せばよい
210
+ {
211
+ type: 'inlineScript',
212
+ id: 'schema-article',
213
+ scriptType: 'application/ld+json',
214
+ body: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Article', ... }),
215
+ }
216
+
205
217
  // Meta / link / noscript
206
218
  { type: 'meta', name: 'theme-color', content: '#fff' }
207
219
  { type: 'meta', property: 'og:image', content: 'https://…' }
@@ -223,6 +235,36 @@ trust level がズレているプラグインは「権限不足で sliently fail
223
235
  }
224
236
  ```
225
237
 
238
+ ### `PublicPostBodyDescriptor`(Phase 4)
239
+
240
+ `publicBodyForPost` は `PublicPostBodyDescriptor[]` を返します。これは `inlineScript` の制限サブセットで、`scriptType` が必須かつ `'application/ld+json'` のみ有効です:
241
+
242
+ ```ts
243
+ // publicBodyForPost で返せる唯一の形:
244
+ {
245
+ type: 'inlineScript',
246
+ id: 'schema-article',
247
+ scriptType: 'application/ld+json', // 必須 — これ以外は drop + warn
248
+ body: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Article', ... }),
249
+ }
250
+ ```
251
+
252
+ `meta` / `link` を除いている理由:投稿単位のメタデータは Next.js `generateMetadata()` 経由の `metadata()` サーフェスが担い、フレームワークの deduplication・streaming と統合されている。`publicBodyForPost` は `generateMetadata` が生成できない構造化データ(`<script type="application/ld+json">`)のためだけに存在する。
253
+
254
+ ### JSON-LD 自動 escape
255
+
256
+ `scriptType === 'application/ld+json'` のとき、runtime は描画前に **`body` 文字列を自動 escape** する — `<` → `<`、`>` → `>`、`&` → `&`、U+2028 → ` `、U+2029 → ` `。この処理は `inlineScript` を受け付ける 3 つのサーフェス(`publicHead` / `publicBodyEnd` / `publicBodyForPost`)すべてで行われる。プラグイン作者は生の JSON 文字列を返せばよく、自前で escape しなくてよい。
257
+
258
+ サポート外の `scriptType` を持つ descriptor は **console warning 付きで drop** される。
259
+
260
+ ### サーフェス別 scriptType ルール
261
+
262
+ | サーフェス | `scriptType` |
263
+ |---|---|
264
+ | `publicHead` | `undefined`(デフォルト JS)または `'application/ld+json'` |
265
+ | `publicBodyEnd` | `publicHead` と同じ |
266
+ | `publicBodyForPost` | `'application/ld+json'` **必須**。他の値(省略含む)は drop + warn |
267
+
226
268
  ### Validation ルール
227
269
 
228
270
  - **URL scheme allowlist**: `http`、`https`、または相対パス。`javascript:`、`data:`、`vbscript:`、`blob:`、`file:` は要素描画前に拒否されます
@@ -244,6 +286,39 @@ trust level がズレているプラグインは「権限不足で sliently fail
244
286
 
245
287
  `cms.config.plugins` の順序は集約後も保たれます。
246
288
 
289
+ ### `publicBodyForPost` の使用例(Phase 4)
290
+
291
+ `schema` capability を宣言してサーフェスを実装します:
292
+
293
+ ```typescript
294
+ import { definePlugin } from 'ampless'
295
+
296
+ export default function schemaJsonldPlugin() {
297
+ return definePlugin({
298
+ name: 'schema-jsonld',
299
+ apiVersion: 1,
300
+ trust_level: 'untrusted',
301
+ capabilities: ['schema'],
302
+ publicBodyForPost(post, ctx) {
303
+ return [{
304
+ type: 'inlineScript',
305
+ id: 'schema-article',
306
+ scriptType: 'application/ld+json',
307
+ body: JSON.stringify({
308
+ '@context': 'https://schema.org',
309
+ '@type': 'Article',
310
+ headline: post.title,
311
+ url: `${ctx.site.url}/${post.slug}`,
312
+ datePublished: post.publishedAt,
313
+ }),
314
+ }]
315
+ },
316
+ })
317
+ }
318
+ ```
319
+
320
+ テーマの `pages/post.tsx` が `ampless.publicBodyForPost(post)` を呼び、返された descriptor を描画します。runtime は自動 escape した body を持つ `<script type="application/ld+json">` 要素をページに挿入します。
321
+
247
322
  ---
248
323
 
249
324
  ## 7. 非同期イベントフック
@@ -493,6 +568,7 @@ it('admin が空文字保存した場合は空配列', () => {
493
568
  - [`packages/plugin-seo`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-seo) — `metadata()` + `siteMetadata()`
494
569
  - [`packages/plugin-webhook`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-webhook) — untrusted hook + 外向き HTTP
495
570
  - [`packages/plugin-og-image`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-og-image) — `ogImage` ルートレンダラ
571
+ - [`packages/plugin-schema-jsonld`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-schema-jsonld) — `publicBodyForPost` + `schema` capability、投稿単位 Article JSON-LD。(Phase 4)
496
572
 
497
573
  ---
498
574
 
@@ -510,6 +586,8 @@ it('admin が空文字保存した場合は空配列', () => {
510
586
  - **`inlineScript` の `id` 忘れ**。production では silent に drop、dev では warn。inline script の dedup は id 無しではできません
511
587
  - **`publicHead` から `ReactNode` を返す**。TypeScript で弾かれます — `publicHead` の戻り値型は descriptor のみ。任意 `ReactNode` が必要なら、それは Phase 6b の `developer.headElements` capability 待ち
512
588
  - **admin form から `manifest.default` を保存してしまう**。resolved default を「明示値」として書き戻さないでください — admin form が touched フィールドのみ書き込む設計はまさにこのため。default 値を保存すると future のパッケージ更新で default が変わってもそのフィールドだけ反映されなくなります
589
+ - **`publicBodyForPost` で `scriptType: 'application/ld+json'` を省略**。`publicBodyForPost` が返す descriptor で `scriptType` を省略するか他の値を指定すると、production では silent に drop、dev では warn されます。このサーフェスで有効なのは `'application/ld+json'` だけです
590
+ - **`schema` capability と `publicBodyForPost` の不一致**。`capabilities: ['schema']` を宣言して `publicBodyForPost` を未実装(またはその逆)にすると起動時に warning が出ます。宣言と実装は同期させてください
513
591
 
514
592
  ---
515
593
 
@@ -12,8 +12,8 @@
12
12
  This guide walks through everything a plugin author needs to ship a
13
13
  working ampless plugin, from first `definePlugin()` call to the
14
14
  admin-editable settings panel and an npm publish. It covers the
15
- Phase 1 + Phase 2 surfaces — descriptor-based `<head>` / `<body>`
16
- injection, the async event hooks, and admin-managed
15
+ Phase 1–4 surfaces — descriptor-based `<head>` / `<body>` /
16
+ per-post body injection, the async event hooks, and admin-managed
17
17
  `settings.public` values.
18
18
 
19
19
  The design rationale is in [`docs/architecture/08-plugin-architecture.md`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md);
@@ -33,6 +33,7 @@ surfaces:
33
33
  | `siteMetadata(site)` | root layout `generateMetadata()` | sync | Existing |
34
34
  | `publicHead(ctx)` | root layout `<head>` | sync (called from async layout) | 1 |
35
35
  | `publicBodyEnd(ctx)` | root layout end of `<body>` | sync | 1 |
36
+ | `publicBodyForPost(post, ctx)` | theme post page template (per-post) | sync | 4 |
36
37
  | `ogImage` | `/og/[slug]` route | request-time, in public Lambda | Existing |
37
38
  | `hooks` | trust_level-matched processor Lambda | async, on SQS event | Existing |
38
39
  | `settings.public` | `/admin/plugins` form | declarative manifest | 2 |
@@ -117,6 +118,7 @@ interface AmplessPlugin {
117
118
  siteMetadata?(site): PluginMetadata
118
119
  publicHead?(ctx): readonly PublicHeadDescriptor[]
119
120
  publicBodyEnd?(ctx): readonly PublicBodyDescriptor[]
121
+ publicBodyForPost?(post: Post, ctx): readonly PublicPostBodyDescriptor[]
120
122
  ogImage?: OgImageConfig
121
123
  settings?: { public?: readonly PluginSettingField[] }
122
124
  }
@@ -201,6 +203,7 @@ network — and execute synchronously.
201
203
  | `siteMetadata(site)` | `PluginMetadata` | Site-wide `<title>` / favicon / RSS `<link rel="alternate">` |
202
204
  | `publicHead(ctx)` | `PublicHeadDescriptor[]` | Analytics loader, fonts, jsonld, hreflang |
203
205
  | `publicBodyEnd(ctx)` | `PublicBodyDescriptor[]` | GTM no-script frame, chat widgets, tail snippets |
206
+ | `publicBodyForPost(post, ctx)` | `PublicPostBodyDescriptor[]` | Per-post body injection — JSON-LD structured data; rendered by the theme's post page template |
204
207
 
205
208
  The `ctx` object carries:
206
209
 
@@ -246,6 +249,15 @@ untrusted code in the public render path.
246
249
  strategy: 'afterInteractive',
247
250
  }
248
251
 
252
+ // Inline script — JSON-LD variant (valid in publicHead, publicBodyEnd, publicBodyForPost)
253
+ // The runtime auto-escapes the body; return raw JSON, not pre-escaped.
254
+ {
255
+ type: 'inlineScript',
256
+ id: 'schema-article',
257
+ scriptType: 'application/ld+json',
258
+ body: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Article', ... }),
259
+ }
260
+
249
261
  // Meta / link / noscript
250
262
  { type: 'meta', name: 'theme-color', content: '#fff' }
251
263
  { type: 'meta', property: 'og:image', content: 'https://…' }
@@ -267,6 +279,49 @@ untrusted code in the public render path.
267
279
  }
268
280
  ```
269
281
 
282
+ ### `PublicPostBodyDescriptor` (Phase 4)
283
+
284
+ `publicBodyForPost` returns `PublicPostBodyDescriptor[]`. This is a
285
+ restricted subset of `inlineScript` where `scriptType` is required
286
+ and must be `'application/ld+json'`:
287
+
288
+ ```ts
289
+ // The only valid form for publicBodyForPost:
290
+ {
291
+ type: 'inlineScript',
292
+ id: 'schema-article',
293
+ scriptType: 'application/ld+json', // required — only value accepted
294
+ body: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Article', ... }),
295
+ }
296
+ ```
297
+
298
+ Why `meta` and `link` are excluded: per-post metadata is already
299
+ handled by Next.js `generateMetadata()` via the `metadata()` surface,
300
+ which integrates with the framework's deduplication and streaming
301
+ behaviour. `publicBodyForPost` exists solely for the structured data
302
+ use-case (`<script type="application/ld+json">`) that `generateMetadata`
303
+ cannot produce.
304
+
305
+ ### JSON-LD auto-escape
306
+
307
+ When `scriptType === 'application/ld+json'`, the runtime **automatically
308
+ escapes the `body` string** before rendering — `<` → `<`,
309
+ `>` → `>`, `&` → `&`, U+2028 → `
`, U+2029 → `
`.
310
+ This applies across all three surfaces that accept `inlineScript`
311
+ (`publicHead`, `publicBodyEnd`, `publicBodyForPost`). Return the raw
312
+ JSON string; do not pre-escape it.
313
+
314
+ Descriptors with an unsupported `scriptType` value are **dropped with
315
+ a console warning** and never rendered.
316
+
317
+ ### Surface-dependent `scriptType` rules
318
+
319
+ | Surface | `scriptType` |
320
+ |---|---|
321
+ | `publicHead` | `undefined` (default JS) or `'application/ld+json'` |
322
+ | `publicBodyEnd` | same as `publicHead` |
323
+ | `publicBodyForPost` | `'application/ld+json'` **required**; any other value (including omitted) is dropped + warned |
324
+
270
325
  ### Validation rules
271
326
 
272
327
  - **URL scheme allowlist**: `http`, `https`, or relative paths.
@@ -302,6 +357,42 @@ interpolates that fragment directly:
302
357
  `cms.config.plugins` iteration order is preserved across the
303
358
  collected list.
304
359
 
360
+ ### `publicBodyForPost` example (Phase 4)
361
+
362
+ Declare the `schema` capability and implement the surface:
363
+
364
+ ```typescript
365
+ import { definePlugin } from 'ampless'
366
+
367
+ export default function schemaJsonldPlugin() {
368
+ return definePlugin({
369
+ name: 'schema-jsonld',
370
+ apiVersion: 1,
371
+ trust_level: 'untrusted',
372
+ capabilities: ['schema'],
373
+ publicBodyForPost(post, ctx) {
374
+ return [{
375
+ type: 'inlineScript',
376
+ id: 'schema-article',
377
+ scriptType: 'application/ld+json',
378
+ body: JSON.stringify({
379
+ '@context': 'https://schema.org',
380
+ '@type': 'Article',
381
+ headline: post.title,
382
+ url: `${ctx.site.url}/${post.slug}`,
383
+ datePublished: post.publishedAt,
384
+ }),
385
+ }]
386
+ },
387
+ })
388
+ }
389
+ ```
390
+
391
+ The theme's `pages/post.tsx` calls `ampless.publicBodyForPost(post)`
392
+ and renders the returned descriptors. The runtime inserts a
393
+ `<script type="application/ld+json">` element with the auto-escaped
394
+ body into the page.
395
+
305
396
  ---
306
397
 
307
398
  ## 7. Async event hooks
@@ -619,6 +710,7 @@ Worked examples to crib from:
619
710
  - [`packages/plugin-seo`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-seo) — `metadata()` + `siteMetadata()`.
620
711
  - [`packages/plugin-webhook`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-webhook) — untrusted hook with outbound HTTP.
621
712
  - [`packages/plugin-og-image`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-og-image) — `ogImage` route renderer.
713
+ - [`packages/plugin-schema-jsonld`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-schema-jsonld) — `publicBodyForPost` + `schema` capability; per-post Article JSON-LD. (Phase 4)
622
714
 
623
715
  ---
624
716
 
@@ -654,6 +746,14 @@ Worked examples to crib from:
654
746
  admin form only writes touched fields for exactly this reason.
655
747
  Saving a default value freezes it: future package updates that
656
748
  change the default won't take effect for that field anymore.
749
+ - **`publicBodyForPost` without `scriptType: 'application/ld+json'`.** Descriptors
750
+ returned from `publicBodyForPost` that omit `scriptType` or use any
751
+ other value are silently dropped in production and warned in dev.
752
+ Only `scriptType: 'application/ld+json'` is valid in that surface.
753
+ - **`schema` capability + `publicBodyForPost` mismatch.** Declaring
754
+ `capabilities: ['schema']` without implementing `publicBodyForPost`
755
+ (or the reverse) prints a startup warning. Keep the declaration and
756
+ implementation in sync.
657
757
 
658
758
  ---
659
759
 
@@ -25,18 +25,19 @@
25
25
  "@tiptap/pm": "^3.23.6",
26
26
  "@tiptap/react": "^3.23.6",
27
27
  "@tiptap/starter-kit": "^3.23.6",
28
- "@ampless/plugin-analytics-ga4": "^0.2.0-alpha.3",
29
- "@ampless/plugin-gtm": "^0.1.1-alpha.2",
30
- "@ampless/plugin-og-image": "^0.2.0-alpha.20",
31
- "@ampless/plugin-plausible": "^0.1.1-alpha.2",
32
- "@ampless/plugin-rss": "^0.2.0-alpha.20",
33
- "@ampless/plugin-seo": "^0.2.0-alpha.20",
34
- "@ampless/plugin-webhook": "^0.2.0-alpha.20",
35
- "@ampless/admin": "^1.0.0-alpha.46",
36
- "@ampless/backend": "^1.0.0-alpha.37",
37
- "@ampless/runtime": "^1.0.0-alpha.26",
28
+ "@ampless/plugin-analytics-ga4": "^0.2.0-alpha.4",
29
+ "@ampless/plugin-gtm": "^0.1.1-alpha.3",
30
+ "@ampless/plugin-og-image": "^0.2.0-alpha.21",
31
+ "@ampless/plugin-plausible": "^0.1.1-alpha.3",
32
+ "@ampless/plugin-rss": "^0.2.0-alpha.21",
33
+ "@ampless/plugin-schema-jsonld": "^0.1.1-alpha.0",
34
+ "@ampless/plugin-seo": "^0.2.0-alpha.21",
35
+ "@ampless/plugin-webhook": "^0.2.0-alpha.21",
36
+ "@ampless/admin": "^1.0.0-alpha.48",
37
+ "@ampless/backend": "^1.0.0-alpha.38",
38
+ "@ampless/runtime": "^1.0.0-alpha.27",
38
39
  "@digital-go-jp/tailwind-theme-plugin": "^0.3.4",
39
- "ampless": "^1.0.0-alpha.20",
40
+ "ampless": "^1.0.0-alpha.21",
40
41
  "aws-amplify": "^6.17.0",
41
42
  "class-variance-authority": "^0.7.1",
42
43
  "clsx": "^2.1.1",
@@ -0,0 +1,111 @@
1
+ > English: [README.md](./README.md)
2
+
3
+ # サイトローカルプラグイン
4
+
5
+ このディレクトリは **このサイト専用のプラグイン置き場**。別 npm
6
+ パッケージとして publish したくない小さなカスタマイズを書く場所。
7
+
8
+ `update-ampless` はこのディレクトリのファイルに一切触らない。ここに置いた
9
+ ものは消えない、上書きされない。
10
+
11
+ ## いつ使うか
12
+
13
+ サイト固有の機能で、以下に当てはまるときに使う:
14
+
15
+ - プラグイン surface (`publicHead` / `publicBodyEnd` / `metadata` / `eventHooks` 等) が必要
16
+ - このサイト限定の用途 (一度きりのフッタークレジット、サイト特有の JSON-LD 拡張、まだ汎用化したくない analytics スニペット等)
17
+ - 別パッケージとして version 上げて publish するのは大袈裟
18
+
19
+ 複数サイトで使いたくなったら、独立 npm パッケージに切り出す (Phase 5 で
20
+ `npx create-ampless plugin --standalone` が来る予定。今は手でコピーで OK)。
21
+
22
+ ## 最小例
23
+
24
+ `plugins/footer-credit/index.ts`:
25
+
26
+ ```typescript
27
+ import { definePlugin, type AmplessPlugin } from 'ampless'
28
+
29
+ export interface FooterCreditOptions {
30
+ instanceId?: string
31
+ }
32
+
33
+ export default function footerCreditPlugin(
34
+ options: FooterCreditOptions = {}
35
+ ): AmplessPlugin {
36
+ const instanceId = options.instanceId ?? 'footer-credit'
37
+ return definePlugin({
38
+ name: 'footer-credit',
39
+ instanceId,
40
+ apiVersion: 1,
41
+ trust_level: 'untrusted',
42
+ displayName: { en: 'Footer credit', ja: 'フッタークレジット' },
43
+ capabilities: ['publicBody', 'adminSettings'],
44
+ settings: {
45
+ public: [
46
+ {
47
+ type: 'text',
48
+ key: 'html',
49
+ label: { en: 'Snippet', ja: 'スニペット' },
50
+ default: '',
51
+ },
52
+ ],
53
+ },
54
+ publicBodyEnd(ctx) {
55
+ const html = (ctx.setting<string>('html') ?? '').trim()
56
+ if (!html) return []
57
+ return [{ type: 'noscript', id: `footer-credit-${instanceId}`, html }]
58
+ },
59
+ })
60
+ }
61
+ ```
62
+
63
+ `cms.config.ts` で register:
64
+
65
+ ```typescript
66
+ import footerCreditPlugin from './plugins/footer-credit'
67
+
68
+ export default defineConfig({
69
+ // ...
70
+ plugins: [
71
+ // ...既存 plugin...
72
+ footerCreditPlugin(),
73
+ ],
74
+ })
75
+ ```
76
+
77
+ これだけ。`next dev` 再起動 → `/admin/plugins` でスニペットを設定すれば、
78
+ すべての公開ページの `</body>` 直前にレンダされる。
79
+
80
+ ## 宣言できる surface
81
+
82
+ プラグインは `definePlugin({...})` で組み立てるただのオブジェクト。型は
83
+ `node_modules/ampless/dist/plugin.d.ts` を見るか、[プラグイン作者ガイド][guide]
84
+ の散文で。
85
+
86
+ 現状 active な capability:
87
+
88
+ | capability | 用途 |
89
+ |---|---|
90
+ | `publicHead` | `<head>` に site-wide で挿入される descriptor |
91
+ | `publicBody` | `</body>` 直前に site-wide で挿入される descriptor |
92
+ | `metadata` | post ごとの Next.js Metadata 拡張 |
93
+ | `eventHooks` | trusted/untrusted の背景ハンドラ (content lifecycle / media event 等) |
94
+ | `adminSettings` | `settings.public[]` で宣言する admin 編集可能な設定 |
95
+ | `writePublicAsset` | trusted plugin が `public/plugins/<instanceId>/...` に書き込み |
96
+ | `schema` | post ごとの JSON-LD を `publicBodyForPost` 経由で (テーマ側で `ampless.publicBodyForPost(post)` を呼ぶ前提) |
97
+
98
+ [guide]: https://github.com/heavymoons/ampless/blob/main/packages/ampless/docs/plugin-author-guide.md
99
+
100
+ ## TypeScript 設定
101
+
102
+ `tsconfig.json` の `**/*.ts` include glob でこのディレクトリも自動的に
103
+ カバーされている。`import x from './plugins/foo'` も
104
+ `import x from '@/plugins/foo'` も追加設定なしで動く。
105
+
106
+ ## `update-ampless` はどうなるか
107
+
108
+ 何もしない。`plugins/` は upgrade ツールの protected list に入っているので、
109
+ ここに置いたファイルは上書きも削除もされない。代償として **この README
110
+ も更新されない** — プラグイン API に大きな変更があるときは、本家リポジトリの
111
+ [プラグイン作者ガイド][guide] で最新版を確認すること。
@@ -0,0 +1,113 @@
1
+ > 日本語版: [README.ja.md](./README.ja.md)
2
+
3
+ # Site-local plugins
4
+
5
+ This directory is for **plugins that belong only to this site** — small
6
+ customizations you don't want to publish as a separate npm package.
7
+
8
+ `update-ampless` never touches files in this directory, so anything you
9
+ add here is yours to keep.
10
+
11
+ ## When to use a local plugin
12
+
13
+ Reach for a local plugin when you want a site-specific feature that:
14
+
15
+ - needs the plugin surface (`publicHead` / `publicBodyEnd` / `metadata` / `eventHooks` / etc.)
16
+ - is specific to this site (a one-off footer credit, a custom JSON-LD enrichment, an analytics snippet you're not yet ready to ship as a reusable package)
17
+ - you'd rather not version-bump or republish to iterate on
18
+
19
+ When the plugin grows useful for more than one site, lift it into its
20
+ own npm package (see `npx create-ampless plugin --standalone` once it
21
+ ships in Phase 5; for now you can copy it out by hand).
22
+
23
+ ## Minimal example
24
+
25
+ `plugins/footer-credit/index.ts`:
26
+
27
+ ```typescript
28
+ import { definePlugin, type AmplessPlugin } from 'ampless'
29
+
30
+ export interface FooterCreditOptions {
31
+ instanceId?: string
32
+ }
33
+
34
+ export default function footerCreditPlugin(
35
+ options: FooterCreditOptions = {}
36
+ ): AmplessPlugin {
37
+ const instanceId = options.instanceId ?? 'footer-credit'
38
+ return definePlugin({
39
+ name: 'footer-credit',
40
+ instanceId,
41
+ apiVersion: 1,
42
+ trust_level: 'untrusted',
43
+ displayName: { en: 'Footer credit', ja: 'フッタークレジット' },
44
+ capabilities: ['publicBody', 'adminSettings'],
45
+ settings: {
46
+ public: [
47
+ {
48
+ type: 'text',
49
+ key: 'html',
50
+ label: { en: 'Snippet', ja: 'スニペット' },
51
+ default: '',
52
+ },
53
+ ],
54
+ },
55
+ publicBodyEnd(ctx) {
56
+ const html = (ctx.setting<string>('html') ?? '').trim()
57
+ if (!html) return []
58
+ return [{ type: 'noscript', id: `footer-credit-${instanceId}`, html }]
59
+ },
60
+ })
61
+ }
62
+ ```
63
+
64
+ Register it in `cms.config.ts`:
65
+
66
+ ```typescript
67
+ import footerCreditPlugin from './plugins/footer-credit'
68
+
69
+ export default defineConfig({
70
+ // ...
71
+ plugins: [
72
+ // ...existing plugins...
73
+ footerCreditPlugin(),
74
+ ],
75
+ })
76
+ ```
77
+
78
+ That's it. Restart `next dev` and visit `/admin/plugins` to configure
79
+ the snippet, then any public page renders it before `</body>`.
80
+
81
+ ## What you can declare
82
+
83
+ A plugin is just an object built with `definePlugin({...})`. The full
84
+ shape lives in `node_modules/ampless/dist/plugin.d.ts` (or [the plugin
85
+ author guide][guide] if you prefer prose).
86
+
87
+ Capabilities currently active:
88
+
89
+ | capability | purpose |
90
+ |---|---|
91
+ | `publicHead` | descriptors rendered in `<head>` site-wide |
92
+ | `publicBody` | descriptors rendered before `</body>` site-wide |
93
+ | `metadata` | per-post Next.js Metadata contributions |
94
+ | `eventHooks` | trusted/untrusted background handlers (content lifecycle, media events, ...) |
95
+ | `adminSettings` | admin-editable settings declared via `settings.public[]` |
96
+ | `writePublicAsset` | trusted plugins can write namespaced files under `public/plugins/<instanceId>/...` |
97
+ | `schema` | per-post JSON-LD via `publicBodyForPost` (theme template must call `ampless.publicBodyForPost(post)`) |
98
+
99
+ [guide]: https://github.com/heavymoons/ampless/blob/main/packages/ampless/docs/plugin-author-guide.md
100
+
101
+ ## What about TypeScript?
102
+
103
+ `tsconfig.json` already covers this directory through its `**/*.ts`
104
+ include glob. Both `import x from './plugins/foo'` and
105
+ `import x from '@/plugins/foo'` work; no extra setup needed.
106
+
107
+ ## What `update-ampless` does
108
+
109
+ Nothing. `plugins/` is in the upgrade tool's protected list, so files
110
+ here are never overwritten or deleted on upgrade. The flip side is that
111
+ this README does not get refreshed either — when ampless ships
112
+ significant changes to the plugin API, check the [plugin author guide][guide]
113
+ in the canonical repo for the up-to-date version.
@@ -81,7 +81,7 @@ export default async function BlogHome(_: ThemeRouteContext) {
81
81
  )}
82
82
  {post.excerpt && <p className="mt-2 text-gray-700">{post.excerpt}</p>}
83
83
  </Link>
84
- <TagList tags={post.tags} className="mt-3" />
84
+ <TagList tags={post.tags} className="mt-3" max={3} />
85
85
  </li>
86
86
  ))}
87
87
  </ul>
@@ -12,6 +12,16 @@ import { SiteFooter } from '@/components/site-chrome/site-footer'
12
12
 
13
13
  type PostCtx = ThemeRouteContext<{ slug: string }>
14
14
 
15
+ function buildPostUrl(siteUrl: string, slug: string) {
16
+ return new URL(`/${slug}`, siteUrl).toString()
17
+ }
18
+
19
+ function buildXShareUrl(articleUrl: string, title: string, excerpt?: string | null) {
20
+ const text = [`"${title}"`, excerpt].filter(Boolean).join('\n\n')
21
+ const params = new URLSearchParams({ url: articleUrl, text })
22
+ return `https://x.com/intent/tweet?${params.toString()}`
23
+ }
24
+
15
25
  export async function generatePostMetadata({ params }: PostCtx): Promise<Metadata> {
16
26
  const { slug } = await params
17
27
  const post = await ampless.getPublishedPost(slug)
@@ -28,6 +38,8 @@ export default async function BlogPost({ params }: PostCtx) {
28
38
  ])
29
39
  if (!post) notFound()
30
40
 
41
+ const postBody = await ampless.publicBodyForPost(post)
42
+
31
43
  const defaultLightbox = settings.media.imageDisplay === 'lightbox'
32
44
  const maxWidth = settings.media.imageMaxWidth ?? '100%'
33
45
  const proseStyle: React.CSSProperties = {
@@ -36,6 +48,8 @@ export default async function BlogPost({ params }: PostCtx) {
36
48
  const showHeader =
37
49
  parseLinkList(theme.values.headerNav).length > 0 || !!theme.values.logoUrl?.trim()
38
50
  const showFooter = parseLinkList(theme.values.footerLinks).length > 0
51
+ const articleUrl = buildPostUrl(settings.site.url, post.slug)
52
+ const xShareUrl = buildXShareUrl(articleUrl, post.title, post.excerpt)
39
53
 
40
54
  return (
41
55
  <>
@@ -63,6 +77,8 @@ export default async function BlogPost({ params }: PostCtx) {
63
77
  )}
64
78
  </header>
65
79
 
80
+ {postBody}
81
+
66
82
  <div
67
83
  id="post-body"
68
84
  className="prose prose-neutral dark:prose-invert max-w-none [&_img]:max-w-[var(--ampless-img-max-width)] [&_img]:mx-auto"
@@ -71,6 +87,16 @@ export default async function BlogPost({ params }: PostCtx) {
71
87
  />
72
88
 
73
89
  <TagList tags={post.tags} className="mt-8 border-t pt-6" />
90
+
91
+ <footer className="mt-6 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-gray-500">
92
+ <a href={articleUrl} className="hover:text-foreground hover:underline">{admin.t('public.permalink')}</a>
93
+ <a href={xShareUrl} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1.5 hover:text-foreground hover:underline">
94
+ <svg aria-hidden="true" viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="currentColor">
95
+ <path d="M13.8 10.4 21 2h-1.7l-6.2 7.3L8.1 2H2.3l7.6 11.1L2.3 22h1.7l6.7-7.8 5.4 7.8h5.8l-8.1-11.6Zm-2.4 2.8-.8-1.1L4.5 3.3h2.8l4.9 7.1.8 1.1 6.4 9.2h-2.8l-5.2-7.5Z" />
96
+ </svg>
97
+ {admin.t('public.shareOnX')}
98
+ </a>
99
+ </footer>
74
100
  </article>
75
101
 
76
102
  <LightboxBinder scopeSelector="#post-body" defaultLightbox={defaultLightbox} />
@@ -28,6 +28,8 @@ export default async function CorporatePost({ params }: PostCtx) {
28
28
  ])
29
29
  if (!post) notFound()
30
30
 
31
+ const postBody = await ampless.publicBodyForPost(post)
32
+
31
33
  const defaultLightbox = settings.media.imageDisplay === 'lightbox'
32
34
  const maxWidth = settings.media.imageMaxWidth ?? '100%'
33
35
  const proseStyle: React.CSSProperties = {
@@ -64,6 +66,8 @@ export default async function CorporatePost({ params }: PostCtx) {
64
66
  )}
65
67
  </header>
66
68
 
69
+ {postBody}
70
+
67
71
  <div
68
72
  id="post-body"
69
73
  className="prose prose-neutral dark:prose-invert max-w-none [&_img]:max-w-[var(--ampless-img-max-width)] [&_img]:mx-auto"
@@ -28,6 +28,8 @@ export default async function DadsPost({ params }: PostCtx) {
28
28
  ])
29
29
  if (!post) notFound()
30
30
 
31
+ const postBody = await ampless.publicBodyForPost(post)
32
+
31
33
  const defaultLightbox = settings.media.imageDisplay === 'lightbox'
32
34
  const maxWidth = settings.media.imageMaxWidth ?? '100%'
33
35
  const proseStyle: React.CSSProperties = {
@@ -69,6 +71,8 @@ export default async function DadsPost({ params }: PostCtx) {
69
71
  )}
70
72
  </header>
71
73
 
74
+ {postBody}
75
+
72
76
  <div
73
77
  id="post-body"
74
78
  className="prose prose-neutral dark:prose-invert max-w-none [&_a]:text-[var(--primary)] [&_a]:underline-offset-4 [&_img]:max-w-[var(--ampless-img-max-width)] [&_img]:mx-auto"
@@ -32,6 +32,8 @@ export default async function DocsPost({ params }: PostCtx) {
32
32
  ])
33
33
  if (!post) notFound()
34
34
 
35
+ const postBody = await ampless.publicBodyForPost(post)
36
+
35
37
  const defaultLightbox = settings.media.imageDisplay === 'lightbox'
36
38
  const maxWidth = settings.media.imageMaxWidth ?? '100%'
37
39
  const proseStyle: React.CSSProperties = {
@@ -66,6 +68,8 @@ export default async function DocsPost({ params }: PostCtx) {
66
68
  )}
67
69
  </header>
68
70
 
71
+ {postBody}
72
+
69
73
  <div
70
74
  id="post-body"
71
75
  className="prose prose-neutral dark:prose-invert max-w-none [&_img]:max-w-[var(--ampless-img-max-width)] [&_img]:mx-auto"
@@ -28,6 +28,8 @@ export default async function LandingPost({ params }: PostCtx) {
28
28
  ])
29
29
  if (!post) notFound()
30
30
 
31
+ const postBody = await ampless.publicBodyForPost(post)
32
+
31
33
  const defaultLightbox = settings.media.imageDisplay === 'lightbox'
32
34
  const maxWidth = settings.media.imageMaxWidth ?? '100%'
33
35
  const proseStyle: React.CSSProperties = {
@@ -66,6 +68,8 @@ export default async function LandingPost({ params }: PostCtx) {
66
68
  )}
67
69
  </header>
68
70
 
71
+ {postBody}
72
+
69
73
  <div
70
74
  id="post-body"
71
75
  className="prose prose-neutral dark:prose-invert max-w-none [&_img]:max-w-[var(--ampless-img-max-width)] [&_img]:mx-auto"
@@ -25,6 +25,8 @@ export default async function MinimalPost({ params }: PostCtx) {
25
25
  ])
26
26
  if (!post) notFound()
27
27
 
28
+ const postBody = await ampless.publicBodyForPost(post)
29
+
28
30
  const defaultLightbox = settings.media.imageDisplay === 'lightbox'
29
31
  const maxWidth = settings.media.imageMaxWidth ?? '100%'
30
32
  const proseStyle: React.CSSProperties = {
@@ -47,6 +49,8 @@ export default async function MinimalPost({ params }: PostCtx) {
47
49
  )}
48
50
  </header>
49
51
 
52
+ {postBody}
53
+
50
54
  <div
51
55
  id="post-body"
52
56
  className="prose prose-neutral dark:prose-invert max-w-none [&_img]:max-w-[var(--ampless-img-max-width)] [&_img]:mx-auto"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ampless",
3
- "version": "1.0.0-alpha.71",
3
+ "version": "1.0.0-alpha.76",
4
4
  "description": "Create a new ampless project",
5
5
  "license": "MIT",
6
6
  "type": "module",