@ampless/runtime 0.2.0-alpha.8 → 1.0.0-alpha.13

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.
@@ -2,112 +2,86 @@ import { Ampless } from '../index.js';
2
2
  import 'ampless';
3
3
  import 'next';
4
4
 
5
- interface Ctx$4 {
5
+ interface Ctx$3 {
6
6
  params: Promise<{
7
7
  siteId: string;
8
8
  slug: string;
9
9
  }>;
10
10
  }
11
- type OgRouteHandler = (req: Request, ctx: Ctx$4) => Promise<Response>;
11
+ type OgRouteHandler = (req: Request, ctx: Ctx$3) => Promise<Response>;
12
12
  declare function createOgRouteHandler(ampless: Ampless): OgRouteHandler;
13
13
 
14
- interface Ctx$3 {
14
+ interface Ctx$2 {
15
15
  params: Promise<{
16
16
  siteId: string;
17
17
  }>;
18
18
  }
19
- type SitemapRouteHandler = (req: Request, ctx: Ctx$3) => Promise<Response>;
19
+ type SitemapRouteHandler = (req: Request, ctx: Ctx$2) => Promise<Response>;
20
20
  /**
21
- * Sitemap route delegate. Looks up the active theme for the request's
22
- * siteId and forwards to whichever `routes.sitemap` handler the theme
23
- * provides. Themes without a sitemap handler return 404.
21
+ * Sitemap route delegate. Looks up the active theme and forwards to
22
+ * whichever `routes.sitemap` handler the theme provides. Themes
23
+ * without a sitemap handler return 404.
24
24
  */
25
25
  declare function createSitemapRouteHandler(ampless: Ampless): SitemapRouteHandler;
26
26
 
27
- interface Ctx$2 {
27
+ interface Ctx$1 {
28
28
  params: Promise<{
29
29
  siteId: string;
30
30
  }>;
31
31
  }
32
- type FeedRouteHandler = (req: Request, ctx: Ctx$2) => Promise<Response>;
32
+ type FeedRouteHandler = (req: Request, ctx: Ctx$1) => Promise<Response>;
33
33
  /**
34
- * Feed route delegate. Looks up the active theme for the request's
35
- * siteId and forwards to whichever `routes.feed` handler the theme
36
- * provides. Themes without a feed handler return 404.
34
+ * Feed route delegate. Looks up the active theme and forwards to
35
+ * whichever `routes.feed` handler the theme provides. Themes without
36
+ * a feed handler return 404.
37
37
  */
38
38
  declare function createFeedRouteHandler(ampless: Ampless): FeedRouteHandler;
39
39
 
40
- interface Ctx$1 {
40
+ interface Ctx {
41
41
  params: Promise<{
42
- siteId: string;
43
42
  slug: string;
43
+ path?: string[];
44
44
  }>;
45
45
  }
46
- type RawRouteHandler = (req: Request, ctx: Ctx$1) => Promise<Response>;
46
+ type UnderscoreRouteHandler = (req: Request, ctx: Ctx) => Promise<Response>;
47
47
  /**
48
- * Bare HTML route. Returns the published post's rendered body as the
49
- * entire HTTP response — no Next.js root layout, no theme chrome.
50
- *
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).
48
+ * Unified `/_/<slug>(/...)` route handler.
58
49
  *
59
- * Why this is a route handler (not a page): Next.js's root layout
60
- * always emits `<html>` / `<head>` / `<body>`, which means a normal
61
- * page can't replace the document. A `route.ts` returns a Response
62
- * directly and bypasses React rendering entirely.
50
+ * The public URL prefix `/_/` is reserved for two related cases that
51
+ * both need to bypass the theme's post page:
63
52
  *
64
- * Format pairing:
65
- * - `format: 'html'` with a full `<!DOCTYPE html>...</html>` body
66
- * the body lands in the response unchanged. Best fit for
67
- * custom landing pages with their own `<head>`, styles, scripts.
68
- * - `format: 'tiptap'` / `'markdown'` renderBody returns an
69
- * HTML fragment. Browsers render fragments fine, but the page
70
- * ships without `<!DOCTYPE>` / `<head>` / `<title>`. If you care
71
- * about those, use `format: 'html'`.
72
- *
73
- * Trust model: the response body is the editor's content verbatim,
74
- * with no sanitization. Same trust assumption as `format: 'html'`
75
- * post bodies on the regular path — see
76
- * docs/architecture/04-access-layer-mcp.md §"editor の信頼モデル".
77
- */
78
- declare function createRawRouteHandler(ampless: Ampless): RawRouteHandler;
79
-
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).
53
+ * 1. `format: 'html'` posts with `metadata.no_layout === true` — the
54
+ * body is its own complete HTML document and ships as the entire
55
+ * response. URL: `/_/<slug>` (no trailing slash, no extra path).
56
+ * 2. `format: 'static'` posts the body is a manifest describing a
57
+ * bundle of files in S3 at `public/static/<slug>/`. The bundle's
58
+ * entrypoint is served at `/_/<slug>/`, every internal file at
59
+ * `/_/<slug>/<relative-path>`.
93
60
  *
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.
61
+ * Routing model:
62
+ * - File location: `app/site/[siteId]/r/[slug]/[[...path]]/route.ts`.
63
+ * The literal folder is `r/` (not `_/`) because Next.js's App
64
+ * Router skips any path part starting with `_` during route
65
+ * discovery (see `recursive-readdir` + `ignorePartFilter` in
66
+ * next/dist/build/route-discovery.js). Middleware rewrites the
67
+ * public `/_/` prefix to `/r/` internally; the public URL stays
68
+ * `/_/<slug>(/...)`.
69
+ * - `params.path` is `undefined` (or `[]`) for single-segment
70
+ * requests `/_/<slug>`, an array of remaining segments otherwise.
99
71
  *
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.
72
+ * Trailing-slash responsibility lives here, not in the dispatcher:
73
+ * the dispatcher redirects `format='static'` posts to `/_/<slug>`,
74
+ * and this handler then 308-redirects to `/_/<slug>/` to anchor
75
+ * relative paths inside the bundle. Same anchoring reason as the
76
+ * legacy static route's behaviour — `<img src="img.png">` must resolve
77
+ * to `/_/<slug>/img.png`, not the site root.
103
78
  *
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.
79
+ * Trust model: no_layout HTML bodies are emitted verbatim, same trust
80
+ * shape as `format: 'html'` post bodies on the regular path. Static
81
+ * bundle assets are served via short-lived S3 presigned URLs; the
82
+ * bucket stays private. See docs/architecture/04-access-layer-mcp.md
83
+ * §"editor の信頼モデル".
110
84
  */
111
- declare function createStaticRouteHandler(ampless: Ampless): StaticRouteHandler;
85
+ declare function createUnderscoreRouteHandler(ampless: Ampless): UnderscoreRouteHandler;
112
86
 
113
- export { type FeedRouteHandler, type OgRouteHandler, type RawRouteHandler, type SitemapRouteHandler, type StaticRouteHandler, createFeedRouteHandler, createOgRouteHandler, createRawRouteHandler, createSitemapRouteHandler, createStaticRouteHandler };
87
+ export { type FeedRouteHandler, type OgRouteHandler, type SitemapRouteHandler, type UnderscoreRouteHandler, createFeedRouteHandler, createOgRouteHandler, createSitemapRouteHandler, createUnderscoreRouteHandler };
@@ -6,13 +6,13 @@ function hasOgImage(p) {
6
6
  }
7
7
  function createOgRouteHandler(ampless) {
8
8
  return async function GET(_req, { params }) {
9
- const { siteId, slug } = await params;
9
+ const { slug } = await params;
10
10
  const cleanSlug = slug.replace(/\.png$/, "");
11
- const post = await ampless.getPublishedPost(cleanSlug, { siteId });
11
+ const post = await ampless.getPublishedPost(cleanSlug);
12
12
  if (!post) return new Response("not found", { status: 404 });
13
13
  const plugin = (ampless.cmsConfig.plugins ?? []).find(hasOgImage);
14
14
  if (!plugin) return new Response("og not configured", { status: 404 });
15
- const settings = await ampless.loadSiteSettings(siteId);
15
+ const settings = await ampless.loadSiteSettings();
16
16
  const fonts = await Promise.all(
17
17
  plugin.ogImage.fonts.map(async (f) => ({
18
18
  name: f.name,
@@ -43,122 +43,127 @@ function createOgRouteHandler(ampless) {
43
43
 
44
44
  // src/routes/sitemap.ts
45
45
  function createSitemapRouteHandler(ampless) {
46
- return async function GET(request, { params }) {
47
- const { siteId } = await params;
48
- const { module } = await ampless.resolveActiveTheme(siteId);
46
+ return async function GET(request) {
47
+ const { module } = await ampless.resolveActiveTheme();
49
48
  const handler = module.routes?.sitemap;
50
49
  if (!handler) {
51
50
  return new Response("sitemap not implemented for this theme", { status: 404 });
52
51
  }
53
- return handler({ siteId, request });
52
+ return handler({ request });
54
53
  };
55
54
  }
56
55
 
57
56
  // src/routes/feed.ts
58
57
  function createFeedRouteHandler(ampless) {
59
- return async function GET(request, { params }) {
60
- const { siteId } = await params;
61
- const { module } = await ampless.resolveActiveTheme(siteId);
58
+ return async function GET(request) {
59
+ const { module } = await ampless.resolveActiveTheme();
62
60
  const handler = module.routes?.feed;
63
61
  if (!handler) {
64
62
  return new Response("feed not implemented for this theme", { status: 404 });
65
63
  }
66
- return handler({ siteId, request });
64
+ return handler({ request });
67
65
  };
68
66
  }
69
67
 
70
- // src/routes/raw.ts
71
- function createRawRouteHandler(ampless) {
72
- return async function GET(_request, { params }) {
73
- const { siteId, slug } = await params;
74
- const post = await ampless.getPublishedPost(slug, { siteId });
75
- if (!post) {
76
- return new Response("Not Found", { status: 404 });
77
- }
78
- if (post.metadata?.no_layout !== true) {
79
- return new Response("Not Found", { status: 404 });
80
- }
81
- return new Response(ampless.renderBody(post), {
82
- status: 200,
83
- headers: {
84
- "Content-Type": "text/html; charset=utf-8",
85
- "Cache-Control": "public, max-age=300"
86
- }
87
- });
88
- };
89
- }
90
-
91
- // src/routes/static.ts
68
+ // src/routes/underscore.ts
92
69
  import { createServerRunner } from "@aws-amplify/adapter-nextjs";
93
70
  import { cookies } from "next/headers";
94
71
  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) {
72
+ function createUnderscoreRouteHandler(ampless) {
100
73
  const { runWithAmplifyServerContext } = createServerRunner({
101
74
  config: ampless.outputs
102
75
  });
103
76
  return async function GET(request, { params }) {
104
- const { siteId, path } = await params;
105
- if (!path || path.length === 0) {
77
+ const { slug, path } = await params;
78
+ const restSegments = path ?? [];
79
+ const post = await ampless.getPublishedPost(slug);
80
+ if (!post) {
81
+ return new Response("Not Found", { status: 404 });
82
+ }
83
+ if (restSegments.length === 0) {
84
+ if (post.format === "html" && post.metadata?.no_layout === true) {
85
+ return new Response(ampless.renderBody(post), {
86
+ status: 200,
87
+ headers: {
88
+ "Content-Type": "text/html; charset=utf-8",
89
+ "Cache-Control": "public, max-age=300"
90
+ }
91
+ });
92
+ }
93
+ if (post.format === "static") {
94
+ const url = new URL(request.url);
95
+ if (!url.pathname.endsWith("/")) {
96
+ const next = new URL(url);
97
+ next.pathname = `${url.pathname}/`;
98
+ return Response.redirect(next.toString(), 308);
99
+ }
100
+ const body2 = post.body ?? null;
101
+ const entrypoint = typeof body2?.entrypoint === "string" && body2.entrypoint ? body2.entrypoint : "index.html";
102
+ const presignedUrl2 = await signStaticAsset({
103
+ runWithAmplifyServerContext,
104
+ slug,
105
+ rest: entrypoint
106
+ });
107
+ if (!presignedUrl2) {
108
+ return new Response("Not Found", { status: 404 });
109
+ }
110
+ return Response.redirect(presignedUrl2, 302);
111
+ }
112
+ return new Response("Not Found", { status: 404 });
113
+ }
114
+ if (post.format !== "static") {
106
115
  return new Response("Not Found", { status: 404 });
107
116
  }
108
- const slug = path[0];
109
- const restSegments = path.slice(1);
110
117
  if (restSegments.some(
111
118
  (s) => !s || s === "." || s === ".." || s.includes("/") || s.includes("\\") || s.includes("\0")
112
119
  )) {
113
120
  return new Response("Invalid path", { status: 400 });
114
121
  }
115
- const post = await ampless.getPublishedPost(slug, { siteId });
116
- if (!post || post.format !== "static") {
117
- return new Response("Not Found", { status: 404 });
118
- }
119
122
  const body = post.body ?? null;
120
- const entrypoint = typeof body?.entrypoint === "string" && body.entrypoint ? body.entrypoint : "index.html";
121
123
  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
- }
124
+ const rest = restSegments.join("/");
132
125
  if (fileList.length > 0 && !fileList.includes(rest)) {
133
126
  return new Response("Not Found", { status: 404 });
134
127
  }
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
- });
128
+ const presignedUrl = await signStaticAsset({
129
+ runWithAmplifyServerContext,
130
+ slug,
131
+ rest
132
+ });
133
+ if (!presignedUrl) {
134
+ return new Response("Not Found", { status: 404 });
155
135
  }
136
+ return Response.redirect(presignedUrl, 302);
156
137
  };
157
138
  }
139
+ async function signStaticAsset({
140
+ runWithAmplifyServerContext,
141
+ slug,
142
+ rest
143
+ }) {
144
+ const objectPath = `public/static/${slug}/${rest}`;
145
+ try {
146
+ return await runWithAmplifyServerContext({
147
+ nextServerContext: { cookies },
148
+ operation: async (amplifyContext) => {
149
+ const result = await getUrl(amplifyContext, {
150
+ path: objectPath,
151
+ options: { expiresIn: 60 * 60 }
152
+ });
153
+ return result.url.toString();
154
+ }
155
+ });
156
+ } catch (err) {
157
+ console.error(
158
+ `[underscore-route] presign failed for ${objectPath}:`,
159
+ err
160
+ );
161
+ return null;
162
+ }
163
+ }
158
164
  export {
159
165
  createFeedRouteHandler,
160
166
  createOgRouteHandler,
161
- createRawRouteHandler,
162
167
  createSitemapRouteHandler,
163
- createStaticRouteHandler
168
+ createUnderscoreRouteHandler
164
169
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/runtime",
3
- "version": "0.2.0-alpha.8",
3
+ "version": "1.0.0-alpha.13",
4
4
  "description": "Public-side runtime for ampless: post fetching, theme dispatch, middleware, public route handlers",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -47,9 +47,10 @@
47
47
  "class-variance-authority": "^0.7.1",
48
48
  "clsx": "^2.1.1",
49
49
  "lucide-react": "^1.16.0",
50
+ "marked": "^14.1.4",
50
51
  "tailwind-merge": "^3.6.0",
51
- "ampless": "0.2.0-alpha.5",
52
- "@ampless/plugin-og-image": "0.2.0-alpha.5"
52
+ "ampless": "1.0.0-alpha.9",
53
+ "@ampless/plugin-og-image": "0.2.0-alpha.9"
53
54
  },
54
55
  "peerDependencies": {
55
56
  "next": "^15 || ^16",