@ampless/runtime 0.2.0-alpha.0 → 0.2.0-alpha.10

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/README.ja.md ADDED
@@ -0,0 +1,86 @@
1
+ > English: [README.md](./README.md)
2
+ >
3
+
4
+ # @ampless/runtime
5
+
6
+ [ampless](https://github.com/heavymoons/ampless) 向けパブリックサイドランタイム。投稿取得クライアント、サイト設定、テーマ解決、SEO メタデータ集約、ミドルウェア、パブリックルートハンドラーを `createAmpless()` ひとつのファクトリーにまとめます。
7
+
8
+ > **プレリリース / アルファ版。** v1.0 まではマイナーバージョンでも破壊的変更が入る可能性があります。
9
+
10
+ テンプレートから切り出すことで、スキャフォールドされたファイルに触れずに `npm update @ampless/runtime` でアップデートできます — パブリックサイトの動作改善はスキャフォールダーの再実行ではなく、パッケージを通じて届きます。
11
+
12
+ ## インストール
13
+
14
+ ```bash
15
+ npm install @ampless/runtime@alpha ampless@alpha
16
+ ```
17
+
18
+ `@ampless/runtime` は `next`(15+)、`react`(18/19)、`aws-amplify`(6+)、`@aws-amplify/adapter-nextjs`(1+)をピア依存として宣言します。CLI スキャフォールダーがテンプレートの `package.json` に互換バージョンをピン留めします。
19
+
20
+ ## 使い方
21
+
22
+ テンプレートは `lib/ampless.ts` に共有インスタンスを 1 つ作成します:
23
+
24
+ ```ts
25
+ import outputs from '../amplify_outputs.json'
26
+ import cmsConfig from '../cms.config'
27
+ import { themes, DEFAULT_THEME } from '../themes-registry'
28
+ import { createAmpless } from '@ampless/runtime'
29
+
30
+ export const ampless = createAmpless({
31
+ outputs,
32
+ cmsConfig,
33
+ themes: { themes, defaultTheme: DEFAULT_THEME },
34
+ })
35
+ ```
36
+
37
+ `app/site/[siteId]/` 以下のルートとディスパッチャーはワンライナーのファクトリー呼び出しになります:
38
+
39
+ ```ts
40
+ // app/site/[siteId]/page.tsx
41
+ import { ampless } from '@/lib/ampless'
42
+ import {
43
+ createThemeHomeDispatcher,
44
+ createThemeHomeMetadata,
45
+ } from '@ampless/runtime/dispatchers'
46
+
47
+ export const dynamic = 'force-dynamic'
48
+ export const generateMetadata = createThemeHomeMetadata(ampless)
49
+ export default createThemeHomeDispatcher(ampless)
50
+ ```
51
+
52
+ ```ts
53
+ // app/site/[siteId]/og/[slug]/route.ts
54
+ import { ampless } from '@/lib/ampless'
55
+ import { createOgRouteHandler } from '@ampless/runtime/routes'
56
+
57
+ export const runtime = 'nodejs'
58
+ export const dynamic = 'force-dynamic'
59
+ export const GET = createOgRouteHandler(ampless)
60
+ ```
61
+
62
+ ミドルウェア:
63
+
64
+ ```ts
65
+ // middleware.ts
66
+ import cmsConfig from './cms.config'
67
+ import { createAmplessMiddleware, defaultMatcherConfig } from '@ampless/runtime/middleware'
68
+
69
+ export const middleware = createAmplessMiddleware({ cmsConfig })
70
+ export const config = defaultMatcherConfig
71
+ ```
72
+
73
+ ## サブパス
74
+
75
+ - `@ampless/runtime` — `createAmpless`、ランタイム型、`renderBody`・`renderThemeCss`・フォーマットコンバーターの再エクスポート
76
+ - `@ampless/runtime/middleware` — `createAmplessMiddleware`、`defaultMatcherConfig`
77
+ - `@ampless/runtime/routes` — `createOgRouteHandler`、`createSitemapRouteHandler`、`createFeedRouteHandler`、`createRawRouteHandler`
78
+ - `@ampless/runtime/dispatchers` — `createThemeHomeDispatcher`、`createThemePostDispatcher`、`createThemeTagDispatcher`(それぞれ対応する `*Metadata` ファクトリーあり)
79
+
80
+ ## テンプレートに残るもの
81
+
82
+ 管理側モジュール(投稿プロバイダー、テーマアクション、認証、KV 書き込み)とテーマコンポーネントはスキャフォールドに残ります。これらは後のリリースで `@ampless/admin` に移行する予定です。それまでは、これらのファイルへの変更はユーザーのプロジェクト内で行ってください。
83
+
84
+ ## ライセンス
85
+
86
+ MIT
package/README.md CHANGED
@@ -1,3 +1,6 @@
1
+ > 日本語版: [README.ja.md](./README.ja.md)
2
+ >
3
+
1
4
  # @ampless/runtime
2
5
 
3
6
  Public-side runtime for [ampless](https://github.com/heavymoons/ampless). Bundles the post-fetching client, site settings, theme resolution, SEO metadata aggregation, middleware, and public route handlers behind a single `createAmpless()` factory.
@@ -1,3 +1,4 @@
1
+ import { ReactNode } from 'react';
1
2
  import { Metadata } from 'next';
2
3
  import { Ampless } from '../index.js';
3
4
  import 'ampless';
@@ -7,16 +8,12 @@ interface Props$2 {
7
8
  siteId: string;
8
9
  }>;
9
10
  }
10
- type ThemeHomeDispatcher = (props: Props$2) => Promise<unknown>;
11
+ type ThemeHomeDispatcher = (props: Props$2) => Promise<ReactNode>;
11
12
  type ThemeHomeMetadata = (props: Props$2) => Promise<Metadata>;
12
13
  /**
13
14
  * Home page dispatcher. Resolves the active theme for the request's
14
15
  * siteId and renders the theme's `components.Home` server component
15
16
  * with the same `params` Promise it was passed.
16
- *
17
- * The return type is `unknown` (cast at the route boundary) because
18
- * Next.js page-component prop types vary by route shape and the
19
- * underlying theme components are arbitrary server components.
20
17
  */
21
18
  declare function createThemeHomeDispatcher(ampless: Ampless): ThemeHomeDispatcher;
22
19
  /** generateMetadata factory for the home dispatcher. */
@@ -28,12 +25,28 @@ interface Props$1 {
28
25
  slug: string;
29
26
  }>;
30
27
  }
