@ampless/runtime 0.2.0-alpha.1 → 0.2.0-alpha.11
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 +86 -0
- package/README.md +3 -0
- package/dist/dispatchers/index.d.ts +22 -9
- package/dist/dispatchers/index.js +11 -2
- package/dist/index.d.ts +44 -7
- package/dist/index.js +158 -53
- package/dist/middleware.d.ts +19 -7
- package/dist/middleware.js +4 -8
- package/dist/routes/index.d.ts +49 -13
- package/dist/routes/index.js +73 -1
- package/package.json +4 -3
package/README.ja.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
> English: [README.md](./README.md)
|
|
2
|
+
>
|
|
3
|
+
|
|
4
|
+
# @ampless/runtime
|
|
5
|
+
|
|
6
|
+
[ampless](https://github.com/heavymoons/ampless) 向けパブリックサイドランタイム。投稿取得クライアント、サイト設定、テーマ解決、SEO メタデータ集約、ミドルウェア、パブリックルートハンドラーを `createAmpless()` ひとつのファクトリーにまとめます。
|
|
7
|
+
|
|
8
|
+
> **プレリリース / アルファ版。** v1.0 まではマイナーバージョンでも破壊的変更が入る可能性があります。
|
|
9
|
+
|
|
10
|
+
テンプレートから切り出すことで、スキャフォールドされたファイルに触れずに `npm update @ampless/runtime` でアップデートできます — パブリックサイトの動作改善はスキャフォールダーの再実行ではなく、パッケージを通じて届きます。
|
|
11
|
+
|
|
12
|
+
## インストール
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install @ampless/runtime@alpha ampless@alpha
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`@ampless/runtime` は `next`(15+)、`react`(18/19)、`aws-amplify`(6+)、`@aws-amplify/adapter-nextjs`(1+)をピア依存として宣言します。CLI スキャフォールダーがテンプレートの `package.json` に互換バージョンをピン留めします。
|
|
19
|
+
|
|
20
|
+
## 使い方
|
|
21
|
+
|
|
22
|
+
テンプレートは `lib/ampless.ts` に共有インスタンスを 1 つ作成します:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import outputs from '../amplify_outputs.json'
|
|
26
|
+
import cmsConfig from '../cms.config'
|
|
27
|
+
import { themes, DEFAULT_THEME } from '../themes-registry'
|
|
28
|
+
import { createAmpless } from '@ampless/runtime'
|
|
29
|
+
|
|
30
|
+
export const ampless = createAmpless({
|
|
31
|
+
outputs,
|
|
32
|
+
cmsConfig,
|
|
33
|
+
themes: { themes, defaultTheme: DEFAULT_THEME },
|
|
34
|
+
})
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`app/site/[siteId]/` 以下のルートとディスパッチャーはワンライナーのファクトリー呼び出しになります:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
// app/site/[siteId]/page.tsx
|
|
41
|
+
import { ampless } from '@/lib/ampless'
|
|
42
|
+
import {
|
|
43
|
+
createThemeHomeDispatcher,
|
|
44
|
+
createThemeHomeMetadata,
|
|
45
|
+
} from '@ampless/runtime/dispatchers'
|
|
46
|
+
|
|
47
|
+
export const dynamic = 'force-dynamic'
|
|
48
|
+
export const generateMetadata = createThemeHomeMetadata(ampless)
|
|
49
|
+
export default createThemeHomeDispatcher(ampless)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// app/site/[siteId]/og/[slug]/route.ts
|
|
54
|
+
import { ampless } from '@/lib/ampless'
|
|
55
|
+
import { createOgRouteHandler } from '@ampless/runtime/routes'
|
|
56
|
+
|
|
57
|
+
export const runtime = 'nodejs'
|
|
58
|
+
export const dynamic = 'force-dynamic'
|
|
59
|
+
export const GET = createOgRouteHandler(ampless)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
ミドルウェア:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// middleware.ts
|
|
66
|
+
import cmsConfig from './cms.config'
|
|
67
|
+
import { createAmplessMiddleware, defaultMatcherConfig } from '@ampless/runtime/middleware'
|
|
68
|
+
|
|
69
|
+
export const middleware = createAmplessMiddleware({ cmsConfig })
|
|
70
|
+
export const config = defaultMatcherConfig
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## サブパス
|
|
74
|
+
|
|
75
|
+
- `@ampless/runtime` — `createAmpless`、ランタイム型、`renderBody`・`renderThemeCss`・フォーマットコンバーターの再エクスポート
|
|
76
|
+
- `@ampless/runtime/middleware` — `createAmplessMiddleware`、`defaultMatcherConfig`
|
|
77
|
+
- `@ampless/runtime/routes` — `createOgRouteHandler`、`createSitemapRouteHandler`、`createFeedRouteHandler`、`createRawRouteHandler`
|
|
78
|
+
- `@ampless/runtime/dispatchers` — `createThemeHomeDispatcher`、`createThemePostDispatcher`、`createThemeTagDispatcher`(それぞれ対応する `*Metadata` ファクトリーあり)
|
|
79
|
+
|
|
80
|
+
## テンプレートに残るもの
|
|
81
|
+
|
|
82
|
+
管理側モジュール(投稿プロバイダー、テーマアクション、認証、KV 書き込み)とテーマコンポーネントはスキャフォールドに残ります。これらは後のリリースで `@ampless/admin` に移行する予定です。それまでは、これらのファイルへの変更はユーザーのプロジェクト内で行ってください。
|
|
83
|
+
|
|
84
|
+
## ライセンス
|
|
85
|
+
|
|
86
|
+
MIT
|
package/README.md
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
> 日本語版: [README.ja.md](./README.ja.md)
|
|
2
|
+
>
|
|
3
|
+
|
|
1
4
|
# @ampless/runtime
|
|
2
5
|
|
|
3
6
|
Public-side runtime for [ampless](https://github.com/heavymoons/ampless). Bundles the post-fetching client, site settings, theme resolution, SEO metadata aggregation, middleware, and public route handlers behind a single `createAmpless()` factory.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
1
2
|
import { Metadata } from 'next';
|
|
2
3
|
import { Ampless } from '../index.js';
|
|
3
4
|
import 'ampless';
|
|
@@ -7,16 +8,12 @@ interface Props$2 {
|
|
|
7
8
|
siteId: string;
|
|
8
9
|
}>;
|
|
9
10
|
}
|
|
10
|
-
type ThemeHomeDispatcher = (props: Props$2) => Promise<
|
|
11
|
+
type ThemeHomeDispatcher = (props: Props$2) => Promise<ReactNode>;
|
|
11
12
|
type ThemeHomeMetadata = (props: Props$2) => Promise<Metadata>;
|
|
12
13
|
/**
|
|
13
14
|
* Home page dispatcher. Resolves the active theme for the request's
|
|
14
15
|
* siteId and renders the theme's `components.Home` server component
|
|
15
16
|
* with the same `params` Promise it was passed.
|
|
16
|
-
*
|
|
17
|
-
* The return type is `unknown` (cast at the route boundary) because
|
|
18
|
-
* Next.js page-component prop types vary by route shape and the
|
|
19
|
-
* underlying theme components are arbitrary server components.
|
|
20
17
|
*/
|
|
21
18
|
declare function createThemeHomeDispatcher(ampless: Ampless): ThemeHomeDispatcher;
|
|
22
19
|
/** generateMetadata factory for the home dispatcher. */
|
|
@@ -28,12 +25,28 @@ interface Props$1 {
|
|
|
28
25
|
slug: string;
|
|
29
26
|
}>;
|
|
30
27
|
}
|
|
31
|
-
type ThemePostDispatcher = (props: Props$1) => Promise<
|
|
28
|
+
type ThemePostDispatcher = (props: Props$1) => Promise<ReactNode>;
|
|
32
29
|
type ThemePostMetadata = (props: Props$1) => Promise<Metadata>;
|
|
33
30
|
/**
|
|
34
31
|
* Post page dispatcher. Resolves the active theme and renders the
|
|
35
|
-
* theme's `components.Post` server component.
|
|
36
|
-
*
|
|
32
|
+
* theme's `components.Post` server component.
|
|
33
|
+
*
|
|
34
|
+
* Before delegating, the dispatcher peeks at the post's metadata: if
|
|
35
|
+
* `metadata.no_layout === true`, the post is meant to be served as
|
|
36
|
+
* bare HTML (own DOCTYPE, no theme chrome, no Next.js root layout).
|
|
37
|
+
* Next.js page.tsx can't bypass the root layout, so we 308-redirect
|
|
38
|
+
* to the raw route handler at `/raw/<slug>` which returns the body
|
|
39
|
+
* unchanged. The redirect is permanent because no_layout is a
|
|
40
|
+
* persistent property of the post — bookmarks naturally settle on
|
|
41
|
+
* the `/raw/` URL.
|
|
42
|
+
*
|
|
43
|
+
* The metadata peek is an extra AppSync call before theme resolve,
|
|
44
|
+
* but it's the same query the theme's Post component would make
|
|
45
|
+
* anyway; AppSync's query-level dedupe within a single request keeps
|
|
46
|
+
* the wire cost flat in practice.
|
|
47
|
+
*
|
|
48
|
+
* If the theme doesn't declare a Post component at all, returns
|
|
49
|
+
* Next.js's notFound() (404).
|
|
37
50
|
*/
|
|
38
51
|
declare function createThemePostDispatcher(ampless: Ampless): ThemePostDispatcher;
|
|
39
52
|
/** generateMetadata factory for the post dispatcher. */
|
|
@@ -45,7 +58,7 @@ interface Props {
|
|
|
45
58
|
tag: string;
|
|
46
59
|
}>;
|
|
47
60
|
}
|
|
48
|
-
type ThemeTagDispatcher = (props: Props) => Promise<
|
|
61
|
+
type ThemeTagDispatcher = (props: Props) => Promise<ReactNode>;
|
|
49
62
|
type ThemeTagMetadata = (props: Props) => Promise<Metadata>;
|
|
50
63
|
/**
|
|
51
64
|
* Tag page dispatcher. Resolves the active theme and renders the
|
|
@@ -17,10 +17,19 @@ function createThemeHomeMetadata(ampless) {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
// src/dispatchers/post.ts
|
|
20
|
-
import { notFound } from "next/navigation";
|
|
20
|
+
import { notFound, redirect } from "next/navigation";
|
|
21
21
|
function createThemePostDispatcher(ampless) {
|
|
22
22
|
return async function SitePostDispatcher({ params }) {
|
|
23
|
-
const { siteId } = await params;
|
|
23
|
+
const { siteId, slug } = await params;
|
|
24
|
+
const post = await ampless.getPublishedPost(slug, { siteId });
|
|
25
|
+
if (post?.metadata?.no_layout === true) {
|
|
26
|
+
redirect(`/raw/${slug}`);
|
|
27
|
+
}
|
|
28
|
+
if (post?.format === "static") {
|
|
29
|
+
const body = post.body ?? null;
|
|
30
|
+
const entrypoint = typeof body?.entrypoint === "string" && body.entrypoint ? body.entrypoint : "index.html";
|
|
31
|
+
redirect(`/${slug}/${entrypoint}`);
|
|
32
|
+
}
|
|
24
33
|
const { module } = await ampless.resolveActiveTheme(siteId);
|
|
25
34
|
const Post = module.components.Post;
|
|
26
35
|
if (!Post) notFound();
|
package/dist/index.d.ts
CHANGED
|
@@ -34,6 +34,7 @@ interface PublicPostShape {
|
|
|
34
34
|
status?: string | null;
|
|
35
35
|
publishedAt?: string | null;
|
|
36
36
|
tags?: Array<string | null> | null;
|
|
37
|
+
metadata?: unknown;
|
|
37
38
|
}
|
|
38
39
|
interface PublicPostConnectionShape {
|
|
39
40
|
items?: Array<PublicPostShape | null> | null;
|
|
@@ -108,6 +109,22 @@ interface ThemeActiveApi {
|
|
|
108
109
|
resolveActiveTheme(siteId?: string): Promise<ResolvedTheme>;
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
type ColorScheme = 'auto' | 'light' | 'dark';
|
|
113
|
+
declare const DEFAULT_COLOR_SCHEME: ColorScheme;
|
|
114
|
+
/**
|
|
115
|
+
* Storage key for the per-site color-scheme override. Lives under the
|
|
116
|
+
* same `theme.*` namespace as manifest fields so it flows through the
|
|
117
|
+
* existing KvStore → S3 cache pipeline, but is read separately from
|
|
118
|
+
* the manifest field set (it's a site-wide concern, not theme-specific).
|
|
119
|
+
*/
|
|
120
|
+
declare const COLOR_SCHEME_SETTING_KEY = "theme.colorScheme";
|
|
121
|
+
/**
|
|
122
|
+
* Narrow an arbitrary stored value to a known `ColorScheme`. Anything
|
|
123
|
+
* unrecognised (including `undefined`, malformed strings, accidental
|
|
124
|
+
* objects) falls back to `'auto'` so a typo never strands a site
|
|
125
|
+
* forced into a mode it can't undo.
|
|
126
|
+
*/
|
|
127
|
+
declare function validateColorScheme(raw: unknown): ColorScheme;
|
|
111
128
|
interface EffectiveThemeConfig {
|
|
112
129
|
/** Resolved active theme name (e.g. 'blog'). */
|
|
113
130
|
activeTheme: string;
|
|
@@ -116,6 +133,16 @@ interface EffectiveThemeConfig {
|
|
|
116
133
|
values: Record<string, string>;
|
|
117
134
|
/** Subset of `values` for fields that have a `cssVar`. */
|
|
118
135
|
cssVars: Record<string, string>;
|
|
136
|
+
/**
|
|
137
|
+
* Per-site color-scheme override. `'auto'` (default) lets the
|
|
138
|
+
* visitor's system `prefers-color-scheme` decide; `'light'` /
|
|
139
|
+
* `'dark'` pin one mode regardless of system preference.
|
|
140
|
+
*
|
|
141
|
+
* Consumed by the root layout to set `<html data-color-scheme>`,
|
|
142
|
+
* which the theme tokens.css files key off in combination with the
|
|
143
|
+
* `light-dark()` CSS function.
|
|
144
|
+
*/
|
|
145
|
+
colorScheme: ColorScheme;
|
|
119
146
|
}
|
|
120
147
|
interface ThemeConfigApi {
|
|
121
148
|
loadThemeConfig(siteId?: string): Promise<EffectiveThemeConfig>;
|
|
@@ -132,32 +159,42 @@ declare function renderBody(post: Post): string;
|
|
|
132
159
|
* Convert a tiptap doc to its HTML form. Same renderer the public
|
|
133
160
|
* site uses. Defensive: tiptap accepts an HTML string as initial
|
|
134
161
|
* content and parses it on mount, but won't fire onUpdate until the
|
|
135
|
-
* user edits
|
|
162
|
+
* user edits, so a format-switch chain (e.g. markdown -> tiptap ->
|
|
136
163
|
* markdown without editing) can still hand us a raw HTML string
|
|
137
164
|
* here. In that case, return it as-is rather than walking it as a
|
|
138
165
|
* malformed tiptap node and producing empty output.
|
|
139
166
|
*/
|
|
140
167
|
declare function tiptapToHtml(doc: unknown): string;
|
|
141
|
-
/** Convert markdown to HTML using
|
|
168
|
+
/** Convert markdown to HTML using marked + GFM. */
|
|
142
169
|
declare function markdownToHtml(md: string): string;
|
|
143
170
|
/**
|
|
144
171
|
* Walk a tiptap doc and emit Markdown. Mirrors `renderTiptap` in
|
|
145
172
|
* shape but produces markdown syntax. Loses anything markdown can't
|
|
146
173
|
* express (data attributes, image display modes, custom marks).
|
|
147
174
|
*
|
|
175
|
+
* Notes on info loss:
|
|
176
|
+
* - underline / highlight are not in GFM, so they fall back to the
|
|
177
|
+
* literal `<u>` / `<mark>` HTML tags (preserved as-is across round trips).
|
|
178
|
+
* - paragraph / heading textAlign cannot be expressed in markdown and
|
|
179
|
+
* is therefore lost on conversion.
|
|
180
|
+
*
|
|
148
181
|
* Same defensive path as tiptapToHtml: a string input means tiptap
|
|
149
182
|
* hasn't emitted JSON yet (the body is still the HTML we handed it).
|
|
150
183
|
* Route through htmlToMarkdown so the content survives.
|
|
151
184
|
*/
|
|
152
185
|
declare function tiptapToMarkdown(doc: unknown): string;
|
|
153
186
|
/**
|
|
154
|
-
* Regex-based HTML
|
|
187
|
+
* Regex-based HTML -> Markdown converter. Handles the tag set the
|
|
155
188
|
* editor produces (`<p>` `<h1>`-`<h6>` `<strong>` `<em>` `<a>`
|
|
156
189
|
* `<img>` `<ul>` `<ol>` `<li>` `<code>` `<pre>` `<blockquote>` `<hr>`
|
|
157
|
-
* `<br>`
|
|
158
|
-
*
|
|
190
|
+
* `<br>` `<u>` `<mark>` `<table>` task-list `<ul data-type="taskList">`).
|
|
191
|
+
* Decorative containers like `<div style="text-align:...">` are dropped.
|
|
192
|
+
*
|
|
193
|
+
* Tables are reduced to GFM pipe syntax via convertHtmlTable. Complex
|
|
194
|
+
* nested content inside cells (lists, other tables) is flattened to
|
|
195
|
+
* plain text.
|
|
159
196
|
*
|
|
160
|
-
* Not a full library
|
|
197
|
+
* Not a full library, there are known limits like nested formatting
|
|
161
198
|
* inside list items potentially merging. Acceptable for a v0.x
|
|
162
199
|
* format-switch convenience; complex HTML round-trips shouldn't be
|
|
163
200
|
* relied on.
|
|
@@ -203,4 +240,4 @@ interface Ampless {
|
|
|
203
240
|
*/
|
|
204
241
|
declare function createAmpless(opts: CreateAmplessOpts): Ampless;
|
|
205
242
|
|
|
206
|
-
export { type Ampless, type AmplessOutputs, type CreateAmplessOpts, type DataOutput, type EffectiveSiteSettings, type EffectiveThemeConfig, type ListPostsByTagOptions, type ListPostsOptions, type ListPostsResult, type PostsApi, type PublicPostConnectionShape, type PublicPostShape, type ResolvedTheme, type SeoApi, type SiteSettingsApi, type StorageOutput, type ThemeActiveApi, type ThemeConfigApi, type ThemesRegistry, createAmpless, htmlToMarkdown, markdownToHtml, renderBody, renderThemeCss, tiptapToHtml, tiptapToMarkdown };
|
|
243
|
+
export { type Ampless, type AmplessOutputs, COLOR_SCHEME_SETTING_KEY, type ColorScheme, type CreateAmplessOpts, DEFAULT_COLOR_SCHEME, type DataOutput, type EffectiveSiteSettings, type EffectiveThemeConfig, type ListPostsByTagOptions, type ListPostsOptions, type ListPostsResult, type PostsApi, type PublicPostConnectionShape, type PublicPostShape, type ResolvedTheme, type SeoApi, type SiteSettingsApi, type StorageOutput, type ThemeActiveApi, type ThemeConfigApi, type ThemesRegistry, createAmpless, htmlToMarkdown, markdownToHtml, renderBody, renderThemeCss, tiptapToHtml, tiptapToMarkdown, validateColorScheme };
|
package/dist/index.js
CHANGED
|
@@ -20,13 +20,11 @@ function createStorage(outputs) {
|
|
|
20
20
|
// src/posts.ts
|
|
21
21
|
import { cookies } from "next/headers";
|
|
22
22
|
import { generateServerClientUsingCookies } from "@aws-amplify/adapter-nextjs/api";
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return value;
|
|
29
|
-
}
|
|
23
|
+
import { decodeAwsJson } from "ampless";
|
|
24
|
+
function decodeMetadata(value) {
|
|
25
|
+
if (value === null || value === void 0) return void 0;
|
|
26
|
+
const parsed = decodeAwsJson(value);
|
|
27
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
|
|
30
28
|
}
|
|
31
29
|
function toCorePost(p) {
|
|
32
30
|
return {
|
|
@@ -36,10 +34,11 @@ function toCorePost(p) {
|
|
|
36
34
|
title: p.title,
|
|
37
35
|
excerpt: p.excerpt ?? void 0,
|
|
38
36
|
format: p.format ?? "markdown",
|
|
39
|
-
body:
|
|
37
|
+
body: decodeAwsJson(p.body),
|
|
40
38
|
status: p.status ?? "draft",
|
|
41
39
|
publishedAt: p.publishedAt ?? void 0,
|
|
42
|
-
tags: (p.tags ?? []).filter((t) => typeof t === "string")
|
|
40
|
+
tags: (p.tags ?? []).filter((t) => typeof t === "string"),
|
|
41
|
+
metadata: decodeMetadata(p.metadata)
|
|
43
42
|
};
|
|
44
43
|
}
|
|
45
44
|
function createPostsApi(outputs) {
|
|
@@ -227,6 +226,12 @@ import {
|
|
|
227
226
|
resolveThemeValues,
|
|
228
227
|
themeSettingKey
|
|
229
228
|
} from "ampless";
|
|
229
|
+
var DEFAULT_COLOR_SCHEME = "auto";
|
|
230
|
+
var COLOR_SCHEME_SETTING_KEY = "theme.colorScheme";
|
|
231
|
+
function validateColorScheme(raw) {
|
|
232
|
+
if (raw === "light" || raw === "dark" || raw === "auto") return raw;
|
|
233
|
+
return DEFAULT_COLOR_SCHEME;
|
|
234
|
+
}
|
|
230
235
|
function createThemeConfig(themeActive, storage) {
|
|
231
236
|
async function fetchRemote(siteId) {
|
|
232
237
|
if (!storage.isStorageConfigured()) return null;
|
|
@@ -256,7 +261,8 @@ function createThemeConfig(themeActive, storage) {
|
|
|
256
261
|
}
|
|
257
262
|
const values = resolveThemeValues(manifest, stored);
|
|
258
263
|
const cssVars = collectCssVars(manifest.fields, values);
|
|
259
|
-
|
|
264
|
+
const colorScheme = validateColorScheme(flat?.[COLOR_SCHEME_SETTING_KEY]);
|
|
265
|
+
return { activeTheme: active.name, manifest, values, cssVars, colorScheme };
|
|
260
266
|
}
|
|
261
267
|
};
|
|
262
268
|
}
|
|
@@ -279,9 +285,17 @@ ${lines.join("\n")}
|
|
|
279
285
|
}
|
|
280
286
|
|
|
281
287
|
// src/rendering.ts
|
|
288
|
+
import { marked } from "marked";
|
|
282
289
|
function escape(s) {
|
|
283
290
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
284
291
|
}
|
|
292
|
+
function textAlignStyle(attrs) {
|
|
293
|
+
const v = attrs?.textAlign;
|
|
294
|
+
if (v === "left" || v === "center" || v === "right" || v === "justify") {
|
|
295
|
+
return ` style="text-align: ${v}"`;
|
|
296
|
+
}
|
|
297
|
+
return "";
|
|
298
|
+
}
|
|
285
299
|
function renderTiptap(node) {
|
|
286
300
|
if (node.type === "text") {
|
|
287
301
|
let html = escape(node.text ?? "");
|
|
@@ -290,6 +304,8 @@ function renderTiptap(node) {
|
|
|
290
304
|
else if (mark.type === "italic") html = `<em>${html}</em>`;
|
|
291
305
|
else if (mark.type === "code") html = `<code>${html}</code>`;
|
|
292
306
|
else if (mark.type === "strike") html = `<s>${html}</s>`;
|
|
307
|
+
else if (mark.type === "underline") html = `<u>${html}</u>`;
|
|
308
|
+
else if (mark.type === "highlight") html = `<mark>${html}</mark>`;
|
|
293
309
|
else if (mark.type === "link") {
|
|
294
310
|
const href = escape(String(mark.attrs?.href ?? "#"));
|
|
295
311
|
html = `<a href="${href}" target="_blank" rel="noopener">${html}</a>`;
|
|
@@ -302,10 +318,10 @@ function renderTiptap(node) {
|
|
|
302
318
|
case "doc":
|
|
303
319
|
return children;
|
|
304
320
|
case "paragraph":
|
|
305
|
-
return `<p>${children}</p>`;
|
|
321
|
+
return `<p${textAlignStyle(node.attrs)}>${children}</p>`;
|
|
306
322
|
case "heading": {
|
|
307
323
|
const level = Number(node.attrs?.level ?? 1);
|
|
308
|
-
return `<h${level}>${children}</h${level}>`;
|
|
324
|
+
return `<h${level}${textAlignStyle(node.attrs)}>${children}</h${level}>`;
|
|
309
325
|
}
|
|
310
326
|
case "bulletList":
|
|
311
327
|
return `<ul>${children}</ul>`;
|
|
@@ -330,52 +346,39 @@ function renderTiptap(node) {
|
|
|
330
346
|
const display = node.attrs?.display ? ` data-display="${escape(String(node.attrs.display))}"` : "";
|
|
331
347
|
return `<img src="${src}" alt="${alt}"${title}${display} loading="lazy" />`;
|
|
332
348
|
}
|
|
349
|
+
case "table":
|
|
350
|
+
return `<table class="tiptap-table"><tbody>${children}</tbody></table>`;
|
|
351
|
+
case "tableRow":
|
|
352
|
+
return `<tr>${children}</tr>`;
|
|
353
|
+
case "tableHeader":
|
|
354
|
+
return `<th${tableCellAttrs(node.attrs)}>${children}</th>`;
|
|
355
|
+
case "tableCell":
|
|
356
|
+
return `<td${tableCellAttrs(node.attrs)}>${children}</td>`;
|
|
357
|
+
case "taskList":
|
|
358
|
+
return `<ul data-type="taskList">${children}</ul>`;
|
|
359
|
+
case "taskItem": {
|
|
360
|
+
const checked = node.attrs?.checked === true ? "true" : "false";
|
|
361
|
+
return `<li data-type="taskItem" data-checked="${checked}">${children}</li>`;
|
|
362
|
+
}
|
|
333
363
|
default:
|
|
334
364
|
return children;
|
|
335
365
|
}
|
|
336
366
|
}
|
|
337
|
-
function
|
|
338
|
-
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
out.push(`<h2>${escape(line.slice(3))}</h2>`);
|
|
348
|
-
i++;
|
|
349
|
-
} else if (line.startsWith("```")) {
|
|
350
|
-
const lang = line.slice(3).trim();
|
|
351
|
-
const code = [];
|
|
352
|
-
i++;
|
|
353
|
-
while (i < lines.length && !(lines[i] ?? "").startsWith("```")) {
|
|
354
|
-
code.push(lines[i] ?? "");
|
|
355
|
-
i++;
|
|
356
|
-
}
|
|
357
|
-
i++;
|
|
358
|
-
out.push(
|
|
359
|
-
`<pre><code${lang ? ` class="language-${escape(lang)}"` : ""}>${escape(code.join("\n"))}</code></pre>`
|
|
360
|
-
);
|
|
361
|
-
} else if (line.startsWith("- ")) {
|
|
362
|
-
const items = [];
|
|
363
|
-
while (i < lines.length && (lines[i] ?? "").startsWith("- ")) {
|
|
364
|
-
items.push(`<li>${renderInlineMarkdown((lines[i] ?? "").slice(2))}</li>`);
|
|
365
|
-
i++;
|
|
366
|
-
}
|
|
367
|
-
out.push(`<ul>${items.join("")}</ul>`);
|
|
368
|
-
} else if (line.trim() === "") {
|
|
369
|
-
i++;
|
|
370
|
-
} else {
|
|
371
|
-
out.push(`<p>${renderInlineMarkdown(line)}</p>`);
|
|
372
|
-
i++;
|
|
373
|
-
}
|
|
367
|
+
function tableCellAttrs(attrs) {
|
|
368
|
+
let out = "";
|
|
369
|
+
const colspan = Number(attrs?.colspan ?? 1);
|
|
370
|
+
if (colspan > 1) out += ` colspan="${colspan}"`;
|
|
371
|
+
const rowspan = Number(attrs?.rowspan ?? 1);
|
|
372
|
+
if (rowspan > 1) out += ` rowspan="${rowspan}"`;
|
|
373
|
+
const colwidth = attrs?.colwidth;
|
|
374
|
+
if (Array.isArray(colwidth) && colwidth.length > 0) {
|
|
375
|
+
const w = Number(colwidth[0]);
|
|
376
|
+
if (Number.isFinite(w) && w > 0) out += ` style="width: ${w}px"`;
|
|
374
377
|
}
|
|
375
|
-
return out
|
|
378
|
+
return out;
|
|
376
379
|
}
|
|
377
|
-
function
|
|
378
|
-
return
|
|
380
|
+
function renderMarkdown(md) {
|
|
381
|
+
return marked.parse(md, { gfm: true, breaks: false, async: false });
|
|
379
382
|
}
|
|
380
383
|
function renderBody(post) {
|
|
381
384
|
if (post.format === "html") return String(post.body);
|
|
@@ -406,6 +409,8 @@ function tiptapNodeToMarkdown(node) {
|
|
|
406
409
|
else if (mark.type === "italic") txt = `*${txt}*`;
|
|
407
410
|
else if (mark.type === "code") txt = `\`${txt}\``;
|
|
408
411
|
else if (mark.type === "strike") txt = `~~${txt}~~`;
|
|
412
|
+
else if (mark.type === "underline") txt = `<u>${txt}</u>`;
|
|
413
|
+
else if (mark.type === "highlight") txt = `<mark>${txt}</mark>`;
|
|
409
414
|
else if (mark.type === "link") txt = `[${txt}](${String(mark.attrs?.href ?? "#")})`;
|
|
410
415
|
}
|
|
411
416
|
return txt;
|
|
@@ -443,12 +448,54 @@ function tiptapNodeToMarkdown(node) {
|
|
|
443
448
|
const alt = String(node.attrs?.alt ?? "");
|
|
444
449
|
return ``;
|
|
445
450
|
}
|
|
451
|
+
case "table":
|
|
452
|
+
return tiptapTableToMarkdown(node);
|
|
453
|
+
case "taskList":
|
|
454
|
+
return children + "\n";
|
|
455
|
+
case "taskItem": {
|
|
456
|
+
const checked = node.attrs?.checked === true ? "x" : " ";
|
|
457
|
+
const inner = children.replace(/\n+$/, "");
|
|
458
|
+
const [first, ...rest] = inner.split("\n");
|
|
459
|
+
const cont = rest.map((l) => l ? " " + l : l).join("\n");
|
|
460
|
+
return `- [${checked}] ${first ?? ""}${cont ? "\n" + cont : ""}
|
|
461
|
+
`;
|
|
462
|
+
}
|
|
446
463
|
default:
|
|
447
464
|
return children;
|
|
448
465
|
}
|
|
449
466
|
}
|
|
467
|
+
function tiptapTableToMarkdown(node) {
|
|
468
|
+
const rows = node.content ?? [];
|
|
469
|
+
if (rows.length === 0) return "";
|
|
470
|
+
const renderedRows = [];
|
|
471
|
+
let headerIdx = -1;
|
|
472
|
+
for (let i = 0; i < rows.length; i++) {
|
|
473
|
+
const row = rows[i];
|
|
474
|
+
const cells = row.content ?? [];
|
|
475
|
+
const cellTexts = cells.map((c) => {
|
|
476
|
+
const inner = (c.content ?? []).map(tiptapNodeToMarkdown).join("");
|
|
477
|
+
return inner.replace(/\n+$/, "").replace(/\n/g, "<br>").replace(/\|/g, "\\|");
|
|
478
|
+
});
|
|
479
|
+
renderedRows.push(cellTexts);
|
|
480
|
+
if (headerIdx === -1 && cells.some((c) => c.type === "tableHeader")) headerIdx = i;
|
|
481
|
+
}
|
|
482
|
+
if (headerIdx === -1) headerIdx = 0;
|
|
483
|
+
const header = renderedRows[headerIdx] ?? [];
|
|
484
|
+
const body = renderedRows.filter((_, i) => i !== headerIdx);
|
|
485
|
+
const cols = header.length;
|
|
486
|
+
const headerLine = "| " + header.join(" | ") + " |";
|
|
487
|
+
const sepLine = "| " + Array.from({ length: cols }, () => "---").join(" | ") + " |";
|
|
488
|
+
const bodyLines = body.map((r) => {
|
|
489
|
+
const cells = Array.from({ length: cols }, (_, i) => r[i] ?? "");
|
|
490
|
+
return "| " + cells.join(" | ") + " |";
|
|
491
|
+
});
|
|
492
|
+
return "\n" + [headerLine, sepLine, ...bodyLines].join("\n") + "\n\n";
|
|
493
|
+
}
|
|
450
494
|
function htmlToMarkdown(html) {
|
|
451
495
|
let md = html;
|
|
496
|
+
md = md.replace(/<table[^>]*>([\s\S]*?)<\/table>/gi, (_, inner) => {
|
|
497
|
+
return "\n" + convertHtmlTable(String(inner)) + "\n";
|
|
498
|
+
});
|
|
452
499
|
md = md.replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (_, level, text) => {
|
|
453
500
|
return "\n" + "#".repeat(Number(level)) + " " + String(text).trim() + "\n\n";
|
|
454
501
|
});
|
|
@@ -461,6 +508,9 @@ function htmlToMarkdown(html) {
|
|
|
461
508
|
md = md.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (_, content) => {
|
|
462
509
|
return "\n" + String(content).trim().split("\n").map((l) => "> " + l).join("\n") + "\n\n";
|
|
463
510
|
});
|
|
511
|
+
md = md.replace(/<ul[^>]*data-type="taskList"[^>]*>([\s\S]*?)<\/ul>/gi, (_, items) => {
|
|
512
|
+
return "\n" + convertHtmlTaskList(String(items)) + "\n";
|
|
513
|
+
});
|
|
464
514
|
md = md.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, (_, items) => {
|
|
465
515
|
return "\n" + String(items).replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, "- $1\n") + "\n";
|
|
466
516
|
});
|
|
@@ -480,11 +530,63 @@ function htmlToMarkdown(html) {
|
|
|
480
530
|
md = md.replace(/<(em|i)>([\s\S]*?)<\/\1>/gi, "*$2*");
|
|
481
531
|
md = md.replace(/<s>([\s\S]*?)<\/s>/gi, "~~$1~~");
|
|
482
532
|
md = md.replace(/<code>([\s\S]*?)<\/code>/gi, "`$1`");
|
|
533
|
+
const PH_U_OPEN = "AMP_U_OPEN";
|
|
534
|
+
const PH_U_CLOSE = "AMP_U_CLOSE";
|
|
535
|
+
const PH_MARK_OPEN = "AMP_MARK_OPEN";
|
|
536
|
+
const PH_MARK_CLOSE = "AMP_MARK_CLOSE";
|
|
537
|
+
md = md.replace(/<u>([\s\S]*?)<\/u>/gi, `${PH_U_OPEN}$1${PH_U_CLOSE}`);
|
|
538
|
+
md = md.replace(/<mark>([\s\S]*?)<\/mark>/gi, `${PH_MARK_OPEN}$1${PH_MARK_CLOSE}`);
|
|
483
539
|
md = md.replace(/<\/?[^>]+>/g, "");
|
|
540
|
+
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>");
|
|
484
541
|
md = md.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, " ");
|
|
485
542
|
md = md.replace(/\n{3,}/g, "\n\n");
|
|
486
543
|
return md.trim() + "\n";
|
|
487
544
|
}
|
|
545
|
+
function convertHtmlTable(inner) {
|
|
546
|
+
const stripped = inner.replace(/<\/?(thead|tbody)[^>]*>/gi, "");
|
|
547
|
+
const rowRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
|
|
548
|
+
const rows = [];
|
|
549
|
+
let m;
|
|
550
|
+
while ((m = rowRe.exec(stripped)) !== null) {
|
|
551
|
+
const rowHtml = m[1] ?? "";
|
|
552
|
+
const cellRe = /<(th|td)[^>]*>([\s\S]*?)<\/\1>/gi;
|
|
553
|
+
const cells = [];
|
|
554
|
+
let isHeader = false;
|
|
555
|
+
let cm;
|
|
556
|
+
while ((cm = cellRe.exec(rowHtml)) !== null) {
|
|
557
|
+
if ((cm[1] ?? "").toLowerCase() === "th") isHeader = true;
|
|
558
|
+
cells.push(normalizeTableCell(cm[2] ?? ""));
|
|
559
|
+
}
|
|
560
|
+
if (cells.length > 0) rows.push({ isHeader, cells });
|
|
561
|
+
}
|
|
562
|
+
if (rows.length === 0) return "";
|
|
563
|
+
let headerIdx = rows.findIndex((r) => r.isHeader);
|
|
564
|
+
if (headerIdx === -1) headerIdx = 0;
|
|
565
|
+
const header = rows[headerIdx].cells;
|
|
566
|
+
const body = rows.filter((_, i) => i !== headerIdx).map((r) => r.cells);
|
|
567
|
+
const cols = header.length;
|
|
568
|
+
const headerLine = "| " + header.join(" | ") + " |";
|
|
569
|
+
const sepLine = "| " + Array.from({ length: cols }, () => "---").join(" | ") + " |";
|
|
570
|
+
const bodyLines = body.map((cells) => {
|
|
571
|
+
const padded = Array.from({ length: cols }, (_, i) => cells[i] ?? "");
|
|
572
|
+
return "| " + padded.join(" | ") + " |";
|
|
573
|
+
});
|
|
574
|
+
return [headerLine, sepLine, ...bodyLines].join("\n") + "\n";
|
|
575
|
+
}
|
|
576
|
+
function normalizeTableCell(html) {
|
|
577
|
+
return html.replace(/<br\s*\/?>/gi, " ").replace(/<\/?[^>]+>/g, "").replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
|
|
578
|
+
}
|
|
579
|
+
function convertHtmlTaskList(items) {
|
|
580
|
+
const liRe = /<li[^>]*data-checked="(true|false)"[^>]*>([\s\S]*?)<\/li>/gi;
|
|
581
|
+
const out = [];
|
|
582
|
+
let m;
|
|
583
|
+
while ((m = liRe.exec(items)) !== null) {
|
|
584
|
+
const checked = m[1] === "true" ? "x" : " ";
|
|
585
|
+
const inner = String(m[2] ?? "").replace(/<\/?p[^>]*>/gi, "").trim();
|
|
586
|
+
out.push(`- [${checked}] ${inner}`);
|
|
587
|
+
}
|
|
588
|
+
return out.join("\n");
|
|
589
|
+
}
|
|
488
590
|
|
|
489
591
|
// src/index.ts
|
|
490
592
|
function createAmpless(opts) {
|
|
@@ -520,11 +622,14 @@ function createAmpless(opts) {
|
|
|
520
622
|
};
|
|
521
623
|
}
|
|
522
624
|
export {
|
|
625
|
+
COLOR_SCHEME_SETTING_KEY,
|
|
626
|
+
DEFAULT_COLOR_SCHEME,
|
|
523
627
|
createAmpless,
|
|
524
628
|
htmlToMarkdown,
|
|
525
629
|
markdownToHtml,
|
|
526
630
|
renderBody,
|
|
527
631
|
renderThemeCss,
|
|
528
632
|
tiptapToHtml,
|
|
529
|
-
tiptapToMarkdown
|
|
633
|
+
tiptapToMarkdown,
|
|
634
|
+
validateColorScheme
|
|
530
635
|
};
|
package/dist/middleware.d.ts
CHANGED
|
@@ -10,25 +10,37 @@ type MiddlewareFn = (request: NextRequest) => NextResponse;
|
|
|
10
10
|
*
|
|
11
11
|
* - hostname → siteId resolution (multi-site rewrites)
|
|
12
12
|
* - `/path` → `/site/<siteId>/path` internal rewrite
|
|
13
|
-
* - `<slug>.html` → `/site/<siteId>/raw/<slug>.html` (bare HTML route)
|
|
14
13
|
* - `?previewTheme=<name>` → `x-preview-theme` header forwarding
|
|
15
14
|
* - `Cache-Control: private, no-store` in multi-site mode (Amplify
|
|
16
15
|
* Hosting's CloudFront cache key doesn't include Host, so SSR
|
|
17
16
|
* responses would cross-contaminate at the same path)
|
|
18
17
|
*
|
|
18
|
+
* No-layout / bare-HTML routing is data-driven: the theme post
|
|
19
|
+
* dispatcher reads `post.metadata.no_layout` and redirects to
|
|
20
|
+
* `/raw/<slug>` when set. The plain prefix-prepend below picks up
|
|
21
|
+
* that path because the raw route lives at
|
|
22
|
+
* `app/site/<siteId>/raw/<slug>/route.ts` — no special middleware
|
|
23
|
+
* branch is needed.
|
|
24
|
+
*
|
|
19
25
|
* The factory captures the multi-site flag at construction time so the
|
|
20
26
|
* hot path stays a pair of cheap header lookups.
|
|
21
27
|
*/
|
|
22
28
|
declare function createAmplessMiddleware({ cmsConfig }: CreateMiddlewareOpts): MiddlewareFn;
|
|
23
29
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
30
|
+
* Reference matcher config — admin / api / login / static assets /
|
|
31
|
+
* amplify_outputs.json are excluded so middleware doesn't rewrite
|
|
32
|
+
* legitimate non-blog routes into the public site tree.
|
|
26
33
|
*
|
|
27
|
-
*
|
|
34
|
+
* **You can't re-export this directly.** Next.js 16's Turbopack
|
|
35
|
+
* requires `export const config` in `proxy.ts` (or `middleware.ts`)
|
|
36
|
+
* to be a statically analysable object literal — referencing an
|
|
37
|
+
* imported variable fails the build with:
|
|
38
|
+
* "Next.js can't recognize the exported `config` field in route.
|
|
39
|
+
* It needs to be a static object."
|
|
28
40
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* non-
|
|
41
|
+
* So the scaffold inlines the matcher into the user's `proxy.ts`.
|
|
42
|
+
* This export is kept as a reference for documentation and for
|
|
43
|
+
* non-Next.js callers that want to inspect the canonical matcher.
|
|
32
44
|
*/
|
|
33
45
|
declare const defaultMatcherConfig: {
|
|
34
46
|
matcher: string[];
|
package/dist/middleware.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// src/middleware.ts
|
|
2
2
|
import { NextResponse } from "next/server";
|
|
3
3
|
import { resolveSiteId, isMultiSite } from "ampless";
|
|
4
|
-
var RAW_HTML_PATH_RE = /^\/([^/]+\.html)$/;
|
|
5
4
|
function createAmplessMiddleware({ cmsConfig }) {
|
|
6
5
|
const MULTI_SITE = isMultiSite(cmsConfig);
|
|
7
6
|
return function middleware(request) {
|
|
@@ -12,18 +11,15 @@ function createAmplessMiddleware({ cmsConfig }) {
|
|
|
12
11
|
}
|
|
13
12
|
const url = request.nextUrl.clone();
|
|
14
13
|
if (!url.pathname.startsWith("/site/")) {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
url.pathname = `/site/${siteId}/raw/${rawMatch[1]}`;
|
|
18
|
-
} else {
|
|
19
|
-
const tail = url.pathname === "/" ? "" : url.pathname;
|
|
20
|
-
url.pathname = `/site/${siteId}${tail}`;
|
|
21
|
-
}
|
|
14
|
+
const tail = url.pathname === "/" ? "" : url.pathname;
|
|
15
|
+
url.pathname = `/site/${siteId}${tail}`;
|
|
22
16
|
}
|
|
23
17
|
const previewTheme = url.searchParams.get("previewTheme");
|
|
18
|
+
const previewColorScheme = url.searchParams.get("previewColorScheme");
|
|
24
19
|
const requestHeaders = new Headers(request.headers);
|
|
25
20
|
requestHeaders.set("x-site-id", siteId);
|
|
26
21
|
if (previewTheme) requestHeaders.set("x-preview-theme", previewTheme);
|
|
22
|
+
if (previewColorScheme) requestHeaders.set("x-preview-color-scheme", previewColorScheme);
|
|
27
23
|
const response = NextResponse.rewrite(url, { request: { headers: requestHeaders } });
|
|
28
24
|
response.headers.set("x-site-id", siteId);
|
|
29
25
|
if (MULTI_SITE) {
|
package/dist/routes/index.d.ts
CHANGED
|
@@ -2,21 +2,21 @@ import { Ampless } from '../index.js';
|
|
|
2
2
|
import 'ampless';
|
|
3
3
|
import 'next';
|
|
4
4
|
|
|
5
|
-
interface Ctx$
|
|
5
|
+
interface Ctx$4 {
|
|
6
6
|
params: Promise<{
|
|
7
7
|
siteId: string;
|
|
8
8
|
slug: string;
|
|
9
9
|
}>;
|
|
10
10
|
}
|
|
11
|
-
type OgRouteHandler = (req: Request, ctx: Ctx$
|
|
11
|
+
type OgRouteHandler = (req: Request, ctx: Ctx$4) => Promise<Response>;
|
|
12
12
|
declare function createOgRouteHandler(ampless: Ampless): OgRouteHandler;
|
|
13
13
|
|
|
14
|
-
interface Ctx$
|
|
14
|
+
interface Ctx$3 {
|
|
15
15
|
params: Promise<{
|
|
16
16
|
siteId: string;
|
|
17
17
|
}>;
|
|
18
18
|
}
|
|
19
|
-
type SitemapRouteHandler = (req: Request, ctx: Ctx$
|
|
19
|
+
type SitemapRouteHandler = (req: Request, ctx: Ctx$3) => Promise<Response>;
|
|
20
20
|
/**
|
|
21
21
|
* Sitemap route delegate. Looks up the active theme for the request's
|
|
22
22
|
* siteId and forwards to whichever `routes.sitemap` handler the theme
|
|
@@ -24,12 +24,12 @@ type SitemapRouteHandler = (req: Request, ctx: Ctx$2) => Promise<Response>;
|
|
|
24
24
|
*/
|
|
25
25
|
declare function createSitemapRouteHandler(ampless: Ampless): SitemapRouteHandler;
|
|
26
26
|
|
|
27
|
-
interface Ctx$
|
|
27
|
+
interface Ctx$2 {
|
|
28
28
|
params: Promise<{
|
|
29
29
|
siteId: string;
|
|
30
30
|
}>;
|
|
31
31
|
}
|
|
32
|
-
type FeedRouteHandler = (req: Request, ctx: Ctx$
|
|
32
|
+
type FeedRouteHandler = (req: Request, ctx: Ctx$2) => Promise<Response>;
|
|
33
33
|
/**
|
|
34
34
|
* Feed route delegate. Looks up the active theme for the request's
|
|
35
35
|
* siteId and forwards to whichever `routes.feed` handler the theme
|
|
@@ -37,21 +37,24 @@ type FeedRouteHandler = (req: Request, ctx: Ctx$1) => Promise<Response>;
|
|
|
37
37
|
*/
|
|
38
38
|
declare function createFeedRouteHandler(ampless: Ampless): FeedRouteHandler;
|
|
39
39
|
|
|
40
|
-
interface Ctx {
|
|
40
|
+
interface Ctx$1 {
|
|
41
41
|
params: Promise<{
|
|
42
42
|
siteId: string;
|
|
43
43
|
slug: string;
|
|
44
44
|
}>;
|
|
45
45
|
}
|
|
46
|
-
type RawRouteHandler = (req: Request, ctx: Ctx) => Promise<Response>;
|
|
46
|
+
type RawRouteHandler = (req: Request, ctx: Ctx$1) => Promise<Response>;
|
|
47
47
|
/**
|
|
48
48
|
* Bare HTML route. Returns the published post's rendered body as the
|
|
49
49
|
* entire HTTP response — no Next.js root layout, no theme chrome.
|
|
50
50
|
*
|
|
51
|
-
* Reached via the
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
51
|
+
* Reached via the data-driven dispatcher path: the theme post page
|
|
52
|
+
* checks `post.metadata.no_layout` and redirects to `/raw/<slug>` when
|
|
53
|
+
* set. Posts without that flag never hit this route in practice; the
|
|
54
|
+
* handler additionally enforces the same check here so a direct
|
|
55
|
+
* `/raw/<slug>` request can't bypass the theme for a post that wasn't
|
|
56
|
+
* marked no-layout (would otherwise surface the body without any
|
|
57
|
+
* chrome the editor didn't intend).
|
|
55
58
|
*
|
|
56
59
|
* Why this is a route handler (not a page): Next.js's root layout
|
|
57
60
|
* always emits `<html>` / `<head>` / `<body>`, which means a normal
|
|
@@ -74,4 +77,37 @@ type RawRouteHandler = (req: Request, ctx: Ctx) => Promise<Response>;
|
|
|
74
77
|
*/
|
|
75
78
|
declare function createRawRouteHandler(ampless: Ampless): RawRouteHandler;
|
|
76
79
|
|
|
77
|
-
|
|
80
|
+
interface Ctx {
|
|
81
|
+
params: Promise<{
|
|
82
|
+
siteId: string;
|
|
83
|
+
path: string[];
|
|
84
|
+
}>;
|
|
85
|
+
}
|
|
86
|
+
type StaticRouteHandler = (req: Request, ctx: Ctx) => Promise<Response>;
|
|
87
|
+
/**
|
|
88
|
+
* Catch-all route for static bundles. Lives at
|
|
89
|
+
* `app/site/[siteId]/[...path]/route.ts` and serves every file
|
|
90
|
+
* inside a `format: 'static'` post's bundle. The first segment of
|
|
91
|
+
* `path` is the post slug; the rest is the relative path inside the
|
|
92
|
+
* bundle (or empty, in which case the manifest's entrypoint is used).
|
|
93
|
+
*
|
|
94
|
+
* Routing precedence: Next.js prefers the more specific
|
|
95
|
+
* `[siteId]/[slug]/page.tsx` for single-segment URLs, so this catch-all
|
|
96
|
+
* only fires when path.length >= 2. The post dispatcher detects
|
|
97
|
+
* `format === 'static'` and 308-redirects to `/<slug>/<entrypoint>`
|
|
98
|
+
* so the catch-all picks up from there.
|
|
99
|
+
*
|
|
100
|
+
* Single-segment requests (`/<siteId>/<slug>`) that somehow do reach
|
|
101
|
+
* here are 308-redirected to add a trailing slash, which is what makes
|
|
102
|
+
* relative paths in the bundle resolve correctly in the browser.
|
|
103
|
+
*
|
|
104
|
+
* Implementation: generate a short-lived presigned URL for the S3
|
|
105
|
+
* object and 302-redirect to it. Same model as `/api/media/[...path]`
|
|
106
|
+
* — keeps the bucket private while letting CloudFront cache the 302
|
|
107
|
+
* for the Cache-Control window. The presigned URL itself expires in
|
|
108
|
+
* an hour; the 302 cache is 5 minutes so re-issued presigns stay
|
|
109
|
+
* fresh.
|
|
110
|
+
*/
|
|
111
|
+
declare function createStaticRouteHandler(ampless: Ampless): StaticRouteHandler;
|
|
112
|
+
|
|
113
|
+
export { type FeedRouteHandler, type OgRouteHandler, type RawRouteHandler, type SitemapRouteHandler, type StaticRouteHandler, createFeedRouteHandler, createOgRouteHandler, createRawRouteHandler, createSitemapRouteHandler, createStaticRouteHandler };
|
package/dist/routes/index.js
CHANGED
|
@@ -75,6 +75,9 @@ function createRawRouteHandler(ampless) {
|
|
|
75
75
|
if (!post) {
|
|
76
76
|
return new Response("Not Found", { status: 404 });
|
|
77
77
|
}
|
|
78
|
+
if (post.metadata?.no_layout !== true) {
|
|
79
|
+
return new Response("Not Found", { status: 404 });
|
|
80
|
+
}
|
|
78
81
|
return new Response(ampless.renderBody(post), {
|
|
79
82
|
status: 200,
|
|
80
83
|
headers: {
|
|
@@ -84,9 +87,78 @@ function createRawRouteHandler(ampless) {
|
|
|
84
87
|
});
|
|
85
88
|
};
|
|
86
89
|
}
|
|
90
|
+
|
|
91
|
+
// src/routes/static.ts
|
|
92
|
+
import { createServerRunner } from "@aws-amplify/adapter-nextjs";
|
|
93
|
+
import { cookies } from "next/headers";
|
|
94
|
+
import { getUrl } from "aws-amplify/storage/server";
|
|
95
|
+
var FALLBACK_MIME = {
|
|
96
|
+
".html": "text/html; charset=utf-8",
|
|
97
|
+
".htm": "text/html; charset=utf-8"
|
|
98
|
+
};
|
|
99
|
+
function createStaticRouteHandler(ampless) {
|
|
100
|
+
const { runWithAmplifyServerContext } = createServerRunner({
|
|
101
|
+
config: ampless.outputs
|
|
102
|
+
});
|
|
103
|
+
return async function GET(request, { params }) {
|
|
104
|
+
const { siteId, path } = await params;
|
|
105
|
+
if (!path || path.length === 0) {
|
|
106
|
+
return new Response("Not Found", { status: 404 });
|
|
107
|
+
}
|
|
108
|
+
const slug = path[0];
|
|
109
|
+
const restSegments = path.slice(1);
|
|
110
|
+
if (restSegments.some(
|
|
111
|
+
(s) => !s || s === "." || s === ".." || s.includes("/") || s.includes("\\") || s.includes("\0")
|
|
112
|
+
)) {
|
|
113
|
+
return new Response("Invalid path", { status: 400 });
|
|
114
|
+
}
|
|
115
|
+
const post = await ampless.getPublishedPost(slug, { siteId });
|
|
116
|
+
if (!post || post.format !== "static") {
|
|
117
|
+
return new Response("Not Found", { status: 404 });
|
|
118
|
+
}
|
|
119
|
+
const body = post.body ?? null;
|
|
120
|
+
const entrypoint = typeof body?.entrypoint === "string" && body.entrypoint ? body.entrypoint : "index.html";
|
|
121
|
+
const fileList = Array.isArray(body?.files) ? body.files : [];
|
|
122
|
+
let rest = restSegments.join("/");
|
|
123
|
+
if (rest === "") {
|
|
124
|
+
const url = new URL(request.url);
|
|
125
|
+
if (!url.pathname.endsWith("/")) {
|
|
126
|
+
const next = new URL(url);
|
|
127
|
+
next.pathname = `${url.pathname}/`;
|
|
128
|
+
return Response.redirect(next.toString(), 308);
|
|
129
|
+
}
|
|
130
|
+
rest = entrypoint;
|
|
131
|
+
}
|
|
132
|
+
if (fileList.length > 0 && !fileList.includes(rest)) {
|
|
133
|
+
return new Response("Not Found", { status: 404 });
|
|
134
|
+
}
|
|
135
|
+
const objectPath = `public/static/${siteId}/${slug}/${rest}`;
|
|
136
|
+
try {
|
|
137
|
+
const presignedUrl = await runWithAmplifyServerContext({
|
|
138
|
+
nextServerContext: { cookies },
|
|
139
|
+
operation: async (amplifyContext) => {
|
|
140
|
+
const result = await getUrl(amplifyContext, {
|
|
141
|
+
path: objectPath,
|
|
142
|
+
options: { expiresIn: 60 * 60 }
|
|
143
|
+
});
|
|
144
|
+
return result.url.toString();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
return Response.redirect(presignedUrl, 302);
|
|
148
|
+
} catch {
|
|
149
|
+
const ext = rest.slice(rest.lastIndexOf("."));
|
|
150
|
+
const fallback = FALLBACK_MIME[ext] ?? "text/plain; charset=utf-8";
|
|
151
|
+
return new Response("Not Found", {
|
|
152
|
+
status: 404,
|
|
153
|
+
headers: { "Content-Type": fallback }
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
|
87
158
|
export {
|
|
88
159
|
createFeedRouteHandler,
|
|
89
160
|
createOgRouteHandler,
|
|
90
161
|
createRawRouteHandler,
|
|
91
|
-
createSitemapRouteHandler
|
|
162
|
+
createSitemapRouteHandler,
|
|
163
|
+
createStaticRouteHandler
|
|
92
164
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ampless/runtime",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.11",
|
|
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.
|
|
52
|
-
"@ampless/plugin-og-image": "0.2.0-alpha.
|
|
52
|
+
"ampless": "0.2.0-alpha.7",
|
|
53
|
+
"@ampless/plugin-og-image": "0.2.0-alpha.7"
|
|
53
54
|
},
|
|
54
55
|
"peerDependencies": {
|
|
55
56
|
"next": "^15 || ^16",
|