create-ampless 0.2.0-alpha.0

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.
Files changed (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +38 -0
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.js +229 -0
  5. package/dist/templates/_shared/RUNBOOK.md +178 -0
  6. package/dist/templates/_shared/amplify/auth/post-confirmation/handler.ts +4 -0
  7. package/dist/templates/_shared/amplify/auth/post-confirmation/resource.ts +6 -0
  8. package/dist/templates/_shared/amplify/auth/resource.ts +8 -0
  9. package/dist/templates/_shared/amplify/backend.ts +29 -0
  10. package/dist/templates/_shared/amplify/data/get-published-post.js +33 -0
  11. package/dist/templates/_shared/amplify/data/list-posts-by-tag.js +52 -0
  12. package/dist/templates/_shared/amplify/data/list-published-posts.js +57 -0
  13. package/dist/templates/_shared/amplify/data/resource.ts +30 -0
  14. package/dist/templates/_shared/amplify/events/dispatcher/handler.ts +4 -0
  15. package/dist/templates/_shared/amplify/events/dispatcher/resource.ts +12 -0
  16. package/dist/templates/_shared/amplify/events/processor-trusted/handler.ts +12 -0
  17. package/dist/templates/_shared/amplify/events/processor-trusted/resource.ts +14 -0
  18. package/dist/templates/_shared/amplify/events/processor-untrusted/handler.ts +10 -0
  19. package/dist/templates/_shared/amplify/events/processor-untrusted/resource.ts +9 -0
  20. package/dist/templates/_shared/amplify/functions/api-key-renewer/handler.ts +4 -0
  21. package/dist/templates/_shared/amplify/functions/api-key-renewer/resource.ts +12 -0
  22. package/dist/templates/_shared/amplify/storage/resource.ts +7 -0
  23. package/dist/templates/_shared/app/(admin)/admin/layout.tsx +4 -0
  24. package/dist/templates/_shared/app/(admin)/admin/media/page.tsx +4 -0
  25. package/dist/templates/_shared/app/(admin)/admin/page.tsx +4 -0
  26. package/dist/templates/_shared/app/(admin)/admin/posts/[postId]/page.tsx +4 -0
  27. package/dist/templates/_shared/app/(admin)/admin/posts/new/page.tsx +4 -0
  28. package/dist/templates/_shared/app/(admin)/admin/posts/page.tsx +4 -0
  29. package/dist/templates/_shared/app/(admin)/admin/sites/[siteId]/page.tsx +5 -0
  30. package/dist/templates/_shared/app/(admin)/admin/sites/[siteId]/theme/page.tsx +6 -0
  31. package/dist/templates/_shared/app/(admin)/admin/sites/page.tsx +5 -0
  32. package/dist/templates/_shared/app/api/media/[...path]/route.ts +5 -0
  33. package/dist/templates/_shared/app/globals.css +114 -0
  34. package/dist/templates/_shared/app/layout.tsx +48 -0
  35. package/dist/templates/_shared/app/login/page.tsx +4 -0
  36. package/dist/templates/_shared/app/providers.tsx +13 -0
  37. package/dist/templates/_shared/app/site/[siteId]/[slug]/page.tsx +10 -0
  38. package/dist/templates/_shared/app/site/[siteId]/feed.xml/route.ts +5 -0
  39. package/dist/templates/_shared/app/site/[siteId]/og/[slug]/route.ts +6 -0
  40. package/dist/templates/_shared/app/site/[siteId]/page.tsx +10 -0
  41. package/dist/templates/_shared/app/site/[siteId]/raw/[slug]/route.ts +5 -0
  42. package/dist/templates/_shared/app/site/[siteId]/sitemap.xml/route.ts +5 -0
  43. package/dist/templates/_shared/app/site/[siteId]/tag/[tag]/page.tsx +10 -0
  44. package/dist/templates/_shared/cms.config.ts +110 -0
  45. package/dist/templates/_shared/components/i18n-provider.tsx +7 -0
  46. package/dist/templates/_shared/components/lightbox-content.tsx +69 -0
  47. package/dist/templates/_shared/components/site-chrome/collapsible-sidebar.tsx +54 -0
  48. package/dist/templates/_shared/components/site-chrome/mobile-menu.tsx +68 -0
  49. package/dist/templates/_shared/components/site-chrome/site-footer.tsx +43 -0
  50. package/dist/templates/_shared/components/site-chrome/site-header.tsx +94 -0
  51. package/dist/templates/_shared/components/site-chrome/site-sidebar.tsx +81 -0
  52. package/dist/templates/_shared/components/tag-list.tsx +25 -0
  53. package/dist/templates/_shared/components.json +21 -0
  54. package/dist/templates/_shared/lib/admin-site-client.ts +10 -0
  55. package/dist/templates/_shared/lib/admin-site.ts +8 -0
  56. package/dist/templates/_shared/lib/admin.ts +24 -0
  57. package/dist/templates/_shared/lib/ampless.ts +23 -0
  58. package/dist/templates/_shared/lib/amplify-server.ts +7 -0
  59. package/dist/templates/_shared/lib/amplify.ts +9 -0
  60. package/dist/templates/_shared/lib/auth-server.ts +11 -0
  61. package/dist/templates/_shared/lib/cn.ts +5 -0
  62. package/dist/templates/_shared/lib/i18n.ts +31 -0
  63. package/dist/templates/_shared/lib/kv-provider.ts +7 -0
  64. package/dist/templates/_shared/lib/media.ts +6 -0
  65. package/dist/templates/_shared/lib/posts-provider.ts +7 -0
  66. package/dist/templates/_shared/lib/posts-public.ts +19 -0
  67. package/dist/templates/_shared/lib/posts.ts +12 -0
  68. package/dist/templates/_shared/lib/seo.ts +8 -0
  69. package/dist/templates/_shared/lib/site-settings.ts +8 -0
  70. package/dist/templates/_shared/lib/storage.ts +7 -0
  71. package/dist/templates/_shared/lib/theme-actions.ts +5 -0
  72. package/dist/templates/_shared/lib/theme-active.ts +8 -0
  73. package/dist/templates/_shared/lib/theme-config.ts +10 -0
  74. package/dist/templates/_shared/lib/upload.ts +6 -0
  75. package/dist/templates/_shared/middleware.ts +13 -0
  76. package/dist/templates/_shared/next.config.mjs +11 -0
  77. package/dist/templates/_shared/package.json +63 -0
  78. package/dist/templates/_shared/postcss.config.mjs +5 -0
  79. package/dist/templates/_shared/themes-registry.ts +38 -0
  80. package/dist/templates/_shared/tsconfig.json +23 -0
  81. package/dist/templates/blog/README.md +52 -0
  82. package/dist/templates/blog/index.ts +29 -0
  83. package/dist/templates/blog/manifest.ts +144 -0
  84. package/dist/templates/blog/pages/feed.ts +31 -0
  85. package/dist/templates/blog/pages/home.tsx +108 -0
  86. package/dist/templates/blog/pages/post.tsx +94 -0
  87. package/dist/templates/blog/pages/sitemap.ts +30 -0
  88. package/dist/templates/blog/pages/tag.tsx +76 -0
  89. package/dist/templates/blog/tokens.css +54 -0
  90. package/dist/templates/corporate/README.md +20 -0
  91. package/dist/templates/corporate/index.ts +25 -0
  92. package/dist/templates/corporate/manifest.ts +94 -0
  93. package/dist/templates/corporate/pages/feed.ts +29 -0
  94. package/dist/templates/corporate/pages/home.tsx +130 -0
  95. package/dist/templates/corporate/pages/post.tsx +96 -0
  96. package/dist/templates/corporate/pages/sitemap.ts +28 -0
  97. package/dist/templates/corporate/pages/tag.tsx +81 -0
  98. package/dist/templates/corporate/tokens.css +47 -0
  99. package/dist/templates/dads/README.md +35 -0
  100. package/dist/templates/dads/index.ts +25 -0
  101. package/dist/templates/dads/manifest.ts +84 -0
  102. package/dist/templates/dads/pages/feed.ts +29 -0
  103. package/dist/templates/dads/pages/home.tsx +126 -0
  104. package/dist/templates/dads/pages/post.tsx +102 -0
  105. package/dist/templates/dads/pages/sitemap.ts +28 -0
  106. package/dist/templates/dads/pages/tag.tsx +86 -0
  107. package/dist/templates/dads/tokens.css +67 -0
  108. package/dist/templates/docs/README.md +27 -0
  109. package/dist/templates/docs/index.ts +25 -0
  110. package/dist/templates/docs/manifest.ts +89 -0
  111. package/dist/templates/docs/pages/feed.ts +29 -0
  112. package/dist/templates/docs/pages/home.tsx +88 -0
  113. package/dist/templates/docs/pages/post.tsx +96 -0
  114. package/dist/templates/docs/pages/sitemap.ts +28 -0
  115. package/dist/templates/docs/pages/tag.tsx +79 -0
  116. package/dist/templates/docs/tokens.css +55 -0
  117. package/dist/templates/landing/README.md +25 -0
  118. package/dist/templates/landing/index.ts +25 -0
  119. package/dist/templates/landing/manifest.ts +118 -0
  120. package/dist/templates/landing/pages/feed.ts +31 -0
  121. package/dist/templates/landing/pages/home.tsx +123 -0
  122. package/dist/templates/landing/pages/post.tsx +95 -0
  123. package/dist/templates/landing/pages/sitemap.ts +28 -0
  124. package/dist/templates/landing/pages/tag.tsx +85 -0
  125. package/dist/templates/landing/tokens.css +47 -0
  126. package/dist/templates/minimal/README.md +52 -0
  127. package/dist/templates/minimal/index.ts +25 -0
  128. package/dist/templates/minimal/manifest.ts +35 -0
  129. package/dist/templates/minimal/pages/feed.ts +31 -0
  130. package/dist/templates/minimal/pages/home.tsx +44 -0
  131. package/dist/templates/minimal/pages/post.tsx +65 -0
  132. package/dist/templates/minimal/pages/sitemap.ts +30 -0
  133. package/dist/templates/minimal/pages/tag.tsx +46 -0
  134. package/dist/templates/minimal/tokens.css +46 -0
  135. package/package.json +41 -0
@@ -0,0 +1,13 @@
1
+ // Public-site middleware. Implementation moved to `@ampless/runtime`
2
+ // (L1 extraction); this file wires the project's `cms.config` into
3
+ // the factory and re-exports the default matcher.
4
+ //
5
+ // See `@ampless/runtime/middleware` for behaviour details: multi-site
6
+ // host rewrite, `<slug>.html` → raw route, `?previewTheme=` header
7
+ // forwarding, multi-site Cache-Control override.
8
+
9
+ import cmsConfig from './cms.config'
10
+ import { createAmplessMiddleware, defaultMatcherConfig } from '@ampless/runtime/middleware'
11
+
12
+ export const middleware = createAmplessMiddleware({ cmsConfig })
13
+ export const config = defaultMatcherConfig
@@ -0,0 +1,11 @@
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {
3
+ images: {
4
+ remotePatterns: [
5
+ { protocol: 'https', hostname: '*.s3.amazonaws.com' },
6
+ { protocol: 'https', hostname: '*.amazonaws.com' },
7
+ ],
8
+ },
9
+ }
10
+
11
+ export default nextConfig
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "{{projectName}}",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "next dev",
8
+ "build": "next build",
9
+ "start": "next start",
10
+ "lint": "next lint",
11
+ "sandbox": "ampx sandbox"
12
+ },
13
+ "dependencies": {
14
+ "@aws-amplify/adapter-nextjs": "^1.6.0",
15
+ "@radix-ui/react-dialog": "^1.1.4",
16
+ "@radix-ui/react-label": "^2.1.1",
17
+ "@radix-ui/react-slot": "^1.1.1",
18
+ "@tiptap/extension-image": "^2.10.4",
19
+ "@tiptap/extension-link": "^2.10.4",
20
+ "@tiptap/pm": "^2.10.4",
21
+ "@tiptap/react": "^2.10.4",
22
+ "@tiptap/starter-kit": "^2.10.4",
23
+ "@ampless/plugin-og-image": "^0.2.0-alpha.0",
24
+ "@ampless/plugin-rss": "^0.2.0-alpha.0",
25
+ "@ampless/plugin-seo": "^0.2.0-alpha.0",
26
+ "@ampless/plugin-webhook": "^0.2.0-alpha.0",
27
+ "@ampless/admin": "^0.2.0-alpha.0",
28
+ "@ampless/backend": "^0.2.0-alpha.0",
29
+ "@ampless/runtime": "^0.2.0-alpha.0",
30
+ "@digital-go-jp/tailwind-theme-plugin": "^0.3.4",
31
+ "ampless": "^0.2.0-alpha.0",
32
+ "aws-amplify": "^6.10.0",
33
+ "class-variance-authority": "^0.7.1",
34
+ "clsx": "^2.1.1",
35
+ "lucide-react": "^0.469.0",
36
+ "next": "^15.1.0",
37
+ "react": "^19.0.0",
38
+ "react-dom": "^19.0.0",
39
+ "react-image-crop": "^11.0.7",
40
+ "tailwind-merge": "^2.6.0"
41
+ },
42
+ "devDependencies": {
43
+ "@aws-amplify/backend": "^1.13.0",
44
+ "@aws-amplify/backend-cli": "^1.4.0",
45
+ "@aws-sdk/client-appsync": "^3.717.0",
46
+ "@aws-sdk/client-cognito-identity-provider": "^3.717.0",
47
+ "@aws-sdk/client-dynamodb": "^3.717.0",
48
+ "@aws-sdk/client-s3": "^3.717.0",
49
+ "@aws-sdk/client-sqs": "^3.717.0",
50
+ "@aws-sdk/lib-dynamodb": "^3.717.0",
51
+ "@aws-sdk/util-dynamodb": "^3.717.0",
52
+ "@tailwindcss/postcss": "^4.0.0",
53
+ "@tailwindcss/typography": "^0.5.16",
54
+ "@types/aws-lambda": "^8.10.147",
55
+ "@types/node": "^22.10.0",
56
+ "@types/react": "^19.0.0",
57
+ "@types/react-dom": "^19.0.0",
58
+ "aws-cdk-lib": "^2.174.0",
59
+ "postcss": "^8.4.0",
60
+ "tailwindcss": "^4.0.0",
61
+ "typescript": "^5.7.0"
62
+ }
63
+ }
@@ -0,0 +1,5 @@
1
+ export default {
2
+ plugins: {
3
+ '@tailwindcss/postcss': {},
4
+ },
5
+ }
@@ -0,0 +1,38 @@
1
+ // Generated by create-ampless. Lists every theme installed under
2
+ // `themes/<name>/`. Adding a theme = drop a directory under themes/,
3
+ // add an import + map entry below, and redeploy.
4
+ //
5
+ // `theme.active` (per-site, in KvStore) picks which theme renders for
6
+ // each request; everything listed here gets bundled so switching is
7
+ // instant.
8
+ //
9
+ // This shared placeholder lists every default theme so the file
10
+ // compiles inside _shared/. The scaffold rewrites it at install time
11
+ // to import only the themes the user picked.
12
+
13
+ import blog from '@/themes/blog'
14
+ import minimal from '@/themes/minimal'
15
+ import landing from '@/themes/landing'
16
+ import corporate from '@/themes/corporate'
17
+ import docs from '@/themes/docs'
18
+ import dads from '@/themes/dads'
19
+
20
+ export const themes = {
21
+ blog,
22
+ minimal,
23
+ landing,
24
+ corporate,
25
+ docs,
26
+ dads,
27
+ } as const
28
+
29
+ export type ThemeName = keyof typeof themes
30
+
31
+ export const themeList = Object.values(themes)
32
+
33
+ /**
34
+ * Default theme used when a site has no `theme.active` override.
35
+ * Falls back to the first registered theme so an empty registry would
36
+ * still surface a clear runtime error rather than silent breakage.
37
+ */
38
+ export const DEFAULT_THEME: ThemeName = (themeList[0]?.name as ThemeName) ?? 'blog'
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["dom", "dom.iterable", "ES2022"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "preserve",
15
+ "incremental": true,
16
+ "plugins": [{ "name": "next" }],
17
+ "paths": {
18
+ "@/*": ["./*"]
19
+ }
20
+ },
21
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
22
+ "exclude": ["node_modules"]
23
+ }
@@ -0,0 +1,52 @@
1
+ # {{siteName}}
2
+
3
+ A blog site powered by [ampless](https://github.com/heavymoons/ampless).
4
+
5
+ ## Getting Started
6
+
7
+ This project uses Amplify Gen 2 for the backend (Cognito, DynamoDB, S3) and Next.js for the frontend.
8
+
9
+ ```bash
10
+ # 1. Install dependencies
11
+ npm install
12
+
13
+ # 2. Start a personal AWS sandbox (terminal 1)
14
+ # Requires AWS credentials configured (`aws configure`).
15
+ # First run takes ~5–10 min to provision resources.
16
+ # Generates amplify_outputs.json when ready.
17
+ npx ampx sandbox
18
+
19
+ # 3. Start the Next.js dev server (terminal 2)
20
+ npm run dev
21
+ ```
22
+
23
+ Then open [http://localhost:3000](http://localhost:3000).
24
+
25
+ ## First admin user
26
+
27
+ Open [http://localhost:3000/login](http://localhost:3000/login) and click **Create admin account**. The first user to register is automatically added to the `ampless-admin` Cognito group.
28
+
29
+ After that, manage content from `/admin`:
30
+
31
+ - `/admin` — dashboard
32
+ - `/admin/posts` — list / create / edit posts (tiptap editor)
33
+ - `/admin/media` — upload images to S3
34
+
35
+ ## Production deploy
36
+
37
+ ```bash
38
+ git init && git add . && git commit -m "init"
39
+ git remote add origin <your-repo>
40
+ git push
41
+ # Then connect the repo to AWS Amplify Hosting in the AWS console.
42
+ ```
43
+
44
+ ## Customize
45
+
46
+ - `cms.config.ts` — site name, media delivery mode, plugins
47
+ - `app/` — Next.js App Router pages (`(public)/` for the blog, `(admin)/` for the CMS)
48
+ - `amplify/` — Amplify Gen 2 backend definitions (auth / data / storage)
49
+
50
+ ## Plugins
51
+
52
+ Enabled: {{plugins}}
@@ -0,0 +1,29 @@
1
+ import { defineThemeModule } from 'ampless'
2
+ import './tokens.css'
3
+ import manifest from './manifest'
4
+ import BlogHome from './pages/home'
5
+ import BlogPost, { generatePostMetadata } from './pages/post'
6
+ import BlogTag from './pages/tag'
7
+ import { blogFeedHandler } from './pages/feed'
8
+ import { blogSitemapHandler } from './pages/sitemap'
9
+
10
+ // `tokens.css` is imported as a side-effect so Next.js bundles it
11
+ // whenever the theme registry pulls this module in. Multiple themes
12
+ // can ship their own tokens.css side-by-side; only the active theme's
13
+ // `[data-theme="<name>"]` selector matches at runtime.
14
+ export default defineThemeModule({
15
+ name: 'blog',
16
+ manifest,
17
+ components: {
18
+ Home: BlogHome,
19
+ Post: BlogPost,
20
+ Tag: BlogTag,
21
+ },
22
+ metadata: {
23
+ Post: generatePostMetadata,
24
+ },
25
+ routes: {
26
+ feed: blogFeedHandler,
27
+ sitemap: blogSitemapHandler,
28
+ },
29
+ })
@@ -0,0 +1,144 @@
1
+ import { defineTheme } from 'ampless'
2
+
3
+ // Customizable fields for the Blog theme. Edit values in
4
+ // `/admin/sites/<siteId>/theme` — they're stored in KvStore and applied
5
+ // at render time as CSS variables on every public page.
6
+ //
7
+ // Labels / descriptions / groups accept either a plain string (works in
8
+ // any locale) or a `Record<locale, string>` map. The defaults ship in
9
+ // English + Japanese; custom themes can mix both forms.
10
+ export default defineTheme({
11
+ name: 'blog',
12
+ label: { en: 'Blog', ja: 'ブログ' },
13
+ description: {
14
+ en: 'Neutral monochrome with shadcn/ui defaults.',
15
+ ja: 'シャドCN/UIのデフォルトに準じたニュートラル系モノクロ。',
16
+ },
17
+ fields: [
18
+ {
19
+ key: 'primary',
20
+ label: { en: 'Primary color', ja: 'プライマリカラー' },
21
+ group: { en: 'Colors', ja: 'カラー' },
22
+ type: 'color',
23
+ default: 'oklch(0.205 0 0)',
24
+ cssVar: '--primary',
25
+ description: {
26
+ en: 'Buttons, links, accent fills.',
27
+ ja: 'ボタン、リンク、強調表示の背景色。',
28
+ },
29
+ },
30
+ {
31
+ key: 'accent',
32
+ label: { en: 'Accent color', ja: 'アクセントカラー' },
33
+ group: { en: 'Colors', ja: 'カラー' },
34
+ type: 'color',
35
+ default: 'oklch(0.97 0 0)',
36
+ cssVar: '--accent',
37
+ },
38
+ {
39
+ key: 'ring',
40
+ label: { en: 'Focus ring', ja: 'フォーカスリング' },
41
+ group: { en: 'Colors', ja: 'カラー' },
42
+ type: 'color',
43
+ default: 'oklch(0.708 0 0)',
44
+ cssVar: '--ring',
45
+ },
46
+ {
47
+ key: 'destructive',
48
+ label: { en: 'Destructive', ja: '破壊的操作' },
49
+ group: { en: 'Colors', ja: 'カラー' },
50
+ type: 'color',
51
+ default: 'oklch(0.577 0.245 27.325)',
52
+ cssVar: '--destructive',
53
+ description: {
54
+ en: 'Delete buttons and error highlights.',
55
+ ja: '削除ボタンやエラー表示の色。',
56
+ },
57
+ },
58
+ {
59
+ key: 'radius',
60
+ label: { en: 'Corner radius', ja: '角丸' },
61
+ group: { en: 'Shape', ja: '形状' },
62
+ type: 'length',
63
+ default: '0.5rem',
64
+ cssVar: '--radius',
65
+ description: {
66
+ en: 'Border radius for cards, buttons, inputs.',
67
+ ja: 'カード、ボタン、入力欄の角丸。',
68
+ },
69
+ },
70
+ {
71
+ key: 'bodyFont',
72
+ label: { en: 'Body font', ja: '本文フォント' },
73
+ group: { en: 'Typography', ja: 'タイポグラフィ' },
74
+ type: 'fontFamily',
75
+ default: 'system-ui, -apple-system, sans-serif',
76
+ cssVar: '--ampless-body-font',
77
+ options: [
78
+ {
79
+ value: 'system-ui, -apple-system, sans-serif',
80
+ label: { en: 'System sans', ja: 'システムサンセリフ' },
81
+ },
82
+ {
83
+ value: 'Georgia, "Times New Roman", serif',
84
+ label: { en: 'Serif (Georgia)', ja: 'セリフ (Georgia)' },
85
+ },
86
+ {
87
+ value: '"Iowan Old Style", "Apple Garamond", serif',
88
+ label: { en: 'Serif (Iowan)', ja: 'セリフ (Iowan)' },
89
+ },
90
+ {
91
+ value: 'ui-monospace, SFMono-Regular, Menlo, monospace',
92
+ label: { en: 'Monospace', ja: '等幅' },
93
+ },
94
+ ],
95
+ },
96
+ {
97
+ key: 'featuredSlug',
98
+ label: { en: 'Featured post slug', ja: 'トップに固定する記事のスラッグ' },
99
+ group: { en: 'Home', ja: 'トップページ' },
100
+ type: 'text',
101
+ default: '',
102
+ maxLength: 200,
103
+ description: {
104
+ en: 'Slug of a published post to pin at the top of the home page. The post is rendered inline above the post list and removed from the regular feed to avoid duplication. Empty disables the feature.',
105
+ ja: 'トップページ先頭に固定したい公開記事のスラッグ。指定すると本文がインラインで描画され、通常の一覧からは除外されます。空なら無効。',
106
+ },
107
+ },
108
+ {
109
+ key: 'logoUrl',
110
+ label: { en: 'Logo image URL', ja: 'ロゴ画像 URL' },
111
+ group: { en: 'Branding', ja: 'ブランディング' },
112
+ type: 'image',
113
+ default: '',
114
+ description: {
115
+ en: 'URL or media path. Empty falls back to the site name as text.',
116
+ ja: '画像 URL またはメディアパス。空欄ならサイト名がテキスト表示されます。',
117
+ },
118
+ },
119
+ {
120
+ key: 'headerNav',
121
+ label: { en: 'Header navigation', ja: 'ヘッダーナビ' },
122
+ group: { en: 'Navigation', ja: 'ナビゲーション' },
123
+ type: 'linkList',
124
+ default: [],
125
+ maxItems: 8,
126
+ description: {
127
+ en: 'Optional. When empty and no logo is set, the header is omitted entirely.',
128
+ ja: '任意。空かつロゴ未設定の場合はヘッダー自体が表示されません。',
129
+ },
130
+ },
131
+ {
132
+ key: 'footerLinks',
133
+ label: { en: 'Footer links', ja: 'フッターリンク' },
134
+ group: { en: 'Navigation', ja: 'ナビゲーション' },
135
+ type: 'linkList',
136
+ default: [],
137
+ maxItems: 12,
138
+ description: {
139
+ en: 'Optional. When empty and no copyright text exists, the footer is omitted.',
140
+ ja: '任意。空の場合はフッター自体が省略されます。',
141
+ },
142
+ },
143
+ ],
144
+ })
@@ -0,0 +1,31 @@
1
+ import { publicAssetUrl } from '@/lib/storage'
2
+
3
+ interface Ctx {
4
+ siteId: string
5
+ request: Request
6
+ }
7
+
8
+ // /feed.xml proxy — plugin-rss regenerates the feed on content events
9
+ // and writes it to `public/plugins/rss/{siteId}/feed.xml`.
10
+ export async function blogFeedHandler({ siteId }: Ctx): Promise<Response> {
11
+ const url = publicAssetUrl(`public/plugins/rss/${siteId}/feed.xml`)
12
+ const upstream = await fetch(url, { cache: 'no-store' })
13
+ if (!upstream.ok) {
14
+ return new Response(
15
+ `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0"><channel></channel></rss>\n`,
16
+ {
17
+ status: 200,
18
+ headers: {
19
+ 'Content-Type': 'application/rss+xml; charset=utf-8',
20
+ 'Cache-Control': 'public, max-age=60',
21
+ },
22
+ }
23
+ )
24
+ }
25
+ return new Response(upstream.body, {
26
+ headers: {
27
+ 'Content-Type': 'application/rss+xml; charset=utf-8',
28
+ 'Cache-Control': 'public, max-age=300',
29
+ },
30
+ })
31
+ }
@@ -0,0 +1,108 @@
1
+ import Link from 'next/link'
2
+ import { formatDate, parseLinkList, type ThemeRouteContext } from 'ampless'
3
+ import { listPublishedPosts, getPublishedPost } from '@/lib/posts-public'
4
+ import { loadSiteSettings } from '@/lib/site-settings'
5
+ import { loadThemeConfig } from '@/lib/theme-config'
6
+ import { renderBody } from '@/lib/posts'
7
+ import { TagList } from '@/components/tag-list'
8
+ import { SiteHeader } from '@/components/site-chrome/site-header'
9
+ import { SiteFooter } from '@/components/site-chrome/site-footer'
10
+ import { t } from '@/lib/i18n'
11
+
12
+ export default async function BlogHome({ params }: ThemeRouteContext) {
13
+ const { siteId } = await params
14
+ const [settings, theme, postsResult] = await Promise.all([
15
+ loadSiteSettings(siteId),
16
+ loadThemeConfig(siteId),
17
+ listPublishedPosts({ siteId }),
18
+ ])
19
+
20
+ // Featured (pinned) post: render the body inline above the list,
21
+ // then drop the same slug from the feed so it doesn't show twice.
22
+ // Missing / unpublished slugs return null and the section is skipped.
23
+ const featuredSlug = theme.values.featuredSlug?.trim()
24
+ const featured = featuredSlug
25
+ ? await getPublishedPost(featuredSlug, { siteId })
26
+ : null
27
+ const posts = featured
28
+ ? postsResult.items.filter((p) => p.slug !== featured.slug)
29
+ : postsResult.items
30
+
31
+ const showHeader =
32
+ parseLinkList(theme.values.headerNav).length > 0 || !!theme.values.logoUrl?.trim()
33
+ const showFooter = parseLinkList(theme.values.footerLinks).length > 0
34
+
35
+ return (
36
+ <>
37
+ {showHeader && (
38
+ <SiteHeader
39
+ links={theme.values.headerNav}
40
+ logoUrl={theme.values.logoUrl}
41
+ siteName={settings.site.name}
42
+ brandClassName="font-semibold hover:underline"
43
+ />
44
+ )}
45
+
46
+ <main className="mx-auto max-w-2xl px-6 py-12">
47
+ <header className="mb-12 border-b pb-6">
48
+ <h1 className="text-4xl font-bold tracking-tight">{settings.site.name}</h1>
49
+ {settings.site.description && (
50
+ <p className="mt-2 text-gray-600">{settings.site.description}</p>
51
+ )}
52
+ </header>
53
+
54
+ {featured && (
55
+ <article className="mb-12 rounded-lg border bg-[var(--card)] p-6">
56
+ <Link href={`/${featured.slug}`} className="group">
57
+ <h2 className="text-2xl font-semibold group-hover:underline">{featured.title}</h2>
58
+ {featured.publishedAt && (
59
+ <time
60
+ dateTime={featured.publishedAt}
61
+ className="mt-1 block text-sm text-gray-500"
62
+ >
63
+ {formatDate(featured.publishedAt, settings.dateFormat, settings.timezone)}
64
+ </time>
65
+ )}
66
+ </Link>
67
+ <div
68
+ className="prose prose-neutral dark:prose-invert mt-4 max-w-none"
69
+ dangerouslySetInnerHTML={{ __html: renderBody(featured) }}
70
+ />
71
+ </article>
72
+ )}
73
+
74
+ {posts.length === 0 ? (
75
+ !featured && <p className="text-gray-500">{t('public.noPosts')}</p>
76
+ ) : (
77
+ <ul className="space-y-8">
78
+ {posts.map((post) => (
79
+ <li key={post.postId}>
80
+ <Link href={`/${post.slug}`} className="block group">
81
+ <h2 className="text-2xl font-semibold group-hover:underline">{post.title}</h2>
82
+ {post.publishedAt && (
83
+ <time dateTime={post.publishedAt} className="text-sm text-gray-500">
84
+ {formatDate(post.publishedAt, settings.dateFormat, settings.timezone)}
85
+ </time>
86
+ )}
87
+ {post.excerpt && <p className="mt-2 text-gray-700">{post.excerpt}</p>}
88
+ </Link>
89
+ <TagList tags={post.tags} className="mt-3" />
90
+ </li>
91
+ ))}
92
+ </ul>
93
+ )}
94
+ </main>
95
+
96
+ {showFooter && (
97
+ <SiteFooter
98
+ links={theme.values.footerLinks}
99
+ legend={
100
+ <span>
101
+ © {new Date().getFullYear()} {settings.site.name}
102
+ </span>
103
+ }
104
+ />
105
+ )}
106
+ </>
107
+ )
108
+ }
@@ -0,0 +1,94 @@
1
+ import type { Metadata } from 'next'
2
+ import Link from 'next/link'
3
+ import { notFound } from 'next/navigation'
4
+ import { formatDate, parseLinkList, type ThemeRouteContext } from 'ampless'
5
+ import { renderBody } from '@/lib/posts'
6
+ import { LightboxBinder } from '@/components/lightbox-content'
7
+ import { TagList } from '@/components/tag-list'
8
+ import { postMetadata } from '@/lib/seo'
9
+ import { loadSiteSettings } from '@/lib/site-settings'
10
+ import { loadThemeConfig } from '@/lib/theme-config'
11
+ import { getPublishedPost } from '@/lib/posts-public'
12
+ import { SiteHeader } from '@/components/site-chrome/site-header'
13
+ import { SiteFooter } from '@/components/site-chrome/site-footer'
14
+ import { t } from '@/lib/i18n'
15
+
16
+ type PostCtx = ThemeRouteContext<{ slug: string }>
17
+
18
+ export async function generatePostMetadata({ params }: PostCtx): Promise<Metadata> {
19
+ const { siteId, slug } = await params
20
+ const post = await getPublishedPost(slug, { siteId })
21
+ if (!post) return {}
22
+ return postMetadata(post, siteId)
23
+ }
24
+
25
+ export default async function BlogPost({ params }: PostCtx) {
26
+ const { siteId, slug } = await params
27
+ const [post, settings, theme] = await Promise.all([
28
+ getPublishedPost(slug, { siteId }),
29
+ loadSiteSettings(siteId),
30
+ loadThemeConfig(siteId),
31
+ ])
32
+ if (!post) notFound()
33
+
34
+ const defaultLightbox = settings.media.imageDisplay === 'lightbox'
35
+ const maxWidth = settings.media.imageMaxWidth ?? '100%'
36
+ const proseStyle: React.CSSProperties = {
37
+ ['--ampless-img-max-width' as string]: maxWidth,
38
+ }
39
+ const showHeader =
40
+ parseLinkList(theme.values.headerNav).length > 0 || !!theme.values.logoUrl?.trim()
41
+ const showFooter = parseLinkList(theme.values.footerLinks).length > 0
42
+
43
+ return (
44
+ <>
45
+ {showHeader && (
46
+ <SiteHeader
47
+ links={theme.values.headerNav}
48
+ logoUrl={theme.values.logoUrl}
49
+ siteName={settings.site.name}
50
+ brandClassName="font-semibold hover:underline"
51
+ />
52
+ )}
53
+
54
+ <main className="mx-auto max-w-2xl px-6 py-12">
55
+ <nav className="mb-8">
56
+ <Link href="/" className="text-sm text-gray-500 hover:underline">{t('public.back')}</Link>
57
+ </nav>
58
+
59
+ <article>
60
+ <header className="mb-8 border-b pb-6">
61
+ <h1 className="text-4xl font-bold tracking-tight">{post.title}</h1>
62
+ {post.publishedAt && (
63
+ <time dateTime={post.publishedAt} className="mt-2 block text-sm text-gray-500">
64
+ {formatDate(post.publishedAt, settings.dateFormat, settings.timezone)}
65
+ </time>
66
+ )}
67
+ </header>
68
+
69
+ <div
70
+ id="post-body"
71
+ className="prose prose-neutral dark:prose-invert max-w-none [&_img]:max-w-[var(--ampless-img-max-width)] [&_img]:mx-auto"
72
+ style={proseStyle}
73
+ dangerouslySetInnerHTML={{ __html: renderBody(post) }}
74
+ />
75
+
76
+ <TagList tags={post.tags} className="mt-8 border-t pt-6" />
77
+ </article>
78
+
79
+ <LightboxBinder scopeSelector="#post-body" defaultLightbox={defaultLightbox} />
80
+ </main>
81
+
82
+ {showFooter && (
83
+ <SiteFooter
84
+ links={theme.values.footerLinks}
85
+ legend={
86
+ <span>
87
+ © {new Date().getFullYear()} {settings.site.name}
88
+ </span>
89
+ }
90
+ />
91
+ )}
92
+ </>
93
+ )
94
+ }
@@ -0,0 +1,30 @@
1
+ import { publicAssetUrl } from '@/lib/storage'
2
+
3
+ interface Ctx {
4
+ siteId: string
5
+ request: Request
6
+ }
7
+
8
+ // /sitemap.xml proxy — plugin-seo regenerates the sitemap on every
9
+ // content event and writes it to `public/plugins/seo/{siteId}/sitemap.xml`.
10
+ export async function blogSitemapHandler({ siteId }: Ctx): Promise<Response> {
11
+ const url = publicAssetUrl(`public/plugins/seo/${siteId}/sitemap.xml`)
12
+ const upstream = await fetch(url, { cache: 'no-store' })
13
+ if (!upstream.ok) {
14
+ return new Response(
15
+ `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>\n`,
16
+ {
17
+ headers: {
18
+ 'Content-Type': 'application/xml; charset=utf-8',
19
+ 'Cache-Control': 'public, max-age=60',
20
+ },
21
+ }
22
+ )
23
+ }
24
+ return new Response(upstream.body, {
25
+ headers: {
26
+ 'Content-Type': 'application/xml; charset=utf-8',
27
+ 'Cache-Control': 'public, max-age=300',
28
+ },
29
+ })
30
+ }