@ampless/runtime 0.2.0-alpha.3 → 0.2.0-alpha.4

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.
@@ -29,8 +29,24 @@ type ThemePostDispatcher = (props: Props$1) => Promise<ReactNode>;
29
29
  type ThemePostMetadata = (props: Props$1) => Promise<Metadata>;
30
30
  /**
31
31
  * Post page dispatcher. Resolves the active theme and renders the
32
- * theme's `components.Post` server component. If the theme doesn't
33
- * 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).
34
50
  */
35
51
  declare function createThemePostDispatcher(ampless: Ampless): ThemePostDispatcher;
36
52
  /** generateMetadata factory for the post dispatcher. */
@@ -17,10 +17,14 @@ 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
+ }
24
28
  const { module } = await ampless.resolveActiveTheme(siteId);
25
29
  const Post = module.components.Post;
26
30
  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;
package/dist/index.js CHANGED
@@ -28,6 +28,18 @@ function decodeBody(value) {
28
28
  return value;
29
29
  }
30
30
  }
31
+ function decodeMetadata(value) {
32
+ if (value === null || value === void 0) return void 0;
33
+ const parsed = typeof value === "string" ? safeJsonParse(value) : value;
34
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
35
+ }
36
+ function safeJsonParse(value) {
37
+ try {
38
+ return JSON.parse(value);
39
+ } catch {
40
+ return value;
41
+ }
42
+ }
31
43
  function toCorePost(p) {
32
44
  return {
33
45
  postId: p.postId,
@@ -39,7 +51,8 @@ function toCorePost(p) {
39
51
  body: decodeBody(p.body),
40
52
  status: p.status ?? "draft",
41
53
  publishedAt: p.publishedAt ?? void 0,
42
- tags: (p.tags ?? []).filter((t) => typeof t === "string")
54
+ tags: (p.tags ?? []).filter((t) => typeof t === "string"),
55
+ metadata: decodeMetadata(p.metadata)
43
56
  };
44
57
  }
45
58
  function createPostsApi(outputs) {
@@ -10,12 +10,18 @@ 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
  */
@@ -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,13 +11,8 @@ 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");
24
18
  const requestHeaders = new Headers(request.headers);
@@ -48,10 +48,13 @@ type RawRouteHandler = (req: Request, ctx: Ctx) => Promise<Response>;
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
@@ -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: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/runtime",
3
- "version": "0.2.0-alpha.3",
3
+ "version": "0.2.0-alpha.4",
4
4
  "description": "Public-side runtime for ampless: post fetching, theme dispatch, middleware, public route handlers",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -48,8 +48,8 @@
48
48
  "clsx": "^2.1.1",
49
49
  "lucide-react": "^1.16.0",
50
50
  "tailwind-merge": "^3.6.0",
51
- "ampless": "0.2.0-alpha.1",
52
- "@ampless/plugin-og-image": "0.2.0-alpha.1"
51
+ "ampless": "0.2.0-alpha.2",
52
+ "@ampless/plugin-og-image": "0.2.0-alpha.2"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "next": "^15 || ^16",