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.
- package/LICENSE +21 -0
- package/README.md +38 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +229 -0
- package/dist/templates/_shared/RUNBOOK.md +178 -0
- package/dist/templates/_shared/amplify/auth/post-confirmation/handler.ts +4 -0
- package/dist/templates/_shared/amplify/auth/post-confirmation/resource.ts +6 -0
- package/dist/templates/_shared/amplify/auth/resource.ts +8 -0
- package/dist/templates/_shared/amplify/backend.ts +29 -0
- package/dist/templates/_shared/amplify/data/get-published-post.js +33 -0
- package/dist/templates/_shared/amplify/data/list-posts-by-tag.js +52 -0
- package/dist/templates/_shared/amplify/data/list-published-posts.js +57 -0
- package/dist/templates/_shared/amplify/data/resource.ts +30 -0
- package/dist/templates/_shared/amplify/events/dispatcher/handler.ts +4 -0
- package/dist/templates/_shared/amplify/events/dispatcher/resource.ts +12 -0
- package/dist/templates/_shared/amplify/events/processor-trusted/handler.ts +12 -0
- package/dist/templates/_shared/amplify/events/processor-trusted/resource.ts +14 -0
- package/dist/templates/_shared/amplify/events/processor-untrusted/handler.ts +10 -0
- package/dist/templates/_shared/amplify/events/processor-untrusted/resource.ts +9 -0
- package/dist/templates/_shared/amplify/functions/api-key-renewer/handler.ts +4 -0
- package/dist/templates/_shared/amplify/functions/api-key-renewer/resource.ts +12 -0
- package/dist/templates/_shared/amplify/storage/resource.ts +7 -0
- package/dist/templates/_shared/app/(admin)/admin/layout.tsx +4 -0
- package/dist/templates/_shared/app/(admin)/admin/media/page.tsx +4 -0
- package/dist/templates/_shared/app/(admin)/admin/page.tsx +4 -0
- package/dist/templates/_shared/app/(admin)/admin/posts/[postId]/page.tsx +4 -0
- package/dist/templates/_shared/app/(admin)/admin/posts/new/page.tsx +4 -0
- package/dist/templates/_shared/app/(admin)/admin/posts/page.tsx +4 -0
- package/dist/templates/_shared/app/(admin)/admin/sites/[siteId]/page.tsx +5 -0
- package/dist/templates/_shared/app/(admin)/admin/sites/[siteId]/theme/page.tsx +6 -0
- package/dist/templates/_shared/app/(admin)/admin/sites/page.tsx +5 -0
- package/dist/templates/_shared/app/api/media/[...path]/route.ts +5 -0
- package/dist/templates/_shared/app/globals.css +114 -0
- package/dist/templates/_shared/app/layout.tsx +48 -0
- package/dist/templates/_shared/app/login/page.tsx +4 -0
- package/dist/templates/_shared/app/providers.tsx +13 -0
- package/dist/templates/_shared/app/site/[siteId]/[slug]/page.tsx +10 -0
- package/dist/templates/_shared/app/site/[siteId]/feed.xml/route.ts +5 -0
- package/dist/templates/_shared/app/site/[siteId]/og/[slug]/route.ts +6 -0
- package/dist/templates/_shared/app/site/[siteId]/page.tsx +10 -0
- package/dist/templates/_shared/app/site/[siteId]/raw/[slug]/route.ts +5 -0
- package/dist/templates/_shared/app/site/[siteId]/sitemap.xml/route.ts +5 -0
- package/dist/templates/_shared/app/site/[siteId]/tag/[tag]/page.tsx +10 -0
- package/dist/templates/_shared/cms.config.ts +110 -0
- package/dist/templates/_shared/components/i18n-provider.tsx +7 -0
- package/dist/templates/_shared/components/lightbox-content.tsx +69 -0
- package/dist/templates/_shared/components/site-chrome/collapsible-sidebar.tsx +54 -0
- package/dist/templates/_shared/components/site-chrome/mobile-menu.tsx +68 -0
- package/dist/templates/_shared/components/site-chrome/site-footer.tsx +43 -0
- package/dist/templates/_shared/components/site-chrome/site-header.tsx +94 -0
- package/dist/templates/_shared/components/site-chrome/site-sidebar.tsx +81 -0
- package/dist/templates/_shared/components/tag-list.tsx +25 -0
- package/dist/templates/_shared/components.json +21 -0
- package/dist/templates/_shared/lib/admin-site-client.ts +10 -0
- package/dist/templates/_shared/lib/admin-site.ts +8 -0
- package/dist/templates/_shared/lib/admin.ts +24 -0
- package/dist/templates/_shared/lib/ampless.ts +23 -0
- package/dist/templates/_shared/lib/amplify-server.ts +7 -0
- package/dist/templates/_shared/lib/amplify.ts +9 -0
- package/dist/templates/_shared/lib/auth-server.ts +11 -0
- package/dist/templates/_shared/lib/cn.ts +5 -0
- package/dist/templates/_shared/lib/i18n.ts +31 -0
- package/dist/templates/_shared/lib/kv-provider.ts +7 -0
- package/dist/templates/_shared/lib/media.ts +6 -0
- package/dist/templates/_shared/lib/posts-provider.ts +7 -0
- package/dist/templates/_shared/lib/posts-public.ts +19 -0
- package/dist/templates/_shared/lib/posts.ts +12 -0
- package/dist/templates/_shared/lib/seo.ts +8 -0
- package/dist/templates/_shared/lib/site-settings.ts +8 -0
- package/dist/templates/_shared/lib/storage.ts +7 -0
- package/dist/templates/_shared/lib/theme-actions.ts +5 -0
- package/dist/templates/_shared/lib/theme-active.ts +8 -0
- package/dist/templates/_shared/lib/theme-config.ts +10 -0
- package/dist/templates/_shared/lib/upload.ts +6 -0
- package/dist/templates/_shared/middleware.ts +13 -0
- package/dist/templates/_shared/next.config.mjs +11 -0
- package/dist/templates/_shared/package.json +63 -0
- package/dist/templates/_shared/postcss.config.mjs +5 -0
- package/dist/templates/_shared/themes-registry.ts +38 -0
- package/dist/templates/_shared/tsconfig.json +23 -0
- package/dist/templates/blog/README.md +52 -0
- package/dist/templates/blog/index.ts +29 -0
- package/dist/templates/blog/manifest.ts +144 -0
- package/dist/templates/blog/pages/feed.ts +31 -0
- package/dist/templates/blog/pages/home.tsx +108 -0
- package/dist/templates/blog/pages/post.tsx +94 -0
- package/dist/templates/blog/pages/sitemap.ts +30 -0
- package/dist/templates/blog/pages/tag.tsx +76 -0
- package/dist/templates/blog/tokens.css +54 -0
- package/dist/templates/corporate/README.md +20 -0
- package/dist/templates/corporate/index.ts +25 -0
- package/dist/templates/corporate/manifest.ts +94 -0
- package/dist/templates/corporate/pages/feed.ts +29 -0
- package/dist/templates/corporate/pages/home.tsx +130 -0
- package/dist/templates/corporate/pages/post.tsx +96 -0
- package/dist/templates/corporate/pages/sitemap.ts +28 -0
- package/dist/templates/corporate/pages/tag.tsx +81 -0
- package/dist/templates/corporate/tokens.css +47 -0
- package/dist/templates/dads/README.md +35 -0
- package/dist/templates/dads/index.ts +25 -0
- package/dist/templates/dads/manifest.ts +84 -0
- package/dist/templates/dads/pages/feed.ts +29 -0
- package/dist/templates/dads/pages/home.tsx +126 -0
- package/dist/templates/dads/pages/post.tsx +102 -0
- package/dist/templates/dads/pages/sitemap.ts +28 -0
- package/dist/templates/dads/pages/tag.tsx +86 -0
- package/dist/templates/dads/tokens.css +67 -0
- package/dist/templates/docs/README.md +27 -0
- package/dist/templates/docs/index.ts +25 -0
- package/dist/templates/docs/manifest.ts +89 -0
- package/dist/templates/docs/pages/feed.ts +29 -0
- package/dist/templates/docs/pages/home.tsx +88 -0
- package/dist/templates/docs/pages/post.tsx +96 -0
- package/dist/templates/docs/pages/sitemap.ts +28 -0
- package/dist/templates/docs/pages/tag.tsx +79 -0
- package/dist/templates/docs/tokens.css +55 -0
- package/dist/templates/landing/README.md +25 -0
- package/dist/templates/landing/index.ts +25 -0
- package/dist/templates/landing/manifest.ts +118 -0
- package/dist/templates/landing/pages/feed.ts +31 -0
- package/dist/templates/landing/pages/home.tsx +123 -0
- package/dist/templates/landing/pages/post.tsx +95 -0
- package/dist/templates/landing/pages/sitemap.ts +28 -0
- package/dist/templates/landing/pages/tag.tsx +85 -0
- package/dist/templates/landing/tokens.css +47 -0
- package/dist/templates/minimal/README.md +52 -0
- package/dist/templates/minimal/index.ts +25 -0
- package/dist/templates/minimal/manifest.ts +35 -0
- package/dist/templates/minimal/pages/feed.ts +31 -0
- package/dist/templates/minimal/pages/home.tsx +44 -0
- package/dist/templates/minimal/pages/post.tsx +65 -0
- package/dist/templates/minimal/pages/sitemap.ts +30 -0
- package/dist/templates/minimal/pages/tag.tsx +46 -0
- package/dist/templates/minimal/tokens.css +46 -0
- 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
|
+
}
|