31
- type ThemePostDispatcher = (props: Props$1) => Promise<unknown>;
28
+ type ThemePostDispatcher = (props: Props$1) => Promise<ReactNode>;
32
29
  type ThemePostMetadata = (props: Props$1) => Promise<Metadata>;
33
30
  /**
34
31
  * Post page dispatcher. Resolves the active theme and renders the
35
- * theme's `components.Post` server component. If the theme doesn't
36
- * declare a Post component, returns Next.js's notFound() (404).
32
+ * theme's `components.Post` server component.
33
+ *
34
+ * Before delegating, the dispatcher peeks at the post's metadata: if
35
+ * `metadata.no_layout === true`, the post is meant to be served as
36
+ * bare HTML (own DOCTYPE, no theme chrome, no Next.js root layout).
37
+ * Next.js page.tsx can't bypass the root layout, so we 308-redirect
38
+ * to the raw route handler at `/raw/<slug>` which returns the body
39
+ * unchanged. The redirect is permanent because no_layout is a
40
+ * persistent property of the post — bookmarks naturally settle on
41
+ * the `/raw/` URL.
42
+ *
43
+ * The metadata peek is an extra AppSync call before theme resolve,
44
+ * but it's the same query the theme's Post component would make
45
+ * anyway; AppSync's query-level dedupe within a single request keeps
46
+ * the wire cost flat in practice.
47
+ *
48
+ * If the theme doesn't declare a Post component at all, returns
49
+ * Next.js's notFound() (404).
37
50
  */
38
51
  declare function createThemePostDispatcher(ampless: Ampless): ThemePostDispatcher;
39
52
  /** generateMetadata factory for the post dispatcher. */
@@ -45,7 +58,7 @@ interface Props {
45
58
  tag: string;
46
59
  }>;
47
60
  }
