@ampless/runtime 1.0.0-beta.68 → 1.0.0-beta.70

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 CHANGED
@@ -92,6 +92,18 @@ middleware はリクエストごとに AppSync から `post.format` /
92
92
  `app/raw/[slug]/route.ts` で配信
93
93
  - `format: 'static'` → `/static/<slug>(/<path>)`、
94
94
  `app/static/[slug]/[[...path]]/route.ts` で配信
95
+ - `/<slug>.md`(フォーマット不問) → `/md/<slug>`、
96
+ `app/md/[slug]/route.ts` で配信 — `ampless.postToMarkdown()` による
97
+ Markdown 投影。`cms.config.ai.markdownRoutes: false` で無効化できます。
98
+
99
+ `/llms.txt` は書き換えを経由せず、`app/llms.txt/route.ts` に直接マウ
100
+ ントされます。直近の公開投稿(デフォルト 100 件、
101
+ `cms.config.ai.llmsTxt.limit` で設定可能、1..1000 にクランプ)を
102
+ `.md` リンクの一覧として返し、サイト全体を 1 ファイルで把握したい AI
103
+ エージェント / クローラー向けの索引になります。
104
+ `cms.config.ai.llmsTxt: false` で無効化できます。どちらの設定でも
105
+ `llms.txt` は予約 slug になります — slug が `llms.txt` の投稿はテーマ
106
+ ページに到達できなくなります。
95
107
 
96
108
  また、`post.metadata.cache`(auto / deep / hot)+ `post.updatedAt` +
97
109
  `cms.config.cache.{cooldownMs, freshTtlSeconds, deepTtlSeconds}` から
@@ -102,7 +114,7 @@ middleware はリクエストごとに AppSync から `post.format` /
102
114
 
103
115
  - `@ampless/runtime` — `createAmpless`、ランタイム型、`renderBody`・`renderThemeCss`・フォーマットコンバーターの再エクスポート
104
116
  - `@ampless/runtime/middleware` — `createAmplessMiddleware`、`defaultMatcherConfig`
105
- - `@ampless/runtime/routes` — `createOgRouteHandler`、`createSitemapRouteHandler`、`createFeedRouteHandler`、`createRawRouteHandler`、`createStaticRouteHandler`
117
+ - `@ampless/runtime/routes` — `createOgRouteHandler`、`createSitemapRouteHandler`、`createFeedRouteHandler`、`createRawRouteHandler`、`createStaticRouteHandler`、`createMarkdownRouteHandler`、`createLlmsTxtRouteHandler`
106
118
  - `@ampless/runtime/dispatchers` — `createThemeHomeDispatcher`、`createThemePostDispatcher`、`createThemeTagDispatcher`(それぞれ対応する `*Metadata` ファクトリーあり)
107
119
 
108
120
  ## テンプレートに残るもの
package/README.md CHANGED
@@ -92,6 +92,17 @@ rewrites the request to the right internal handler:
92
92
  `app/raw/[slug]/route.ts`
93
93
  - `format: 'static'` → `/static/<slug>(/<path>)`, served by
94
94
  `app/static/[slug]/[[...path]]/route.ts`
95
+ - `/<slug>.md` (any format) → `/md/<slug>`, served by
96
+ `app/md/[slug]/route.ts` — Markdown projection via
97
+ `ampless.postToMarkdown()`. Disable with `cms.config.ai.markdownRoutes: false`.
98
+
99
+ `/llms.txt` isn't rewritten — it's mounted directly at
100
+ `app/llms.txt/route.ts` and lists the site's most recent published
101
+ posts (default 100, configurable via `cms.config.ai.llmsTxt.limit`,
102
+ clamped to 1..1000) as `.md` links, for AI agents / crawlers that want
103
+ a single-file index. Disable with `cms.config.ai.llmsTxt: false`. Note
104
+ that `llms.txt` becomes a reserved slug either way — a post whose slug
105
+ happens to be `llms.txt` can no longer reach the themed route.
95
106
 
