@ampless/runtime 0.2.0-alpha.9 → 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.
package/README.ja.md CHANGED
@@ -74,7 +74,7 @@ export const config = defaultMatcherConfig
74
74
 
75
75
  - `@ampless/runtime` — `createAmpless`、ランタイム型、`renderBody`・`renderThemeCss`・フォーマットコンバーターの再エクスポート
76
76
  - `@ampless/runtime/middleware` — `createAmplessMiddleware`、`defaultMatcherConfig`
77
- - `@ampless/runtime/routes` — `createOgRouteHandler`、`createSitemapRouteHandler`、`createFeedRouteHandler`、`createRawRouteHandler`
77
+ - `@ampless/runtime/routes` — `createOgRouteHandler`、`createSitemapRouteHandler`、`createFeedRouteHandler`、`createUnderscoreRouteHandler`
78
78
  - `@ampless/runtime/dispatchers` — `createThemeHomeDispatcher`、`createThemePostDispatcher`、`createThemeTagDispatcher`(それぞれ対応する `*Metadata` ファクトリーあり)
79
79
 
80
80
  ## テンプレートに残るもの
package/README.md CHANGED
@@ -74,7 +74,7 @@ export const config = defaultMatcherConfig
74
74
 
75
75
  - `@ampless/runtime` — `createAmpless`, runtime types, and re-exports of `renderBody`, `renderThemeCss`, format converters
76
76
  - `@ampless/runtime/middleware` — `createAmplessMiddleware`, `defaultMatcherConfig`
77
- - `@ampless/runtime/routes` — `createOgRouteHandler`, `createSitemapRouteHandler`, `createFeedRouteHandler`, `createRawRouteHandler`
77
+ - `@ampless/runtime/routes` — `createOgRouteHandler`, `createSitemapRouteHandler`, `createFeedRouteHandler`, `createUnderscoreRouteHandler`
78
78
  - `@ampless/runtime/dispatchers` — `createThemeHomeDispatcher`, `createThemePostDispatcher`, `createThemeTagDispatcher` (each with a matching `*Metadata` factory)
79
79
 
80
80
  ## What's still in the template