48
- type ThemeTagDispatcher = (props: Props) => Promise<unknown>;
61
+ type ThemeTagDispatcher = (props: Props) => Promise<ReactNode>;
49
62
  type ThemeTagMetadata = (props: Props) => Promise<Metadata>;
50
63
  /**
51
64
  * Tag page dispatcher. Resolves the active theme and renders the
@@ -17,10 +17,19 @@ function createThemeHomeMetadata(ampless) {
17
17
  }
18
18
 
19
19
  // src/dispatchers/post.ts
20
- import { notFound } from "next/navigation";
20
+ import { notFound, redirect } from "next/navigation";
21
21
  function createThemePostDispatcher(ampless) {
22
22
  return async function SitePostDispatcher({ params }) {
23
- const { siteId } = await params;
23
+ const { siteId, slug } = await params;
24
+ const post = await ampless.getPublishedPost(slug, { siteId });
25
+ if (post?.metadata?.no_layout === true) {
26
+ redirect(`/raw/${slug}`);
27
+ }
28
+ if (post?.format === "static") {
29
+ const body = post.body ?? null;
30
+ const entrypoint = typeof body?.entrypoint === "string" && body.entrypoint ? body.entrypoint : "index.html";
31
+ redirect(`/${slug}/${entrypoint}`);
32
+ }
24
33
  const { module } = await ampless.resolveActiveTheme(siteId);
25
34
  const Post = module.components.Post;
26
35
  if (!Post) notFound();
package/dist/index.d.ts CHANGED
@@ -34,6 +34,7 @@ interface PublicPostShape {
34
34
  status?: string | null;
35
35
  publishedAt?: string | null;
36
36
  tags?: Array<string | null> | null;
37
+ metadata?: unknown;
37
38
  }
38
39
  interface PublicPostConnectionShape {
39
40
  items?: Array<PublicPostShape | null> | null;
@@ -108,6 +109,22 @@ interface ThemeActiveApi {
108
109
  resolveActiveTheme(siteId?: string): Promise<ResolvedTheme>;
109
110
  }
110
111
 
112
+ type ColorScheme = 'auto' | 'light' | 'dark';
113
+ declare const DEFAULT_COLOR_SCHEME: ColorScheme;
114
+ /**
115
+ * Storage key for the per-site color-scheme override. Lives under the
116
+ * same `theme.*` namespace as manifest fields so it flows through the
117
+ * existing KvStore → S3 cache pipeline, but is read separately from
118
+ * the manifest field set (it's a site-wide concern, not theme-specific).
119
+ */
120
+ declare const COLOR_SCHEME_SETTING_KEY = "theme.colorScheme";
121
+ /**
122
+ * Narrow an arbitrary stored value to a known `ColorScheme`. Anything
123
+ * unrecognised (including `undefined`, malformed strings, accidental
124
+ * objects) falls back to `'auto'` so a typo never strands a site
125
+ * forced into a mode it can't undo.
126
+ */
127
+ declare function validateColorScheme(raw: unknown): ColorScheme;
111
128
  interface EffectiveThemeConfig {
112
129
  /** Resolved active theme name (e.g. 'blog'). */
113
130
  activeTheme: string;
@@ -116,6 +133,16 @@ interface EffectiveThemeConfig {
116
133
  values: Record<string, string>;
117
134
  /** Subset of `values` for fields that have a `cssVar`. */
118
135
  cssVars: Record<string, string>;
136
+ /**
137
+ * Per-site color-scheme override. `'auto'` (default) lets the
138
+ * visitor's system `prefers-color-scheme` decide; `'light'` /
139
+ * `'dark'` pin one mode regardless of system preference.
140
+ *
141
+ * Consumed by the root layout to set `<html data-color-scheme>`,
142
+ * which the theme tokens.css files key off in combination with the
143
+ * `light-dark()` CSS function.
144
+ */
145
+ colorScheme: ColorScheme;
119
146
  }
120
147
  interface ThemeConfigApi {
121
148
  loadThemeConfig(siteId?: string): Promise<EffectiveThemeConfig>;
@@ -203,4 +230,4 @@ interface Ampless {
203
230
  */
204
231
  declare function createAmpless(opts: CreateAmplessOpts): Ampless;
205
232
 
206
- export { type Ampless, type AmplessOutputs, type CreateAmplessOpts, type DataOutput, type EffectiveSiteSettings, type EffectiveThemeConfig, type ListPostsByTagOptions, type ListPostsOptions, type ListPostsResult, type PostsApi, type PublicPostConnectionShape, type PublicPostShape, type ResolvedTheme, type SeoApi, type SiteSettingsApi, type StorageOutput, type ThemeActiveApi, type ThemeConfigApi, type ThemesRegistry, createAmpless, htmlToMarkdown, markdownToHtml, renderBody, renderThemeCss, tiptapToHtml, tiptapToMarkdown };
233
+ export { type Ampless, type AmplessOutputs, COLOR_SCHEME_SETTING_KEY, type ColorScheme, type CreateAmplessOpts, DEFAULT_COLOR_SCHEME, type DataOutput, type EffectiveSiteSettings, type EffectiveThemeConfig, type ListPostsByTagOptions, type ListPostsOptions, type ListPostsResult, type PostsApi, type PublicPostConnectionShape, type PublicPostShape, type ResolvedTheme, type SeoApi, type SiteSettingsApi, type StorageOutput, type ThemeActiveApi, type ThemeConfigApi, type ThemesRegistry, createAmpless, htmlToMarkdown, markdownToHtml, renderBody, renderThemeCss, tiptapToHtml, tiptapToMarkdown, validateColorScheme };
package/dist/index.js CHANGED
@@ -20,13 +20,11 @@ function createStorage(outputs) {
20
20
  // src/posts.ts
21
21
  import { cookies } from "next/headers";
22
22
  import { generateServerClientUsingCookies } from "@aws-amplify/adapter-nextjs/api";
23
- function decodeBody(value) {
24
- if (typeof value !== "string") return value;
25
- try {
26
- return JSON.parse(value);
27
- } catch {
28
- return value;
29
- }
23
+ import { decodeAwsJson } from "ampless";
24
+ function decodeMetadata(value) {
25
+ if (value === null || value === void 0) return void 0;
26
+ const parsed = decodeAwsJson(value);
27
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
30
28
  }
31
29
  function toCorePost(p) {
32
30
  return {
@@ -36,10 +34,11 @@ function toCorePost(p) {
36
34
  title: p.title,
37
35
  excerpt: p.excerpt ?? void 0,
38
36
  format: p.format ?? "markdown",
39
- body: decodeBody(p.body),
37
+ body: decodeAwsJson(p.body),
40
38
  status: p.status ?? "draft",
41
39
  publishedAt: p.publishedAt ?? void 0,
42
- tags: (p.tags ?? []).filter((t) => typeof t === "string")
40
+ tags: (p.tags ?? []).filter((t) => typeof t === "string"),
41
+ metadata: decodeMetadata(p.metadata)
43
42
  };
44
43
  }
45
44
  function createPostsApi(outputs) {
@@ -227,6 +226,12 @@ import {
227
226
  resolveThemeValues,
228
227
  themeSettingKey
229
228
  } from "ampless";
229
+ var DEFAULT_COLOR_SCHEME = "auto";
230
+ var COLOR_SCHEME_SETTING_KEY = "theme.colorScheme";
231
+ function validateColorScheme(raw) {
232
+ if (raw === "light" || raw === "dark" || raw === "auto") return raw;
233
+ return DEFAULT_COLOR_SCHEME;
234
+ }
230
235
  function createThemeConfig(themeActive, storage) {
231
236
  async function fetchRemote(siteId) {
232
237
  if (!storage.isStorageConfigured()) return null;
@@ -256,7 +261,8 @@ function createThemeConfig(themeActive, storage) {
256
261
  }
257
262
  const values = resolveThemeValues(manifest, stored);
258
263
  const cssVars = collectCssVars(manifest.fields, values);
259
- return { activeTheme: active.name, manifest, values, cssVars };
264
+ const colorScheme = validateColorScheme(flat?.[COLOR_SCHEME_SETTING_KEY]);
265
+ return { activeTheme: active.name, manifest, values, cssVars, colorScheme };
260
266
  }
261
267
  };
262
268
  }
@@ -520,11 +526,14 @@ function createAmpless(opts) {
520
526
  };
521
527
  }
522
528
  export {
529
+ COLOR_SCHEME_SETTING_KEY,
530
+ DEFAULT_COLOR_SCHEME,
523
531
  createAmpless,
524
532
  htmlToMarkdown,
525
533
  markdownToHtml,
526
534
  renderBody,
527
535
  renderThemeCss,
528
536
  tiptapToHtml,
529
- tiptapToMarkdown
537
+ tiptapToMarkdown,
538
+ validateColorScheme
530
539
  };
@@ -10,25 +10,37 @@ type MiddlewareFn = (request: NextRequest) => NextResponse;
10
10
  *
11
11
  * - hostname → siteId resolution (multi-site rewrites)
12
12
  * - `/path` → `/site/<siteId>/path` internal rewrite
13
- * - `<slug>.html` → `/site/<siteId>/raw/<slug>.html` (bare HTML route)
14
13
  * - `?previewTheme=<name>` → `x-preview-theme` header forwarding
15
14
  * - `Cache-Control: private, no-store` in multi-site mode (Amplify
16
15
  * Hosting's CloudFront cache key doesn't include Host, so SSR
17
16
  * responses would cross-contaminate at the same path)
18
17
  *
18
+ * No-layout / bare-HTML routing is data-driven: the theme post
19
+ * dispatcher reads `post.metadata.no_layout` and redirects to
20
+ * `/raw/<slug>` when set. The plain prefix-prepend below picks up
21
+ * that path because the raw route lives at
22
+ * `app/site/<siteId>/raw/<slug>/route.ts` — no special middleware
23
+ * branch is needed.
24
+ *
19
25
  * The factory captures the multi-site flag at construction time so the
20
26
  * hot path stays a pair of cheap header lookups.
21
27
  */
22
28
  declare function createAmplessMiddleware({ cmsConfig }: CreateMiddlewareOpts): MiddlewareFn;
23
29
  /**
24
- * Default Next.js middleware matcher config. Exposed as a constant so
25
- * templates can re-export it directly:
30
+ * Reference matcher config admin / api / login / static assets /
31
+ * amplify_outputs.json are excluded so middleware doesn't rewrite
32
+ * legitimate non-blog routes into the public site tree.
26
33
  *
27
- * export const config = defaultMatcherConfig
34
+ * **You can't re-export this directly.** Next.js 16's Turbopack
35
+ * requires `export const config` in `proxy.ts` (or `middleware.ts`)
36
+ * to be a statically analysable object literal — referencing an
37
+ * imported variable fails the build with:
38
+ * "Next.js can't recognize the exported `config` field in route.
39
+ * It needs to be a static object."
28
40
  *
29
- * Excludes admin / api / login / static assets / amplify_outputs.json
30
- * without these exclusions middleware would rewrite legitimate
31
- * non-blog routes into the public site tree.
41
+ * So the scaffold inlines the matcher into the user's `proxy.ts`.
42
+ * This export is kept as a reference for documentation and for
43
+ * non-Next.js callers that want to inspect the canonical matcher.
32
44
  */
33
45
  declare const defaultMatcherConfig: {
34
46
  matcher: string[];
@@ -1,7 +1,6 @@
1
1
  // src/middleware.ts
2
2
  import { NextResponse } from "next/server";
3
3
  import { resolveSiteId, isMultiSite } from "ampless";
4
- var RAW_HTML_PATH_RE = /^\/([^/]+\.html)$/;
5
4
  function createAmplessMiddleware({ cmsConfig }) {
6
5
  const MULTI_SITE = isMultiSite(cmsConfig);
7
6
  return function middleware(request) {
@@ -12,18 +11,15 @@ function createAmplessMiddleware({ cmsConfig }) {
12
11
  }
13
12
  const url = request.nextUrl.clone();
14
13
  if (!url.pathname.startsWith("/site/")) {
15
- const rawMatch = RAW_HTML_PATH_RE.exec(url.pathname);
16
- if (rawMatch) {
17
- url.pathname = `/site/${siteId}/raw/${rawMatch[1]}`;
18
- } else {
19
- const tail = url.pathname === "/" ? "" : url.pathname;
20
- url.pathname = `/site/${siteId}${tail}`;
21
- }
14
+ const tail = url.pathname === "/" ? "" : url.pathname;
15
+ url.pathname = `/site/${siteId}${tail}`;
22
16
  }
23
17
  const previewTheme = url.searchParams.get("previewTheme");
18
+ const previewColorScheme = url.searchParams.get("previewColorScheme");
24
19
  const requestHeaders = new Headers(request.headers);
25
20
  requestHeaders.set("x-site-id", siteId);
26
21
  if (previewTheme) requestHeaders.set("x-preview-theme", previewTheme);
22
+ if (previewColorScheme) requestHeaders.set("x-preview-color-scheme", previewColorScheme);
27
23
  const response = NextResponse.rewrite(url, { request: { headers: requestHeaders } });
28
24
  response.headers.set("x-site-id", siteId);
29
25
  if (MULTI_SITE) {
@@ -2,21 +2,21 @@ import { Ampless } from '../index.js';
2
2
  import 'ampless';
3
3
  import 'next';
4
4
 
5
- interface Ctx$3 {
5
+ interface Ctx$4 {
6
6
  params: Promise<{
7
7
  siteId: string;
8
8
  slug: string;
9
9
  }>;
10
10
  }
11
- type OgRouteHandler = (req: Request, ctx: Ctx$3) => Promise<Response>;
11
+ type OgRouteHandler = (req: Request, ctx: Ctx$4) => Promise<Response>;
12
12
  declare function createOgRouteHandler(ampless: Ampless): OgRouteHandler;
13
13
 
14
- interface Ctx$2 {
14
+ interface Ctx$3 {
15
15
  params: Promise<{
16
16
  siteId: string;
17
17
  }>;
18
18
  }
19
- type SitemapRouteHandler = (req: Request, ctx: Ctx$2) => Promise<Response>;
19
+ type SitemapRouteHandler = (req: Request, ctx: Ctx$3) => Promise<Response>;
20
20
  /**
21
21
  * Sitemap route delegate. Looks up the active theme for the request's
22
22
  * siteId and forwards to whichever `routes.sitemap` handler the theme
@@ -24,12 +24,12 @@ type SitemapRouteHandler = (req: Request, ctx: Ctx$2) => Promise<Response>;
24
24
  */
25
25
  declare function createSitemapRouteHandler(ampless: Ampless): SitemapRouteHandler;
26
26
 
27
- interface Ctx$1 {
27
+ interface Ctx$2 {
28
28
  params: Promise<{
29
29
  siteId: string;
30
30
  }>;
31
31
  }
32
- type FeedRouteHandler = (req: Request, ctx: Ctx$1) => Promise<Response>;
32
+ type FeedRouteHandler = (req: Request, ctx: Ctx$2) => Promise<Response>;
33
33
  /**
34
34
  * Feed route delegate. Looks up the active theme for the request's
35
35
  * siteId and forwards to whichever `routes.feed` handler the theme
@@ -37,21 +37,24 @@ type FeedRouteHandler = (req: Request, ctx: Ctx$1) => Promise<Response>;
37
37
  */
38
38
  declare function createFeedRouteHandler(ampless: Ampless): FeedRouteHandler;
39
39
 
40
- interface Ctx {
40
+ interface Ctx$1 {
41
41
  params: Promise<{
42
42
  siteId: string;
43
43
  slug: string;
44
44
  }>;
45
45
  }
46
- type RawRouteHandler = (req: Request, ctx: Ctx) => Promise<Response>;
46
+ type RawRouteHandler = (req: Request, ctx: Ctx$1) => Promise<Response>;
47
47
  /**
48
48
  * Bare HTML route. Returns the published post's rendered body as the
49
49
  * entire HTTP response — no Next.js root layout, no theme chrome.
50
50
  *
51
- * Reached via the slug-suffix convention: middleware rewrites
52
- * `/<slug>.html` `/site/<siteId>/raw/<slug>.html`. The post is
53
- * looked up by the full slug (including the `.html` part), so what
54
- * the admin types in the slug field IS the URL.
51
+ * Reached via the data-driven dispatcher path: the theme post page
52
+ * checks `post.metadata.no_layout` and redirects to `/raw/<slug>` when
53
+ * set. Posts without that flag never hit this route in practice; the
54
+ * handler additionally enforces the same check here so a direct
55
+ * `/raw/<slug>` request can't bypass the theme for a post that wasn't
56
+ * marked no-layout (would otherwise surface the body without any
57
+ * chrome the editor didn't intend).
55
58
  *
56
59
  * Why this is a route handler (not a page): Next.js's root layout
57
60
  * always emits `<html>` / `<head>` / `<body>`, which means a normal
@@ -74,4 +77,37 @@ type RawRouteHandler = (req: Request, ctx: Ctx) => Promise<Response>;
74
77
  */
75
78
  declare function createRawRouteHandler(ampless: Ampless): RawRouteHandler;
76
79
 
77
- export { type FeedRouteHandler, type OgRouteHandler, type RawRouteHandler, type SitemapRouteHandler, createFeedRouteHandler, createOgRouteHandler, createRawRouteHandler, createSitemapRouteHandler };
80
+ interface Ctx {
81
+ params: Promise<{
82
+ siteId: string;
83
+ path: string[];
84
+ }>;
85
+ }
86
+ type StaticRouteHandler = (req: Request, ctx: Ctx) => Promise<Response>;
87
+ /**
88
+ * Catch-all route for static bundles. Lives at
89
+ * `app/site/[siteId]/[...path]/route.ts` and serves every file
90
+ * inside a `format: 'static'` post's bundle. The first segment of
91
+ * `path` is the post slug; the rest is the relative path inside the
92
+ * bundle (or empty, in which case the manifest's entrypoint is used).
93
+ *
94
+ * Routing precedence: Next.js prefers the more specific
95
+ * `[siteId]/[slug]/page.tsx` for single-segment URLs, so this catch-all
96
+ * only fires when path.length >= 2. The post dispatcher detects
97
+ * `format === 'static'` and 308-redirects to `/<slug>/<entrypoint>`
98
+ * so the catch-all picks up from there.
99
+ *
100
+ * Single-segment requests (`/<siteId>/<slug>`) that somehow do reach
101
+ * here are 308-redirected to add a trailing slash, which is what makes
102
+ * relative paths in the bundle resolve correctly in the browser.
103
+ *
104
+ * Implementation: generate a short-lived presigned URL for the S3
105
+ * object and 302-redirect to it. Same model as `/api/media/[...path]`
106
+ * — keeps the bucket private while letting CloudFront cache the 302
107
+ * for the Cache-Control window. The presigned URL itself expires in
108
+ * an hour; the 302 cache is 5 minutes so re-issued presigns stay
109
+ * fresh.
110
+ */
111
+ declare function createStaticRouteHandler(ampless: Ampless): StaticRouteHandler;
112
+
113
+ export { type FeedRouteHandler, type OgRouteHandler, type RawRouteHandler, type SitemapRouteHandler, type StaticRouteHandler, createFeedRouteHandler, createOgRouteHandler, createRawRouteHandler, createSitemapRouteHandler, createStaticRouteHandler };
@@ -75,6 +75,9 @@ function createRawRouteHandler(ampless) {
75
75
  if (!post) {
76
76
  return new Response("Not Found", { status: 404 });
77
77
  }
78
+ if (post.metadata?.no_layout !== true) {
79
+ return new Response("Not Found", { status: 404 });
80
+ }
78
81
  return new Response(ampless.renderBody(post), {
79
82
  status: 200,
80
83
  headers: {
@@ -84,9 +87,78 @@ function createRawRouteHandler(ampless) {
84
87
  });
85
88
  };
86
89
  }
90
+
91
+ // src/routes/static.ts
92
+ import { createServerRunner } from "@aws-amplify/adapter-nextjs";
93
+ import { cookies } from "next/headers";
94
+ import { getUrl } from "aws-amplify/storage/server";
95
+ var FALLBACK_MIME = {
96
+ ".html": "text/html; charset=utf-8",
97
+ ".htm": "text/html; charset=utf-8"
98
+ };
99
+ function createStaticRouteHandler(ampless) {
100
+ const { runWithAmplifyServerContext } = createServerRunner({
101
+ config: ampless.outputs
102
+ });
103
+ return async function GET(request, { params }) {
104
+ const { siteId, path } = await params;
105
+ if (!path || path.length === 0) {
106
+ return new Response("Not Found", { status: 404 });
107
+ }
108
+ const slug = path[0];
109
+ const restSegments = path.slice(1);
110
+ if (restSegments.some(
111
+ (s) => !s || s === "." || s === ".." || s.includes("/") || s.includes("\\") || s.includes("\0")
112
+ )) {
113
+ return new Response("Invalid path", { status: 400 });
114
+ }
115
+ const post = await ampless.getPublishedPost(slug, { siteId });
116
+ if (!post || post.format !== "static") {
117
+ return new Response("Not Found", { status: 404 });
118
+ }
119
+ const body = post.body ?? null;
120
+ const entrypoint = typeof body?.entrypoint === "string" && body.entrypoint ? body.entrypoint : "index.html";
121
+ const fileList = Array.isArray(body?.files) ? body.files : [];
122
+ let rest = restSegments.join("/");
123
+ if (rest === "") {
124
+ const url = new URL(request.url);
125
+ if (!url.pathname.endsWith("/")) {
126
+ const next = new URL(url);
127
+ next.pathname = `${url.pathname}/`;
128
+ return Response.redirect(next.toString(), 308);
129
+ }
130
+ rest = entrypoint;
131
+ }
132
+ if (fileList.length > 0 && !fileList.includes(rest)) {
133
+ return new Response("Not Found", { status: 404 });
134
+ }
135
+ const objectPath = `public/static/${siteId}/${slug}/${rest}`;
136
+ try {
137
+ const presignedUrl = await runWithAmplifyServerContext({
138
+ nextServerContext: { cookies },
139
+ operation: async (amplifyContext) => {
140
+ const result = await getUrl(amplifyContext, {
141
+ path: objectPath,
142
+ options: { expiresIn: 60 * 60 }
143
+ });
144
+ return result.url.toString();
145
+ }
146
+ });
147
+ return Response.redirect(presignedUrl, 302);
148
+ } catch {
149
+ const ext = rest.slice(rest.lastIndexOf("."));
150
+ const fallback = FALLBACK_MIME[ext] ?? "text/plain; charset=utf-8";
151
+ return new Response("Not Found", {
152
+ status: 404,
153
+ headers: { "Content-Type": fallback }
154
+ });
155
+ }
156
+ };
157
+ }
87
158
  export {
88
159
  createFeedRouteHandler,
89
160
  createOgRouteHandler,
90
161
  createRawRouteHandler,
91
- createSitemapRouteHandler
162
+ createSitemapRouteHandler,
163
+ createStaticRouteHandler
92
164
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/runtime",
3
- "version": "0.2.0-alpha.0",
3
+ "version": "0.2.0-alpha.10",
4
4
  "description": "Public-side runtime for ampless: post fetching, theme dispatch, middleware, public route handlers",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -46,13 +46,13 @@
46
46
  "@radix-ui/react-slot": "^1.1.1",
47
47
  "class-variance-authority": "^0.7.1",
48
48
  "clsx": "^2.1.1",
49
- "lucide-react": "^0.469.0",
50
- "tailwind-merge": "^2.6.0",
51
- "ampless": "0.2.0-alpha.0",
52
- "@ampless/plugin-og-image": "0.2.0-alpha.0"
49
+ "lucide-react": "^1.16.0",
50
+ "tailwind-merge": "^3.6.0",
51
+ "ampless": "0.2.0-alpha.7",
52
+ "@ampless/plugin-og-image": "0.2.0-alpha.7"
53
53
  },
54
54
  "peerDependencies": {
55
- "next": "^15",
55
+ "next": "^15 || ^16",
56
56
  "react": "^18 || ^19",
57
57
  "react-dom": "^18 || ^19",
58
58
  "aws-amplify": "^6",
@@ -60,7 +60,7 @@
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/react": "^19.0.0",
63
- "next": "^15.1.0",
63
+ "next": "^16.2.6",
64
64
  "react": "^19.0.0"
65
65
  },
66
66
  "keywords": [