96
107
  It also computes `Cache-Control` from `post.metadata.cache` (auto /
97
108
  deep / hot) + `post.updatedAt` + `cms.config.cache.{cooldownMs,
@@ -102,7 +113,7 @@ See `docs/CONTENT.md` for the cache strategy contract.
102
113
 
103
114
  - `@ampless/runtime` — `createAmpless`, runtime types, and re-exports of `renderBody`, `renderThemeCss`, format converters
104
115
  - `@ampless/runtime/middleware` — `createAmplessMiddleware`, `defaultMatcherConfig`
105
- - `@ampless/runtime/routes` — `createOgRouteHandler`, `createSitemapRouteHandler`, `createFeedRouteHandler`, `createRawRouteHandler`, `createStaticRouteHandler`
116
+ - `@ampless/runtime/routes` — `createOgRouteHandler`, `createSitemapRouteHandler`, `createFeedRouteHandler`, `createRawRouteHandler`, `createStaticRouteHandler`, `createMarkdownRouteHandler`, `createLlmsTxtRouteHandler`
106
117
  - `@ampless/runtime/dispatchers` — `createThemeHomeDispatcher`, `createThemePostDispatcher`, `createThemeTagDispatcher` (each with a matching `*Metadata` factory)
107
118
 
108
119
  ## What's still in the template
@@ -32,6 +32,8 @@ declare function computeCacheControl(flags: {
32
32
  * - Rewrite to `/static/<slug>(/<path>)` when the post is a static
33
33
  * bundle (`format='static'`). Themed posts (default) get no
34
34
  * rewrite — `app/[slug]/page.tsx` serves them directly.
35
+ * - Rewrite `/<slug>.md` → `/md/<slug>` (any format) unless
36
+ * `cms.config.ai.markdownRoutes` is explicitly `false`.
35
37
  * - `Cache-Control` header computed from `post.metadata.cache` +
36
38
  * `post.updatedAt` + `cms.config.cache.*` and set on the response.
37
39
  * - `?previewTheme` / `?previewColorScheme` → `x-preview-theme` /
@@ -120,6 +120,13 @@ function computeCacheControl(flags, cmsConfig) {
120
120
  }
121
121
  return `public, max-age=${freshTtl}, s-maxage=${freshTtl}`;
122
122
  }
123
+ function safeDecodeURIComponent(s) {
124
+ try {
125
+ return decodeURIComponent(s);
126
+ } catch {
127
+ return s;
128
+ }
129
+ }
123
130
  var RESERVED_PREFIXES = /* @__PURE__ */ new Set([
124
131
  "admin",
125
132
  "api",
@@ -131,11 +138,13 @@ var RESERVED_PREFIXES = /* @__PURE__ */ new Set([
131
138
  // route handler folders that live alongside `app/[slug]/page.tsx`
132
139
  "feed.xml",
133
140
  "sitemap.xml",
141
+ "llms.txt",
134
142
  "og",
135
143
  "tag",
136
144
  // internal rewrite targets — direct hits should not be middleware-driven
137
145
  "raw",
138
- "static"
146
+ "static",
147
+ "md"
139
148
  ]);
140
149
  function createAmplessMiddleware(opts) {
141
150
  return async function middleware(request) {
@@ -160,9 +169,12 @@ function createAmplessMiddleware(opts) {
160
169
  return passthrough();
161
170
  }
162
171
  const restPath = segments.slice(1);
163
- const flags = await getCachedFlags(opts, slug);
172
+ const isMdRequest = restPath.length === 0 && slug.endsWith(".md") && slug.length > 3 && opts.cmsConfig.ai?.markdownRoutes !== false;
173
+ const lookupSlug = isMdRequest ? slug.slice(0, -3) : slug;
174
+ const flagsSlug = isMdRequest ? safeDecodeURIComponent(lookupSlug) : lookupSlug;
175
+ const flags = await getCachedFlags(opts, flagsSlug);
164
176
  if (!flags) {
165
- if (restPath.length > 0) {
177
+ if (restPath.length > 0 || isMdRequest) {
166
178
  return new NextResponse("Not Found", { status: 404 });
167
179
  }
168
180
  return passthrough();
@@ -170,7 +182,12 @@ function createAmplessMiddleware(opts) {
170
182
  const cacheControl = computeCacheControl(flags, opts.cmsConfig);
171
183
  let response;
172
184
  if (restPath.length === 0) {
173
- if (flags.format === "html" && flags.metadata?.no_layout === true) {
185
+ if (isMdRequest) {
186
+ url.pathname = `/md/${lookupSlug}`;
187
+ response = NextResponse.rewrite(url, {
188
+ request: { headers: requestHeaders }
189
+ });
190
+ } else if (flags.format === "html" && flags.metadata?.no_layout === true) {
174
191
  url.pathname = `/raw/${slug}`;
175
192
  response = NextResponse.rewrite(url, {
176
193
  request: { headers: requestHeaders }
@@ -5,18 +5,18 @@ import 'react';
5
5
  import 'aws-amplify/storage/server';
6
6
  import 'next/headers';
7
7
 
8
- interface Ctx$4 {
8
+ interface Ctx$6 {
9
9
  params: Promise<{
10
10
  slug: string;
11
11
  }>;
12
12
  }
13
- type OgRouteHandler = (req: Request, ctx: Ctx$4) => Promise<Response>;
13
+ type OgRouteHandler = (req: Request, ctx: Ctx$6) => Promise<Response>;
14
14
  declare function createOgRouteHandler(ampless: Ampless): OgRouteHandler;
15
15
 
16
- interface Ctx$3 {
16
+ interface Ctx$5 {
17
17
  params: Promise<Record<string, never>>;
18
18
  }
19
- type SitemapRouteHandler = (req: Request, ctx: Ctx$3) => Promise<Response>;
19
+ type SitemapRouteHandler = (req: Request, ctx: Ctx$5) => Promise<Response>;
20
20
  /**
21
21
  * Sitemap route delegate. Looks up the active theme and forwards to
22
22
  * whichever `routes.sitemap` handler the theme provides. Themes
@@ -24,10 +24,10 @@ type SitemapRouteHandler = (req: Request, ctx: Ctx$3) => Promise<Response>;
24
24
  */
25
25
  declare function createSitemapRouteHandler(ampless: Ampless): SitemapRouteHandler;
26
26
 
27
- interface Ctx$2 {
27
+ interface Ctx$4 {
28
28
  params: Promise<Record<string, never>>;
29
29
  }
30
- type FeedRouteHandler = (req: Request, ctx: Ctx$2) => Promise<Response>;
30
+ type FeedRouteHandler = (req: Request, ctx: Ctx$4) => Promise<Response>;
31
31
  /**
32
32
  * Feed route delegate. Looks up the active theme and forwards to
33
33
  * whichever `routes.feed` handler the theme provides. Themes without
@@ -35,12 +35,12 @@ type FeedRouteHandler = (req: Request, ctx: Ctx$2) => Promise<Response>;
35
35
  */
36
36
  declare function createFeedRouteHandler(ampless: Ampless): FeedRouteHandler;
37
37
 
38
- interface Ctx$1 {
38
+ interface Ctx$3 {
39
39
  params: Promise<{
40
40
  slug: string;
41
41
  }>;
42
42
  }
43
- type RawRouteHandler = (req: Request, ctx: Ctx$1) => Promise<Response>;
43
+ type RawRouteHandler = (req: Request, ctx: Ctx$3) => Promise<Response>;
44
44
  /**
45
45
  * Internal route handler for `format: 'html'` posts whose
46
46
  * `metadata.no_layout === true` — the body is its own complete HTML
@@ -63,13 +63,13 @@ type RawRouteHandler = (req: Request, ctx: Ctx$1) => Promise<Response>;
63
63
  */
64
64
  declare function createRawRouteHandler(ampless: Ampless): RawRouteHandler;
65
65
 
66
- interface Ctx {
66
+ interface Ctx$2 {
67
67
  params: Promise<{
68
68
  slug: string;
69
69
  path?: string[];
70
70
  }>;
71
71
  }
72
- type StaticRouteHandler = (req: Request, ctx: Ctx) => Promise<Response>;
72
+ type StaticRouteHandler = (req: Request, ctx: Ctx$2) => Promise<Response>;
73
73
  /**
74
74
  * Internal route handler for `format: 'static'` posts — the body is a
75
75
  * manifest describing a bundle of files in S3 at
@@ -97,4 +97,38 @@ type StaticRouteHandler = (req: Request, ctx: Ctx) => Promise<Response>;
97
97
  */
98
98
  declare function createStaticRouteHandler(ampless: Ampless): StaticRouteHandler;
99
99
 
100
- export { type FeedRouteHandler, type OgRouteHandler, type RawRouteHandler, type SitemapRouteHandler, type StaticRouteHandler, createFeedRouteHandler, createOgRouteHandler, createRawRouteHandler, createSitemapRouteHandler, createStaticRouteHandler };
100
+ interface Ctx$1 {
101
+ params: Promise<{
102
+ slug: string;
103
+ }>;
104
+ }
105
+ type MarkdownRouteHandler = (req: Request, ctx: Ctx$1) => Promise<Response>;
106
+ /**
107
+ * Route handler serving the per-post Markdown projection
108
+ * (`ampless.postToMarkdown()`) for published posts of any format.
109
+ *
110
+ * Mounted at `app/md/[slug]/route.ts`; reached via middleware rewrite
111
+ * of `/<slug>.md` → `/md/<slug>` internally — the public/canonical URL
112
+ * is `/<slug>.md`. Middleware adds `md` to its reserved-prefix list so
113
+ * a user post with slug `md` passes through to a 404 rather than
114
+ * reaching this handler with the wrong content.
115
+ *
116
+ * Cache-Control: deliberately omitted from the response. Middleware
117
+ * computes the strategy from `post.metadata.cache` + `post.updatedAt`
118
+ * and sets the header on the rewritten response.
119
+ */
120
+ declare function createMarkdownRouteHandler(ampless: Ampless): MarkdownRouteHandler;
121
+
122
+ interface Ctx {
123
+ params: Promise<Record<string, never>>;
124
+ }
125
+ type LlmsTxtRouteHandler = (req: Request, ctx: Ctx) => Promise<Response>;
126
+ /**
127
+ * `ai.llmsTxt: false` disables the route (404). Note that `llms.txt`
128
+ * is always a reserved slug (`middleware.ts` `RESERVED_PREFIXES`)
129
+ * regardless of that flag — a post whose slug happens to be
130
+ * `llms.txt` can never reach the themed route.
131
+ */
132
+ declare function createLlmsTxtRouteHandler(ampless: Ampless): LlmsTxtRouteHandler;
133
+
134
+ export { type FeedRouteHandler, type LlmsTxtRouteHandler, type MarkdownRouteHandler, type OgRouteHandler, type RawRouteHandler, type SitemapRouteHandler, type StaticRouteHandler, createFeedRouteHandler, createLlmsTxtRouteHandler, createMarkdownRouteHandler, createOgRouteHandler, createRawRouteHandler, createSitemapRouteHandler, createStaticRouteHandler };
@@ -184,8 +184,177 @@ async function signStaticAsset({
184
184
  return null;
185
185
  }
186
186
  }
187
+
188
+ // src/routes/md.ts
189
+ function createMarkdownRouteHandler(ampless) {
190
+ return async function GET(_request, { params }) {
191
+ const { slug } = await params;
192
+ if (ampless.cmsConfig.ai?.markdownRoutes === false) {
193
+ return new Response("Not Found", { status: 404 });
194
+ }
195
+ const post = await ampless.getPublishedPost(slug);
196
+ if (!post) {
197
+ return new Response("Not Found", { status: 404 });
198
+ }
199
+ return new Response(await ampless.postToMarkdown(post), {
200
+ status: 200,
201
+ headers: {
202
+ "Content-Type": "text/markdown; charset=utf-8"
203
+ // Cache-Control set by middleware.
204
+ }
205
+ });
206
+ };
207
+ }
208
+
209
+ // src/routes/llms.ts
210
+ var DEFAULT_LIMIT = 100;
211
+ var MIN_LIMIT = 1;
212
+ var MAX_LIMIT = 1e3;
213
+ var PAGE_SIZE_CAP = 50;
214
+ var MAX_PAGES = 21;
215
+ var EXCERPT_MAX = 200;
216
+ function clampLimit(raw) {
217
+ const n = raw ?? DEFAULT_LIMIT;
218
+ if (!Number.isFinite(n)) return DEFAULT_LIMIT;
219
+ return Math.min(MAX_LIMIT, Math.max(MIN_LIMIT, Math.floor(n)));
220
+ }
221
+ function resolveLimit(ampless) {
222
+ const cfg = ampless.cmsConfig.ai?.llmsTxt;
223
+ if (cfg === false) return false;
224
+ const raw = typeof cfg === "object" && cfg !== null ? cfg.limit : void 0;
225
+ return clampLimit(raw);
226
+ }
227
+ var CONTROL_CHARS_RE = /[\x00-\x1f\x7f]/g;
228
+ function normalizeText(s) {
229
+ return s.replace(CONTROL_CHARS_RE, " ").replace(/\s+/g, " ").trim();
230
+ }
231
+ function escapeLinkText(s) {
232
+ return s.replace(/\[/g, "&#91;").replace(/\]/g, "&#93;");
233
+ }
234
+ function truncateExcerpt(s) {
235
+ return s.length > EXCERPT_MAX ? s.slice(0, EXCERPT_MAX) + "\u2026" : s;
236
+ }
237
+ function fixedEncodeURIComponent(s) {
238
+ return encodeURIComponent(s).replace(
239
+ /[!'()*]/g,
240
+ (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase()
241
+ );
242
+ }
243
+ async function collectPosts(ampless, limit) {
244
+ const items = [];
245
+ const seenTokens = /* @__PURE__ */ new Set();
246
+ let token;
247
+ let truncated = null;
248
+ for (let page = 0; page < MAX_PAGES; page++) {
249
+ const remaining = limit + 1 - items.length;
250
+ const pageLimit = Math.min(PAGE_SIZE_CAP, remaining);
251
+ const res = await ampless.listPublishedPosts({ limit: pageLimit, nextToken: token });
252
+ items.push(...res.items);
253
+ if (items.length > limit) {
254
+ truncated = "limit";
255
+ break;
256
+ }
257
+ if (!res.nextToken) {
258
+ break;
259
+ }
260
+ if (seenTokens.has(res.nextToken)) {
261
+ truncated = "early";
262
+ break;
263
+ }
264
+ seenTokens.add(res.nextToken);
265
+ token = res.nextToken;
266
+ if (page === MAX_PAGES - 1) {
267
+ truncated = "early";
268
+ }
269
+ }
270
+ return {
271
+ items: truncated === "limit" ? items.slice(0, limit) : items,
272
+ truncated
273
+ };
274
+ }
275
+ function truncationNote(truncated, limit) {
276
+ if (truncated === "limit") {
277
+ return `Note: only the ${limit} most recent posts are listed; older posts are omitted.`;
278
+ }
279
+ if (truncated === "early") {
280
+ return "Note: this index was truncated early while scanning the post list; some older posts may be missing. Lower ai.llmsTxt.limit or reduce post sizes.";
281
+ }
282
+ return null;
283
+ }
284
+ function createLlmsTxtRouteHandler(ampless) {
285
+ return async function GET(request) {
286
+ const limit = resolveLimit(ampless);
287
+ if (limit === false) {
288
+ return new Response("Not Found", { status: 404 });
289
+ }
290
+ const url = new URL(request.url);
291
+ if (url.search) {
292
+ return new Response(null, {
293
+ status: 308,
294
+ headers: {
295
+ Location: url.pathname,
296
+ "Cache-Control": "public, max-age=3600"
297
+ }
298
+ });
299
+ }
300
+ const [{ items, truncated }, settings] = await Promise.all([
301
+ collectPosts(ampless, limit),
302
+ ampless.loadSiteSettings()
303
+ ]);
304
+ const markdownRoutesEnabled = ampless.cmsConfig.ai?.markdownRoutes !== false;
305
+ const siteUrl = (settings.site.url || "").replace(/\/+$/, "");
306
+ const buildUrl = (path) => siteUrl ? `${siteUrl}${path}` : path;
307
+ const blocks = [`# ${normalizeText(settings.site.name)}`];
308
+ if (settings.site.description) {
309
+ blocks.push(`> ${normalizeText(settings.site.description)}`);
310
+ }
311
+ if (markdownRoutesEnabled) {
312
+ blocks.push(
313
+ "Each entry links to the markdown projection of the post; drop the `.md` suffix for the themed HTML page."
314
+ );
315
+ }
316
+ const note = truncationNote(truncated, limit);
317
+ if (note) blocks.push(note);
318
+ if (items.length > 0) {
319
+ const lines = items.map((post) => {
320
+ const encodedSlug = fixedEncodeURIComponent(post.slug);
321
+ const href = markdownRoutesEnabled ? buildUrl(`/${encodedSlug}.md`) : buildUrl(`/${encodedSlug}`);
322
+ const title = escapeLinkText(normalizeText(post.title));
323
+ const excerpt = post.excerpt ? truncateExcerpt(normalizeText(post.excerpt)) : "";
324
+ const tags = (post.tags ?? []).map((t) => normalizeText(t)).filter(Boolean);
325
+ let line = `- [${title}](${href})`;
326
+ if (excerpt && tags.length > 0) {
327
+ line += `: ${excerpt} (tags: ${tags.join(", ")})`;
328
+ } else if (excerpt) {
329
+ line += `: ${excerpt}`;
330
+ } else if (tags.length > 0) {
331
+ line += `: (tags: ${tags.join(", ")})`;
332
+ }
333
+ return line;
334
+ });
335
+ blocks.push(`## Posts
336
+
337
+ ${lines.join("\n")}`);
338
+ }
339
+ const body = blocks.join("\n\n") + "\n";
340
+ return new Response(body, {
341
+ status: 200,
342
+ headers: {
343
+ "Content-Type": "text/plain; charset=utf-8",
344
+ // Self-computed: this route isn't reached via a middleware
345
+ // rewrite, so nothing else would set Cache-Control (see the
346
+ // header comment / og.ts for the same pattern). CDN-cacheable
347
+ // for an hour so repeated crawler hits don't each cost a fresh
348
+ // paginated AppSync walk.
349
+ "Cache-Control": "public, max-age=300, s-maxage=3600, stale-while-revalidate=3600"
350
+ }
351
+ });
352
+ };
353
+ }
187
354
  export {
188
355
  createFeedRouteHandler,
356
+ createLlmsTxtRouteHandler,
357
+ createMarkdownRouteHandler,
189
358
  createOgRouteHandler,
190
359
  createRawRouteHandler,
191
360
  createSitemapRouteHandler,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/runtime",
3
- "version": "1.0.0-beta.68",
3
+ "version": "1.0.0-beta.70",
4
4
  "description": "Public-side runtime for ampless: post fetching, theme dispatch, middleware, public route handlers",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -51,8 +51,8 @@
51
51
  "marked": "^18.0.4",
52
52
  "sanitize-html": "^2.17.4",
53
53
  "tailwind-merge": "^3.6.0",
54
- "@ampless/plugin-og-image": "0.2.0-beta.55",
55
- "ampless": "1.0.0-beta.55"
54
+ "@ampless/plugin-og-image": "0.2.0-beta.57",
55
+ "ampless": "1.0.0-beta.57"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "@aws-amplify/adapter-nextjs": "^1",