@@ -11,9 +11,9 @@ interface Props$2 {
11
11
  type ThemeHomeDispatcher = (props: Props$2) => Promise<ReactNode>;
12
12
  type ThemeHomeMetadata = (props: Props$2) => Promise<Metadata>;
13
13
  /**
14
- * Home page dispatcher. Resolves the active theme for the request's
15
- * siteId and renders the theme's `components.Home` server component
16
- * with the same `params` Promise it was passed.
14
+ * Home page dispatcher. Resolves the active theme and renders the
15
+ * theme's `components.Home` server component with the same `params`
16
+ * Promise it was passed.
17
17
  */
18
18
  declare function createThemeHomeDispatcher(ampless: Ampless): ThemeHomeDispatcher;
19
19
  /** generateMetadata factory for the home dispatcher. */
@@ -31,14 +31,24 @@ type ThemePostMetadata = (props: Props$1) => Promise<Metadata>;
31
31
  * Post page dispatcher. Resolves the active theme and renders the
32
32
  * theme's `components.Post` server component.
33
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.
34
+ * Before delegating, the dispatcher peeks at the post's data and
35
+ * 308-redirects to `/_/<slug>` for both formats that need to bypass
36
+ * the theme's post page:
37
+ *
38
+ * - `metadata.no_layout === true` the body is its own complete
39
+ * HTML document and ships as the entire response. The unified
40
+ * route handler at `/_/<slug>` emits the body verbatim.
41
+ * - `format === 'static'` — the body is a manifest pointing at a
42
+ * bundle of files in S3. The unified route handler then 308s
43
+ * again to `/_/<slug>/` (trailing slash) so relative paths inside
44
+ * the bundle resolve correctly.
45
+ *
46
+ * Why the redirect lands on `/_/<slug>` rather than `/_/<slug>/` for
47
+ * static posts: trailing-slash anchoring is the unified route
48
+ * handler's responsibility — keeps the dispatcher format-agnostic
49
+ * and the public URL pattern symmetric (no_layout and static both
50
+ * settle on `/_/<slug>(/...)`). One extra 308 round-trip is cheap;
51
+ * it deduplicates the trailing-slash branch into a single place.
42
52
  *
43
53
  * The metadata peek is an extra AppSync call before theme resolve,
44
54
  * but it's the same query the theme's Post component would make
@@ -1,16 +1,14 @@
1
1
  // src/dispatchers/home.ts
2
2
  function createThemeHomeDispatcher(ampless) {
3
3
  return async function SiteHomeDispatcher({ params }) {
4
- const { siteId } = await params;
5
- const { module } = await ampless.resolveActiveTheme(siteId);
4
+ const { module } = await ampless.resolveActiveTheme();
6
5
  const Home = module.components.Home;
7
6
  return await Home({ params });
8
7
  };
9
8
  }
10
9
  function createThemeHomeMetadata(ampless) {
11
10
  return async function generateMetadata({ params }) {
12
- const { siteId } = await params;
13
- const { module } = await ampless.resolveActiveTheme(siteId);
11
+ const { module } = await ampless.resolveActiveTheme();
14
12
  const fn = module.metadata?.Home;
15
13
  return fn ? await fn({ params }) : {};
16
14
  };
@@ -20,17 +18,12 @@ function createThemeHomeMetadata(ampless) {
20
18
  import { notFound, redirect } from "next/navigation";
21
19
  function createThemePostDispatcher(ampless) {
22
20
  return async function SitePostDispatcher({ 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}`);
21
+ const { slug } = await params;
22
+ const post = await ampless.getPublishedPost(slug);
23
+ if (post?.metadata?.no_layout === true || post?.format === "static") {
24
+ redirect(`/_/${slug}`);
27
25
  }
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
- }
33
- const { module } = await ampless.resolveActiveTheme(siteId);
26
+ const { module } = await ampless.resolveActiveTheme();
34
27
  const Post = module.components.Post;
35
28
  if (!Post) notFound();
36
29
  return await Post({ params });
@@ -38,8 +31,7 @@ function createThemePostDispatcher(ampless) {
38
31
  }
39
32
  function createThemePostMetadata(ampless) {
40
33
  return async function generateMetadata({ params }) {
41
- const { siteId } = await params;
42
- const { module } = await ampless.resolveActiveTheme(siteId);
34
+ const { module } = await ampless.resolveActiveTheme();
43
35
  const fn = module.metadata?.Post;
44
36
  return fn ? await fn({ params }) : {};
45
37
  };
@@ -49,8 +41,7 @@ function createThemePostMetadata(ampless) {
49
41
  import { notFound as notFound2 } from "next/navigation";
50
42
  function createThemeTagDispatcher(ampless) {
51
43
  return async function SiteTagDispatcher({ params }) {
52
- const { siteId } = await params;
53
- const { module } = await ampless.resolveActiveTheme(siteId);
44
+ const { module } = await ampless.resolveActiveTheme();
54
45
  const Tag = module.components.Tag;
55
46
  if (!Tag) notFound2();
56
47
  return await Tag({ params });
@@ -58,8 +49,7 @@ function createThemeTagDispatcher(ampless) {
58
49
  }
59
50
  function createThemeTagMetadata(ampless) {
60
51
  return async function generateMetadata({ params }) {
61
- const { siteId } = await params;
62
- const { module } = await ampless.resolveActiveTheme(siteId);
52
+ const { module } = await ampless.resolveActiveTheme();
63
53
  const fn = module.metadata?.Tag;
64
54
  return fn ? await fn({ params }) : {};
65
55
  };
package/dist/index.d.ts CHANGED
@@ -25,7 +25,6 @@ interface StorageApi {
25
25
 
26
26
  interface PublicPostShape {
27
27
  postId: string;
28
- siteId: string;
29
28
  slug: string;
30
29
  title: string;
31
30
  excerpt?: string | null;
@@ -41,7 +40,6 @@ interface PublicPostConnectionShape {
41
40
  nextToken?: string | null;
42
41
  }
43
42
  interface ListPostsOptions {
44
- siteId?: string;
45
43
  /** ISO 8601 timestamp; SK lower bound (inclusive). */
46
44
  from?: string;
47
45
  /** ISO 8601 timestamp; SK upper bound (inclusive). */
@@ -51,7 +49,6 @@ interface ListPostsOptions {
51
49
  nextToken?: string;
52
50
  }
53
51
  interface ListPostsByTagOptions {
54
- siteId?: string;
55
52
  limit?: number;
56
53
  nextToken?: string;
57
54
  }
@@ -61,9 +58,7 @@ interface ListPostsResult {
61
58
  }
62
59
  interface PostsApi {
63
60
  listPublishedPosts(opts?: ListPostsOptions): Promise<ListPostsResult>;
64
- getPublishedPost(slug: string, opts?: {
65
- siteId?: string;
66
- }): Promise<Post | null>;
61
+ getPublishedPost(slug: string): Promise<Post | null>;
67
62
  listPostsByTag(tag: string, opts?: ListPostsByTagOptions): Promise<ListPostsResult>;
68
63
  }
69
64
 
@@ -87,18 +82,18 @@ interface EffectiveSiteSettings {
87
82
  timezone?: string;
88
83
  }
89
84
  interface SiteSettingsApi {
90
- loadSiteSettings(siteId?: string): Promise<EffectiveSiteSettings>;
85
+ loadSiteSettings(): Promise<EffectiveSiteSettings>;
91
86
  }
92
87
 
93
88
  interface SeoApi {
94
- postMetadata(post: Post, siteId?: string): Promise<Metadata>;
95
- siteMetadata(siteId?: string): Promise<Metadata>;
89
+ postMetadata(post: Post): Promise<Metadata>;
90
+ siteMetadata(): Promise<Metadata>;
96
91
  }
97
92
 
98
93
  interface ThemesRegistry {
99
94
  /** Map of theme name → loaded theme module. */
100
95
  themes: Record<string, ThemeModule>;
101
- /** Name used when no `theme.active` override is stored for the site. */
96
+ /** Name used when no `theme.active` override is stored. */
102
97
  defaultTheme: string;
103
98
  }
104
99
  interface ResolvedTheme {
@@ -106,7 +101,7 @@ interface ResolvedTheme {
106
101
  module: ThemeModule;
107
102
  }
108
103
  interface ThemeActiveApi {
109
- resolveActiveTheme(siteId?: string): Promise<ResolvedTheme>;
104
+ resolveActiveTheme(): Promise<ResolvedTheme>;
110
105
  }
111
106
 
112
107
  type ColorScheme = 'auto' | 'light' | 'dark';
@@ -145,7 +140,7 @@ interface EffectiveThemeConfig {
145
140
  colorScheme: ColorScheme;
146
141
  }
147
142
  interface ThemeConfigApi {
148
- loadThemeConfig(siteId?: string): Promise<EffectiveThemeConfig>;
143
+ loadThemeConfig(): Promise<EffectiveThemeConfig>;
149
144
  }
150
145
  /**
151
146
  * Render `cssVars` as the body of a `:root { ... }` CSS block. Values
@@ -159,32 +154,42 @@ declare function renderBody(post: Post): string;
159
154
  * Convert a tiptap doc to its HTML form. Same renderer the public
160
155
  * site uses. Defensive: tiptap accepts an HTML string as initial
161
156
  * content and parses it on mount, but won't fire onUpdate until the
162
- * user edits so a format-switch chain (e.g. markdown tiptap
157
+ * user edits, so a format-switch chain (e.g. markdown -> tiptap ->
163
158
  * markdown without editing) can still hand us a raw HTML string
164
159
  * here. In that case, return it as-is rather than walking it as a
165
160
  * malformed tiptap node and producing empty output.
166
161
  */
167
162
  declare function tiptapToHtml(doc: unknown): string;
168
- /** Convert markdown to HTML using the built-in minimal renderer. */
163
+ /** Convert markdown to HTML using marked + GFM. */
169
164
  declare function markdownToHtml(md: string): string;
170
165
  /**
171
166
  * Walk a tiptap doc and emit Markdown. Mirrors `renderTiptap` in
172
167
  * shape but produces markdown syntax. Loses anything markdown can't
173
168
  * express (data attributes, image display modes, custom marks).
174
169
  *
170
+ * Notes on info loss:
171
+ * - underline / highlight are not in GFM, so they fall back to the
172
+ * literal `<u>` / `<mark>` HTML tags (preserved as-is across round trips).
173
+ * - paragraph / heading textAlign cannot be expressed in markdown and
174
+ * is therefore lost on conversion.
175
+ *
175
176
  * Same defensive path as tiptapToHtml: a string input means tiptap
176
177
  * hasn't emitted JSON yet (the body is still the HTML we handed it).
177
178
  * Route through htmlToMarkdown so the content survives.
178
179
  */
179
180
  declare function tiptapToMarkdown(doc: unknown): string;
180
181
  /**
181
- * Regex-based HTML Markdown converter. Handles the tag set the
182
+ * Regex-based HTML -> Markdown converter. Handles the tag set the
182
183
  * editor produces (`<p>` `<h1>`-`<h6>` `<strong>` `<em>` `<a>`
183
184
  * `<img>` `<ul>` `<ol>` `<li>` `<code>` `<pre>` `<blockquote>` `<hr>`
184
- * `<br>`). Anything else (tables, sections, divs) keeps its content
185
- * but loses structural meaning.
185
+ * `<br>` `<u>` `<mark>` `<table>` task-list `<ul data-type="taskList">`).
186
+ * Decorative containers like `<div style="text-align:...">` are dropped.
187
+ *
188
+ * Tables are reduced to GFM pipe syntax via convertHtmlTable. Complex
189
+ * nested content inside cells (lists, other tables) is flattened to
190
+ * plain text.
186
191
  *
187
- * Not a full library there are known limits like nested formatting
192
+ * Not a full library, there are known limits like nested formatting
188
193
  * inside list items potentially merging. Acceptable for a v0.x
189
194
  * format-switch convenience; complex HTML round-trips shouldn't be
190
195
  * relied on.
@@ -198,15 +203,13 @@ interface CreateAmplessOpts {
198
203
  }
199
204
  interface Ampless {
200
205
  listPublishedPosts(opts?: ListPostsOptions): Promise<ListPostsResult>;
201
- getPublishedPost(slug: string, opts?: {
202
- siteId?: string;
203
- }): Promise<Post | null>;
206
+ getPublishedPost(slug: string): Promise<Post | null>;
204
207
  listPostsByTag(tag: string, opts?: ListPostsByTagOptions): Promise<ListPostsResult>;
205
- loadSiteSettings(siteId?: string): Promise<EffectiveSiteSettings>;
206
- resolveActiveTheme(siteId?: string): Promise<ResolvedTheme>;
207
- loadThemeConfig(siteId?: string): Promise<EffectiveThemeConfig>;
208
- postMetadata(post: Post, siteId?: string): Promise<Metadata>;
209
- siteMetadata(siteId?: string): Promise<Metadata>;
208
+ loadSiteSettings(): Promise<EffectiveSiteSettings>;
209
+ resolveActiveTheme(): Promise<ResolvedTheme>;
210
+ loadThemeConfig(): Promise<EffectiveThemeConfig>;
211
+ postMetadata(post: Post): Promise<Metadata>;
212
+ siteMetadata(): Promise<Metadata>;
210
213
  renderBody(post: Post): string;
211
214
  renderThemeCss(cssVars: Record<string, string>): string;
212
215
  publicAssetUrl(key: string): string;
package/dist/index.js CHANGED
@@ -20,35 +20,20 @@ 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
- }
30
- }
23
+ import { decodeAwsJson } from "ampless";
31
24
  function decodeMetadata(value) {
32
25
  if (value === null || value === void 0) return void 0;
33
- const parsed = typeof value === "string" ? safeJsonParse(value) : value;
26
+ const parsed = decodeAwsJson(value);
34
27
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
35
28
  }
36
- function safeJsonParse(value) {
37
- try {
38
- return JSON.parse(value);
39
- } catch {
40
- return value;
41
- }
42
- }
43
29
  function toCorePost(p) {
44
30
  return {
45
31
  postId: p.postId,
46
- siteId: p.siteId,
47
32
  slug: p.slug,
48
33
  title: p.title,
49
34
  excerpt: p.excerpt ?? void 0,
50
35
  format: p.format ?? "markdown",
51
- body: decodeBody(p.body),
36
+ body: decodeAwsJson(p.body),
52
37
  status: p.status ?? "draft",
53
38
  publishedAt: p.publishedAt ?? void 0,
54
39
  tags: (p.tags ?? []).filter((t) => typeof t === "string"),
@@ -66,7 +51,6 @@ function createPostsApi(outputs) {
66
51
  return {
67
52
  async listPublishedPosts(opts = {}) {
68
53
  const { data, errors } = await client.queries.listPublishedPosts({
69
- siteId: opts.siteId ?? "default",
70
54
  from: opts.from,
71
55
  to: opts.to,
72
56
  limit: opts.limit ?? 20,
@@ -76,9 +60,8 @@ function createPostsApi(outputs) {
76
60
  const items = (data?.items ?? []).filter((p) => p !== null).map(toCorePost);
77
61
  return { items, nextToken: data?.nextToken ?? null };
78
62
  },
79
- async getPublishedPost(slug, opts = {}) {
63
+ async getPublishedPost(slug) {
80
64
  const { data, errors } = await client.queries.getPublishedPost({
81
- siteId: opts.siteId ?? "default",
82
65
  slug
83
66
  });
84
67
  if (errors) throw new Error(errors[0]?.message ?? "Failed to get post");
@@ -86,7 +69,6 @@ function createPostsApi(outputs) {
86
69
  },
87
70
  async listPostsByTag(tag, opts = {}) {
88
71
  const { data, errors } = await client.queries.listPostsByTag({
89
- siteId: opts.siteId ?? "default",
90
72
  tag,
91
73
  limit: opts.limit ?? 20,
92
74
  nextToken: opts.nextToken
@@ -99,25 +81,27 @@ function createPostsApi(outputs) {
99
81
  }
100
82
 
101
83
  // src/site-settings.ts
102
- import { DEFAULT_SITE_ID, siteFor, unflattenSettings } from "ampless";
84
+ import { unflattenSettings } from "ampless";
103
85
  function createSiteSettings(cmsConfig, storage) {
104
- async function fetchRemote(siteId) {
86
+ async function fetchRemote() {
105
87
  if (!storage.isStorageConfigured()) return null;
106
88
  let url;
107
89
  try {
108
- url = storage.publicAssetUrl(`public/site-settings/${siteId}.json`);
90
+ url = storage.publicAssetUrl("public/site-settings.json");
109
91
  } catch {
110
92
  return null;
111
93
  }
112
- const res = await fetch(url, { next: { revalidate: 60, tags: [`site-settings:${siteId}`] } });
94
+ const res = await fetch(url, {
95
+ next: { revalidate: 60, tags: ["site-settings"] }
96
+ });
113
97
  if (!res.ok) return null;
114
98
  const flat = await res.json();
115
99
  return unflattenSettings(flat);
116
100
  }
117
101
  return {
118
- async loadSiteSettings(siteId = DEFAULT_SITE_ID) {
119
- const remote = await fetchRemote(siteId).catch(() => null);
120
- const baseSite = siteFor(siteId, cmsConfig);
102
+ async loadSiteSettings() {
103
+ const remote = await fetchRemote().catch(() => null);
104
+ const baseSite = cmsConfig.site;
121
105
  return {
122
106
  site: {
123
107
  name: remote?.site?.name ?? baseSite.name,
@@ -142,15 +126,14 @@ function createSiteSettings(cmsConfig, storage) {
142
126
  }
143
127
 
144
128
  // src/seo.ts
145
- import { DEFAULT_SITE_ID as DEFAULT_SITE_ID2 } from "ampless";
146
129
  function isPlugin(p) {
147
130
  return typeof p === "object" && p !== null && "apiVersion" in p;
148
131
  }
149
132
  function createSeo(cmsConfig, settingsApi) {
150
133
  const plugins = (cmsConfig.plugins ?? []).filter(isPlugin);
151
134
  return {
152
- async postMetadata(post, siteId = DEFAULT_SITE_ID2) {
153
- const settings = await settingsApi.loadSiteSettings(siteId);
135
+ async postMetadata(post) {
136
+ const settings = await settingsApi.loadSiteSettings();
154
137
  const accum = {};
155
138
  for (const plugin of plugins) {
156
139
  if (!plugin.metadata) continue;
@@ -167,8 +150,8 @@ function createSeo(cmsConfig, settingsApi) {
167
150
  }
168
151
  return accum;
169
152
  },
170
- async siteMetadata(siteId = DEFAULT_SITE_ID2) {
171
- const settings = await settingsApi.loadSiteSettings(siteId);
153
+ async siteMetadata() {
154
+ const settings = await settingsApi.loadSiteSettings();
172
155
  const accum = {
173
156
  title: settings.site.name,
174
157
  description: settings.site.description
@@ -193,24 +176,25 @@ function createSeo(cmsConfig, settingsApi) {
193
176
 
194
177
  // src/theme-active.ts
195
178
  import { headers } from "next/headers";
196
- import { DEFAULT_SITE_ID as DEFAULT_SITE_ID3 } from "ampless";
197
179
  function createThemeActive(registry, storage) {
198
- async function fetchActiveFromCache(siteId) {
180
+ async function fetchActiveFromCache() {
199
181
  if (!storage.isStorageConfigured()) return null;
200
182
  let url;
201
183
  try {
202
- url = storage.publicAssetUrl(`public/site-settings/${siteId}.json`);
184
+ url = storage.publicAssetUrl("public/site-settings.json");
203
185
  } catch {
204
186
  return null;
205
187
  }
206
- const res = await fetch(url, { next: { revalidate: 60, tags: [`site-settings:${siteId}`] } });
188
+ const res = await fetch(url, {
189
+ next: { revalidate: 60, tags: ["site-settings"] }
190
+ });
207
191
  if (!res.ok) return null;
208
192
  const flat = await res.json();
209
193
  const v = flat["theme.active"];
210
194
  return typeof v === "string" ? v : null;
211
195
  }
212
196
  return {
213
- async resolveActiveTheme(siteId = DEFAULT_SITE_ID3) {
197
+ async resolveActiveTheme() {
214
198
  let previewOverride = null;
215
199
  try {
216
200
  const h = await headers();
@@ -221,7 +205,7 @@ function createThemeActive(registry, storage) {
221
205
  const mod2 = registry.themes[previewOverride];
222
206
  if (mod2) return { name: previewOverride, module: mod2 };
223
207
  }
224
- const stored = await fetchActiveFromCache(siteId).catch(() => null);
208
+ const stored = await fetchActiveFromCache().catch(() => null);
225
209
  const name = stored && stored in registry.themes ? stored : registry.defaultTheme;
226
210
  const mod = registry.themes[name] ?? registry.themes[registry.defaultTheme];
227
211
  if (!mod) {
@@ -236,7 +220,6 @@ function createThemeActive(registry, storage) {
236
220
 
237
221
  // src/theme-config.ts
238
222
  import {
239
- DEFAULT_SITE_ID as DEFAULT_SITE_ID4,
240
223
  resolveThemeValues,
241
224
  themeSettingKey
242
225
  } from "ampless";
@@ -247,23 +230,23 @@ function validateColorScheme(raw) {
247
230
  return DEFAULT_COLOR_SCHEME;
248
231
  }
249
232
  function createThemeConfig(themeActive, storage) {
250
- async function fetchRemote(siteId) {
233
+ async function fetchRemote() {
251
234
  if (!storage.isStorageConfigured()) return null;
252
235
  let url;
253
236
  try {
254
- url = storage.publicAssetUrl(`public/site-settings/${siteId}.json`);
237
+ url = storage.publicAssetUrl("public/site-settings.json");
255
238
  } catch {
256
239
  return null;
257
240
  }
258
- const res = await fetch(url, { next: { revalidate: 60, tags: [`site-settings:${siteId}`] } });
241
+ const res = await fetch(url, { next: { revalidate: 60, tags: ["site-settings"] } });
259
242
  if (!res.ok) return null;
260
243
  return await res.json();
261
244
  }
262
245
  return {
263
- async loadThemeConfig(siteId = DEFAULT_SITE_ID4) {
246
+ async loadThemeConfig() {
264
247
  const [active, flat] = await Promise.all([
265
- themeActive.resolveActiveTheme(siteId),
266
- fetchRemote(siteId).catch(() => null)
248
+ themeActive.resolveActiveTheme(),
249
+ fetchRemote().catch(() => null)
267
250
  ]);
268
251
  const manifest = active.module.manifest;
269
252
  const stored = {};
@@ -299,9 +282,17 @@ ${lines.join("\n")}
299
282
  }
300
283
 
301
284
  // src/rendering.ts
285
+ import { marked } from "marked";
302
286
  function escape(s) {
303
287
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
304
288
  }
289
+ function textAlignStyle(attrs) {
290
+ const v = attrs?.textAlign;
291
+ if (v === "left" || v === "center" || v === "right" || v === "justify") {
292
+ return ` style="text-align: ${v}"`;
293
+ }
294
+ return "";
295
+ }
305
296
  function renderTiptap(node) {
306
297
  if (node.type === "text") {
307
298
  let html = escape(node.text ?? "");
@@ -310,6 +301,8 @@ function renderTiptap(node) {
310
301
  else if (mark.type === "italic") html = `<em>${html}</em>`;
311
302
  else if (mark.type === "code") html = `<code>${html}</code>`;
312
303
  else if (mark.type === "strike") html = `<s>${html}</s>`;
304
+ else if (mark.type === "underline") html = `<u>${html}</u>`;
305
+ else if (mark.type === "highlight") html = `<mark>${html}</mark>`;
313
306
  else if (mark.type === "link") {
314
307
  const href = escape(String(mark.attrs?.href ?? "#"));
315
308
  html = `<a href="${href}" target="_blank" rel="noopener">${html}</a>`;
@@ -322,10 +315,10 @@ function renderTiptap(node) {
322
315
  case "doc":
323
316
  return children;
324
317
  case "paragraph":
325
- return `<p>${children}</p>`;
318
+ return `<p${textAlignStyle(node.attrs)}>${children}</p>`;
326
319
  case "heading": {
327
320
  const level = Number(node.attrs?.level ?? 1);
328
- return `<h${level}>${children}</h${level}>`;
321
+ return `<h${level}${textAlignStyle(node.attrs)}>${children}</h${level}>`;
329
322
  }
330
323
  case "bulletList":
331
324
  return `<ul>${children}</ul>`;
@@ -350,52 +343,39 @@ function renderTiptap(node) {
350
343
  const display = node.attrs?.display ? ` data-display="${escape(String(node.attrs.display))}"` : "";
351
344
  return `<img src="${src}" alt="${alt}"${title}${display} loading="lazy" />`;
352
345
  }
346
+ case "table":
347
+ return `<table class="tiptap-table"><tbody>${children}</tbody></table>`;
348
+ case "tableRow":
349
+ return `<tr>${children}</tr>`;
350
+ case "tableHeader":
351
+ return `<th${tableCellAttrs(node.attrs)}>${children}</th>`;
352
+ case "tableCell":
353
+ return `<td${tableCellAttrs(node.attrs)}>${children}</td>`;
354
+ case "taskList":
355
+ return `<ul data-type="taskList">${children}</ul>`;
356
+ case "taskItem": {
357
+ const checked = node.attrs?.checked === true ? "true" : "false";
358
+ return `<li data-type="taskItem" data-checked="${checked}">${children}</li>`;
359
+ }
353
360
  default:
354
361
  return children;
355
362
  }
356
363
  }
357
- function renderMarkdown(md) {
358
- const lines = md.split("\n");
359
- const out = [];
360
- let i = 0;
361
- while (i < lines.length) {
362
- const line = lines[i] ?? "";
363
- if (line.startsWith("# ")) {
364
- out.push(`<h1>${escape(line.slice(2))}</h1>`);
365
- i++;
366
- } else if (line.startsWith("## ")) {
367
- out.push(`<h2>${escape(line.slice(3))}</h2>`);
368
- i++;
369
- } else if (line.startsWith("```")) {
370
- const lang = line.slice(3).trim();
371
- const code = [];
372
- i++;
373
- while (i < lines.length && !(lines[i] ?? "").startsWith("```")) {
374
- code.push(lines[i] ?? "");
375
- i++;
376
- }
377
- i++;
378
- out.push(
379
- `<pre><code${lang ? ` class="language-${escape(lang)}"` : ""}>${escape(code.join("\n"))}</code></pre>`
380
- );
381
- } else if (line.startsWith("- ")) {
382
- const items = [];
383
- while (i < lines.length && (lines[i] ?? "").startsWith("- ")) {
384
- items.push(`<li>${renderInlineMarkdown((lines[i] ?? "").slice(2))}</li>`);
385
- i++;
386
- }
387
- out.push(`<ul>${items.join("")}</ul>`);
388
- } else if (line.trim() === "") {
389
- i++;
390
- } else {
391
- out.push(`<p>${renderInlineMarkdown(line)}</p>`);
392
- i++;
393
- }
364
+ function tableCellAttrs(attrs) {
365
+ let out = "";
366
+ const colspan = Number(attrs?.colspan ?? 1);
367
+ if (colspan > 1) out += ` colspan="${colspan}"`;
368
+ const rowspan = Number(attrs?.rowspan ?? 1);
369
+ if (rowspan > 1) out += ` rowspan="${rowspan}"`;
370
+ const colwidth = attrs?.colwidth;
371
+ if (Array.isArray(colwidth) && colwidth.length > 0) {
372
+ const w = Number(colwidth[0]);
373
+ if (Number.isFinite(w) && w > 0) out += ` style="width: ${w}px"`;
394
374
  }
395
- return out.join("\n");
375
+ return out;
396
376
  }
397
- function renderInlineMarkdown(text) {
398
- return text.replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
377
+ function renderMarkdown(md) {
378
+ return marked.parse(md, { gfm: true, breaks: false, async: false });
399
379
  }
400
380
  function renderBody(post) {
401
381
  if (post.format === "html") return String(post.body);
@@ -426,6 +406,8 @@ function tiptapNodeToMarkdown(node) {
426
406
  else if (mark.type === "italic") txt = `*${txt}*`;
427
407
  else if (mark.type === "code") txt = `\`${txt}\``;
428
408
  else if (mark.type === "strike") txt = `~~${txt}~~`;
409
+ else if (mark.type === "underline") txt = `<u>${txt}</u>`;
410
+ else if (mark.type === "highlight") txt = `<mark>${txt}</mark>`;
429
411
  else if (mark.type === "link") txt = `[${txt}](${String(mark.attrs?.href ?? "#")})`;
430
412
  }
431
413
  return txt;
@@ -463,12 +445,54 @@ function tiptapNodeToMarkdown(node) {
463
445
  const alt = String(node.attrs?.alt ?? "");
464
446
  return `![${alt}](${src})`;
465
447
  }
448
+ case "table":
449
+ return tiptapTableToMarkdown(node);
450
+ case "taskList":
451
+ return children + "\n";
452
+ case "taskItem": {
453
+ const checked = node.attrs?.checked === true ? "x" : " ";
454
+ const inner = children.replace(/\n+$/, "");
455
+ const [first, ...rest] = inner.split("\n");
456
+ const cont = rest.map((l) => l ? " " + l : l).join("\n");
457
+ return `- [${checked}] ${first ?? ""}${cont ? "\n" + cont : ""}
458
+ `;
459
+ }
466
460
  default:
467
461
  return children;
468
462
  }
469
463
  }
464
+ function tiptapTableToMarkdown(node) {
465
+ const rows = node.content ?? [];
466
+ if (rows.length === 0) return "";
467
+ const renderedRows = [];
468
+ let headerIdx = -1;
469
+ for (let i = 0; i < rows.length; i++) {
470
+ const row = rows[i];
471
+ const cells = row.content ?? [];
472
+ const cellTexts = cells.map((c) => {
473
+ const inner = (c.content ?? []).map(tiptapNodeToMarkdown).join("");
474
+ return inner.replace(/\n+$/, "").replace(/\n/g, "<br>").replace(/\|/g, "\\|");
475
+ });
476
+ renderedRows.push(cellTexts);
477
+ if (headerIdx === -1 && cells.some((c) => c.type === "tableHeader")) headerIdx = i;
478
+ }
479
+ if (headerIdx === -1) headerIdx = 0;
480
+ const header = renderedRows[headerIdx] ?? [];
481
+ const body = renderedRows.filter((_, i) => i !== headerIdx);
482
+ const cols = header.length;
483
+ const headerLine = "| " + header.join(" | ") + " |";
484
+ const sepLine = "| " + Array.from({ length: cols }, () => "---").join(" | ") + " |";
485
+ const bodyLines = body.map((r) => {
486
+ const cells = Array.from({ length: cols }, (_, i) => r[i] ?? "");
487
+ return "| " + cells.join(" | ") + " |";
488
+ });
489
+ return "\n" + [headerLine, sepLine, ...bodyLines].join("\n") + "\n\n";
490
+ }
470
491
  function htmlToMarkdown(html) {
471
492
  let md = html;
493
+ md = md.replace(/<table[^>]*>([\s\S]*?)<\/table>/gi, (_, inner) => {
494
+ return "\n" + convertHtmlTable(String(inner)) + "\n";
495
+ });
472
496
  md = md.replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (_, level, text) => {
473
497
  return "\n" + "#".repeat(Number(level)) + " " + String(text).trim() + "\n\n";
474
498
  });
@@ -481,6 +505,9 @@ function htmlToMarkdown(html) {
481
505
  md = md.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (_, content) => {
482
506
  return "\n" + String(content).trim().split("\n").map((l) => "> " + l).join("\n") + "\n\n";
483
507
  });
508
+ md = md.replace(/<ul[^>]*data-type="taskList"[^>]*>([\s\S]*?)<\/ul>/gi, (_, items) => {
509
+ return "\n" + convertHtmlTaskList(String(items)) + "\n";
510
+ });
484
511
  md = md.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, (_, items) => {
485
512
  return "\n" + String(items).replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, "- $1\n") + "\n";
486
513
  });
@@ -500,11 +527,63 @@ function htmlToMarkdown(html) {
500
527
  md = md.replace(/<(em|i)>([\s\S]*?)<\/\1>/gi, "*$2*");
501
528
  md = md.replace(/<s>([\s\S]*?)<\/s>/gi, "~~$1~~");
502
529
  md = md.replace(/<code>([\s\S]*?)<\/code>/gi, "`$1`");
530
+ const PH_U_OPEN = "AMP_U_OPEN";
531
+ const PH_U_CLOSE = "AMP_U_CLOSE";
532
+ const PH_MARK_OPEN = "AMP_MARK_OPEN";
533
+ const PH_MARK_CLOSE = "AMP_MARK_CLOSE";
534
+ md = md.replace(/<u>([\s\S]*?)<\/u>/gi, `${PH_U_OPEN}$1${PH_U_CLOSE}`);
535
+ md = md.replace(/<mark>([\s\S]*?)<\/mark>/gi, `${PH_MARK_OPEN}$1${PH_MARK_CLOSE}`);
503
536
  md = md.replace(/<\/?[^>]+>/g, "");
537
+ md = md.split(PH_U_OPEN).join("<u>").split(PH_U_CLOSE).join("</u>").split(PH_MARK_OPEN).join("<mark>").split(PH_MARK_CLOSE).join("</mark>");
504
538
  md = md.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ");
505
539
  md = md.replace(/\n{3,}/g, "\n\n");
506
540
  return md.trim() + "\n";
507
541
  }
542
+ function convertHtmlTable(inner) {
543
+ const stripped = inner.replace(/<\/?(thead|tbody)[^>]*>/gi, "");
544
+ const rowRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
545
+ const rows = [];
546
+ let m;
547
+ while ((m = rowRe.exec(stripped)) !== null) {
548
+ const rowHtml = m[1] ?? "";
549
+ const cellRe = /<(th|td)[^>]*>([\s\S]*?)<\/\1>/gi;
550
+ const cells = [];
551
+ let isHeader = false;
552
+ let cm;
553
+ while ((cm = cellRe.exec(rowHtml)) !== null) {
554
+ if ((cm[1] ?? "").toLowerCase() === "th") isHeader = true;
555
+ cells.push(normalizeTableCell(cm[2] ?? ""));
556
+ }
557
+ if (cells.length > 0) rows.push({ isHeader, cells });
558
+ }
559
+ if (rows.length === 0) return "";
560
+ let headerIdx = rows.findIndex((r) => r.isHeader);
561
+ if (headerIdx === -1) headerIdx = 0;
562
+ const header = rows[headerIdx].cells;
563
+ const body = rows.filter((_, i) => i !== headerIdx).map((r) => r.cells);
564
+ const cols = header.length;
565
+ const headerLine = "| " + header.join(" | ") + " |";
566
+ const sepLine = "| " + Array.from({ length: cols }, () => "---").join(" | ") + " |";
567
+ const bodyLines = body.map((cells) => {
568
+ const padded = Array.from({ length: cols }, (_, i) => cells[i] ?? "");
569
+ return "| " + padded.join(" | ") + " |";
570
+ });
571
+ return [headerLine, sepLine, ...bodyLines].join("\n") + "\n";
572
+ }
573
+ function normalizeTableCell(html) {
574
+ return html.replace(/<br\s*\/?>/gi, " ").replace(/<\/?[^>]+>/g, "").replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
575
+ }
576
+ function convertHtmlTaskList(items) {
577
+ const liRe = /<li[^>]*data-checked="(true|false)"[^>]*>([\s\S]*?)<\/li>/gi;
578
+ const out = [];
579
+ let m;
580
+ while ((m = liRe.exec(items)) !== null) {
581
+ const checked = m[1] === "true" ? "x" : " ";
582
+ const inner = String(m[2] ?? "").replace(/<\/?p[^>]*>/gi, "").trim();
583
+ out.push(`- [${checked}] ${inner}`);
584
+ }
585
+ return out.join("\n");
586
+ }
508
587
 
509
588
  // src/index.ts
510
589
  function createAmpless(opts) {
@@ -517,13 +596,13 @@ function createAmpless(opts) {
517
596
  const themeConfig = createThemeConfig(themeActive, storage);
518
597
  return {
519
598
  listPublishedPosts: (o) => posts.listPublishedPosts(o),
520
- getPublishedPost: (slug, o) => posts.getPublishedPost(slug, o),
599
+ getPublishedPost: (slug) => posts.getPublishedPost(slug),
521
600
  listPostsByTag: (tag, o) => posts.listPostsByTag(tag, o),
522
- loadSiteSettings: (siteId) => settings.loadSiteSettings(siteId),
523
- resolveActiveTheme: (siteId) => themeActive.resolveActiveTheme(siteId),
524
- loadThemeConfig: (siteId) => themeConfig.loadThemeConfig(siteId),
525
- postMetadata: (post, siteId) => seo.postMetadata(post, siteId),
526
- siteMetadata: (siteId) => seo.siteMetadata(siteId),
601
+ loadSiteSettings: () => settings.loadSiteSettings(),
602
+ resolveActiveTheme: () => themeActive.resolveActiveTheme(),
603
+ loadThemeConfig: () => themeConfig.loadThemeConfig(),
604
+ postMetadata: (post) => seo.postMetadata(post),
605
+ siteMetadata: () => seo.siteMetadata(),
527
606
  renderBody: (post) => renderBody(post),
528
607
  renderThemeCss: (cssVars) => renderThemeCss(cssVars),
529
608
  publicAssetUrl: (key) => storage.publicAssetUrl(key),
@@ -8,24 +8,26 @@ type MiddlewareFn = (request: NextRequest) => NextResponse;
8
8
  /**
9
9
  * Build the ampless public-site middleware. Performs:
10
10
  *
11
- * - hostnamesiteId resolution (multi-site rewrites)
12
- * - `/path` → `/site/<siteId>/path` internal rewrite
11
+ * - `/path``/site/default/path` internal rewrite
12
+ * - `/_/<slug>(/...)` → `/site/default/r/<slug>(/...)` rewrite for
13
+ * the unified bare-HTML / static-bundle route
13
14
  * - `?previewTheme=<name>` → `x-preview-theme` header forwarding
14
- * - `Cache-Control: private, no-store` in multi-site mode (Amplify
15
- * Hosting's CloudFront cache key doesn't include Host, so SSR
16
- * responses would cross-contaminate at the same path)
17
15
  *
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.
16
+ * Bare-HTML / static routing is data-driven: the theme post dispatcher
17
+ * redirects `metadata.no_layout` and `format='static'` posts to
18
+ * `/_/<slug>`. The middleware rewrites the public `_` prefix to the
19
+ * routable internal folder name `r/`, because Next.js's App Router
20
+ * excludes any path part starting with `_` during route discovery
21
+ * (see `recursive-readdir` + `ignorePartFilter` in
22
+ * `next/dist/build/route-discovery.js`). The browser URL stays
23
+ * `/_/<slug>(/...)`; only the rewrite target uses `r/`.
24
24
  *
25
- * The factory captures the multi-site flag at construction time so the
26
- * hot path stays a pair of cheap header lookups.
25
+ * Single site per Amplify deployment: the rewritten internal path uses
26
+ * the fixed `default` segment. The internal `/site/[siteId]/` routing
27
+ * tree is retained as an implementation detail; a follow-up PR
28
+ * flattens the URL structure further.
27
29
  */
28
- declare function createAmplessMiddleware({ cmsConfig }: CreateMiddlewareOpts): MiddlewareFn;
30
+ declare function createAmplessMiddleware(_opts: CreateMiddlewareOpts): MiddlewareFn;
29
31
  /**
30
32
  * Reference matcher config — admin / api / login / static assets /
31
33
  * amplify_outputs.json are excluded so middleware doesn't rewrite
@@ -1,31 +1,22 @@
1
1
  // src/middleware.ts
2
2
  import { NextResponse } from "next/server";
3
- import { resolveSiteId, isMultiSite } from "ampless";
4
- function createAmplessMiddleware({ cmsConfig }) {
5
- const MULTI_SITE = isMultiSite(cmsConfig);
3
+ var INTERNAL_SITE_SEGMENT = "default";
4
+ function createAmplessMiddleware(_opts) {
6
5
  return function middleware(request) {
7
- const host = (request.headers.get("host") ?? "").split(":")[0];
8
- const siteId = resolveSiteId(host ?? "", cmsConfig);
9
- if (!siteId) {
10
- return new NextResponse("Site not found", { status: 404 });
11
- }
12
6
  const url = request.nextUrl.clone();
13
7
  if (!url.pathname.startsWith("/site/")) {
14
- const tail = url.pathname === "/" ? "" : url.pathname;
15
- url.pathname = `/site/${siteId}${tail}`;
8
+ let tail = url.pathname === "/" ? "" : url.pathname;
9
+ if (tail === "/_" || tail.startsWith("/_/")) {
10
+ tail = `/r${tail.slice(2)}`;
11
+ }
12
+ url.pathname = `/site/${INTERNAL_SITE_SEGMENT}${tail}`;
16
13
  }
17
14
  const previewTheme = url.searchParams.get("previewTheme");
18
15
  const previewColorScheme = url.searchParams.get("previewColorScheme");
19
16
  const requestHeaders = new Headers(request.headers);
20
- requestHeaders.set("x-site-id", siteId);
21
17
  if (previewTheme) requestHeaders.set("x-preview-theme", previewTheme);
22
18
  if (previewColorScheme) requestHeaders.set("x-preview-color-scheme", previewColorScheme);
23
- const response = NextResponse.rewrite(url, { request: { headers: requestHeaders } });
24
- response.headers.set("x-site-id", siteId);
25
- if (MULTI_SITE) {
26
- response.headers.set("Cache-Control", "private, no-store");
27
- }
28
- return response;
19
+ return NextResponse.rewrite(url, { request: { headers: requestHeaders } });
29
20
  };
30
21
  }
31
22
  var defaultMatcherConfig = {
@@ -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.9",
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.6",
52
- "@ampless/plugin-og-image": "0.2.0-alpha.6"
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",