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,76 @@
1
+ import Link from 'next/link'
2
+ import { notFound } from 'next/navigation'
3
+ import { formatDate, parseLinkList, 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 BlogTag({ 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
+ const showHeader =
23
+ parseLinkList(theme.values.headerNav).length > 0 || !!theme.values.logoUrl?.trim()
24
+ const showFooter = parseLinkList(theme.values.footerLinks).length > 0
25
+
26
+ return (
27
+ <>
28
+ {showHeader && (
29
+ <SiteHeader
30
+ links={theme.values.headerNav}
31
+ logoUrl={theme.values.logoUrl}
32
+ siteName={settings.site.name}
33
+ brandClassName="font-semibold hover:underline"
34
+ />
35
+ )}
36
+
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.home')}</Link>
40
+ </nav>
41
+
42
+ <header className="mb-12 border-b pb-6">
43
+ <p className="text-sm text-gray-500">{t('public.tagLabel')}</p>
44
+ <h1 className="text-4xl font-bold tracking-tight">#{decodedTag}</h1>
45
+ </header>
46
+
47
+ <ul className="space-y-8">
48
+ {posts.map((post) => (
49
+ <li key={post.postId}>
50
+ <Link href={`/${post.slug}`} className="block group">
51
+ <h2 className="text-2xl font-semibold group-hover:underline">{post.title}</h2>
52
+ {post.publishedAt && (
53
+ <time dateTime={post.publishedAt} className="text-sm text-gray-500">
54
+ {formatDate(post.publishedAt, settings.dateFormat, settings.timezone)}
55
+ </time>
56
+ )}
57
+ {post.excerpt && <p className="mt-2 text-gray-700">{post.excerpt}</p>}
58
+ </Link>
59
+ </li>
60
+ ))}
61
+ </ul>
62
+ </main>
63
+
64
+ {showFooter && (
65
+ <SiteFooter
66
+ links={theme.values.footerLinks}
67
+ legend={
68
+ <span>
69
+ © {new Date().getFullYear()} {settings.site.name}
70
+ </span>
71
+ }
72
+ />
73
+ )}
74
+ </>
75
+ )
76
+ }
@@ -0,0 +1,54 @@
1
+ /* Blog theme tokens.
2
+ *
3
+ * Each variable is namespaced under [data-theme="blog"] so that all
4
+ * installed themes can coexist in the bundle and `<body data-theme=...>`
5
+ * picks which set actually wins. The active theme's `theme.manifest.ts`
6
+ * runtime overrides come in as inline `:root { ... }` after this file
7
+ * loads, so `:root` selectors trump scoped attribute selectors of
8
+ * equal specificity. The dispatcher emits `--primary` etc. only for
9
+ * fields that have an override, leaving everything else to fall back
10
+ * to these defaults. */
11
+
12
+ [data-theme='blog'] {
13
+ --background: oklch(1 0 0);
14
+ --foreground: oklch(0.145 0 0);
15
+ --card: oklch(1 0 0);
16
+ --card-foreground: oklch(0.145 0 0);
17
+ --primary: oklch(0.205 0 0);
18
+ --primary-foreground: oklch(0.985 0 0);
19
+ --secondary: oklch(0.97 0 0);
20
+ --secondary-foreground: oklch(0.205 0 0);
21
+ --muted: oklch(0.97 0 0);
22
+ --muted-foreground: oklch(0.556 0 0);
23
+ --accent: oklch(0.97 0 0);
24
+ --accent-foreground: oklch(0.205 0 0);
25
+ --destructive: oklch(0.577 0.245 27.325);
26
+ --destructive-foreground: oklch(0.985 0 0);
27
+ --border: oklch(0.922 0 0);
28
+ --input: oklch(0.922 0 0);
29
+ --ring: oklch(0.708 0 0);
30
+ --radius: 0.5rem;
31
+ --ampless-body-font: system-ui, -apple-system, sans-serif;
32
+ }
33
+
34
+ @media (prefers-color-scheme: dark) {
35
+ [data-theme='blog'] {
36
+ --background: oklch(0.145 0 0);
37
+ --foreground: oklch(0.985 0 0);
38
+ --card: oklch(0.145 0 0);
39
+ --card-foreground: oklch(0.985 0 0);
40
+ --primary: oklch(0.985 0 0);
41
+ --primary-foreground: oklch(0.205 0 0);
42
+ --secondary: oklch(0.269 0 0);
43
+ --secondary-foreground: oklch(0.985 0 0);
44
+ --muted: oklch(0.269 0 0);
45
+ --muted-foreground: oklch(0.708 0 0);
46
+ --accent: oklch(0.269 0 0);
47
+ --accent-foreground: oklch(0.985 0 0);
48
+ --destructive: oklch(0.396 0.141 25.723);
49
+ --destructive-foreground: oklch(0.985 0 0);
50
+ --border: oklch(0.269 0 0);
51
+ --input: oklch(0.269 0 0);
52
+ --ring: oklch(0.439 0 0);
53
+ }
54
+ }
@@ -0,0 +1,20 @@
1
+ # {{siteName}}
2
+
3
+ Corporate / company-site theme: navy on near-white, hero + news layout, header + footer navigation.
4
+
5
+ ## Customizing
6
+
7
+ In `/admin/sites/<siteId>/theme`:
8
+
9
+ - Tagline (small line above the site name in the hero)
10
+ - Header navigation (label + URL pairs)
11
+ - Footer links + legend (address / company info / extra small print)
12
+ - Primary color, corner radius
13
+
14
+ ## Getting started
15
+
16
+ ```bash
17
+ npm install
18
+ npx ampx sandbox
19
+ npm run dev
20
+ ```
@@ -0,0 +1,25 @@
1
+ import { defineThemeModule } from 'ampless'
2
+ import './tokens.css'
3
+ import manifest from './manifest'
4
+ import CorporateHome from './pages/home'
5
+ import CorporatePost, { generatePostMetadata } from './pages/post'
6
+ import CorporateTag from './pages/tag'
7
+ import { corporateFeedHandler } from './pages/feed'
8
+ import { corporateSitemapHandler } from './pages/sitemap'
9
+
10
+ export default defineThemeModule({
11
+ name: 'corporate',
12
+ manifest,
13
+ components: {
14
+ Home: CorporateHome,
15
+ Post: CorporatePost,
16
+ Tag: CorporateTag,
17
+ },
18
+ metadata: {
19
+ Post: generatePostMetadata,
20
+ },
21
+ routes: {
22
+ feed: corporateFeedHandler,
23
+ sitemap: corporateSitemapHandler,
24
+ },
25
+ })
@@ -0,0 +1,94 @@
1
+ import { defineTheme } from 'ampless'
2
+
3
+ // Corporate theme manifest. Conservative blue/slate palette with
4
+ // header + footer nav, an optional tagline, and a "news" section
5
+ // driven by published posts.
6
+ export default defineTheme({
7
+ name: 'corporate',
8
+ label: { en: 'Corporate', ja: 'コーポレート' },
9
+ description: {
10
+ en: 'Conservative business / company-site layout with hero and news section.',
11
+ ja: '企業サイト向けの落ち着いたレイアウト。ヒーローとお知らせ一覧を併設。',
12
+ },
13
+ fields: [
14
+ {
15
+ key: 'tagline',
16
+ label: { en: 'Tagline', ja: 'タグライン' },
17
+ group: { en: 'Hero', ja: 'ヒーロー' },
18
+ type: 'text',
19
+ default: '',
20
+ maxLength: 120,
21
+ description: {
22
+ en: 'Short phrase shown above the site name in the hero.',
23
+ ja: 'ヒーロー内、サイト名の上に表示される短いフレーズ。',
24
+ },
25
+ },
26
+ {
27
+ key: 'primary',
28
+ label: { en: 'Primary color', ja: 'プライマリカラー' },
29
+ group: { en: 'Colors', ja: 'カラー' },
30
+ type: 'color',
31
+ default: 'oklch(0.4 0.13 250)',
32
+ cssVar: '--primary',
33
+ },
34
+ {
35
+ key: 'radius',
36
+ label: { en: 'Corner radius', ja: '角丸' },
37
+ group: { en: 'Shape', ja: '形状' },
38
+ type: 'length',
39
+ default: '0.25rem',
40
+ cssVar: '--radius',
41
+ },
42
+ {
43
+ key: 'featuredSlug',
44
+ label: { en: 'Top story slug', ja: 'トップストーリーのスラッグ' },
45
+ group: { en: 'Hero', ja: 'ヒーロー' },
46
+ type: 'text',
47
+ default: '',
48
+ maxLength: 200,
49
+ description: {
50
+ en: 'Slug of a published post to feature between the hero and the news list. Empty disables.',
51
+ ja: 'ヒーローとニュース一覧の間に載せたい公開記事のスラッグ。空なら非表示。',
52
+ },
53
+ },
54
+ {
55
+ key: 'logoUrl',
56
+ label: { en: 'Logo image URL', ja: 'ロゴ画像 URL' },
57
+ group: { en: 'Branding', ja: 'ブランディング' },
58
+ type: 'image',
59
+ default: '',
60
+ description: {
61
+ en: 'URL or media path. Empty falls back to the site name as text.',
62
+ ja: '画像 URL またはメディアパス。空欄ならサイト名がテキスト表示されます。',
63
+ },
64
+ },
65
+ {
66
+ key: 'headerNav',
67
+ label: { en: 'Header navigation', ja: 'ヘッダーナビ' },
68
+ group: { en: 'Navigation', ja: 'ナビゲーション' },
69
+ type: 'linkList',
70
+ default: [],
71
+ maxItems: 8,
72
+ },
73
+ {
74
+ key: 'footerLinks',
75
+ label: { en: 'Footer links', ja: 'フッターリンク' },
76
+ group: { en: 'Navigation', ja: 'ナビゲーション' },
77
+ type: 'linkList',
78
+ default: [],
79
+ maxItems: 16,
80
+ },
81
+ {
82
+ key: 'footerLegend',
83
+ label: { en: 'Footer legend', ja: 'フッター注記' },
84
+ group: { en: 'Navigation', ja: 'ナビゲーション' },
85
+ type: 'text',
86
+ default: '',
87
+ maxLength: 200,
88
+ description: {
89
+ en: 'Address / company info / extra small print below footer links.',
90
+ ja: '住所や会社情報、フッター下部の細字。',
91
+ },
92
+ },
93
+ ],
94
+ })
@@ -0,0 +1,29 @@
1
+ import { publicAssetUrl } from '@/lib/storage'
2
+
3
+ interface Ctx {
4
+ siteId: string
5
+ request: Request
6
+ }
7
+
8
+ export async function corporateFeedHandler({ siteId }: Ctx): Promise<Response> {
9
+ const url = publicAssetUrl(`public/plugins/rss/${siteId}/feed.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<rss version="2.0"><channel></channel></rss>\n`,
14
+ {
15
+ status: 200,
16
+ headers: {
17
+ 'Content-Type': 'application/rss+xml; charset=utf-8',
18
+ 'Cache-Control': 'public, max-age=60',
19
+ },
20
+ }
21
+ )
22
+ }
23
+ return new Response(upstream.body, {
24
+ headers: {
25
+ 'Content-Type': 'application/rss+xml; charset=utf-8',
26
+ 'Cache-Control': 'public, max-age=300',
27
+ },
28
+ })
29
+ }
@@ -0,0 +1,130 @@
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
+ export default async function CorporateHome({ params }: ThemeRouteContext) {
11
+ const { siteId } = await params
12
+ const [settings, theme, postsResult] = await Promise.all([
13
+ loadSiteSettings(siteId),
14
+ loadThemeConfig(siteId),
15
+ listPublishedPosts({ siteId, limit: 8 }),
16
+ ])
17
+
18
+ // Top-story embed between hero and news. Filtered out of news to
19
+ // avoid duplication. Skipped silently if missing / unpublished.
20
+ const featuredSlug = theme.values.featuredSlug?.trim()
21
+ const featured = featuredSlug
22
+ ? await getPublishedPost(featuredSlug, { siteId })
23
+ : null
24
+ const posts = featured
25
+ ? postsResult.items.filter((p) => p.slug !== featured.slug)
26
+ : postsResult.items
27
+
28
+ const tagline = theme.values.tagline?.trim()
29
+ const footerLegend = theme.values.footerLegend?.trim()
30
+
31
+ return (
32
+ <>
33
+ <SiteHeader
34
+ links={theme.values.headerNav}
35
+ logoUrl={theme.values.logoUrl}
36
+ siteName={settings.site.name}
37
+ brandClassName="text-lg font-semibold tracking-tight"
38
+ />
39
+
40
+ <main>
41
+ <section className="border-b bg-[var(--secondary)] px-6 py-16">
42
+ <div className="mx-auto max-w-4xl">
43
+ {tagline && (
44
+ <p className="text-sm font-medium uppercase tracking-wider text-[var(--primary)]">
45
+ {tagline}
46
+ </p>
47
+ )}
48
+ <h1 className="mt-2 text-4xl font-bold tracking-tight sm:text-5xl">
49
+ {settings.site.name}
50
+ </h1>
51
+ {settings.site.description && (
52
+ <p className="mt-4 max-w-2xl text-lg text-[var(--muted-foreground)]">
53
+ {settings.site.description}
54
+ </p>
55
+ )}
56
+ </div>
57
+ </section>
58
+
59
+ {featured && (
60
+ <section className="mx-auto max-w-4xl px-6 py-16">
61
+ <article>
62
+ <p className="mb-2 text-xs font-medium uppercase tracking-wider text-[var(--primary)]">
63
+ Top story
64
+ </p>
65
+ <h2 className="text-3xl font-bold tracking-tight">
66
+ <Link href={`/${featured.slug}`} className="hover:text-[var(--primary)]">
67
+ {featured.title}
68
+ </Link>
69
+ </h2>
70
+ {featured.publishedAt && (
71
+ <time
72
+ dateTime={featured.publishedAt}
73
+ className="mt-2 block font-mono text-xs text-[var(--muted-foreground)]"
74
+ >
75
+ {formatDate(featured.publishedAt, settings.dateFormat, settings.timezone)}
76
+ </time>
77
+ )}
78
+ <div
79
+ className="prose prose-neutral dark:prose-invert mt-6 max-w-none"
80
+ dangerouslySetInnerHTML={{ __html: renderBody(featured) }}
81
+ />
82
+ </article>
83
+ </section>
84
+ )}
85
+
86
+ {posts.length > 0 && (
87
+ <section className="mx-auto max-w-4xl px-6 py-16">
88
+ <h2 className="mb-8 border-l-4 border-[var(--primary)] pl-4 text-2xl font-bold">
89
+ News
90
+ </h2>
91
+ <ul className="divide-y border-t border-b">
92
+ {posts.map((post) => (
93
+ <li key={post.postId} className="py-5">
94
+ <Link
95
+ href={`/${post.slug}`}
96
+ className="group flex flex-col gap-1 sm:flex-row sm:items-baseline sm:gap-6"
97
+ >
98
+ {post.publishedAt && (
99
+ <time
100
+ dateTime={post.publishedAt}
101
+ className="font-mono text-xs text-[var(--muted-foreground)] sm:w-28 sm:shrink-0"
102
+ >
103
+ {formatDate(post.publishedAt, settings.dateFormat, settings.timezone)}
104
+ </time>
105
+ )}
106
+ <span className="flex-1 text-base font-medium group-hover:text-[var(--primary)]">
107
+ {post.title}
108
+ </span>
109
+ </Link>
110
+ </li>
111
+ ))}
112
+ </ul>
113
+ </section>
114
+ )}
115
+ </main>
116
+
117
+ <SiteFooter
118
+ links={theme.values.footerLinks}
119
+ legend={
120
+ <div className="space-y-1">
121
+ {footerLegend && <p>{footerLegend}</p>}
122
+ <p>
123
+ © {new Date().getFullYear()} {settings.site.name}
124
+ </p>
125
+ </div>
126
+ }
127
+ />
128
+ </>
129
+ )
130
+ }
@@ -0,0 +1,96 @@
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 CorporatePost({ 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 footerLegend = theme.values.footerLegend?.trim()
40
+
41
+ return (
42
+ <>
43
+ <SiteHeader
44
+ links={theme.values.headerNav}
45
+ logoUrl={theme.values.logoUrl}
46
+ siteName={settings.site.name}
47
+ brandClassName="text-lg font-semibold tracking-tight"
48
+ />
49
+
50
+ <main className="mx-auto max-w-3xl px-6 py-12">
51
+ <nav className="mb-6">
52
+ <Link href="/" className="text-sm text-[var(--muted-foreground)] hover:text-[var(--primary)]">
53
+ {t('public.back')}
54
+ </Link>
55
+ </nav>
56
+
57
+ <article>
58
+ <header className="mb-8 border-b pb-6">
59
+ <h1 className="text-3xl font-bold tracking-tight">{post.title}</h1>
60
+ {post.publishedAt && (
61
+ <time
62
+ dateTime={post.publishedAt}
63
+ className="mt-2 block font-mono text-xs text-[var(--muted-foreground)]"
64
+ >
65
+ {formatDate(post.publishedAt, settings.dateFormat, settings.timezone)}
66
+ </time>
67
+ )}
68
+ </header>
69
+
70
+ <div
71
+ id="post-body"
72
+ className="prose prose-neutral dark:prose-invert max-w-none [&_img]:max-w-[var(--ampless-img-max-width)] [&_img]:mx-auto"
73
+ style={proseStyle}
74
+ dangerouslySetInnerHTML={{ __html: renderBody(post) }}
75
+ />
76
+
77
+ <TagList tags={post.tags} className="mt-8 border-t pt-6" />
78
+ </article>
79
+
80
+ <LightboxBinder scopeSelector="#post-body" defaultLightbox={defaultLightbox} />
81
+ </main>
82
+
83
+ <SiteFooter
84
+ links={theme.values.footerLinks}
85
+ legend={
86
+ <div className="space-y-1">
87
+ {footerLegend && <p>{footerLegend}</p>}
88
+ <p>
89
+ © {new Date().getFullYear()} {settings.site.name}
90
+ </p>
91
+ </div>
92
+ }
93
+ />
94
+ </>
95
+ )
96
+ }
@@ -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 corporateSitemapHandler({ 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,81 @@
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 CorporateTag({ 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
+ if (posts.length === 0) notFound()
20
+
21
+ const footerLegend = theme.values.footerLegend?.trim()
22
+
23
+ return (
24
+ <>
25
+ <SiteHeader
26
+ links={theme.values.headerNav}
27
+ logoUrl={theme.values.logoUrl}
28
+ siteName={settings.site.name}
29
+ brandClassName="text-lg font-semibold tracking-tight"
30
+ />
31
+
32
+ <main className="mx-auto max-w-4xl px-6 py-12">
33
+ <nav className="mb-6">
34
+ <Link href="/" className="text-sm text-[var(--muted-foreground)] hover:text-[var(--primary)]">
35
+ {t('public.home')}
36
+ </Link>
37
+ </nav>
38
+
39
+ <header className="mb-10">
40
+ <p className="text-sm text-[var(--muted-foreground)]">{t('public.tagLabel')}</p>
41
+ <h1 className="text-3xl font-bold tracking-tight">#{decodedTag}</h1>
42
+ </header>
43
+
44
+ <ul className="divide-y border-t border-b">
45
+ {posts.map((post) => (
46
+ <li key={post.postId} className="py-5">
47
+ <Link
48
+ href={`/${post.slug}`}
49
+ className="group flex flex-col gap-1 sm:flex-row sm:items-baseline sm:gap-6"
50
+ >
51
+ {post.publishedAt && (
52
+ <time
53
+ dateTime={post.publishedAt}
54
+ className="font-mono text-xs text-[var(--muted-foreground)] sm:w-28 sm:shrink-0"
55
+ >
56
+ {formatDate(post.publishedAt, settings.dateFormat, settings.timezone)}
57
+ </time>
58
+ )}
59
+ <span className="flex-1 text-base font-medium group-hover:text-[var(--primary)]">
60
+ {post.title}
61
+ </span>
62
+ </Link>
63
+ </li>
64
+ ))}
65
+ </ul>
66
+ </main>
67
+
68
+ <SiteFooter
69
+ links={theme.values.footerLinks}
70
+ legend={
71
+ <div className="space-y-1">
72
+ {footerLegend && <p>{footerLegend}</p>}
73
+ <p>
74
+ © {new Date().getFullYear()} {settings.site.name}
75
+ </p>
76
+ </div>
77
+ }
78
+ />
79
+ </>
80
+ )
81
+ }