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,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 writes the feed to
9
+ // `public/plugins/rss/{siteId}/feed.xml`. Same flow as the blog theme.
10
+ export async function landingFeedHandler({ 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,123 @@
1
+ import Link from 'next/link'
2
+ import { formatDate, 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 { SiteHeader } from '@/components/site-chrome/site-header'
8
+ import { SiteFooter } from '@/components/site-chrome/site-footer'
9
+
10
+ // Hero-led landing layout: big headline + subhead + CTA, then an
11
+ // optional "Latest" grid sourced from published posts. Falls back to
12
+ // site name / description when the manifest hero fields are empty.
13
+ export default async function LandingHome({ params }: ThemeRouteContext) {
14
+ const { siteId } = await params
15
+ const [settings, theme, postsResult] = await Promise.all([
16
+ loadSiteSettings(siteId),
17
+ loadThemeConfig(siteId),
18
+ listPublishedPosts({ siteId, limit: 6 }),
19
+ ])
20
+
21
+ // Featured embed below the hero — typical use is a short "About"
22
+ // or "Welcome" article. Filtered out of the Latest grid below to
23
+ // avoid showing the same post twice.
24
+ const featuredSlug = theme.values.featuredSlug?.trim()
25
+ const featured = featuredSlug
26
+ ? await getPublishedPost(featuredSlug, { siteId })
27
+ : null
28
+ const posts = featured
29
+ ? postsResult.items.filter((p) => p.slug !== featured.slug)
30
+ : postsResult.items
31
+
32
+ const headline = theme.values.heroHeadline?.trim() || settings.site.name
33
+ const subheadline =
34
+ theme.values.heroSubheadline?.trim() || settings.site.description || ''
35
+ const ctaText = theme.values.ctaText?.trim() || ''
36
+ const ctaUrl = theme.values.ctaUrl?.trim() || '#'
37
+
38
+ return (
39
+ <>
40
+ <SiteHeader
41
+ links={theme.values.headerNav}
42
+ logoUrl={theme.values.logoUrl}
43
+ siteName={settings.site.name}
44
+ brandClassName="font-semibold hover:text-[var(--primary)]"
45
+ />
46
+
47
+ <main>
48
+ <section className="bg-gradient-to-b from-[var(--accent)] to-[var(--background)] px-6 py-24 text-center">
49
+ <div className="mx-auto max-w-3xl">
50
+ <h1 className="text-5xl font-bold tracking-tight sm:text-6xl">{headline}</h1>
51
+ {subheadline && (
52
+ <p className="mt-6 text-xl text-[var(--muted-foreground)]">{subheadline}</p>
53
+ )}
54
+ {ctaText && (
55
+ <div className="mt-10">
56
+ <Link
57
+ href={ctaUrl}
58
+ className="inline-block rounded-[var(--radius)] bg-[var(--primary)] px-8 py-3 text-lg font-medium text-[var(--primary-foreground)] transition hover:opacity-90"
59
+ >
60
+ {ctaText}
61
+ </Link>
62
+ </div>
63
+ )}
64
+ </div>
65
+ </section>
66
+
67
+ {featured && (
68
+ <section className="mx-auto max-w-3xl px-6 py-16">
69
+ <article>
70
+ <h2 className="text-3xl font-bold tracking-tight">{featured.title}</h2>
71
+ <div
72
+ className="prose prose-neutral dark:prose-invert mt-6 max-w-none"
73
+ dangerouslySetInnerHTML={{ __html: renderBody(featured) }}
74
+ />
75
+ </article>
76
+ </section>
77
+ )}
78
+
79
+ {posts.length > 0 && (
80
+ <section className="mx-auto max-w-5xl px-6 py-16">
81
+ <h2 className="mb-8 text-3xl font-bold">Latest</h2>
82
+ <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
83
+ {posts.map((post) => (
84
+ <article
85
+ key={post.postId}
86
+ className="rounded-[var(--radius)] border bg-[var(--card)] p-6 transition hover:shadow-lg"
87
+ >
88
+ <Link href={`/${post.slug}`} className="block">
89
+ <h3 className="text-lg font-semibold leading-tight hover:underline">
90
+ {post.title}
91
+ </h3>
92
+ {post.publishedAt && (
93
+ <time
94
+ dateTime={post.publishedAt}
95
+ className="mt-2 block text-xs text-[var(--muted-foreground)]"
96
+ >
97
+ {formatDate(post.publishedAt, settings.dateFormat, settings.timezone)}
98
+ </time>
99
+ )}
100
+ {post.excerpt && (
101
+ <p className="mt-3 text-sm text-[var(--muted-foreground)] line-clamp-3">
102
+ {post.excerpt}
103
+ </p>
104
+ )}
105
+ </Link>
106
+ </article>
107
+ ))}
108
+ </div>
109
+ </section>
110
+ )}
111
+ </main>
112
+
113
+ <SiteFooter
114
+ links={theme.values.footerLinks}
115
+ legend={
116
+ <span>
117
+ © {new Date().getFullYear()} {settings.site.name}
118
+ </span>
119
+ }
120
+ />
121
+ </>
122
+ )
123
+ }
@@ -0,0 +1,95 @@
1
+ import type { Metadata } from 'next'
2
+ import Link from 'next/link'
3
+ import { notFound } from 'next/navigation'
4
+ import { formatDate, 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 LandingPost({ 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
+
40
+ return (
41
+ <>
42
+ <SiteHeader
43
+ links={theme.values.headerNav}
44
+ logoUrl={theme.values.logoUrl}
45
+ siteName={settings.site.name}
46
+ brandClassName="font-semibold hover:text-[var(--primary)]"
47
+ />
48
+
49
+ <main className="mx-auto max-w-3xl px-6 py-16">
50
+ <nav className="mb-10">
51
+ <Link
52
+ href="/"
53
+ className="text-sm text-[var(--muted-foreground)] hover:text-[var(--primary)]"
54
+ >
55
+ {t('public.back')}
56
+ </Link>
57
+ </nav>
58
+
59
+ <article>
60
+ <header className="mb-10">
61
+ <h1 className="text-4xl font-bold tracking-tight sm:text-5xl">{post.title}</h1>
62
+ {post.publishedAt && (
63
+ <time
64
+ dateTime={post.publishedAt}
65
+ className="mt-3 block text-sm text-[var(--muted-foreground)]"
66
+ >
67
+ {formatDate(post.publishedAt, settings.dateFormat, settings.timezone)}
68
+ </time>
69
+ )}
70
+ </header>
71
+
72
+ <div
73
+ id="post-body"
74
+ className="prose prose-neutral dark:prose-invert max-w-none [&_img]:max-w-[var(--ampless-img-max-width)] [&_img]:mx-auto"
75
+ style={proseStyle}
76
+ dangerouslySetInnerHTML={{ __html: renderBody(post) }}
77
+ />
78
+
79
+ <TagList tags={post.tags} className="mt-10 border-t pt-6" />
80
+ </article>
81
+
82
+ <LightboxBinder scopeSelector="#post-body" defaultLightbox={defaultLightbox} />
83
+ </main>
84
+
85
+ <SiteFooter
86
+ links={theme.values.footerLinks}
87
+ legend={
88
+ <span>
89
+ © {new Date().getFullYear()} {settings.site.name}
90
+ </span>
91
+ }
92
+ />
93
+ </>
94
+ )
95
+ }
@@ -0,0 +1,28 @@
1
+ import { publicAssetUrl } from '@/lib/storage'
2
+
3
+ interface Ctx {
4
+ siteId: string
5
+ request: Request
6
+ }
7
+
8
+ export async function landingSitemapHandler({ siteId }: Ctx): Promise<Response> {
9
+ const url = publicAssetUrl(`public/plugins/seo/${siteId}/sitemap.xml`)
10
+ const upstream = await fetch(url, { cache: 'no-store' })
11
+ if (!upstream.ok) {
12
+ return new Response(
13
+ `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>\n`,
14
+ {
15
+ headers: {
16
+ 'Content-Type': 'application/xml; charset=utf-8',
17
+ 'Cache-Control': 'public, max-age=60',
18
+ },
19
+ }
20
+ )
21
+ }
22
+ return new Response(upstream.body, {
23
+ headers: {
24
+ 'Content-Type': 'application/xml; charset=utf-8',
25
+ 'Cache-Control': 'public, max-age=300',
26
+ },
27
+ })
28
+ }
@@ -0,0 +1,85 @@
1
+ import Link from 'next/link'
2
+ import { notFound } from 'next/navigation'
3
+ import { formatDate, type ThemeRouteContext } from 'ampless'
4
+ import { listPostsByTag } from '@/lib/posts-public'
5
+ import { loadSiteSettings } from '@/lib/site-settings'
6
+ import { loadThemeConfig } from '@/lib/theme-config'
7
+ import { SiteHeader } from '@/components/site-chrome/site-header'
8
+ import { SiteFooter } from '@/components/site-chrome/site-footer'
9
+ import { t } from '@/lib/i18n'
10
+
11
+ export default async function LandingTag({ params }: ThemeRouteContext<{ tag: string }>) {
12
+ const { siteId, tag } = await params
13
+ const decodedTag = decodeURIComponent(tag)
14
+ const [{ items: posts }, settings, theme] = await Promise.all([
15
+ listPostsByTag(decodedTag, { siteId, limit: 50 }),
16
+ loadSiteSettings(siteId),
17
+ loadThemeConfig(siteId),
18
+ ])
19
+
20
+ if (posts.length === 0) notFound()
21
+
22
+ return (
23
+ <>
24
+ <SiteHeader
25
+ links={theme.values.headerNav}
26
+ logoUrl={theme.values.logoUrl}
27
+ siteName={settings.site.name}
28
+ brandClassName="font-semibold hover:text-[var(--primary)]"
29
+ />
30
+
31
+ <main className="mx-auto max-w-5xl px-6 py-16">
32
+ <nav className="mb-10">
33
+ <Link
34
+ href="/"
35
+ className="text-sm text-[var(--muted-foreground)] hover:text-[var(--primary)]"
36
+ >
37
+ {t('public.home')}
38
+ </Link>
39
+ </nav>
40
+
41
+ <header className="mb-12">
42
+ <p className="text-sm text-[var(--muted-foreground)]">{t('public.tagLabel')}</p>
43
+ <h1 className="text-4xl font-bold tracking-tight">#{decodedTag}</h1>
44
+ </header>
45
+
46
+ <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
47
+ {posts.map((post) => (
48
+ <article
49
+ key={post.postId}
50
+ className="rounded-[var(--radius)] border bg-[var(--card)] p-6 transition hover:shadow-lg"
51
+ >
52
+ <Link href={`/${post.slug}`} className="block">
53
+ <h2 className="text-lg font-semibold leading-tight hover:underline">
54
+ {post.title}
55
+ </h2>
56
+ {post.publishedAt && (
57
+ <time
58
+ dateTime={post.publishedAt}
59
+ className="mt-2 block text-xs text-[var(--muted-foreground)]"
60
+ >
61
+ {formatDate(post.publishedAt, settings.dateFormat, settings.timezone)}
62
+ </time>
63
+ )}
64
+ {post.excerpt && (
65
+ <p className="mt-3 text-sm text-[var(--muted-foreground)] line-clamp-3">
66
+ {post.excerpt}
67
+ </p>
68
+ )}
69
+ </Link>
70
+ </article>
71
+ ))}
72
+ </div>
73
+ </main>
74
+
75
+ <SiteFooter
76
+ links={theme.values.footerLinks}
77
+ legend={
78
+ <span>
79
+ © {new Date().getFullYear()} {settings.site.name}
80
+ </span>
81
+ }
82
+ />
83
+ </>
84
+ )
85
+ }
@@ -0,0 +1,47 @@
1
+ /* Landing theme tokens — warm coral accent on near-white. Friendly,
2
+ * marketing-flavored palette intended for hero-led sites. See
3
+ * templates/blog/tokens.css for the scoped-selector pattern. */
4
+
5
+ [data-theme='landing'] {
6
+ --background: oklch(0.99 0.01 60);
7
+ --foreground: oklch(0.18 0.02 30);
8
+ --card: oklch(1 0 0);
9
+ --card-foreground: oklch(0.18 0.02 30);
10
+ --primary: oklch(0.6 0.18 35);
11
+ --primary-foreground: oklch(0.99 0.005 60);
12
+ --secondary: oklch(0.96 0.015 50);
13
+ --secondary-foreground: oklch(0.25 0.04 30);
14
+ --muted: oklch(0.96 0.015 50);
15
+ --muted-foreground: oklch(0.5 0.02 40);
16
+ --accent: oklch(0.95 0.05 35);
17
+ --accent-foreground: oklch(0.3 0.1 30);
18
+ --destructive: oklch(0.55 0.22 25);
19
+ --destructive-foreground: oklch(0.99 0 0);
20
+ --border: oklch(0.92 0.01 60);
21
+ --input: oklch(0.92 0.01 60);
22
+ --ring: oklch(0.6 0.18 35);
23
+ --radius: 0.75rem;
24
+ --ampless-body-font: system-ui, -apple-system, sans-serif;
25
+ }
26
+
27
+ @media (prefers-color-scheme: dark) {
28
+ [data-theme='landing'] {
29
+ --background: oklch(0.16 0.02 30);
30
+ --foreground: oklch(0.96 0.01 60);
31
+ --card: oklch(0.22 0.02 30);
32
+ --card-foreground: oklch(0.96 0.01 60);
33
+ --primary: oklch(0.72 0.16 35);
34
+ --primary-foreground: oklch(0.16 0.02 30);
35
+ --secondary: oklch(0.28 0.03 30);
36
+ --secondary-foreground: oklch(0.96 0.01 60);
37
+ --muted: oklch(0.28 0.03 30);
38
+ --muted-foreground: oklch(0.7 0.02 40);
39
+ --accent: oklch(0.32 0.06 30);
40
+ --accent-foreground: oklch(0.96 0.01 60);
41
+ --destructive: oklch(0.55 0.22 25);
42
+ --destructive-foreground: oklch(0.99 0 0);
43
+ --border: oklch(0.32 0.03 30);
44
+ --input: oklch(0.32 0.03 30);
45
+ --ring: oklch(0.72 0.16 35);
46
+ }
47
+ }
@@ -0,0 +1,52 @@
1
+ # {{siteName}}
2
+
3
+ A blog site powered by [ampless](https://github.com/heavymoons/ampless), using the **Minimal** theme — a soft blue accent on a warm-neutral background, derived from shadcn/ui's color tokens.
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,25 @@
1
+ import { defineThemeModule } from 'ampless'
2
+ import './tokens.css'
3
+ import manifest from './manifest'
4
+ import MinimalHome from './pages/home'
5
+ import MinimalPost, { generatePostMetadata } from './pages/post'
6
+ import MinimalTag from './pages/tag'
7
+ import { minimalFeedHandler } from './pages/feed'
8
+ import { minimalSitemapHandler } from './pages/sitemap'
9
+
10
+ export default defineThemeModule({
11
+ name: 'minimal',
12
+ manifest,
13
+ components: {
14
+ Home: MinimalHome,
15
+ Post: MinimalPost,
16
+ Tag: MinimalTag,
17
+ },
18
+ metadata: {
19
+ Post: generatePostMetadata,
20
+ },
21
+ routes: {
22
+ feed: minimalFeedHandler,
23
+ sitemap: minimalSitemapHandler,
24
+ },
25
+ })
@@ -0,0 +1,35 @@
1
+ import { defineTheme } from 'ampless'
2
+
3
+ // Minimal theme: smaller customization surface than Blog by design.
4
+ // Minimal is opinionated about typography and spacing, so only the
5
+ // accent color and corner radius are exposed.
6
+ export default defineTheme({
7
+ name: 'minimal',
8
+ label: { en: 'Minimal', ja: 'ミニマル' },
9
+ description: {
10
+ en: 'Soft blue accent on warm-neutral background.',
11
+ ja: '暖色系ニュートラル地にソフトブルーをアクセントとした構成。',
12
+ },
13
+ fields: [
14
+ {
15
+ key: 'primary',
16
+ label: { en: 'Primary color', ja: 'プライマリカラー' },
17
+ group: { en: 'Colors', ja: 'カラー' },
18
+ type: 'color',
19
+ default: 'oklch(0.55 0.18 250)',
20
+ cssVar: '--primary',
21
+ description: {
22
+ en: 'Buttons, links, accent fills.',
23
+ ja: 'ボタン、リンク、強調表示の背景色。',
24
+ },
25
+ },
26
+ {
27
+ key: 'radius',
28
+ label: { en: 'Corner radius', ja: '角丸' },
29
+ group: { en: 'Shape', ja: '形状' },
30
+ type: 'length',
31
+ default: '0.375rem',
32
+ cssVar: '--radius',
33
+ },
34
+ ],
35
+ })
@@ -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 minimalFeedHandler({ 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,44 @@
1
+ import Link from 'next/link'
2
+ import { formatDate, type ThemeRouteContext } from 'ampless'
3
+ import { listPublishedPosts } from '@/lib/posts-public'
4
+ import { loadSiteSettings } from '@/lib/site-settings'
5
+ import { TagList } from '@/components/tag-list'
6
+ import { t } from '@/lib/i18n'
7
+
8
+ export default async function MinimalHome({ params }: ThemeRouteContext) {
9
+ const { siteId } = await params
10
+ const settings = await loadSiteSettings(siteId)
11
+ const { items: posts } = await listPublishedPosts({ siteId })
12
+
13
+ return (
14
+ <main className="mx-auto max-w-2xl px-6 py-12">
15
+ <header className="mb-12 border-b pb-6">
16
+ <h1 className="text-4xl font-bold tracking-tight">{settings.site.name}</h1>
17
+ {settings.site.description && (
18
+ <p className="mt-2 text-gray-600">{settings.site.description}</p>
19
+ )}
20
+ </header>
21
+
22
+ {posts.length === 0 ? (
23
+ <p className="text-gray-500">{t('public.noPosts')}</p>
24
+ ) : (
25
+ <ul className="space-y-8">
26
+ {posts.map((post) => (
27
+ <li key={post.postId}>
28
+ <Link href={`/${post.slug}`} className="block group">
29
+ <h2 className="text-2xl font-semibold group-hover:underline">{post.title}</h2>
30
+ {post.publishedAt && (
31
+ <time dateTime={post.publishedAt} className="text-sm text-gray-500">
32
+ {formatDate(post.publishedAt, settings.dateFormat, settings.timezone)}
33
+ </time>
34
+ )}
35
+ {post.excerpt && <p className="mt-2 text-gray-700">{post.excerpt}</p>}
36
+ </Link>
37
+ <TagList tags={post.tags} className="mt-3" />
38
+ </li>
39
+ ))}
40
+ </ul>
41
+ )}
42
+ </main>
43
+ )
44
+ }
@@ -0,0 +1,65 @@
1
+ import type { Metadata } from 'next'
2
+ import Link from 'next/link'
3
+ import { notFound } from 'next/navigation'
4
+ import { formatDate, 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 { getPublishedPost } from '@/lib/posts-public'
11
+ import { t } from '@/lib/i18n'
12
+
13
+ type PostCtx = ThemeRouteContext<{ slug: string }>
14
+
15
+ export async function generatePostMetadata({ params }: PostCtx): Promise<Metadata> {
16
+ const { siteId, slug } = await params
17
+ const post = await getPublishedPost(slug, { siteId })
18
+ if (!post) return {}
19
+ return postMetadata(post, siteId)
20
+ }
21
+
22
+ export default async function MinimalPost({ params }: PostCtx) {
23
+ const { siteId, slug } = await params
24
+ const [post, settings] = await Promise.all([
25
+ getPublishedPost(slug, { siteId }),
26
+ loadSiteSettings(siteId),
27
+ ])
28
+ if (!post) notFound()
29
+
30
+ const defaultLightbox = settings.media.imageDisplay === 'lightbox'
31
+ const maxWidth = settings.media.imageMaxWidth ?? '100%'
32
+ const proseStyle: React.CSSProperties = {
33
+ ['--ampless-img-max-width' as string]: maxWidth,
34
+ }
35
+
36
+ return (
37
+ <main className="mx-auto max-w-2xl px-6 py-12">
38
+ <nav className="mb-8">
39
+ <Link href="/" className="text-sm text-gray-500 hover:underline">{t('public.back')}</Link>
40
+ </nav>
41
+
42
+ <article>
43
+ <header className="mb-8 border-b pb-6">
44
+ <h1 className="text-4xl font-bold tracking-tight">{post.title}</h1>
45
+ {post.publishedAt && (
46
+ <time dateTime={post.publishedAt} className="mt-2 block text-sm text-gray-500">
47
+ {formatDate(post.publishedAt, settings.dateFormat, settings.timezone)}
48
+ </time>
49
+ )}
50
+ </header>
51
+
52
+ <div
53
+ id="post-body"
54
+ className="prose prose-neutral dark:prose-invert max-w-none [&_img]:max-w-[var(--ampless-img-max-width)] [&_img]:mx-auto"
55
+ style={proseStyle}
56
+ dangerouslySetInnerHTML={{ __html: renderBody(post) }}
57
+ />
58
+
59
+ <TagList tags={post.tags} className="mt-8 border-t pt-6" />
60
+ </article>
61
+
62
+ <LightboxBinder scopeSelector="#post-body" defaultLightbox={defaultLightbox} />
63
+ </main>
64
+ )
65
+ }