@lynkow/next 0.1.0 → 0.2.0
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.md +21 -5
- package/dist/{chunk-4QTGOTFV.js → chunk-2HMFZKOE.js} +6 -5
- package/dist/chunk-2HMFZKOE.js.map +1 -0
- package/dist/image-loader.d.ts +6 -4
- package/dist/image-loader.js.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/proxy/index.d.ts +23 -23
- package/dist/proxy/index.js +24 -15
- package/dist/proxy/index.js.map +1 -1
- package/dist/server/index.d.ts +19 -22
- package/dist/server/index.js +40 -18
- package/dist/server/index.js.map +1 -1
- package/package.json +3 -3
- package/dist/chunk-4QTGOTFV.js.map +0 -1
package/README.md
CHANGED
|
@@ -11,7 +11,9 @@ stays framework-agnostic.
|
|
|
11
11
|
npm install @lynkow/next lynkow
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
It requires Next.js 16, React 18.2 or 19, and `lynkow` 1.60
|
|
14
|
+
It requires Next.js 16, React 18.2 or 19, and `lynkow` 1.60 (any 1.60.x). A
|
|
15
|
+
minor release of `lynkow` is a breaking one, so each minor of the SDK needs a
|
|
16
|
+
release of this adapter.
|
|
15
17
|
|
|
16
18
|
| Entry | For |
|
|
17
19
|
|---|---|
|
|
@@ -99,9 +101,15 @@ export const server = createLynkowServer({
|
|
|
99
101
|
```
|
|
100
102
|
|
|
101
103
|
- `server.textRoute(read, contentType)` serves a file Lynkow generates, such as
|
|
102
|
-
`sitemap.xml` or `llms.txt`, cached under the SEO tag, with its URLs
|
|
103
|
-
|
|
104
|
-
- `server.JsonLdScript` renders schema.org nodes as one JSON-LD script
|
|
104
|
+
`sitemap.xml` or `llms.txt`, cached under the SEO tag, with its URLs put on
|
|
105
|
+
their public form by `server.rewriteOrigin`.
|
|
106
|
+
- `server.JsonLdScript` renders schema.org nodes as one JSON-LD script, its
|
|
107
|
+
URLs put on their public form the same way.
|
|
108
|
+
- `server.rewriteOrigin(body, siteDomain)` moves the site's domain to this
|
|
109
|
+
deployment's origin and, with `prefixDefaultLocale` off, drops the default
|
|
110
|
+
locale's prefix the API always writes, so a sitemap lists `/about` rather
|
|
111
|
+
than `/en/about`, which would answer 308. Only absolute URLs on this origin
|
|
112
|
+
change.
|
|
105
113
|
- `server.pageMetadata(page, locale, path)` and `server.routeMetadata(...)`
|
|
106
114
|
build Next metadata, canonical and hreflang URLs included, through the locale
|
|
107
115
|
paths.
|
|
@@ -254,4 +262,12 @@ images: { loader: 'custom', loaderFile: './src/image-loader.ts' }
|
|
|
254
262
|
Lynkow media is resized at the Lynkow CDN, at the width `next/image` asks for,
|
|
255
263
|
never upscaled, in the best format the browser accepts, at quality 80 unless
|
|
256
264
|
the `quality` prop says otherwise. A relative URL, such as a file in `/public`,
|
|
257
|
-
comes back unchanged
|
|
265
|
+
comes back unchanged.
|
|
266
|
+
|
|
267
|
+
An absolute URL goes to the SDK's `media.transform`, which recognises Lynkow
|
|
268
|
+
media by the text of the URL alone, whatever its host: a URL containing
|
|
269
|
+
`/sites/` or `/avatars/`, even in its query string, or one the Lynkow CDN
|
|
270
|
+
already transformed. Such a URL on another host is rewritten into a resizing
|
|
271
|
+
URL on that same host, which that host is unlikely to serve. Any other absolute
|
|
272
|
+
URL comes back unchanged. Give an image from another host that matches the
|
|
273
|
+
`unoptimized` prop, so `next/image` does not call the loader for it.
|
|
@@ -30,8 +30,9 @@ function createLocalePaths(config) {
|
|
|
30
30
|
return (pathname === "/" ? segment : segment + pathname) + suffix;
|
|
31
31
|
}
|
|
32
32
|
function fromApiPath(path, locale) {
|
|
33
|
-
if (!path.startsWith("/")
|
|
34
|
-
const { pathname, suffix } = splitSuffix(path);
|
|
33
|
+
if (!path.startsWith("/")) return path;
|
|
34
|
+
const { pathname: raw, suffix } = splitSuffix(path);
|
|
35
|
+
const pathname = onSite(raw);
|
|
35
36
|
const leading = leadingLocale(pathname);
|
|
36
37
|
return leading ? localePath(leading.locale, leading.rest + suffix) : localePath(locale, pathname + suffix);
|
|
37
38
|
}
|
|
@@ -54,7 +55,7 @@ function createLocalePaths(config) {
|
|
|
54
55
|
const [tag = "", ...params] = part.trim().split(";");
|
|
55
56
|
const q = params.find((param) => param.trim().startsWith("q="));
|
|
56
57
|
return { tag: tag.trim().toLowerCase(), q: q ? Number.parseFloat(q.split("=")[1] ?? "") : 1 };
|
|
57
|
-
}).filter((entry) => entry.tag.length > 0 &&
|
|
58
|
+
}).filter((entry) => entry.tag.length > 0 && entry.q > 0).sort((a, b) => b.q - a.q);
|
|
58
59
|
for (const { tag } of ranked) {
|
|
59
60
|
const exact = byKey.get(tag);
|
|
60
61
|
if (exact) return exact;
|
|
@@ -91,5 +92,5 @@ function splitSuffix(path) {
|
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
export { ABSOLUTE_HREF, createLocalePaths };
|
|
94
|
-
//# sourceMappingURL=chunk-
|
|
95
|
-
//# sourceMappingURL=chunk-
|
|
95
|
+
//# sourceMappingURL=chunk-2HMFZKOE.js.map
|
|
96
|
+
//# sourceMappingURL=chunk-2HMFZKOE.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/locale-paths.ts"],"names":[],"mappings":"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","file":"chunk-2HMFZKOE.js","sourcesContent":["import type { LynkowNextConfig } from './config.js'\n\n/** An href that names its own scheme (`https:`, `mailto:`), or a protocol-relative one. */\nexport const ABSOLUTE_HREF = /^([a-z][a-z0-9+.-]*:|\\/\\/)/i\n\n/**\n * The one place a public path gets its locale, bound to a configuration.\n *\n * Every localized path of a site goes through {@link LocalePaths.localePath}\n * or {@link LocalePaths.fromApiPath}. That is what makes `prefixDefaultLocale`\n * hold everywhere: a path built by hand as `/${locale}/...` is right with the\n * flag on and wrong with it off.\n */\nexport interface LocalePaths {\n readonly locales: readonly string[]\n readonly defaultLocale: string\n readonly prefixDefaultLocale: boolean\n\n /** The configured spelling of a locale, matched case-insensitively, or null. */\n toLocale(value: string | null | undefined): string | null\n\n /** Whether a value names a configured locale, in any case. */\n isLocale(value: string | null | undefined): value is string\n\n /**\n * The locale a path belongs to. A path whose first segment names a locale\n * belongs to it. Any other path belongs to the default locale when the\n * default locale is served unprefixed, and to none otherwise. That includes\n * paths that are not pages at all, such as `/_next/...`, so a caller that\n * routes requests filters those first.\n */\n localeFromPath(pathname: string): string | null\n\n /**\n * The public path of `path` in `locale`: `/fr/blog`, or `/blog` for the\n * default locale when it is served unprefixed. The locale segment is written\n * lowercased, as the API writes it. A query or fragment is kept. A leading\n * run of slashes or backslashes collapses to one, so `//evil.example` stays a\n * path once a default locale is stripped. Dot segments are left as written:\n * `/.//evil.example` resolves to `//evil.example` in `new URL()`, so a caller\n * that resolves the result checks the resolved path, as the proxy does.\n *\n * @throws {RangeError} When `locale` is not a configured locale.\n */\n localePath(locale: string, path?: string): string\n\n /**\n * The public path of a path the API returned.\n *\n * On a multilingual site the API prefixes every path with its row's locale,\n * the default one included, so `/en/blog/post` is mapped to `/blog/post`\n * when the default locale is served unprefixed. A path with no locale\n * segment, as on a single-locale site, belongs to `locale`. Anything that is\n * not a root-relative path is returned unchanged.\n *\n * The API never returns a protocol-relative URL, so a leading run of slashes\n * or backslashes collapses to one before the locale is read: on a\n * single-locale site, a blog prefix of `/evil.example` makes an article path\n * `//evil.example/post`, which stays `/evil.example/post` on this site.\n */\n fromApiPath(path: string, locale: string): string\n\n /**\n * An href from CMS content, made to lead to `locale`.\n *\n * Navigation blocks store locale-agnostic hrefs (`/blog`, `/contact`), which\n * is right for an editor, so the locale is added at render: without it every\n * internal link goes through a redirect. An href that already names a locale\n * keeps it, normalized like {@link fromApiPath}. Absolute URLs, `mailto:` and\n * `tel:` links, anchors, query-only and relative hrefs are left untouched.\n */\n localizeHref(href: string, locale: string): string\n\n /**\n * The locale a link leads to on this site, or null when it leads elsewhere:\n * another origin, a `mailto:`, an anchor, a relative path. An absolute URL\n * on the site's own origin counts as this site, which catches an editor's\n * pasted `https://<site>/fr/...`.\n */\n hrefLocale(href: string): string | null\n\n /**\n * The best match between an `Accept-Language` header and the configured\n * locales, falling back to the default locale rather than to a 404. A\n * language the header marks `q=0` is one the browser refuses, so it is\n * never the match.\n */\n negotiateLocale(acceptLanguage: string | null | undefined): string\n\n /** `path` on the site's origin. */\n absoluteUrl(path: string): string\n}\n\n/** Binds the locale path helpers to a configuration from {@link resolveConfig}. */\nexport function createLocalePaths(config: LynkowNextConfig): LocalePaths {\n const { locales, defaultLocale, prefixDefaultLocale, siteUrl } = config\n const byKey = new Map(locales.map((locale) => [locale.toLowerCase(), locale]))\n const site = new URL(siteUrl)\n\n function toLocale(value: string | null | undefined): string | null {\n return value ? (byKey.get(value.toLowerCase()) ?? null) : null\n }\n\n /** The locale named by the first segment of a pathname, and what follows it. */\n function leadingLocale(pathname: string): { locale: string; rest: string } | null {\n const end = pathname.indexOf('/', 1)\n const locale = toLocale(end === -1 ? pathname.slice(1) : pathname.slice(1, end))\n if (!locale) return null\n return { locale, rest: end === -1 ? '/' : pathname.slice(end) }\n }\n\n function localeFromPath(pathname: string): string | null {\n return leadingLocale(pathname)?.locale ?? (prefixDefaultLocale ? null : defaultLocale)\n }\n\n function localePath(locale: string, path = '/'): string {\n const canonical = toLocale(locale)\n if (!canonical) {\n throw new RangeError(\n `${JSON.stringify(locale)} is not one of the configured locales (${locales.join(', ')})`\n )\n }\n const { pathname: raw, suffix } = splitSuffix(path.startsWith('/') ? path : `/${path}`)\n const pathname = onSite(raw)\n if (!prefixDefaultLocale && canonical === defaultLocale) return pathname + suffix\n const segment = `/${canonical.toLowerCase()}`\n return (pathname === '/' ? segment : segment + pathname) + suffix\n }\n\n function fromApiPath(path: string, locale: string): string {\n if (!path.startsWith('/')) return path\n const { pathname: raw, suffix } = splitSuffix(path)\n const pathname = onSite(raw)\n const leading = leadingLocale(pathname)\n return leading\n ? localePath(leading.locale, leading.rest + suffix)\n : localePath(locale, pathname + suffix)\n }\n\n function localizeHref(href: string, locale: string): string {\n if (!href || ABSOLUTE_HREF.test(href) || !href.startsWith('/')) return href\n return fromApiPath(href, locale)\n }\n\n function hrefLocale(href: string): string | null {\n if (!ABSOLUTE_HREF.test(href) && !href.startsWith('/')) return null\n try {\n const url = new URL(href, site)\n return url.origin === site.origin ? localeFromPath(url.pathname) : null\n } catch {\n return null\n }\n }\n\n function negotiateLocale(acceptLanguage: string | null | undefined): string {\n if (!acceptLanguage) return defaultLocale\n\n const ranked = acceptLanguage\n .split(',')\n .map((part) => {\n const [tag = '', ...params] = part.trim().split(';')\n const q = params.find((param) => param.trim().startsWith('q='))\n return { tag: tag.trim().toLowerCase(), q: q ? Number.parseFloat(q.split('=')[1] ?? '') : 1 }\n })\n // `q=0` means \"not acceptable\", so it never matches. A q that is not a\n // number is dropped too: `NaN > 0` is false.\n .filter((entry) => entry.tag.length > 0 && entry.q > 0)\n .sort((a, b) => b.q - a.q)\n\n for (const { tag } of ranked) {\n const exact = byKey.get(tag)\n if (exact) return exact\n\n const base = tag.split('-')[0]\n const partial = locales.find((code) => code.toLowerCase().split('-')[0] === base)\n if (partial) return partial\n }\n\n return defaultLocale\n }\n\n function absoluteUrl(path: string): string {\n return `${siteUrl}${path.startsWith('/') ? path : `/${path}`}`\n }\n\n return {\n locales,\n defaultLocale,\n prefixDefaultLocale,\n toLocale,\n isLocale: (value): value is string => toLocale(value) !== null,\n localeFromPath,\n localePath,\n fromApiPath,\n localizeHref,\n hrefLocale,\n negotiateLocale,\n absoluteUrl,\n }\n}\n\n/**\n * A root-relative pathname that a browser cannot read as another host. The URL\n * parser drops tabs and newlines anywhere and reads a backslash as a slash, so\n * `/\\t/evil.example` and `/\\evil.example` are both `//evil.example` to it.\n */\nfunction onSite(pathname: string): string {\n return pathname.replace(/[\\t\\n\\r]/g, '').replace(/^[/\\\\]+/, '/')\n}\n\n/** A path split before its query string or fragment. */\nfunction splitSuffix(path: string): { pathname: string; suffix: string } {\n const index = path.search(/[?#]/)\n return index === -1\n ? { pathname: path, suffix: '' }\n : { pathname: path.slice(0, index), suffix: path.slice(index) }\n}\n"]}
|
package/dist/image-loader.d.ts
CHANGED
|
@@ -15,10 +15,12 @@ interface ImageLoaderParams {
|
|
|
15
15
|
* calling the loader once per candidate width.
|
|
16
16
|
*
|
|
17
17
|
* A relative URL, a file in `/public`, comes back unchanged: Lynkow media is
|
|
18
|
-
* always absolute, and the SDK recognises it by
|
|
19
|
-
* `/sites/logo.png` would otherwise be sent to a resizing endpoint this
|
|
20
|
-
* does not have.
|
|
21
|
-
*
|
|
18
|
+
* always absolute, and the SDK recognises it by the text of the URL alone, so
|
|
19
|
+
* a local `/sites/logo.png` would otherwise be sent to a resizing endpoint this
|
|
20
|
+
* server does not have. For the same reason an absolute URL on any host that
|
|
21
|
+
* contains `/sites/` or `/avatars/`, even in its query string, is rewritten
|
|
22
|
+
* into a resizing URL on its own host. Any other absolute URL comes back
|
|
23
|
+
* unchanged.
|
|
22
24
|
*
|
|
23
25
|
* Next wants a FILE for a custom loader, so re-export it from one:
|
|
24
26
|
*
|
package/dist/image-loader.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/image-loader.ts"],"names":[],"mappings":";;;AAGA,IAAM,KAAA,GAAQ,IAAI,kBAAA,EAAmB;
|
|
1
|
+
{"version":3,"sources":["../src/image-loader.ts"],"names":[],"mappings":";;;AAGA,IAAM,KAAA,GAAQ,IAAI,kBAAA,EAAmB;AAoCtB,SAAR,iBAAA,CAAmC,EAAE,GAAA,EAAK,KAAA,EAAO,SAAQ,EAA8B;AAC5F,EAAA,IAAI,CAAC,eAAA,CAAgB,IAAA,CAAK,GAAG,GAAG,OAAO,GAAA;AACvC,EAAA,OACE,KAAA,CAAM,UAAU,GAAA,EAAK;AAAA,IACnB,CAAA,EAAG,KAAA;AAAA;AAAA;AAAA,IAGH,GAAA,EAAK,YAAA;AAAA;AAAA;AAAA,IAGL,MAAA,EAAQ,MAAA;AAAA,IACR,SAAS,OAAA,IAAW;AAAA,GACrB,CAAA,IAAK,GAAA;AAEV","file":"image-loader.js","sourcesContent":["import { MediaHelperService } from 'lynkow'\n\n/** Stateless and pure: it only rewrites a URL, so one instance serves every call. */\nconst media = new MediaHelperService()\n\n/** What `next/image` passes to a custom loader. */\nexport interface ImageLoaderParams {\n src: string\n width: number\n quality?: number\n}\n\n/**\n * A `next/image` loader that resizes Lynkow media on the Lynkow CDN.\n *\n * Next's own optimizer downloads the original, resizes it on the app server\n * and caches the result on that server's disk. Lynkow already resizes at the\n * CDN edge, close to the visitor and cached there, so with this loader the app\n * server never touches an image. Everything else about `next/image` stays:\n * lazy loading, `priority`, reserved space, and the srcset Next builds by\n * calling the loader once per candidate width.\n *\n * A relative URL, a file in `/public`, comes back unchanged: Lynkow media is\n * always absolute, and the SDK recognises it by the text of the URL alone, so\n * a local `/sites/logo.png` would otherwise be sent to a resizing endpoint this\n * server does not have. For the same reason an absolute URL on any host that\n * contains `/sites/` or `/avatars/`, even in its query string, is rewritten\n * into a resizing URL on its own host. Any other absolute URL comes back\n * unchanged.\n *\n * Next wants a FILE for a custom loader, so re-export it from one:\n *\n * @example\n * // src/image-loader.ts\n * export { default } from '@lynkow/next/image-loader'\n *\n * // next.config.ts\n * images: { loader: 'custom', loaderFile: './src/image-loader.ts' }\n */\nexport default function lynkowImageLoader({ src, width, quality }: ImageLoaderParams): string {\n if (!/^https?:\\/\\//i.test(src)) return src\n return (\n media.transform(src, {\n w: width,\n // `scale-down` never upscales, so a small original stays small rather than\n // being blown up to the layout's widest candidate.\n fit: 'scale-down',\n // The CDN negotiates the format from `Accept`, so a modern browser gets\n // WebP or AVIF without the application knowing which.\n format: 'auto',\n quality: quality ?? 80,\n }) || src\n )\n}\n\nexport { lynkowImageLoader }\n"]}
|
package/dist/index.d.ts
CHANGED
|
@@ -49,6 +49,11 @@ interface LocalePaths {
|
|
|
49
49
|
* when the default locale is served unprefixed. A path with no locale
|
|
50
50
|
* segment, as on a single-locale site, belongs to `locale`. Anything that is
|
|
51
51
|
* not a root-relative path is returned unchanged.
|
|
52
|
+
*
|
|
53
|
+
* The API never returns a protocol-relative URL, so a leading run of slashes
|
|
54
|
+
* or backslashes collapses to one before the locale is read: on a
|
|
55
|
+
* single-locale site, a blog prefix of `/evil.example` makes an article path
|
|
56
|
+
* `//evil.example/post`, which stays `/evil.example/post` on this site.
|
|
52
57
|
*/
|
|
53
58
|
fromApiPath(path: string, locale: string): string;
|
|
54
59
|
/**
|
|
@@ -70,7 +75,9 @@ interface LocalePaths {
|
|
|
70
75
|
hrefLocale(href: string): string | null;
|
|
71
76
|
/**
|
|
72
77
|
* The best match between an `Accept-Language` header and the configured
|
|
73
|
-
* locales, falling back to the default locale rather than to a 404.
|
|
78
|
+
* locales, falling back to the default locale rather than to a 404. A
|
|
79
|
+
* language the header marks `q=0` is one the browser refuses, so it is
|
|
80
|
+
* never the match.
|
|
74
81
|
*/
|
|
75
82
|
negotiateLocale(acceptLanguage: string | null | undefined): string;
|
|
76
83
|
/** `path` on the site's origin. */
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { REVALIDATE, TAGS, cached, uncached } from './chunk-XNN4SECG.js';
|
|
2
|
-
import { createLocalePaths } from './chunk-
|
|
3
|
-
export { ABSOLUTE_HREF, createLocalePaths } from './chunk-
|
|
2
|
+
import { createLocalePaths } from './chunk-2HMFZKOE.js';
|
|
3
|
+
export { ABSOLUTE_HREF, createLocalePaths } from './chunk-2HMFZKOE.js';
|
|
4
4
|
import { createClient } from 'lynkow';
|
|
5
5
|
|
|
6
6
|
// src/config.ts
|
|
@@ -155,11 +155,13 @@ function trackEvent(type, data) {
|
|
|
155
155
|
const global = tracker();
|
|
156
156
|
if (!global) return false;
|
|
157
157
|
global.track({
|
|
158
|
-
event_type: type,
|
|
159
158
|
page_path: window.location.pathname,
|
|
160
159
|
page_url: window.location.href,
|
|
161
160
|
page_title: document.title,
|
|
162
|
-
|
|
161
|
+
// A page field in `data` wins; `event_type` never does, as in the SDK's
|
|
162
|
+
// `trackEvent`, so a stray key cannot contradict `type`.
|
|
163
|
+
...data,
|
|
164
|
+
event_type: type
|
|
163
165
|
});
|
|
164
166
|
return true;
|
|
165
167
|
}
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/config.ts","../src/clients.ts","../src/analytics/events.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * The configuration a Lynkow site hands this adapter, and the one function\n * that validates it.\n *\n * Nothing in this package reads `process.env`. Next inlines `NEXT_PUBLIC_*`\n * variables at build time by textual substitution, so only a literal\n * `process.env.NEXT_PUBLIC_X` written in the application's own source reaches\n * the browser bundle, and a library cannot spell out names an application\n * chose. The application reads its environment and passes the values here.\n *\n * Everything in the resolved configuration is public, because it reaches\n * client bundles. A server secret, such as the webhook signing secret, never\n * belongs in it.\n */\n\n/** What an application passes to {@link resolveConfig}, typically straight from its environment. */\nexport interface LynkowNextConfigInput {\n /** The site's UUID, shown in the Lynkow admin and returned by the MCP `list_sites` tool. */\n siteId: string | undefined\n /** The public origin of the site, such as `https://www.example.com`. No path, query or fragment. */\n siteUrl: string | undefined\n /**\n * The locales the site serves, as an array or a comma-separated list, in the\n * order they should be advertised. Must mirror the site's enabled locales.\n */\n locales: string | readonly string[] | undefined\n /** The default locale. Defaults to the first of `locales`. */\n defaultLocale?: string | undefined\n /**\n * Whether the default locale carries its prefix in public URLs. `true`, the\n * default, serves `/en/about` and `/fr/a-propos`; `false` serves `/about` and\n * `/fr/a-propos`. The strings `\"true\"` and `\"false\"` are accepted too, so an\n * environment variable can be passed as it is.\n */\n prefixDefaultLocale?: boolean | string | undefined\n /** The Lynkow API origin. Defaults to `https://api.lynkow.com`. */\n apiUrl?: string | undefined\n /** The site's publishable key (`lkw_pk_...`), which storefront reads need. */\n publishableKey?: string | undefined\n}\n\n/** A validated configuration, frozen. Every field is safe to ship to the browser. */\nexport interface LynkowNextConfig {\n readonly siteId: string\n /** The site's origin, with no trailing slash, so `${siteUrl}${path}` is always well formed. */\n readonly siteUrl: string\n /** The API origin, with no trailing slash. */\n readonly apiUrl: string\n readonly publishableKey: string | undefined\n /** The locales as configured, in their configured case. URLs carry them lowercased. */\n readonly locales: readonly string[]\n /** One of `locales`, in its configured case. */\n readonly defaultLocale: string\n readonly prefixDefaultLocale: boolean\n}\n\n/**\n * Names to use for each field in error messages, such as the environment\n * variable the application reads it from.\n */\nexport type LynkowNextConfigLabels = Partial<Record<keyof LynkowNextConfigInput, string>>\n\n/**\n * The SDK's own default API origin, which it does not export. Repeated here\n * because the analytics tracker is a plain script tag rather than an SDK call,\n * and needs a real URL.\n */\nconst DEFAULT_API_URL = 'https://api.lynkow.com'\n\n/**\n * A DNS name or an IP literal. The URL standard lets a host carry `\"`, `'`, `&`\n * or a backtick, and the origin is pasted as text into JSON-LD, XML sitemaps and\n * HTML attributes, so only what a real host is made of passes.\n */\nconst HOST = /^(?:[a-z0-9_-]+(?:\\.[a-z0-9_-]+)*\\.?|\\[[0-9a-f:.]+\\])$/i\n\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\n/**\n * A language subtag, then optional region or variant subtags: `en`, `fr`,\n * `pt-BR`. Strict on purpose: a locale becomes a path segment, and a value\n * such as `//evil.example` or `en/../fr` would turn a localized link into one\n * that leaves the site or lands on another locale.\n */\nconst LOCALE = /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/\n\n/**\n * Validates a configuration and fills in its defaults.\n *\n * It throws from a call the application made, never while this module is\n * imported, so a site owner gets an error they can read rather than a stack\n * trace out of a dependency's import graph.\n *\n * @param input - The values, typically read from the application's environment\n * @param labels - Optional names for the fields in error messages, such as\n * `{ siteId: 'NEXT_PUBLIC_LYNKOW_SITE_ID' }`\n * @throws {TypeError} Listing every problem found, one per line\n *\n * @example\n * export const config = resolveConfig({\n * siteId: process.env.NEXT_PUBLIC_LYNKOW_SITE_ID,\n * siteUrl: process.env.NEXT_PUBLIC_SITE_URL,\n * locales: process.env.NEXT_PUBLIC_LOCALES,\n * })\n */\nexport function resolveConfig(\n input: LynkowNextConfigInput,\n labels: LynkowNextConfigLabels = {}\n): LynkowNextConfig {\n const problems: string[] = []\n const name = (field: keyof LynkowNextConfigInput) => `\\`${labels[field] ?? field}\\``\n\n const siteId = input.siteId?.trim() ?? ''\n if (!siteId) problems.push(`${name('siteId')} is required`)\n else if (!UUID.test(siteId)) problems.push(`${name('siteId')} is not a site UUID: ${quote(siteId)}`)\n\n const rawSiteUrl = input.siteUrl?.trim() ?? ''\n if (!rawSiteUrl) problems.push(`${name('siteUrl')} is required`)\n const siteUrl = rawSiteUrl ? parseOrigin(name('siteUrl'), rawSiteUrl, problems) : undefined\n\n const rawApiUrl = input.apiUrl?.trim() ?? ''\n const apiUrl = rawApiUrl ? parseOrigin(name('apiUrl'), rawApiUrl, problems) : DEFAULT_API_URL\n\n const locales = parseLocales(name('locales'), input.locales, problems)\n\n const wanted = input.defaultLocale?.trim() ?? ''\n const defaultLocale = wanted\n ? locales.find((locale) => locale.toLowerCase() === wanted.toLowerCase())\n : locales[0]\n if (wanted && locales.length > 0 && !defaultLocale) {\n problems.push(\n `${name('defaultLocale')} is ${quote(wanted)}, which is not one of the locales (${locales.join(', ')})`\n )\n }\n\n const prefixDefaultLocale = parseFlag(\n name('prefixDefaultLocale'),\n input.prefixDefaultLocale,\n true,\n problems\n )\n\n // An allowlist, not a denylist of `lkw_sk_`: legacy secret tokens are bare\n // hex and still authenticate, and every publishable key is `lkw_pk_`.\n const publishableKey = input.publishableKey?.trim() || undefined\n if (publishableKey && !publishableKey.startsWith('lkw_pk_')) {\n problems.push(\n `${name('publishableKey')} is not a publishable key (lkw_pk_...). This configuration reaches the browser, so it must never hold a secret API key`\n )\n }\n\n if (problems.length > 0 || !siteUrl || !apiUrl || !defaultLocale) {\n throw new TypeError(`Invalid Lynkow configuration:\\n - ${problems.join('\\n - ')}`)\n }\n\n return Object.freeze({\n siteId: siteId.toLowerCase(),\n siteUrl,\n apiUrl,\n publishableKey,\n locales: Object.freeze([...locales]),\n defaultLocale,\n prefixDefaultLocale,\n })\n}\n\n/**\n * Parses an http(s) origin. Whitespace and control characters are refused\n * before the URL parser sees the value, because it silently strips tabs and\n * newlines, and a newline in a site URL written into robots.txt is a line of\n * its own.\n */\nfunction parseOrigin(field: string, raw: string, problems: string[]): string | undefined {\n if (hasUnsafeCharacter(raw)) {\n problems.push(`${field} contains whitespace or a control character`)\n return undefined\n }\n let url: URL\n try {\n url = new URL(raw)\n } catch {\n problems.push(`${field} is not an absolute URL${echoUrl(raw)}`)\n return undefined\n }\n if (url.protocol !== 'http:' && url.protocol !== 'https:') {\n problems.push(`${field} must use http or https${echoUrl(raw)}`)\n } else if (url.username || url.password) {\n problems.push(`${field} must not carry credentials`)\n } else if (!HOST.test(url.hostname)) {\n problems.push(`${field} has a host with characters no domain carries${echoUrl(raw)}`)\n } else if (!/^\\/+$/.test(url.pathname) || url.search || url.hash) {\n problems.push(`${field} must be an origin, with no path, query or fragment${echoUrl(raw)}`)\n } else {\n return url.origin\n }\n return undefined\n}\n\nfunction parseLocales(\n field: string,\n input: string | readonly string[] | undefined,\n problems: string[]\n): string[] {\n const list = typeof input === 'string' ? input.split(',') : [...(input ?? [])]\n const locales = list.map((locale) => String(locale).trim()).filter(Boolean)\n if (locales.length === 0) {\n problems.push(`${field} must name at least one locale`)\n return []\n }\n const seen = new Set<string>()\n for (const locale of locales) {\n if (!LOCALE.test(locale)) {\n problems.push(`${field} has an invalid locale ${quote(locale)}, expected a code such as \"en\" or \"pt-BR\"`)\n }\n const key = locale.toLowerCase()\n if (seen.has(key)) problems.push(`${field} lists ${quote(locale)} twice`)\n seen.add(key)\n }\n return locales\n}\n\nfunction parseFlag(\n field: string,\n value: boolean | string | undefined,\n fallback: boolean,\n problems: string[]\n): boolean {\n if (typeof value === 'boolean') return value\n const normalized = value?.trim().toLowerCase() ?? ''\n if (normalized === '') return fallback\n if (normalized === 'true') return true\n if (normalized === 'false') return false\n problems.push(`${field} must be true or false, not ${quote(String(value))}`)\n return fallback\n}\n\nfunction hasUnsafeCharacter(value: string): boolean {\n if (/\\s/.test(value)) return true\n for (let index = 0; index < value.length; index++) {\n const code = value.charCodeAt(index)\n if (code < 0x20 || code === 0x7f) return true\n }\n return false\n}\n\n/**\n * A URL value appended to an error message, or nothing when it holds an `@`:\n * a value the parser refused can still carry credentials it never extracted.\n */\nfunction echoUrl(raw: string): string {\n return raw.includes('@') ? '' : `: ${quote(raw)}`\n}\n\n/** A value quoted for an error message, cut short so a pasted blob stays readable. */\nfunction quote(value: string): string {\n return JSON.stringify(value.length > 120 ? `${value.slice(0, 120)}...` : value)\n}\n","import { createClient, type Client } from 'lynkow'\nimport type { LynkowNextConfig } from './config.js'\nimport { createLocalePaths } from './locale-paths.js'\n\n/** Options of the client {@link LynkowClients} returns. */\nexport interface ClientOptions {\n /**\n * `false` returns a client that sends each read once instead of retrying a\n * 429 or a 503. Defaults to `true`.\n */\n retry?: boolean\n}\n\n/** Returns the Lynkow client of a locale, the default one when none is given. */\nexport type LynkowClients = (locale?: string, options?: ClientOptions) => Client\n\n/**\n * One Lynkow client per locale, reused across requests.\n *\n * The SDK's own cache stays OFF. On the server it is a process-global\n * unbounded `Map`, which would duplicate and outlive Next's data cache without\n * any of its invalidation. Caching is Next's job, expressed per read with\n * `cached()`.\n *\n * `{ retry: false }` gives a second client for the locale, because the SDK's\n * retry is a client setting rather than a per-read one. Only a read that\n * already knows what to do when it fails should ask for it.\n *\n * FOR READS THAT ARE THE SAME FOR EVERY VISITOR. The clients are shared by\n * every request the process serves, and the SDK keeps a buyer's state on the\n * client instance: the guest cart token and the signed-in customer session.\n * Calling `cart` or `customers` on one of these from server code would hand\n * one visitor's cart or session to the next. Buyer flows run in the browser,\n * or on a client created for that one request.\n *\n * A locale outside the configuration throws a `RangeError` instead of creating\n * a client. The clients live as long as the process, so a locale taken from a\n * URL and passed through unchecked would grow that set without bound. The\n * locale is matched case-insensitively and the client gets its configured\n * spelling.\n */\nexport function createClients(config: LynkowNextConfig): LynkowClients {\n const { toLocale } = createLocalePaths(config)\n const clients = new Map<string, Client>()\n\n return function lynkow(locale = config.defaultLocale, { retry = true } = {}) {\n const canonical = toLocale(locale)\n if (!canonical) {\n throw new RangeError(\n `${JSON.stringify(locale)} is not one of the configured locales (${config.locales.join(', ')})`\n )\n }\n\n const key = retry ? canonical : `${canonical}:no-retry`\n const existing = clients.get(key)\n if (existing) return existing\n\n const client = createClient({\n siteId: config.siteId,\n locale: canonical,\n baseUrl: config.apiUrl,\n ...(config.publishableKey ? { publishableKey: config.publishableKey } : {}),\n cache: false,\n retry,\n })\n clients.set(key, client)\n return client\n }\n}\n","import type { AnalyticsEventType } from 'lynkow'\n\nexport type { AnalyticsEventType }\n\ninterface TrackerGlobal {\n track: (event: Record<string, unknown>) => void\n}\n\nfunction tracker(): TrackerGlobal | null {\n if (typeof window === 'undefined') return null\n const global = (window as unknown as { LynkowAnalytics?: TrackerGlobal }).LynkowAnalytics\n return global && typeof global.track === 'function' ? global : null\n}\n\n/**\n * Sends one custom event through the tracker already on the page.\n *\n * `type` is a CLOSED enum of mechanical interactions, the SDK's\n * `AnalyticsEventType`: there is no free-form event name. A value outside it is\n * a compile error rather than a silently dropped row, since the collector\n * answers `204` to a payload it rejects.\n *\n * Returns `false` when no tracker has loaded, which is never an error: on an\n * `opt-in` site none loads before the visitor accepts analytics. `true` means\n * the event was handed to the tracker, which adds the session id, the visitor\n * id and the timestamp, and drops the event itself when the visitor refused on\n * an `opt-in` or `opt-out` site.\n *\n * The tracker already records pageviews, scroll, clicks, form events and Core\n * Web Vitals. Reach for this only for an interaction that escapes it, such as a\n * form submitted through `fetch` without a real `submit` event.\n */\nexport function trackEvent(type: AnalyticsEventType, data?: Record<string, unknown>): boolean {\n const global = tracker()\n if (!global) return false\n\n global.track({\n event_type: type,\n page_path: window.location.pathname,\n page_url: window.location.href,\n page_title: document.title,\n ...data,\n })\n return true\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/config.ts","../src/clients.ts","../src/analytics/events.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * The configuration a Lynkow site hands this adapter, and the one function\n * that validates it.\n *\n * Nothing in this package reads `process.env`. Next inlines `NEXT_PUBLIC_*`\n * variables at build time by textual substitution, so only a literal\n * `process.env.NEXT_PUBLIC_X` written in the application's own source reaches\n * the browser bundle, and a library cannot spell out names an application\n * chose. The application reads its environment and passes the values here.\n *\n * Everything in the resolved configuration is public, because it reaches\n * client bundles. A server secret, such as the webhook signing secret, never\n * belongs in it.\n */\n\n/** What an application passes to {@link resolveConfig}, typically straight from its environment. */\nexport interface LynkowNextConfigInput {\n /** The site's UUID, shown in the Lynkow admin and returned by the MCP `list_sites` tool. */\n siteId: string | undefined\n /** The public origin of the site, such as `https://www.example.com`. No path, query or fragment. */\n siteUrl: string | undefined\n /**\n * The locales the site serves, as an array or a comma-separated list, in the\n * order they should be advertised. Must mirror the site's enabled locales.\n */\n locales: string | readonly string[] | undefined\n /** The default locale. Defaults to the first of `locales`. */\n defaultLocale?: string | undefined\n /**\n * Whether the default locale carries its prefix in public URLs. `true`, the\n * default, serves `/en/about` and `/fr/a-propos`; `false` serves `/about` and\n * `/fr/a-propos`. The strings `\"true\"` and `\"false\"` are accepted too, so an\n * environment variable can be passed as it is.\n */\n prefixDefaultLocale?: boolean | string | undefined\n /** The Lynkow API origin. Defaults to `https://api.lynkow.com`. */\n apiUrl?: string | undefined\n /** The site's publishable key (`lkw_pk_...`), which storefront reads need. */\n publishableKey?: string | undefined\n}\n\n/** A validated configuration, frozen. Every field is safe to ship to the browser. */\nexport interface LynkowNextConfig {\n readonly siteId: string\n /** The site's origin, with no trailing slash, so `${siteUrl}${path}` is always well formed. */\n readonly siteUrl: string\n /** The API origin, with no trailing slash. */\n readonly apiUrl: string\n readonly publishableKey: string | undefined\n /** The locales as configured, in their configured case. URLs carry them lowercased. */\n readonly locales: readonly string[]\n /** One of `locales`, in its configured case. */\n readonly defaultLocale: string\n readonly prefixDefaultLocale: boolean\n}\n\n/**\n * Names to use for each field in error messages, such as the environment\n * variable the application reads it from.\n */\nexport type LynkowNextConfigLabels = Partial<Record<keyof LynkowNextConfigInput, string>>\n\n/**\n * The SDK's own default API origin, which it does not export. Repeated here\n * because the analytics tracker is a plain script tag rather than an SDK call,\n * and needs a real URL.\n */\nconst DEFAULT_API_URL = 'https://api.lynkow.com'\n\n/**\n * A DNS name or an IP literal. The URL standard lets a host carry `\"`, `'`, `&`\n * or a backtick, and the origin is pasted as text into JSON-LD, XML sitemaps and\n * HTML attributes, so only what a real host is made of passes.\n */\nconst HOST = /^(?:[a-z0-9_-]+(?:\\.[a-z0-9_-]+)*\\.?|\\[[0-9a-f:.]+\\])$/i\n\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\n/**\n * A language subtag, then optional region or variant subtags: `en`, `fr`,\n * `pt-BR`. Strict on purpose: a locale becomes a path segment, and a value\n * such as `//evil.example` or `en/../fr` would turn a localized link into one\n * that leaves the site or lands on another locale.\n */\nconst LOCALE = /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/\n\n/**\n * Validates a configuration and fills in its defaults.\n *\n * It throws from a call the application made, never while this module is\n * imported, so a site owner gets an error they can read rather than a stack\n * trace out of a dependency's import graph.\n *\n * @param input - The values, typically read from the application's environment\n * @param labels - Optional names for the fields in error messages, such as\n * `{ siteId: 'NEXT_PUBLIC_LYNKOW_SITE_ID' }`\n * @throws {TypeError} Listing every problem found, one per line\n *\n * @example\n * export const config = resolveConfig({\n * siteId: process.env.NEXT_PUBLIC_LYNKOW_SITE_ID,\n * siteUrl: process.env.NEXT_PUBLIC_SITE_URL,\n * locales: process.env.NEXT_PUBLIC_LOCALES,\n * })\n */\nexport function resolveConfig(\n input: LynkowNextConfigInput,\n labels: LynkowNextConfigLabels = {}\n): LynkowNextConfig {\n const problems: string[] = []\n const name = (field: keyof LynkowNextConfigInput) => `\\`${labels[field] ?? field}\\``\n\n const siteId = input.siteId?.trim() ?? ''\n if (!siteId) problems.push(`${name('siteId')} is required`)\n else if (!UUID.test(siteId)) problems.push(`${name('siteId')} is not a site UUID: ${quote(siteId)}`)\n\n const rawSiteUrl = input.siteUrl?.trim() ?? ''\n if (!rawSiteUrl) problems.push(`${name('siteUrl')} is required`)\n const siteUrl = rawSiteUrl ? parseOrigin(name('siteUrl'), rawSiteUrl, problems) : undefined\n\n const rawApiUrl = input.apiUrl?.trim() ?? ''\n const apiUrl = rawApiUrl ? parseOrigin(name('apiUrl'), rawApiUrl, problems) : DEFAULT_API_URL\n\n const locales = parseLocales(name('locales'), input.locales, problems)\n\n const wanted = input.defaultLocale?.trim() ?? ''\n const defaultLocale = wanted\n ? locales.find((locale) => locale.toLowerCase() === wanted.toLowerCase())\n : locales[0]\n if (wanted && locales.length > 0 && !defaultLocale) {\n problems.push(\n `${name('defaultLocale')} is ${quote(wanted)}, which is not one of the locales (${locales.join(', ')})`\n )\n }\n\n const prefixDefaultLocale = parseFlag(\n name('prefixDefaultLocale'),\n input.prefixDefaultLocale,\n true,\n problems\n )\n\n // An allowlist, not a denylist of `lkw_sk_`: legacy secret tokens are bare\n // hex and still authenticate, and every publishable key is `lkw_pk_`.\n const publishableKey = input.publishableKey?.trim() || undefined\n if (publishableKey && !publishableKey.startsWith('lkw_pk_')) {\n problems.push(\n `${name('publishableKey')} is not a publishable key (lkw_pk_...). This configuration reaches the browser, so it must never hold a secret API key`\n )\n }\n\n if (problems.length > 0 || !siteUrl || !apiUrl || !defaultLocale) {\n throw new TypeError(`Invalid Lynkow configuration:\\n - ${problems.join('\\n - ')}`)\n }\n\n return Object.freeze({\n siteId: siteId.toLowerCase(),\n siteUrl,\n apiUrl,\n publishableKey,\n locales: Object.freeze([...locales]),\n defaultLocale,\n prefixDefaultLocale,\n })\n}\n\n/**\n * Parses an http(s) origin. Whitespace and control characters are refused\n * before the URL parser sees the value, because it silently strips tabs and\n * newlines, and a newline in a site URL written into robots.txt is a line of\n * its own.\n */\nfunction parseOrigin(field: string, raw: string, problems: string[]): string | undefined {\n if (hasUnsafeCharacter(raw)) {\n problems.push(`${field} contains whitespace or a control character`)\n return undefined\n }\n let url: URL\n try {\n url = new URL(raw)\n } catch {\n problems.push(`${field} is not an absolute URL${echoUrl(raw)}`)\n return undefined\n }\n if (url.protocol !== 'http:' && url.protocol !== 'https:') {\n problems.push(`${field} must use http or https${echoUrl(raw)}`)\n } else if (url.username || url.password) {\n problems.push(`${field} must not carry credentials`)\n } else if (!HOST.test(url.hostname)) {\n problems.push(`${field} has a host with characters no domain carries${echoUrl(raw)}`)\n } else if (!/^\\/+$/.test(url.pathname) || url.search || url.hash) {\n problems.push(`${field} must be an origin, with no path, query or fragment${echoUrl(raw)}`)\n } else {\n return url.origin\n }\n return undefined\n}\n\nfunction parseLocales(\n field: string,\n input: string | readonly string[] | undefined,\n problems: string[]\n): string[] {\n const list = typeof input === 'string' ? input.split(',') : [...(input ?? [])]\n const locales = list.map((locale) => String(locale).trim()).filter(Boolean)\n if (locales.length === 0) {\n problems.push(`${field} must name at least one locale`)\n return []\n }\n const seen = new Set<string>()\n for (const locale of locales) {\n if (!LOCALE.test(locale)) {\n problems.push(`${field} has an invalid locale ${quote(locale)}, expected a code such as \"en\" or \"pt-BR\"`)\n }\n const key = locale.toLowerCase()\n if (seen.has(key)) problems.push(`${field} lists ${quote(locale)} twice`)\n seen.add(key)\n }\n return locales\n}\n\nfunction parseFlag(\n field: string,\n value: boolean | string | undefined,\n fallback: boolean,\n problems: string[]\n): boolean {\n if (typeof value === 'boolean') return value\n const normalized = value?.trim().toLowerCase() ?? ''\n if (normalized === '') return fallback\n if (normalized === 'true') return true\n if (normalized === 'false') return false\n problems.push(`${field} must be true or false, not ${quote(String(value))}`)\n return fallback\n}\n\nfunction hasUnsafeCharacter(value: string): boolean {\n if (/\\s/.test(value)) return true\n for (let index = 0; index < value.length; index++) {\n const code = value.charCodeAt(index)\n if (code < 0x20 || code === 0x7f) return true\n }\n return false\n}\n\n/**\n * A URL value appended to an error message, or nothing when it holds an `@`:\n * a value the parser refused can still carry credentials it never extracted.\n */\nfunction echoUrl(raw: string): string {\n return raw.includes('@') ? '' : `: ${quote(raw)}`\n}\n\n/** A value quoted for an error message, cut short so a pasted blob stays readable. */\nfunction quote(value: string): string {\n return JSON.stringify(value.length > 120 ? `${value.slice(0, 120)}...` : value)\n}\n","import { createClient, type Client } from 'lynkow'\nimport type { LynkowNextConfig } from './config.js'\nimport { createLocalePaths } from './locale-paths.js'\n\n/** Options of the client {@link LynkowClients} returns. */\nexport interface ClientOptions {\n /**\n * `false` returns a client that sends each read once instead of retrying a\n * 429 or a 503. Defaults to `true`.\n */\n retry?: boolean\n}\n\n/** Returns the Lynkow client of a locale, the default one when none is given. */\nexport type LynkowClients = (locale?: string, options?: ClientOptions) => Client\n\n/**\n * One Lynkow client per locale, reused across requests.\n *\n * The SDK's own cache stays OFF. On the server it is a process-global\n * unbounded `Map`, which would duplicate and outlive Next's data cache without\n * any of its invalidation. Caching is Next's job, expressed per read with\n * `cached()`.\n *\n * `{ retry: false }` gives a second client for the locale, because the SDK's\n * retry is a client setting rather than a per-read one. Only a read that\n * already knows what to do when it fails should ask for it.\n *\n * FOR READS THAT ARE THE SAME FOR EVERY VISITOR. The clients are shared by\n * every request the process serves, and the SDK keeps a buyer's state on the\n * client instance: the guest cart token and the signed-in customer session.\n * Calling `cart` or `customers` on one of these from server code would hand\n * one visitor's cart or session to the next. Buyer flows run in the browser,\n * or on a client created for that one request.\n *\n * A locale outside the configuration throws a `RangeError` instead of creating\n * a client. The clients live as long as the process, so a locale taken from a\n * URL and passed through unchecked would grow that set without bound. The\n * locale is matched case-insensitively and the client gets its configured\n * spelling.\n */\nexport function createClients(config: LynkowNextConfig): LynkowClients {\n const { toLocale } = createLocalePaths(config)\n const clients = new Map<string, Client>()\n\n return function lynkow(locale = config.defaultLocale, { retry = true } = {}) {\n const canonical = toLocale(locale)\n if (!canonical) {\n throw new RangeError(\n `${JSON.stringify(locale)} is not one of the configured locales (${config.locales.join(', ')})`\n )\n }\n\n const key = retry ? canonical : `${canonical}:no-retry`\n const existing = clients.get(key)\n if (existing) return existing\n\n const client = createClient({\n siteId: config.siteId,\n locale: canonical,\n baseUrl: config.apiUrl,\n ...(config.publishableKey ? { publishableKey: config.publishableKey } : {}),\n cache: false,\n retry,\n })\n clients.set(key, client)\n return client\n }\n}\n","import type { AnalyticsEventType } from 'lynkow'\n\nexport type { AnalyticsEventType }\n\ninterface TrackerGlobal {\n track: (event: Record<string, unknown>) => void\n}\n\nfunction tracker(): TrackerGlobal | null {\n if (typeof window === 'undefined') return null\n const global = (window as unknown as { LynkowAnalytics?: TrackerGlobal }).LynkowAnalytics\n return global && typeof global.track === 'function' ? global : null\n}\n\n/**\n * Sends one custom event through the tracker already on the page.\n *\n * `type` is a CLOSED enum of mechanical interactions, the SDK's\n * `AnalyticsEventType`: there is no free-form event name. A value outside it is\n * a compile error rather than a silently dropped row, since the collector\n * answers `204` to a payload it rejects.\n *\n * Returns `false` when no tracker has loaded, which is never an error: on an\n * `opt-in` site none loads before the visitor accepts analytics. `true` means\n * the event was handed to the tracker, which adds the session id, the visitor\n * id and the timestamp, and drops the event itself when the visitor refused on\n * an `opt-in` or `opt-out` site.\n *\n * The tracker already records pageviews, scroll, clicks, form events and Core\n * Web Vitals. Reach for this only for an interaction that escapes it, such as a\n * form submitted through `fetch` without a real `submit` event.\n */\nexport function trackEvent(type: AnalyticsEventType, data?: Record<string, unknown>): boolean {\n const global = tracker()\n if (!global) return false\n\n global.track({\n page_path: window.location.pathname,\n page_url: window.location.href,\n page_title: document.title,\n // A page field in `data` wins; `event_type` never does, as in the SDK's\n // `trackEvent`, so a stray key cannot contradict `type`.\n ...data,\n event_type: type,\n })\n return true\n}\n"]}
|
package/dist/proxy/index.d.ts
CHANGED
|
@@ -51,6 +51,13 @@ interface LynkowProxyOptions {
|
|
|
51
51
|
*/
|
|
52
52
|
declare function createLynkowProxy({ config, client, cmsOrigin }: LynkowProxyOptions): NextMiddleware;
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Options of {@link lynkowPreviewMiddleware} and {@link withLynkowPreview}.
|
|
56
|
+
*
|
|
57
|
+
* There is no option for the query parameter: a preview request is always the
|
|
58
|
+
* one carrying `lynkow-preview`, the only parameter the Lynkow dashboard sends
|
|
59
|
+
* and the visual editor recognises.
|
|
60
|
+
*/
|
|
54
61
|
interface LynkowPreviewOptions {
|
|
55
62
|
/**
|
|
56
63
|
* Origin allowed to frame this site, defaulting to {@link DEFAULT_CMS_ORIGIN}.
|
|
@@ -62,16 +69,6 @@ interface LynkowPreviewOptions {
|
|
|
62
69
|
* inert rather than unsafe.
|
|
63
70
|
*/
|
|
64
71
|
cmsOrigin?: string;
|
|
65
|
-
/**
|
|
66
|
-
* @deprecated Ignored. The middleware always looks for `lynkow-preview`.
|
|
67
|
-
*
|
|
68
|
-
* The Lynkow dashboard always opens the preview with `lynkow-preview=true` in
|
|
69
|
-
* the URL, and the visual editor only starts when it sees that exact parameter.
|
|
70
|
-
* Any other name would stop the dashboard from framing the page and leave the
|
|
71
|
-
* editor inactive, so this option no longer has any effect. Remove it from your
|
|
72
|
-
* configuration.
|
|
73
|
-
*/
|
|
74
|
-
previewParam?: string;
|
|
75
72
|
}
|
|
76
73
|
/**
|
|
77
74
|
* Creates a Next.js middleware that lets the Lynkow dashboard frame the site in
|
|
@@ -101,18 +98,21 @@ type RedirectMatcher = (pathname: string) => Promise<Redirect | null>;
|
|
|
101
98
|
/**
|
|
102
99
|
* A redirect lookup with its own in-process cache.
|
|
103
100
|
*
|
|
104
|
-
* Why its own cache, when every other read hands caching to Next. Two facts
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* 2. **A miss is
|
|
113
|
-
* 404 with `Cache-Control:
|
|
114
|
-
*
|
|
115
|
-
*
|
|
101
|
+
* Why its own cache, when every other read hands caching to Next. Two facts:
|
|
102
|
+
*
|
|
103
|
+
* 1. **Next's fetch cache does not reach the proxy**, measured against Next
|
|
104
|
+
* 16.3.4 on 2026-09-08. `next: { revalidate, tags }` is accepted and
|
|
105
|
+
* ignored there: five identical requests each produced a real network
|
|
106
|
+
* call. So `cached()` would be decoration here, and `revalidateTag()` from
|
|
107
|
+
* the revalidation webhook can never reach this code, since the proxy is a
|
|
108
|
+
* separate bundle with its own module scope.
|
|
109
|
+
* 2. **A miss is only briefly cacheable, and only by a shared cache.** A path
|
|
110
|
+
* with no redirect answers 404 with `Cache-Control: public, max-age=0,
|
|
111
|
+
* s-maxage=30` (it was `no-store` until 2026-09-09), so at best an edge
|
|
112
|
+
* cache in front of the API answers it for 30 seconds, and every lookup
|
|
113
|
+
* is still a network round trip from this process. Almost every request
|
|
114
|
+
* is a miss, so an uncached lookup would cost every page view of the site
|
|
115
|
+
* one.
|
|
116
116
|
*
|
|
117
117
|
* The cache is per process, so a multi-instance deployment holds one copy per
|
|
118
118
|
* instance, and nothing invalidates it early: `TTL_MS` is the whole contract.
|
|
@@ -233,4 +233,4 @@ declare function prefersMarkdown(acceptHeader: string | null): boolean;
|
|
|
233
233
|
*/
|
|
234
234
|
declare function markdownProxy(request: NextRequest, options?: MarkdownProxyOptions): NextResponse | null;
|
|
235
235
|
|
|
236
|
-
export { type LynkowProxyOptions, type MarkdownProxyOptions, type RedirectMatcher, acceptQuality, createLynkowProxy, createRedirectMatcher, lynkowPreviewMiddleware, markdownProxy, prefersMarkdown, withLynkowPreview };
|
|
236
|
+
export { type LynkowPreviewOptions, type LynkowProxyOptions, type MarkdownProxyOptions, type RedirectMatcher, acceptQuality, createLynkowProxy, createRedirectMatcher, lynkowPreviewMiddleware, markdownProxy, prefersMarkdown, withLynkowPreview };
|
package/dist/proxy/index.js
CHANGED
|
@@ -1,17 +1,9 @@
|
|
|
1
|
-
import { createLocalePaths } from '../chunk-
|
|
1
|
+
import { createLocalePaths } from '../chunk-2HMFZKOE.js';
|
|
2
2
|
import { NextResponse } from 'next/server';
|
|
3
3
|
import { DEFAULT_CMS_ORIGIN, PREVIEW_PARAM } from 'lynkow/visual-editor';
|
|
4
4
|
export { DEFAULT_CMS_ORIGIN } from 'lynkow/visual-editor';
|
|
5
5
|
|
|
6
6
|
var CSP_HEADER = "Content-Security-Policy";
|
|
7
|
-
function warnIgnoredPreviewParam(options) {
|
|
8
|
-
const previewParam = options?.previewParam;
|
|
9
|
-
if (previewParam && previewParam !== PREVIEW_PARAM) {
|
|
10
|
-
console.warn(
|
|
11
|
-
`[lynkow] The previewParam option ("${previewParam}") is deprecated and ignored. The preview middleware always uses "${PREVIEW_PARAM}", the only parameter the Lynkow dashboard sends and the visual editor recognises. Remove the option.`
|
|
12
|
-
);
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
7
|
function lynkowPreviewMiddleware(options) {
|
|
16
8
|
return withLynkowPreview(() => void 0, options);
|
|
17
9
|
}
|
|
@@ -35,22 +27,39 @@ function serializeCspList(policies) {
|
|
|
35
27
|
function addCspHeaders(response, request, cmsOrigin) {
|
|
36
28
|
const isPreview = request.nextUrl.searchParams.has(PREVIEW_PARAM);
|
|
37
29
|
const policies = parseCspList(response.headers.get(CSP_HEADER));
|
|
30
|
+
let apply;
|
|
38
31
|
if (isPreview) {
|
|
39
32
|
const withoutFraming = policies.map(
|
|
40
33
|
(policy) => policy.filter((directive) => !isFrameAncestors(directive))
|
|
41
34
|
);
|
|
42
35
|
const merged = appendToFirstPolicy(withoutFraming, `frame-ancestors 'self' ${cmsOrigin}`);
|
|
43
|
-
|
|
44
|
-
|
|
36
|
+
apply = (headers) => {
|
|
37
|
+
headers.set(CSP_HEADER, serializeCspList(merged));
|
|
38
|
+
headers.delete("X-Frame-Options");
|
|
39
|
+
};
|
|
45
40
|
} else if (!policies.some((policy) => policy.some(isFrameAncestors))) {
|
|
46
41
|
const merged = appendToFirstPolicy(policies, "frame-ancestors 'self'");
|
|
47
|
-
|
|
42
|
+
apply = (headers) => headers.set(CSP_HEADER, serializeCspList(merged));
|
|
48
43
|
}
|
|
49
|
-
return response;
|
|
44
|
+
return apply ? withHeaders(response, apply) : response;
|
|
45
|
+
}
|
|
46
|
+
function withHeaders(response, apply) {
|
|
47
|
+
try {
|
|
48
|
+
apply(response.headers);
|
|
49
|
+
return response;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (!(error instanceof TypeError)) throw error;
|
|
52
|
+
}
|
|
53
|
+
const copy = new NextResponse(response.body, {
|
|
54
|
+
status: response.status,
|
|
55
|
+
statusText: response.statusText,
|
|
56
|
+
headers: response.headers
|
|
57
|
+
});
|
|
58
|
+
apply(copy.headers);
|
|
59
|
+
return copy;
|
|
50
60
|
}
|
|
51
61
|
function withLynkowPreview(existingMiddleware, options) {
|
|
52
62
|
const cmsOrigin = options?.cmsOrigin || DEFAULT_CMS_ORIGIN;
|
|
53
|
-
warnIgnoredPreviewParam(options);
|
|
54
63
|
return function middleware(request, event) {
|
|
55
64
|
const response = existingMiddleware(request, event);
|
|
56
65
|
const finalResponse = response instanceof Promise ? response.then((res) => addCspHeaders(res || NextResponse.next(), request, cmsOrigin)) : addCspHeaders(response || NextResponse.next(), request, cmsOrigin);
|
|
@@ -145,7 +154,7 @@ function createLynkowProxy({ config, client, cmsOrigin }) {
|
|
|
145
154
|
const prefix = prefixOf(pathname);
|
|
146
155
|
if (prefix) {
|
|
147
156
|
const canonical = paths.localePath(prefix.locale, prefix.rest);
|
|
148
|
-
return canonical === pathname ? NextResponse.next() : redirectToPath(request, canonical, 308);
|
|
157
|
+
return canonical === pathname || `${canonical}/` === pathname ? NextResponse.next() : redirectToPath(request, canonical, 308);
|
|
149
158
|
}
|
|
150
159
|
if (!prefixDefaultLocale) {
|
|
151
160
|
const url = request.nextUrl.clone();
|
package/dist/proxy/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/proxy/preview.ts","../../src/proxy/redirects.ts","../../src/proxy/lynkow-proxy.ts","../../src/proxy/markdown.ts"],"names":["NextResponse"],"mappings":"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","file":"index.js","sourcesContent":["import { NextResponse, type NextRequest, type NextMiddleware } from 'next/server'\nimport { DEFAULT_CMS_ORIGIN, PREVIEW_PARAM } from 'lynkow/visual-editor'\n\nconst CSP_HEADER = 'Content-Security-Policy'\n\ninterface LynkowPreviewOptions {\n /**\n * Origin allowed to frame this site, defaulting to {@link DEFAULT_CMS_ORIGIN}.\n *\n * Note the deliberate divergence from `VisualEditorConfig.cmsOrigin`: an EMPTY\n * value here takes the default too, because the safe direction for a CSP is a\n * header that names the real dashboard rather than one that names nothing.\n * The editor half instead refuses to start, since an editor pointed nowhere is\n * inert rather than unsafe.\n */\n cmsOrigin?: string\n /**\n * @deprecated Ignored. The middleware always looks for `lynkow-preview`.\n *\n * The Lynkow dashboard always opens the preview with `lynkow-preview=true` in\n * the URL, and the visual editor only starts when it sees that exact parameter.\n * Any other name would stop the dashboard from framing the page and leave the\n * editor inactive, so this option no longer has any effect. Remove it from your\n * configuration.\n */\n previewParam?: string\n}\n\n/**\n * Warns once, when the middleware is created, that a custom `previewParam` is\n * ignored. An empty value is treated like an omitted one.\n */\nfunction warnIgnoredPreviewParam(options?: LynkowPreviewOptions): void {\n const previewParam = options?.previewParam\n if (previewParam && previewParam !== PREVIEW_PARAM) {\n console.warn(\n `[lynkow] The previewParam option (\"${previewParam}\") is deprecated and ignored. ` +\n `The preview middleware always uses \"${PREVIEW_PARAM}\", the only parameter the ` +\n `Lynkow dashboard sends and the visual editor recognises. Remove the option.`\n )\n }\n}\n\n/**\n * Creates a Next.js middleware that lets the Lynkow dashboard frame the site in\n * preview mode. It is {@link withLynkowPreview} around a middleware that returns\n * nothing, so every response starts from `NextResponse.next()`.\n *\n * On a request carrying the `lynkow-preview` query parameter, the response gets\n * `Content-Security-Policy: frame-ancestors 'self' <cmsOrigin>` and loses any\n * `X-Frame-Options`. Every other response gets `frame-ancestors 'self'`.\n */\nexport function lynkowPreviewMiddleware(options?: LynkowPreviewOptions): NextMiddleware {\n return withLynkowPreview(() => undefined, options)\n}\n\nfunction isFrameAncestors(directive: string): boolean {\n const [name = ''] = directive.split(/\\s+/, 1)\n return name.toLowerCase() === 'frame-ancestors'\n}\n\n/**\n * Parses a `Content-Security-Policy` value into policies, each a list of\n * directives. The header may already hold several comma-separated policies, and\n * each policy is a list of `;`-separated directives. Empty entries are dropped.\n */\nfunction parseCspList(value: string | null): string[][] {\n if (!value) return []\n\n return value\n .split(',')\n .map((policy) =>\n policy\n .split(';')\n .map((directive) => directive.trim())\n .filter((directive) => directive.length > 0)\n )\n .filter((policy) => policy.length > 0)\n}\n\n/** Appends a directive to the first policy, creating it when there is none. */\nfunction appendToFirstPolicy(policies: string[][], directive: string): string[][] {\n const [first = [], ...rest] = policies\n return [[...first, directive], ...rest]\n}\n\nfunction serializeCspList(policies: string[][]): string {\n return policies\n .filter((policy) => policy.length > 0)\n .map((policy) => policy.join('; '))\n .join(', ')\n}\n\n/**\n * Merges the framing rule into whatever CSP the response already carries, at\n * the directive level, so a policy written by the wrapped middleware (a nonce\n * CSP, for instance) survives.\n *\n * `Headers.append` would not do: a comma-joined CSP is several policies that\n * are ALL enforced, so a relaxed `frame-ancestors` appended beside an existing\n * `frame-ancestors 'self'` would still block the dashboard. The value is\n * therefore rebuilt and written with a single `set`.\n *\n * - Preview: every existing `frame-ancestors` is replaced by one that allows\n * `cmsOrigin`, and `X-Frame-Options` is removed.\n * - Otherwise: an existing `frame-ancestors` is the site's own decision and is\n * left alone. Without one, `frame-ancestors 'self'` is added.\n *\n * `Content-Security-Policy-Report-Only` is never touched.\n */\nfunction addCspHeaders(\n response: NextResponse | Response,\n request: NextRequest,\n cmsOrigin: string\n): NextResponse | Response {\n const isPreview = request.nextUrl.searchParams.has(PREVIEW_PARAM)\n const policies = parseCspList(response.headers.get(CSP_HEADER))\n\n if (isPreview) {\n const withoutFraming = policies.map((policy) =>\n policy.filter((directive) => !isFrameAncestors(directive))\n )\n const merged = appendToFirstPolicy(withoutFraming, `frame-ancestors 'self' ${cmsOrigin}`)\n\n response.headers.set(CSP_HEADER, serializeCspList(merged))\n response.headers.delete('X-Frame-Options')\n } else if (!policies.some((policy) => policy.some(isFrameAncestors))) {\n const merged = appendToFirstPolicy(policies, \"frame-ancestors 'self'\")\n\n response.headers.set(CSP_HEADER, serializeCspList(merged))\n }\n\n return response\n}\n\n/**\n * Wraps an existing Next.js middleware so its responses let the Lynkow dashboard\n * frame the site in preview mode.\n *\n * The wrapped middleware's own `Content-Security-Policy` is kept and merged at\n * the directive level. In preview mode (the `lynkow-preview` query parameter),\n * its `frame-ancestors` is replaced by one allowing `cmsOrigin` and\n * `X-Frame-Options` is removed. Outside preview mode, a `frame-ancestors` it\n * already set is left as is, and `frame-ancestors 'self'` is added only when it\n * set none. A middleware returning nothing is treated as `NextResponse.next()`.\n */\nexport function withLynkowPreview(\n existingMiddleware: NextMiddleware,\n options?: LynkowPreviewOptions\n): NextMiddleware {\n const cmsOrigin = options?.cmsOrigin || DEFAULT_CMS_ORIGIN\n warnIgnoredPreviewParam(options)\n\n return function middleware(request: NextRequest, event: any) {\n const response = existingMiddleware(request, event)\n\n const finalResponse =\n response instanceof Promise\n ? response.then((res) => addCspHeaders(res || NextResponse.next(), request, cmsOrigin))\n : addCspHeaders(response || NextResponse.next(), request, cmsOrigin)\n\n return finalResponse\n }\n}\n","import type { Client, Redirect } from 'lynkow'\n\n/**\n * How long an answer from `/redirects/match` is trusted, in milliseconds.\n *\n * This is the only freshness control a proxy has, so it doubles as the delay\n * between creating a redirect in the admin and it taking effect. A minute is\n * short enough that nobody notices and long enough that a hot path costs one\n * round trip per minute rather than one per visitor.\n */\nconst TTL_MS = 60_000\n\n/**\n * Upper bound on remembered paths. A crawler walking an unbounded URL space\n * would otherwise grow the map until the process runs out of memory, and the\n * point of the cache is the small set of paths real traffic repeats.\n */\nconst MAX_ENTRIES = 500\n\ntype Entry = { value: Redirect | null; expiresAt: number }\n\n/** Returns the redirect rule matching a pathname, or null. */\nexport type RedirectMatcher = (pathname: string) => Promise<Redirect | null>\n\n/**\n * A redirect lookup with its own in-process cache.\n *\n * Why its own cache, when every other read hands caching to Next. Two facts,\n * both measured against Next 16.3.4 and the live API on 2026-09-08:\n *\n * 1. **Next's fetch cache does not reach the proxy.** `next: { revalidate,\n * tags }` is accepted and ignored there: five identical requests each\n * produced a real network call. So `cached()` would be decoration here,\n * and `revalidateTag()` from the revalidation webhook can never reach this\n * code, since the proxy is a separate bundle with its own module scope.\n * 2. **A miss is uncacheable at the edge.** A path with no redirect answers\n * 404 with `Cache-Control: no-store`, so a CDN bypasses it. Almost every\n * request is a miss, so an uncached lookup would send every page view of\n * the site to the API.\n *\n * The cache is per process, so a multi-instance deployment holds one copy per\n * instance, and nothing invalidates it early: `TTL_MS` is the whole contract.\n *\n * Concurrent lookups of one path share one request, so a link posted somewhere\n * busy costs one call rather than one per simultaneous visitor. A failed lookup\n * answers `null` and is never cached: a transient API error would otherwise\n * blind every redirect on the site for a full TTL, while answering \"no\n * redirect\" for one request gives the visitor exactly the page they would have\n * got without this module.\n *\n * `pathname` must carry no query string: the API compares the stored source\n * against it verbatim, so `/old?page=2` matches nothing.\n */\nexport function createRedirectMatcher(client: () => Client): RedirectMatcher {\n const cache = new Map<string, Entry>()\n const inFlight = new Map<string, Promise<Redirect | null>>()\n\n function remember(path: string, value: Redirect | null): void {\n // A Map iterates in insertion order, so the first key is the oldest write.\n if (cache.size >= MAX_ENTRIES && !cache.has(path)) {\n const oldest = cache.keys().next().value\n if (oldest !== undefined) cache.delete(oldest)\n }\n cache.set(path, { value, expiresAt: Date.now() + TTL_MS })\n }\n\n return async function matchRedirect(pathname) {\n const entry = cache.get(pathname)\n if (entry && entry.expiresAt > Date.now()) return entry.value\n if (entry) cache.delete(pathname)\n\n const pending = inFlight.get(pathname)\n if (pending) return pending\n\n const lookup = Promise.resolve()\n .then(() => client().paths.matchRedirect(pathname))\n .then((value) => {\n remember(pathname, value)\n return value\n })\n .catch((error: unknown) => {\n console.error(`[lynkow] redirect lookup failed for ${pathname}`, error)\n return null\n })\n .finally(() => {\n inFlight.delete(pathname)\n })\n\n inFlight.set(pathname, lookup)\n return lookup\n }\n}\n","import { NextResponse, type NextMiddleware, type NextRequest } from 'next/server'\nimport type { Client, Redirect } from 'lynkow'\nimport type { LynkowNextConfig } from '../config.js'\nimport { createLocalePaths } from '../locale-paths.js'\nimport { withLynkowPreview } from './preview.js'\nimport { createRedirectMatcher } from './redirects.js'\n\n/** The cookie remembering the locale a visitor was sent to. */\nconst LOCALE_COOKIE = 'NEXT_LOCALE'\n\n/** Options of {@link createLynkowProxy}. */\nexport interface LynkowProxyOptions {\n config: LynkowNextConfig\n /** The client of a locale, typically the function `createClients(config)` returns. */\n client: (locale: string) => Client\n /**\n * The origin allowed to frame the site on a preview request. Defaults to the\n * Lynkow dashboard. Give the visual editor the same value, or neither one.\n */\n cmsOrigin?: string\n}\n\n/**\n * The proxy of a Lynkow site: redirects, then locale routing, with the preview\n * headers the visual editor needs.\n *\n * 1. **Redirects**, first, on the path as the visitor typed it. The CMS stores\n * sources that way, a mix of `/old` and `/fr/blog/old` on a multilingual\n * site, so matching after a locale prefix is added would silently break\n * every unprefixed rule. The status code is the one the editor chose, which\n * `redirect()` from `next/navigation` could not give (307 or 308 only). A\n * target an editor wrote without a locale gets the visitor's, so the rule\n * and the locale step cost one hop rather than two.\n * 2. **Locale routing.** With `prefixDefaultLocale` on, a path without a locale\n * is sent (307) to the visitor's best match: the `NEXT_LOCALE` cookie, then\n * `Accept-Language`, then the default. With it off, a path without a locale\n * IS the default locale's page and is served from its `[locale]` route under\n * the same URL, while `/<default>/...` answers 308 to that URL. Either way a\n * locale segment written in another case answers 308 to its lowercase form,\n * the form the API writes, so each page has one URL.\n *\n * The application keeps its own `config.matcher`, which Next requires to be a\n * literal in `src/proxy.ts`. It must exclude everything that is not a page:\n * Next internals, API routes and files.\n *\n * @example\n * // src/proxy.ts\n * import { createClients } from '@lynkow/next'\n * import { createLynkowProxy } from '@lynkow/next/proxy'\n * import { config as site } from './lib/config'\n *\n * export default createLynkowProxy({ config: site, client: createClients(site) })\n *\n * export const config = {\n * matcher: ['/((?!_next/|api/|.*\\\\.[\\\\w]+$).*)'],\n * }\n */\nexport function createLynkowProxy({ config, client, cmsOrigin }: LynkowProxyOptions): NextMiddleware {\n const paths = createLocalePaths(config)\n const { defaultLocale, prefixDefaultLocale } = config\n const defaultSegment = `/${defaultLocale.toLowerCase()}`\n const matchRedirect = createRedirectMatcher(() => client(defaultLocale))\n\n /** The configured locale the first segment of a pathname names, and the path after it. */\n function prefixOf(pathname: string): { locale: string; rest: string } | null {\n const segment = pathname.split('/')[1] ?? ''\n const locale = paths.toLocale(segment)\n return locale ? { locale, rest: pathname.slice(segment.length + 1) || '/' } : null\n }\n\n /**\n * The locale this request will be served in. Shared by both steps, so a\n * redirect target and the locale step can never disagree on one request.\n */\n function resolveLocale(request: NextRequest): string {\n return (\n paths.localeFromPath(request.nextUrl.pathname) ??\n paths.toLocale(request.cookies.get(LOCALE_COOKIE)?.value) ??\n paths.negotiateLocale(request.headers.get('accept-language'))\n )\n }\n\n /**\n * With the default locale unprefixed, a visitor types `/old` for a rule an\n * editor may have written as `/old` or, the way the API writes paths, as\n * `/en/old`. Both are looked up at once, through the same cache, and the path\n * as typed wins.\n */\n async function findRedirect(pathname: string): Promise<Redirect | null> {\n if (prefixDefaultLocale || prefixOf(pathname)) return matchRedirect(pathname)\n const [typed, prefixed] = await Promise.all([\n matchRedirect(pathname),\n matchRedirect(pathname === '/' ? defaultSegment : defaultSegment + pathname),\n ])\n return typed ?? prefixed\n }\n\n /**\n * The response for a matched rule, or null to carry on routing.\n *\n * The target arrives resolved: the API has already substituted any glob or\n * regex capture, so there is nothing to interpolate here. `preserveQueryString`\n * is the caller's to apply, and a parameter the target names itself wins over\n * the incoming one.\n */\n function redirectTo(request: NextRequest, redirect: Redirect, locale: string): NextResponse | null {\n const from = request.nextUrl\n let url: URL\n try {\n url = new URL(paths.localizeHref(redirect.target, locale), from.href)\n } catch {\n // A stored target that will not parse is an admin-side problem this site\n // must not turn into a broken page.\n console.error(`[lynkow] unparseable redirect target ${redirect.target} for ${redirect.source}`)\n return null\n }\n\n // Next shortens a Location on this origin to a relative one, and a browser\n // reads a relative `//evil.example` as another host. A wildcard rule whose\n // target is this site's own absolute URL, or a relative target the visitor\n // fills with dot segments, can resolve to exactly that path, so it is\n // collapsed to one slash. A target on another origin is the editor's choice.\n if (url.origin === from.origin) url.pathname = url.pathname.replace(/^\\/{2,}/, '/')\n\n if (redirect.preserveQueryString) {\n for (const [key, value] of from.searchParams) {\n if (!url.searchParams.has(key)) url.searchParams.append(key, value)\n }\n }\n\n // A rule pointing at its own source is a loop the browser would discover.\n if (url.origin === from.origin && url.pathname === from.pathname && url.search === from.search) {\n console.error(`[lynkow] ignoring self-referential redirect on ${redirect.source}`)\n return null\n }\n\n return NextResponse.redirect(url, redirect.statusCode)\n }\n\n function redirectToPath(request: NextRequest, pathname: string, status: 307 | 308): NextResponse {\n const url = request.nextUrl.clone()\n url.pathname = pathname\n return NextResponse.redirect(url, status)\n }\n\n function route(request: NextRequest, locale: string): NextResponse {\n const { pathname } = request.nextUrl\n const prefix = prefixOf(pathname)\n\n if (prefix) {\n const canonical = paths.localePath(prefix.locale, prefix.rest)\n return canonical === pathname ? NextResponse.next() : redirectToPath(request, canonical, 308)\n }\n\n if (!prefixDefaultLocale) {\n const url = request.nextUrl.clone()\n url.pathname = pathname === '/' ? defaultSegment : defaultSegment + pathname\n return NextResponse.rewrite(url)\n }\n\n const response = redirectToPath(request, paths.localePath(locale, pathname), 307)\n response.cookies.set(LOCALE_COOKIE, locale, {\n path: '/',\n maxAge: 60 * 60 * 24 * 365,\n sameSite: 'lax',\n })\n return response\n }\n\n async function site(request: NextRequest): Promise<NextResponse> {\n const locale = resolveLocale(request)\n const redirect = await findRedirect(request.nextUrl.pathname)\n return (redirect && redirectTo(request, redirect, locale)) || route(request, locale)\n }\n\n return withLynkowPreview(site, { cmsOrigin })\n}\n","import { NextResponse, type NextRequest } from 'next/server'\n\nconst DEFAULT_MD_PREFIX = '/md'\n\n/**\n * Options for the {@link markdownProxy} helper.\n */\nexport interface MarkdownProxyOptions {\n /**\n * URL prefix where the Markdown route handler is mounted. The helper\n * rewrites `Accept: text/markdown` requests from `<path>` to\n * `<mdPathPrefix><path>`. Defaults to `'/md'`.\n *\n * If your route handler lives at `app/markdown/[...path]/route.ts`,\n * set this to `'/markdown'`.\n */\n mdPathPrefix?: string\n\n /**\n * Whether to set `Vary: Accept` on the rewritten response. Defaults\n * to `true`. Set to `false` only if your edge or CDN already sets the\n * header and you want to avoid duplication.\n */\n setVary?: boolean\n\n /**\n * Whether to set `X-Forwarded-Accept: text/markdown` on the rewritten\n * request so the route handler can confirm the request originated\n * from a Markdown negotiation. Defaults to `true`.\n */\n setForwardedAccept?: boolean\n}\n\n/**\n * Parses an `Accept` header per RFC 9110 §12.5.1 and returns the\n * highest q-value for a target media type. Supports exact match\n * (`text/markdown`), type wildcard (`text/*`), full wildcard (`*\\/*`),\n * and q-values.\n *\n * Returns `0` if the type is not present or has `q=0`. Returns `1` if\n * no q value is given (per RFC, missing q means q=1). Out-of-range or\n * non-numeric q values fall back to the default `1`.\n *\n * @param acceptHeader - The raw `Accept` header value, or `null` if absent\n * @param targetType - The target media type, e.g. `'text/markdown'`\n * @returns The highest q-value for the target type (0 to 1)\n *\n * @example\n * ```typescript\n * acceptQuality('text/html;q=0.9, text/markdown;q=0.8', 'text/markdown') // 0.8\n * acceptQuality('text/markdown', 'text/markdown') // 1\n * acceptQuality(null, 'text/markdown') // 0\n * ```\n */\nexport function acceptQuality(acceptHeader: string | null, targetType: string): number {\n if (!acceptHeader) return 0\n const [targetMain, targetSub] = targetType.toLowerCase().split('/')\n let best = 0\n\n for (const raw of acceptHeader.split(',')) {\n const parts = raw\n .trim()\n .split(';')\n .map((p) => p.trim())\n .filter(Boolean)\n const mediaType = parts[0]?.toLowerCase()\n if (!mediaType || !mediaType.includes('/')) continue\n\n const [main, sub] = mediaType.split('/')\n const matches =\n (main === targetMain && sub === targetSub) ||\n (main === targetMain && sub === '*') ||\n (main === '*' && sub === '*')\n\n if (!matches) continue\n\n let q = 1\n for (const param of parts.slice(1)) {\n if (param.startsWith('q=')) {\n const parsed = Number.parseFloat(param.slice(2))\n if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 1) q = parsed\n }\n }\n if (q > best) best = q\n }\n\n return best\n}\n\n/**\n * Decides whether the request prefers Markdown over HTML based on its\n * `Accept` header.\n *\n * Returns `true` when `Accept` contains `text/markdown` with q strictly\n * higher than `text/html`. Ties break in favor of HTML (the default\n * browsers expect).\n *\n * @param acceptHeader - The raw `Accept` header value, or `null` if absent\n * @returns `true` when Markdown is the preferred representation\n *\n * @example\n * ```typescript\n * prefersMarkdown('text/markdown;q=0.9, text/html;q=0.8') // true\n * prefersMarkdown('text/markdown, text/html') // false (tie -> HTML)\n * prefersMarkdown('text/html') // false\n * ```\n */\nexport function prefersMarkdown(acceptHeader: string | null): boolean {\n const md = acceptQuality(acceptHeader, 'text/markdown')\n if (md === 0) return false\n const html = acceptQuality(acceptHeader, 'text/html')\n return md > html\n}\n\n/**\n * Next.js proxy/middleware helper that translates\n * `Accept: text/markdown` requests into rewrites to a dedicated\n * Markdown route handler.\n *\n * Returns the rewritten `NextResponse` when the request preferred\n * Markdown, or `null` when the request did not (so the caller can pass\n * through to the next middleware step).\n *\n * Sets `Vary: Accept` on the rewritten response so intermediate caches\n * keep separate variants for HTML and Markdown.\n *\n * The helper is Edge runtime compatible (no `node:` imports).\n *\n * @param request - The incoming `NextRequest`\n * @param options - Optional {@link MarkdownProxyOptions} overrides\n * @returns A `NextResponse.rewrite(...)` when Markdown was preferred, or `null` otherwise\n *\n * @example\n * ```typescript\n * // Next 16, proxy.ts\n * import { markdownProxy } from '@lynkow/next/proxy'\n * import { NextResponse, type NextRequest } from 'next/server'\n *\n * export function proxy(request: NextRequest) {\n * const md = markdownProxy(request)\n * if (md) return md\n * return NextResponse.next()\n * }\n *\n * export const config = {\n * matcher: ['/((?!_next|favicon.ico|md|api).*)'],\n * }\n * ```\n */\nexport function markdownProxy(\n request: NextRequest,\n options?: MarkdownProxyOptions\n): NextResponse | null {\n const accept = request.headers.get('accept')\n if (!prefersMarkdown(accept)) return null\n\n const prefix = options?.mdPathPrefix ?? DEFAULT_MD_PREFIX\n const setVary = options?.setVary ?? true\n const setForwardedAccept = options?.setForwardedAccept ?? true\n\n const url = request.nextUrl.clone()\n if (url.pathname.startsWith(`${prefix}/`) || url.pathname === prefix) {\n return null\n }\n url.pathname = `${prefix}${url.pathname}`\n\n const headers = new Headers(request.headers)\n if (setForwardedAccept) headers.set('x-forwarded-accept', 'text/markdown')\n\n const response = NextResponse.rewrite(url, { request: { headers } })\n if (setVary) response.headers.set('vary', 'accept')\n\n return response\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/proxy/preview.ts","../../src/proxy/redirects.ts","../../src/proxy/lynkow-proxy.ts","../../src/proxy/markdown.ts"],"names":["NextResponse"],"mappings":"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","file":"index.js","sourcesContent":["import { NextResponse, type NextRequest, type NextMiddleware } from 'next/server'\nimport { DEFAULT_CMS_ORIGIN, PREVIEW_PARAM } from 'lynkow/visual-editor'\n\nconst CSP_HEADER = 'Content-Security-Policy'\n\n/**\n * Options of {@link lynkowPreviewMiddleware} and {@link withLynkowPreview}.\n *\n * There is no option for the query parameter: a preview request is always the\n * one carrying `lynkow-preview`, the only parameter the Lynkow dashboard sends\n * and the visual editor recognises.\n */\nexport interface LynkowPreviewOptions {\n /**\n * Origin allowed to frame this site, defaulting to {@link DEFAULT_CMS_ORIGIN}.\n *\n * Note the deliberate divergence from `VisualEditorConfig.cmsOrigin`: an EMPTY\n * value here takes the default too, because the safe direction for a CSP is a\n * header that names the real dashboard rather than one that names nothing.\n * The editor half instead refuses to start, since an editor pointed nowhere is\n * inert rather than unsafe.\n */\n cmsOrigin?: string\n}\n\n/**\n * Creates a Next.js middleware that lets the Lynkow dashboard frame the site in\n * preview mode. It is {@link withLynkowPreview} around a middleware that returns\n * nothing, so every response starts from `NextResponse.next()`.\n *\n * On a request carrying the `lynkow-preview` query parameter, the response gets\n * `Content-Security-Policy: frame-ancestors 'self' <cmsOrigin>` and loses any\n * `X-Frame-Options`. Every other response gets `frame-ancestors 'self'`.\n */\nexport function lynkowPreviewMiddleware(options?: LynkowPreviewOptions): NextMiddleware {\n return withLynkowPreview(() => undefined, options)\n}\n\nfunction isFrameAncestors(directive: string): boolean {\n const [name = ''] = directive.split(/\\s+/, 1)\n return name.toLowerCase() === 'frame-ancestors'\n}\n\n/**\n * Parses a `Content-Security-Policy` value into policies, each a list of\n * directives. The header may already hold several comma-separated policies, and\n * each policy is a list of `;`-separated directives. Empty entries are dropped.\n */\nfunction parseCspList(value: string | null): string[][] {\n if (!value) return []\n\n return value\n .split(',')\n .map((policy) =>\n policy\n .split(';')\n .map((directive) => directive.trim())\n .filter((directive) => directive.length > 0)\n )\n .filter((policy) => policy.length > 0)\n}\n\n/** Appends a directive to the first policy, creating it when there is none. */\nfunction appendToFirstPolicy(policies: string[][], directive: string): string[][] {\n const [first = [], ...rest] = policies\n return [[...first, directive], ...rest]\n}\n\nfunction serializeCspList(policies: string[][]): string {\n return policies\n .filter((policy) => policy.length > 0)\n .map((policy) => policy.join('; '))\n .join(', ')\n}\n\n/**\n * Merges the framing rule into whatever CSP the response already carries, at\n * the directive level, so a policy written by the wrapped middleware (a nonce\n * CSP, for instance) survives.\n *\n * `Headers.append` would not do: a comma-joined CSP is several policies that\n * are ALL enforced, so a relaxed `frame-ancestors` appended beside an existing\n * `frame-ancestors 'self'` would still block the dashboard. The value is\n * therefore rebuilt and written with a single `set`.\n *\n * - Preview: every existing `frame-ancestors` is replaced by one that allows\n * `cmsOrigin`, and `X-Frame-Options` is removed.\n * - Otherwise: an existing `frame-ancestors` is the site's own decision and is\n * left alone. Without one, `frame-ancestors 'self'` is added.\n *\n * `Content-Security-Policy-Report-Only` is never touched.\n */\nfunction addCspHeaders(\n response: NextResponse | Response,\n request: NextRequest,\n cmsOrigin: string\n): NextResponse | Response {\n const isPreview = request.nextUrl.searchParams.has(PREVIEW_PARAM)\n const policies = parseCspList(response.headers.get(CSP_HEADER))\n\n let apply: ((headers: Headers) => void) | undefined\n if (isPreview) {\n const withoutFraming = policies.map((policy) =>\n policy.filter((directive) => !isFrameAncestors(directive))\n )\n const merged = appendToFirstPolicy(withoutFraming, `frame-ancestors 'self' ${cmsOrigin}`)\n\n apply = (headers) => {\n headers.set(CSP_HEADER, serializeCspList(merged))\n headers.delete('X-Frame-Options')\n }\n } else if (!policies.some((policy) => policy.some(isFrameAncestors))) {\n const merged = appendToFirstPolicy(policies, \"frame-ancestors 'self'\")\n\n apply = (headers) => headers.set(CSP_HEADER, serializeCspList(merged))\n }\n\n return apply ? withHeaders(response, apply) : response\n}\n\n/**\n * Applies a header change to a response, or to a copy of it when its headers\n * are immutable (#836).\n *\n * `Response.redirect()` and a response straight from `fetch()` guard their\n * headers, and `set` on them throws `TypeError: immutable`. The copy keeps the\n * status, the status text, every header and the body. A response whose headers\n * can change, such as any `NextResponse`, is changed in place and returned as\n * is, so nothing set on it is lost.\n */\nfunction withHeaders(\n response: NextResponse | Response,\n apply: (headers: Headers) => void\n): NextResponse | Response {\n try {\n apply(response.headers)\n return response\n } catch (error) {\n if (!(error instanceof TypeError)) throw error\n }\n const copy = new NextResponse(response.body, {\n status: response.status,\n statusText: response.statusText,\n headers: response.headers,\n })\n apply(copy.headers)\n return copy\n}\n\n/**\n * Wraps an existing Next.js middleware so its responses let the Lynkow dashboard\n * frame the site in preview mode.\n *\n * The wrapped middleware's own `Content-Security-Policy` is kept and merged at\n * the directive level. In preview mode (the `lynkow-preview` query parameter),\n * its `frame-ancestors` is replaced by one allowing `cmsOrigin` and\n * `X-Frame-Options` is removed. Outside preview mode, a `frame-ancestors` it\n * already set is left as is, and `frame-ancestors 'self'` is added only when it\n * set none. A middleware returning nothing is treated as `NextResponse.next()`.\n */\nexport function withLynkowPreview(\n existingMiddleware: NextMiddleware,\n options?: LynkowPreviewOptions\n): NextMiddleware {\n const cmsOrigin = options?.cmsOrigin || DEFAULT_CMS_ORIGIN\n\n return function middleware(request: NextRequest, event: any) {\n const response = existingMiddleware(request, event)\n\n const finalResponse =\n response instanceof Promise\n ? response.then((res) => addCspHeaders(res || NextResponse.next(), request, cmsOrigin))\n : addCspHeaders(response || NextResponse.next(), request, cmsOrigin)\n\n return finalResponse\n }\n}\n","import type { Client, Redirect } from 'lynkow'\n\n/**\n * How long an answer from `/redirects/match` is trusted, in milliseconds.\n *\n * This is the only freshness control a proxy has, so it doubles as the delay\n * between creating a redirect in the admin and it taking effect. A minute is\n * short enough that nobody notices and long enough that a hot path costs one\n * round trip per minute rather than one per visitor.\n */\nconst TTL_MS = 60_000\n\n/**\n * Upper bound on remembered paths. A crawler walking an unbounded URL space\n * would otherwise grow the map until the process runs out of memory, and the\n * point of the cache is the small set of paths real traffic repeats.\n */\nconst MAX_ENTRIES = 500\n\ntype Entry = { value: Redirect | null; expiresAt: number }\n\n/** Returns the redirect rule matching a pathname, or null. */\nexport type RedirectMatcher = (pathname: string) => Promise<Redirect | null>\n\n/**\n * A redirect lookup with its own in-process cache.\n *\n * Why its own cache, when every other read hands caching to Next. Two facts:\n *\n * 1. **Next's fetch cache does not reach the proxy**, measured against Next\n * 16.3.4 on 2026-09-08. `next: { revalidate, tags }` is accepted and\n * ignored there: five identical requests each produced a real network\n * call. So `cached()` would be decoration here, and `revalidateTag()` from\n * the revalidation webhook can never reach this code, since the proxy is a\n * separate bundle with its own module scope.\n * 2. **A miss is only briefly cacheable, and only by a shared cache.** A path\n * with no redirect answers 404 with `Cache-Control: public, max-age=0,\n * s-maxage=30` (it was `no-store` until 2026-09-09), so at best an edge\n * cache in front of the API answers it for 30 seconds, and every lookup\n * is still a network round trip from this process. Almost every request\n * is a miss, so an uncached lookup would cost every page view of the site\n * one.\n *\n * The cache is per process, so a multi-instance deployment holds one copy per\n * instance, and nothing invalidates it early: `TTL_MS` is the whole contract.\n *\n * Concurrent lookups of one path share one request, so a link posted somewhere\n * busy costs one call rather than one per simultaneous visitor. A failed lookup\n * answers `null` and is never cached: a transient API error would otherwise\n * blind every redirect on the site for a full TTL, while answering \"no\n * redirect\" for one request gives the visitor exactly the page they would have\n * got without this module.\n *\n * `pathname` must carry no query string: the API compares the stored source\n * against it verbatim, so `/old?page=2` matches nothing.\n */\nexport function createRedirectMatcher(client: () => Client): RedirectMatcher {\n const cache = new Map<string, Entry>()\n const inFlight = new Map<string, Promise<Redirect | null>>()\n\n function remember(path: string, value: Redirect | null): void {\n // A Map iterates in insertion order, so the first key is the oldest write.\n if (cache.size >= MAX_ENTRIES && !cache.has(path)) {\n const oldest = cache.keys().next().value\n if (oldest !== undefined) cache.delete(oldest)\n }\n cache.set(path, { value, expiresAt: Date.now() + TTL_MS })\n }\n\n return async function matchRedirect(pathname) {\n const entry = cache.get(pathname)\n if (entry && entry.expiresAt > Date.now()) return entry.value\n if (entry) cache.delete(pathname)\n\n const pending = inFlight.get(pathname)\n if (pending) return pending\n\n const lookup = Promise.resolve()\n .then(() => client().paths.matchRedirect(pathname))\n .then((value) => {\n remember(pathname, value)\n return value\n })\n .catch((error: unknown) => {\n console.error(`[lynkow] redirect lookup failed for ${pathname}`, error)\n return null\n })\n .finally(() => {\n inFlight.delete(pathname)\n })\n\n inFlight.set(pathname, lookup)\n return lookup\n }\n}\n","import { NextResponse, type NextMiddleware, type NextRequest } from 'next/server'\nimport type { Client, Redirect } from 'lynkow'\nimport type { LynkowNextConfig } from '../config.js'\nimport { createLocalePaths } from '../locale-paths.js'\nimport { withLynkowPreview } from './preview.js'\nimport { createRedirectMatcher } from './redirects.js'\n\n/** The cookie remembering the locale a visitor was sent to. */\nconst LOCALE_COOKIE = 'NEXT_LOCALE'\n\n/** Options of {@link createLynkowProxy}. */\nexport interface LynkowProxyOptions {\n config: LynkowNextConfig\n /** The client of a locale, typically the function `createClients(config)` returns. */\n client: (locale: string) => Client\n /**\n * The origin allowed to frame the site on a preview request. Defaults to the\n * Lynkow dashboard. Give the visual editor the same value, or neither one.\n */\n cmsOrigin?: string\n}\n\n/**\n * The proxy of a Lynkow site: redirects, then locale routing, with the preview\n * headers the visual editor needs.\n *\n * 1. **Redirects**, first, on the path as the visitor typed it. The CMS stores\n * sources that way, a mix of `/old` and `/fr/blog/old` on a multilingual\n * site, so matching after a locale prefix is added would silently break\n * every unprefixed rule. The status code is the one the editor chose, which\n * `redirect()` from `next/navigation` could not give (307 or 308 only). A\n * target an editor wrote without a locale gets the visitor's, so the rule\n * and the locale step cost one hop rather than two.\n * 2. **Locale routing.** With `prefixDefaultLocale` on, a path without a locale\n * is sent (307) to the visitor's best match: the `NEXT_LOCALE` cookie, then\n * `Accept-Language`, then the default. With it off, a path without a locale\n * IS the default locale's page and is served from its `[locale]` route under\n * the same URL, while `/<default>/...` answers 308 to that URL. Either way a\n * locale segment written in another case answers 308 to its lowercase form,\n * the form the API writes, so each page has one URL.\n *\n * The application keeps its own `config.matcher`, which Next requires to be a\n * literal in `src/proxy.ts`. It must exclude everything that is not a page:\n * Next internals, API routes and files.\n *\n * @example\n * // src/proxy.ts\n * import { createClients } from '@lynkow/next'\n * import { createLynkowProxy } from '@lynkow/next/proxy'\n * import { config as site } from './lib/config'\n *\n * export default createLynkowProxy({ config: site, client: createClients(site) })\n *\n * export const config = {\n * matcher: ['/((?!_next/|api/|.*\\\\.[\\\\w]+$).*)'],\n * }\n */\nexport function createLynkowProxy({ config, client, cmsOrigin }: LynkowProxyOptions): NextMiddleware {\n const paths = createLocalePaths(config)\n const { defaultLocale, prefixDefaultLocale } = config\n const defaultSegment = `/${defaultLocale.toLowerCase()}`\n const matchRedirect = createRedirectMatcher(() => client(defaultLocale))\n\n /** The configured locale the first segment of a pathname names, and the path after it. */\n function prefixOf(pathname: string): { locale: string; rest: string } | null {\n const segment = pathname.split('/')[1] ?? ''\n const locale = paths.toLocale(segment)\n return locale ? { locale, rest: pathname.slice(segment.length + 1) || '/' } : null\n }\n\n /**\n * The locale this request will be served in. Shared by both steps, so a\n * redirect target and the locale step can never disagree on one request.\n */\n function resolveLocale(request: NextRequest): string {\n return (\n paths.localeFromPath(request.nextUrl.pathname) ??\n paths.toLocale(request.cookies.get(LOCALE_COOKIE)?.value) ??\n paths.negotiateLocale(request.headers.get('accept-language'))\n )\n }\n\n /**\n * With the default locale unprefixed, a visitor types `/old` for a rule an\n * editor may have written as `/old` or, the way the API writes paths, as\n * `/en/old`. Both are looked up at once, through the same cache, and the path\n * as typed wins.\n */\n async function findRedirect(pathname: string): Promise<Redirect | null> {\n if (prefixDefaultLocale || prefixOf(pathname)) return matchRedirect(pathname)\n const [typed, prefixed] = await Promise.all([\n matchRedirect(pathname),\n matchRedirect(pathname === '/' ? defaultSegment : defaultSegment + pathname),\n ])\n return typed ?? prefixed\n }\n\n /**\n * The response for a matched rule, or null to carry on routing.\n *\n * The target arrives resolved: the API has already substituted any glob or\n * regex capture, so there is nothing to interpolate here. `preserveQueryString`\n * is the caller's to apply, and a parameter the target names itself wins over\n * the incoming one.\n */\n function redirectTo(request: NextRequest, redirect: Redirect, locale: string): NextResponse | null {\n const from = request.nextUrl\n let url: URL\n try {\n url = new URL(paths.localizeHref(redirect.target, locale), from.href)\n } catch {\n // A stored target that will not parse is an admin-side problem this site\n // must not turn into a broken page.\n console.error(`[lynkow] unparseable redirect target ${redirect.target} for ${redirect.source}`)\n return null\n }\n\n // Next shortens a Location on this origin to a relative one, and a browser\n // reads a relative `//evil.example` as another host. A wildcard rule whose\n // target is this site's own absolute URL, or a relative target the visitor\n // fills with dot segments, can resolve to exactly that path, so it is\n // collapsed to one slash. A target on another origin is the editor's choice.\n if (url.origin === from.origin) url.pathname = url.pathname.replace(/^\\/{2,}/, '/')\n\n if (redirect.preserveQueryString) {\n for (const [key, value] of from.searchParams) {\n if (!url.searchParams.has(key)) url.searchParams.append(key, value)\n }\n }\n\n // A rule pointing at its own source is a loop the browser would discover.\n if (url.origin === from.origin && url.pathname === from.pathname && url.search === from.search) {\n console.error(`[lynkow] ignoring self-referential redirect on ${redirect.source}`)\n return null\n }\n\n return NextResponse.redirect(url, redirect.statusCode)\n }\n\n function redirectToPath(request: NextRequest, pathname: string, status: 307 | 308): NextResponse {\n const url = request.nextUrl.clone()\n url.pathname = pathname\n return NextResponse.redirect(url, status)\n }\n\n function route(request: NextRequest, locale: string): NextResponse {\n const { pathname } = request.nextUrl\n const prefix = prefixOf(pathname)\n\n if (prefix) {\n // `localePath` writes a locale root as `/fr`, but `NextURL` puts the\n // request's trailing slash back on any pathname it is given, so `/fr/`\n // answered 308 to `/fr/` for ever. A trailing slash is for Next's own\n // `trailingSlash` setting to decide, so it is not a difference here.\n const canonical = paths.localePath(prefix.locale, prefix.rest)\n return canonical === pathname || `${canonical}/` === pathname\n ? NextResponse.next()\n : redirectToPath(request, canonical, 308)\n }\n\n if (!prefixDefaultLocale) {\n const url = request.nextUrl.clone()\n url.pathname = pathname === '/' ? defaultSegment : defaultSegment + pathname\n return NextResponse.rewrite(url)\n }\n\n const response = redirectToPath(request, paths.localePath(locale, pathname), 307)\n response.cookies.set(LOCALE_COOKIE, locale, {\n path: '/',\n maxAge: 60 * 60 * 24 * 365,\n sameSite: 'lax',\n })\n return response\n }\n\n async function site(request: NextRequest): Promise<NextResponse> {\n const locale = resolveLocale(request)\n const redirect = await findRedirect(request.nextUrl.pathname)\n return (redirect && redirectTo(request, redirect, locale)) || route(request, locale)\n }\n\n return withLynkowPreview(site, { cmsOrigin })\n}\n","import { NextResponse, type NextRequest } from 'next/server'\n\nconst DEFAULT_MD_PREFIX = '/md'\n\n/**\n * Options for the {@link markdownProxy} helper.\n */\nexport interface MarkdownProxyOptions {\n /**\n * URL prefix where the Markdown route handler is mounted. The helper\n * rewrites `Accept: text/markdown` requests from `<path>` to\n * `<mdPathPrefix><path>`. Defaults to `'/md'`.\n *\n * If your route handler lives at `app/markdown/[...path]/route.ts`,\n * set this to `'/markdown'`.\n */\n mdPathPrefix?: string\n\n /**\n * Whether to set `Vary: Accept` on the rewritten response. Defaults\n * to `true`. Set to `false` only if your edge or CDN already sets the\n * header and you want to avoid duplication.\n */\n setVary?: boolean\n\n /**\n * Whether to set `X-Forwarded-Accept: text/markdown` on the rewritten\n * request so the route handler can confirm the request originated\n * from a Markdown negotiation. Defaults to `true`.\n */\n setForwardedAccept?: boolean\n}\n\n/**\n * Parses an `Accept` header per RFC 9110 §12.5.1 and returns the\n * highest q-value for a target media type. Supports exact match\n * (`text/markdown`), type wildcard (`text/*`), full wildcard (`*\\/*`),\n * and q-values.\n *\n * Returns `0` if the type is not present or has `q=0`. Returns `1` if\n * no q value is given (per RFC, missing q means q=1). Out-of-range or\n * non-numeric q values fall back to the default `1`.\n *\n * @param acceptHeader - The raw `Accept` header value, or `null` if absent\n * @param targetType - The target media type, e.g. `'text/markdown'`\n * @returns The highest q-value for the target type (0 to 1)\n *\n * @example\n * ```typescript\n * acceptQuality('text/html;q=0.9, text/markdown;q=0.8', 'text/markdown') // 0.8\n * acceptQuality('text/markdown', 'text/markdown') // 1\n * acceptQuality(null, 'text/markdown') // 0\n * ```\n */\nexport function acceptQuality(acceptHeader: string | null, targetType: string): number {\n if (!acceptHeader) return 0\n const [targetMain, targetSub] = targetType.toLowerCase().split('/')\n let best = 0\n\n for (const raw of acceptHeader.split(',')) {\n const parts = raw\n .trim()\n .split(';')\n .map((p) => p.trim())\n .filter(Boolean)\n const mediaType = parts[0]?.toLowerCase()\n if (!mediaType || !mediaType.includes('/')) continue\n\n const [main, sub] = mediaType.split('/')\n const matches =\n (main === targetMain && sub === targetSub) ||\n (main === targetMain && sub === '*') ||\n (main === '*' && sub === '*')\n\n if (!matches) continue\n\n let q = 1\n for (const param of parts.slice(1)) {\n if (param.startsWith('q=')) {\n const parsed = Number.parseFloat(param.slice(2))\n if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 1) q = parsed\n }\n }\n if (q > best) best = q\n }\n\n return best\n}\n\n/**\n * Decides whether the request prefers Markdown over HTML based on its\n * `Accept` header.\n *\n * Returns `true` when `Accept` contains `text/markdown` with q strictly\n * higher than `text/html`. Ties break in favor of HTML (the default\n * browsers expect).\n *\n * @param acceptHeader - The raw `Accept` header value, or `null` if absent\n * @returns `true` when Markdown is the preferred representation\n *\n * @example\n * ```typescript\n * prefersMarkdown('text/markdown;q=0.9, text/html;q=0.8') // true\n * prefersMarkdown('text/markdown, text/html') // false (tie -> HTML)\n * prefersMarkdown('text/html') // false\n * ```\n */\nexport function prefersMarkdown(acceptHeader: string | null): boolean {\n const md = acceptQuality(acceptHeader, 'text/markdown')\n if (md === 0) return false\n const html = acceptQuality(acceptHeader, 'text/html')\n return md > html\n}\n\n/**\n * Next.js proxy/middleware helper that translates\n * `Accept: text/markdown` requests into rewrites to a dedicated\n * Markdown route handler.\n *\n * Returns the rewritten `NextResponse` when the request preferred\n * Markdown, or `null` when the request did not (so the caller can pass\n * through to the next middleware step).\n *\n * Sets `Vary: Accept` on the rewritten response so intermediate caches\n * keep separate variants for HTML and Markdown.\n *\n * The helper is Edge runtime compatible (no `node:` imports).\n *\n * @param request - The incoming `NextRequest`\n * @param options - Optional {@link MarkdownProxyOptions} overrides\n * @returns A `NextResponse.rewrite(...)` when Markdown was preferred, or `null` otherwise\n *\n * @example\n * ```typescript\n * // Next 16, proxy.ts\n * import { markdownProxy } from '@lynkow/next/proxy'\n * import { NextResponse, type NextRequest } from 'next/server'\n *\n * export function proxy(request: NextRequest) {\n * const md = markdownProxy(request)\n * if (md) return md\n * return NextResponse.next()\n * }\n *\n * export const config = {\n * matcher: ['/((?!_next|favicon.ico|md|api).*)'],\n * }\n * ```\n */\nexport function markdownProxy(\n request: NextRequest,\n options?: MarkdownProxyOptions\n): NextResponse | null {\n const accept = request.headers.get('accept')\n if (!prefersMarkdown(accept)) return null\n\n const prefix = options?.mdPathPrefix ?? DEFAULT_MD_PREFIX\n const setVary = options?.setVary ?? true\n const setForwardedAccept = options?.setForwardedAccept ?? true\n\n const url = request.nextUrl.clone()\n if (url.pathname.startsWith(`${prefix}/`) || url.pathname === prefix) {\n return null\n }\n url.pathname = `${prefix}${url.pathname}`\n\n const headers = new Headers(request.headers)\n if (setForwardedAccept) headers.set('x-forwarded-accept', 'text/markdown')\n\n const response = NextResponse.rewrite(url, { request: { headers } })\n if (setVary) response.headers.set('vary', 'accept')\n\n return response\n}\n"]}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -3,13 +3,16 @@ import { L as LynkowNextConfig } from '../config-CwDV7jFS.js';
|
|
|
3
3
|
import * as react from 'react';
|
|
4
4
|
import { Metadata } from 'next';
|
|
5
5
|
|
|
6
|
+
/** Puts the absolute URLs of a body the API generated on this site's public form. */
|
|
7
|
+
type BodyRewriter = (body: string, siteDomain: string | null | undefined) => string;
|
|
8
|
+
|
|
6
9
|
/** Props of the `JsonLdScript` component {@link createLynkowServer} returns. */
|
|
7
10
|
interface JsonLdScriptProps {
|
|
8
11
|
/** The schema.org nodes, such as a page's `structuredData.graph`. Nothing renders when empty. */
|
|
9
12
|
nodes: object[] | null | undefined;
|
|
10
13
|
locale: string;
|
|
11
14
|
}
|
|
12
|
-
declare function createJsonLdScript(
|
|
15
|
+
declare function createJsonLdScript(rewrite: BodyRewriter, getSiteDomain: (locale: string) => Promise<string | null | undefined>): ({ nodes, locale }: JsonLdScriptProps) => Promise<react.JSX.Element | null>;
|
|
13
16
|
|
|
14
17
|
/** The two metadata builders {@link createLynkowServer} returns. */
|
|
15
18
|
interface MetadataBuilders {
|
|
@@ -76,9 +79,10 @@ interface LynkowServer extends MetadataBuilders {
|
|
|
76
79
|
* Regenerating those files in the application would mean re-deriving rules
|
|
77
80
|
* the API already computes from the site's own settings, and the two would
|
|
78
81
|
* drift on the first setting change. So the handler does two things: cache
|
|
79
|
-
* the upstream read under `TAGS.seo`, and put its URLs on
|
|
80
|
-
*
|
|
81
|
-
* else, or for a locale that is not
|
|
82
|
+
* the upstream read under `TAGS.seo`, and put its URLs on their public form
|
|
83
|
+
* through {@link LynkowServer.rewriteOrigin}. Under a `[locale]` segment the
|
|
84
|
+
* route's locale is used; anywhere else, or for a locale that is not
|
|
85
|
+
* configured, the default one.
|
|
82
86
|
*
|
|
83
87
|
* @example
|
|
84
88
|
* // src/app/sitemap.xml/route.ts
|
|
@@ -88,7 +92,16 @@ interface LynkowServer extends MetadataBuilders {
|
|
|
88
92
|
textRoute(read: TextReader, contentType: string): TextRouteHandler;
|
|
89
93
|
/** An async server component rendering schema.org nodes as one JSON-LD script. */
|
|
90
94
|
JsonLdScript: ReturnType<typeof createJsonLdScript>;
|
|
91
|
-
/**
|
|
95
|
+
/**
|
|
96
|
+
* Puts the absolute URLs of a body the API generated, such as a sitemap or a
|
|
97
|
+
* JSON-LD graph, on this site's public form. The site's domain, given as
|
|
98
|
+
* `siteDomain`, is moved to `siteUrl`, so a copy served from localhost or a
|
|
99
|
+
* preview deployment does not point at production. With `prefixDefaultLocale`
|
|
100
|
+
* off, a URL on `siteUrl` under `/<default locale>` then loses that prefix,
|
|
101
|
+
* which the API always writes: `/en/about` becomes `/about`, as the proxy
|
|
102
|
+
* serves it. Bare paths are left alone. `textRoute` and `JsonLdScript` go
|
|
103
|
+
* through this same function.
|
|
104
|
+
*/
|
|
92
105
|
rewriteOrigin(body: string, siteDomain: string | null | undefined): string;
|
|
93
106
|
}
|
|
94
107
|
/**
|
|
@@ -110,22 +123,6 @@ interface LynkowServer extends MetadataBuilders {
|
|
|
110
123
|
*/
|
|
111
124
|
declare function createLynkowServer({ config, client, getSiteDomain, }: LynkowServerOptions): LynkowServer;
|
|
112
125
|
|
|
113
|
-
/**
|
|
114
|
-
* Rewrite the site's own domain to the origin this deployment is served from.
|
|
115
|
-
*
|
|
116
|
-
* Lynkow generates `sitemap.xml`, `robots.txt`, `llms.txt` and the JSON-LD
|
|
117
|
-
* graph with absolute URLs built from the site's configured domain, which is
|
|
118
|
-
* correct in production and wrong everywhere else: on localhost, on a preview
|
|
119
|
-
* deployment or on a staging host, a verbatim copy would point at another site.
|
|
120
|
-
*
|
|
121
|
-
* When the two match, the body passes through untouched.
|
|
122
|
-
*
|
|
123
|
-
* @param body - What the API returned
|
|
124
|
-
* @param siteDomain - The site's configured domain, such as `www.example.com`
|
|
125
|
-
* @param siteUrl - The origin this deployment is served from, as `resolveConfig` returns it
|
|
126
|
-
*/
|
|
127
|
-
declare function rewriteOrigin(body: string, siteDomain: string | null | undefined, siteUrl: string): string;
|
|
128
|
-
|
|
129
126
|
/** The envelope the Lynkow API POSTs to a webhook. */
|
|
130
127
|
interface WebhookEnvelope {
|
|
131
128
|
event: string;
|
|
@@ -193,4 +190,4 @@ declare function createRevalidateHandler({ secret }: RevalidateHandlerOptions):
|
|
|
193
190
|
*/
|
|
194
191
|
declare function verifySignature(rawBody: string, header: string | null, secret: string): boolean;
|
|
195
192
|
|
|
196
|
-
export { type JsonLdScriptProps, type LynkowServer, type LynkowServerOptions, type MetadataBuilders, type RevalidateHandlerOptions, TEXT, type TextReader, type TextRouteHandler, type WebhookEnvelope, XML, createLynkowServer, createRevalidateHandler,
|
|
193
|
+
export { type JsonLdScriptProps, type LynkowServer, type LynkowServerOptions, type MetadataBuilders, type RevalidateHandlerOptions, TEXT, type TextReader, type TextRouteHandler, type WebhookEnvelope, XML, createLynkowServer, createRevalidateHandler, tagsForEvent, verifySignature };
|
package/dist/server/index.js
CHANGED
|
@@ -1,31 +1,22 @@
|
|
|
1
1
|
import { cached, TAGS, REVALIDATE } from '../chunk-XNN4SECG.js';
|
|
2
|
-
import { createLocalePaths } from '../chunk-
|
|
2
|
+
import { createLocalePaths } from '../chunk-2HMFZKOE.js';
|
|
3
3
|
import { jsx } from 'react/jsx-runtime';
|
|
4
4
|
import { revalidateTag } from 'next/cache';
|
|
5
5
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
function rewriteOrigin(body, siteDomain, siteUrl) {
|
|
9
|
-
if (!siteDomain) return body;
|
|
10
|
-
const sources = [`https://${siteDomain}`, `http://${siteDomain}`];
|
|
11
|
-
return sources.reduce(
|
|
12
|
-
(acc, source) => source === siteUrl ? acc : acc.split(source).join(siteUrl),
|
|
13
|
-
body
|
|
14
|
-
);
|
|
15
|
-
}
|
|
16
|
-
function jsonLdPayload(nodes, siteDomain, siteUrl) {
|
|
7
|
+
function jsonLdPayload(nodes, siteDomain, rewrite) {
|
|
17
8
|
const stripped = nodes.map((node) => {
|
|
18
9
|
const copy = { ...node };
|
|
19
10
|
delete copy["@context"];
|
|
20
11
|
return copy;
|
|
21
12
|
});
|
|
22
13
|
const payload = JSON.stringify({ "@context": "https://schema.org", "@graph": stripped });
|
|
23
|
-
return
|
|
14
|
+
return rewrite(payload, siteDomain).replace(/</g, "\\u003c");
|
|
24
15
|
}
|
|
25
|
-
function createJsonLdScript(
|
|
16
|
+
function createJsonLdScript(rewrite, getSiteDomain) {
|
|
26
17
|
return async function JsonLdScript({ nodes, locale }) {
|
|
27
18
|
if (!nodes || nodes.length === 0) return null;
|
|
28
|
-
const html = jsonLdPayload(nodes, await getSiteDomain(locale),
|
|
19
|
+
const html = jsonLdPayload(nodes, await getSiteDomain(locale), rewrite);
|
|
29
20
|
return /* @__PURE__ */ jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: html } });
|
|
30
21
|
};
|
|
31
22
|
}
|
|
@@ -92,6 +83,36 @@ function createMetadataBuilders(paths) {
|
|
|
92
83
|
return { pageMetadata, routeMetadata };
|
|
93
84
|
}
|
|
94
85
|
|
|
86
|
+
// src/server/origin.ts
|
|
87
|
+
function rewriteOrigin(body, siteDomain, siteUrl) {
|
|
88
|
+
if (!siteDomain) return body;
|
|
89
|
+
const sources = [`https://${siteDomain}`, `http://${siteDomain}`];
|
|
90
|
+
return sources.reduce(
|
|
91
|
+
(acc, source) => source === siteUrl ? acc : acc.split(source).join(siteUrl),
|
|
92
|
+
body
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
function unprefixDefaultLocale(body, siteUrl, defaultLocale) {
|
|
96
|
+
const pattern = new RegExp(
|
|
97
|
+
`${escapeRegExp(siteUrl)}/${escapeRegExp(defaultLocale.toLowerCase())}(?=[/?#"'<>\\s)\\]\\\\]|$)`,
|
|
98
|
+
"g"
|
|
99
|
+
);
|
|
100
|
+
return body.replace(
|
|
101
|
+
pattern,
|
|
102
|
+
(match, offset) => body[offset + match.length] === "/" ? siteUrl : `${siteUrl}/`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
function createBodyRewriter(config) {
|
|
106
|
+
const { siteUrl, defaultLocale, prefixDefaultLocale } = config;
|
|
107
|
+
return (body, siteDomain) => {
|
|
108
|
+
const moved = rewriteOrigin(body, siteDomain, siteUrl);
|
|
109
|
+
return prefixDefaultLocale ? moved : unprefixDefaultLocale(moved, siteUrl, defaultLocale);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function escapeRegExp(value) {
|
|
113
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
114
|
+
}
|
|
115
|
+
|
|
95
116
|
// src/server/create-server.ts
|
|
96
117
|
var XML = "application/xml; charset=utf-8";
|
|
97
118
|
var TEXT = "text/plain; charset=utf-8";
|
|
@@ -101,6 +122,7 @@ function createLynkowServer({
|
|
|
101
122
|
getSiteDomain
|
|
102
123
|
}) {
|
|
103
124
|
const paths = createLocalePaths(config);
|
|
125
|
+
const rewrite = createBodyRewriter(config);
|
|
104
126
|
function textRoute(read, contentType) {
|
|
105
127
|
return async function GET(_request, context) {
|
|
106
128
|
const locale = paths.toLocale((await context?.params)?.locale) ?? config.defaultLocale;
|
|
@@ -108,7 +130,7 @@ function createLynkowServer({
|
|
|
108
130
|
getSiteDomain(locale),
|
|
109
131
|
read(client(locale), cached([TAGS.seo], REVALIDATE.seo), locale)
|
|
110
132
|
]);
|
|
111
|
-
return new Response(
|
|
133
|
+
return new Response(rewrite(body, domain), {
|
|
112
134
|
headers: {
|
|
113
135
|
"content-type": contentType,
|
|
114
136
|
"cache-control": `public, max-age=0, s-maxage=${REVALIDATE.seo}, stale-while-revalidate=86400`
|
|
@@ -118,8 +140,8 @@ function createLynkowServer({
|
|
|
118
140
|
}
|
|
119
141
|
return {
|
|
120
142
|
textRoute,
|
|
121
|
-
JsonLdScript: createJsonLdScript(
|
|
122
|
-
rewriteOrigin:
|
|
143
|
+
JsonLdScript: createJsonLdScript(rewrite, getSiteDomain),
|
|
144
|
+
rewriteOrigin: rewrite,
|
|
123
145
|
...createMetadataBuilders(paths)
|
|
124
146
|
};
|
|
125
147
|
}
|
|
@@ -258,6 +280,6 @@ function createRevalidateHandler({ secret }) {
|
|
|
258
280
|
};
|
|
259
281
|
}
|
|
260
282
|
|
|
261
|
-
export { TEXT, XML, createLynkowServer, createRevalidateHandler,
|
|
283
|
+
export { TEXT, XML, createLynkowServer, createRevalidateHandler, tagsForEvent, verifySignature };
|
|
262
284
|
//# sourceMappingURL=index.js.map
|
|
263
285
|
//# sourceMappingURL=index.js.map
|
package/dist/server/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/server/origin.ts","../../src/server/json-ld.tsx","../../src/server/metadata.ts","../../src/server/create-server.ts","../../src/server/signature.ts","../../src/server/revalidation.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Rewrite the site's own domain to the origin this deployment is served from.\n *\n * Lynkow generates `sitemap.xml`, `robots.txt`, `llms.txt` and the JSON-LD\n * graph with absolute URLs built from the site's configured domain, which is\n * correct in production and wrong everywhere else: on localhost, on a preview\n * deployment or on a staging host, a verbatim copy would point at another site.\n *\n * When the two match, the body passes through untouched.\n *\n * @param body - What the API returned\n * @param siteDomain - The site's configured domain, such as `www.example.com`\n * @param siteUrl - The origin this deployment is served from, as `resolveConfig` returns it\n */\nexport function rewriteOrigin(\n body: string,\n siteDomain: string | null | undefined,\n siteUrl: string\n): string {\n if (!siteDomain) return body\n\n const sources = [`https://${siteDomain}`, `http://${siteDomain}`]\n return sources.reduce(\n (acc, source) => (source === siteUrl ? acc : acc.split(source).join(siteUrl)),\n body\n )\n}\n","import { rewriteOrigin } from './origin.js'\n\n/** Props of the `JsonLdScript` component {@link createLynkowServer} returns. */\nexport interface JsonLdScriptProps {\n /** The schema.org nodes, such as a page's `structuredData.graph`. Nothing renders when empty. */\n nodes: object[] | null | undefined\n locale: string\n}\n\n/**\n * The payload of one `<script type=\"application/ld+json\">`: the nodes as a\n * single `@graph` under one `@context`, which is what lets nodes reference each\n * other by `@id`, with URLs on this deployment's origin.\n *\n * Every `<` is written `<`, which JSON reads back as the same character.\n * The HTML parser then never sees a tag inside the script: escaping only\n * `</script` would still let a `<!--` from CMS content change how the parser\n * reads the rest of the element.\n */\nexport function jsonLdPayload(\n nodes: object[],\n siteDomain: string | null | undefined,\n siteUrl: string\n): string {\n const stripped = nodes.map((node) => {\n const copy = { ...(node as Record<string, unknown>) }\n delete copy['@context']\n return copy\n })\n const payload = JSON.stringify({ '@context': 'https://schema.org', '@graph': stripped })\n return rewriteOrigin(payload, siteDomain, siteUrl).replace(/</g, '\\\\u003c')\n}\n\nexport function createJsonLdScript(\n siteUrl: string,\n getSiteDomain: (locale: string) => Promise<string | null | undefined>\n) {\n /**\n * A list of schema.org nodes as ONE `<script type=\"application/ld+json\">`.\n * An async server component: the site's domain is read to put the graph's\n * URLs on this deployment's origin, so they agree with the canonical tag.\n */\n return async function JsonLdScript({ nodes, locale }: JsonLdScriptProps) {\n if (!nodes || nodes.length === 0) return null\n const html = jsonLdPayload(nodes, await getSiteDomain(locale), siteUrl)\n return <script type=\"application/ld+json\" dangerouslySetInnerHTML={{ __html: html }} />\n }\n}\n","import type { Metadata } from 'next'\nimport type { Page } from 'lynkow'\nimport type { LocalePaths } from '../locale-paths.js'\n\n/** The two metadata builders {@link createLynkowServer} returns. */\nexport interface MetadataBuilders {\n /**\n * Next metadata from a Lynkow page's SEO block.\n *\n * The hreflang set is built from `page.alternates`, so a page translated into\n * two of five locales advertises two alternates rather than five dead ones.\n * The fallbacks walk the same order the API does: an explicit SEO field, then\n * the Open Graph twin, then the page name.\n *\n * An editor's `metaTitle` is the whole `<title>`, brand included, so it is\n * set `absolute` and a layout's `%s | <site name>` template is not appended\n * to it. Only the page name, the fallback, goes through the template.\n *\n * @param fallbackPath - The locale-agnostic path of the route, used for the\n * canonical URL when the page advertises no alternate for `locale`\n */\n pageMetadata(page: Page, locale: string, fallbackPath: string): Metadata\n\n /**\n * Metadata for a route the application owns rather than one Lynkow drives,\n * such as `/reviews`. Its hreflang set is every configured locale: the route\n * exists in each of them by construction.\n *\n * @param path - The locale-agnostic path, with a leading slash\n */\n routeMetadata(locale: string, path: string, title: string, description: string): Metadata\n}\n\nexport function createMetadataBuilders(paths: LocalePaths): MetadataBuilders {\n /** A path the API returned, made public and absolute. */\n function apiUrl(path: string, locale: string): string {\n return paths.absoluteUrl(paths.fromApiPath(path, locale))\n }\n\n /** A route this application owns, which the CMS knows nothing about. */\n function routeUrl(locale: string, path: string): string {\n return paths.absoluteUrl(paths.localePath(locale, path))\n }\n\n function pageMetadata(page: Page, locale: string, fallbackPath: string): Metadata {\n const seo = page.seo\n const title = seo?.metaTitle || page.name || undefined\n const description = seo?.metaDescription || undefined\n\n const wanted = paths.toLocale(locale)\n const current = wanted\n ? page.alternates?.find((alt) => paths.toLocale(alt.locale) === wanted)\n : undefined\n const canonical =\n seo?.canonicalUrl ||\n (current ? apiUrl(current.path, locale) : routeUrl(locale, fallbackPath))\n\n const languages: Record<string, string> = {}\n for (const alternate of page.alternates ?? []) {\n const code = paths.toLocale(alternate.locale)\n if (code) languages[code] = apiUrl(alternate.path, code)\n }\n\n return {\n title: seo?.metaTitle ? { absolute: seo.metaTitle } : title,\n description,\n keywords: seo?.keywords?.length ? seo.keywords : undefined,\n alternates: {\n canonical,\n languages: Object.keys(languages).length > 0 ? languages : undefined,\n },\n robots: seo?.noIndex ? { index: false, follow: false } : undefined,\n openGraph: {\n type: 'website',\n locale,\n url: seo?.ogUrl || canonical,\n siteName: seo?.ogSiteName || undefined,\n title: seo?.ogTitle || title,\n description: seo?.ogDescription || description,\n images: seo?.ogImage?.url\n ? [{ url: seo.ogImage.url, alt: seo.ogImage.alt || undefined }]\n : undefined,\n },\n twitter: {\n card: seo?.twitterCard || 'summary_large_image',\n title: seo?.twitterTitle || seo?.ogTitle || title,\n description: seo?.twitterDescription || seo?.ogDescription || description,\n images: seo?.twitterImage?.url ? [seo.twitterImage.url] : undefined,\n },\n }\n }\n\n function routeMetadata(\n locale: string,\n path: string,\n title: string,\n description: string\n ): Metadata {\n const url = routeUrl(locale, path)\n const languages = Object.fromEntries(paths.locales.map((code) => [code, routeUrl(code, path)]))\n\n return {\n title,\n description,\n alternates: {\n canonical: url,\n languages: paths.locales.length > 1 ? languages : undefined,\n },\n openGraph: { type: 'website', locale, url, title, description },\n }\n }\n\n return { pageMetadata, routeMetadata }\n}\n","import type { BaseRequestOptions, Client } from 'lynkow'\nimport { cached, REVALIDATE, TAGS } from '../cache.js'\nimport type { LynkowNextConfig } from '../config.js'\nimport { createLocalePaths } from '../locale-paths.js'\nimport { createJsonLdScript } from './json-ld.js'\nimport { createMetadataBuilders, type MetadataBuilders } from './metadata.js'\nimport { rewriteOrigin } from './origin.js'\n\n/** `Content-Type` of an XML text route, such as `sitemap.xml`. */\nexport const XML = 'application/xml; charset=utf-8'\n/** `Content-Type` of a plain text route, such as `robots.txt` or `llms.txt`. */\nexport const TEXT = 'text/plain; charset=utf-8'\n\n/**\n * Reads one text file from Lynkow for a route. `options` carries the cache tags\n * and duration; pass it to the SDK call. `locale` is the route's, which a\n * localized file such as `llms.txt` has to pass explicitly, since the SDK reads\n * the locale of that call from its options and not from the client.\n */\nexport type TextReader = (\n client: Client,\n options: BaseRequestOptions,\n locale: string\n) => Promise<string>\n\n/** The route handler {@link LynkowServer.textRoute} returns. */\nexport type TextRouteHandler = (\n request: Request,\n context?: { params?: Promise<{ locale?: string }> }\n) => Promise<Response>\n\n/** Options of {@link createLynkowServer}. */\nexport interface LynkowServerOptions {\n config: LynkowNextConfig\n /** The client of a locale, typically the function `createClients(config)` returns. */\n client: (locale: string) => Client\n /**\n * The site's configured domain, which Lynkow builds the absolute URLs of its\n * generated files from, or null when it has none. Read it from a cached call,\n * such as the site configuration the layout already fetches.\n */\n getSiteDomain: (locale: string) => Promise<string | null | undefined>\n}\n\nexport interface LynkowServer extends MetadataBuilders {\n /**\n * A route handler serving a text file Lynkow generates, such as\n * `sitemap.xml`, `robots.txt` or `llms.txt`.\n *\n * Regenerating those files in the application would mean re-deriving rules\n * the API already computes from the site's own settings, and the two would\n * drift on the first setting change. So the handler does two things: cache\n * the upstream read under `TAGS.seo`, and put its URLs on this deployment's\n * origin. Under a `[locale]` segment the route's locale is used; anywhere\n * else, or for a locale that is not configured, the default one.\n *\n * @example\n * // src/app/sitemap.xml/route.ts\n * export const revalidate = 3600\n * export const GET = server.textRoute((client, options) => client.seo.sitemap(options), XML)\n */\n textRoute(read: TextReader, contentType: string): TextRouteHandler\n\n /** An async server component rendering schema.org nodes as one JSON-LD script. */\n JsonLdScript: ReturnType<typeof createJsonLdScript>\n\n /** {@link rewriteOrigin} bound to this site's origin. */\n rewriteOrigin(body: string, siteDomain: string | null | undefined): string\n}\n\n/**\n * The server-side helpers of a Lynkow site on Next: text routes, JSON-LD and\n * page metadata, bound to one configuration.\n *\n * @example\n * // src/lib/server.ts\n * import { createClients } from '@lynkow/next'\n * import { createLynkowServer } from '@lynkow/next/server'\n * import { config } from './config'\n *\n * export const lynkow = createClients(config)\n * export const server = createLynkowServer({\n * config,\n * client: (locale) => lynkow(locale),\n * getSiteDomain: async (locale) => (await getSiteShell(locale)).domain,\n * })\n */\nexport function createLynkowServer({\n config,\n client,\n getSiteDomain,\n}: LynkowServerOptions): LynkowServer {\n const paths = createLocalePaths(config)\n\n function textRoute(read: TextReader, contentType: string): TextRouteHandler {\n return async function GET(_request, context) {\n const locale = paths.toLocale((await context?.params)?.locale) ?? config.defaultLocale\n\n const [domain, body] = await Promise.all([\n getSiteDomain(locale),\n read(client(locale), cached([TAGS.seo], REVALIDATE.seo), locale),\n ])\n\n return new Response(rewriteOrigin(body, domain, config.siteUrl), {\n headers: {\n 'content-type': contentType,\n 'cache-control': `public, max-age=0, s-maxage=${REVALIDATE.seo}, stale-while-revalidate=86400`,\n },\n })\n }\n }\n\n return {\n textRoute,\n JsonLdScript: createJsonLdScript(config.siteUrl, getSiteDomain),\n rewriteOrigin: (body, siteDomain) => rewriteOrigin(body, siteDomain, config.siteUrl),\n ...createMetadataBuilders(paths),\n }\n}\n","import { createHmac, timingSafeEqual } from 'node:crypto'\n\nconst PREFIX = 'sha256='\nconst HEX_DIGEST = /^[0-9a-f]{64}$/i\n\n/**\n * Verify a Lynkow webhook: its HMAC first, then the freshness of the timestamp\n * that HMAC covers.\n *\n * The API builds `{ event, timestamp, data }` once per delivery attempt, signs\n * `JSON.stringify` of it with the webhook's secret, sends the digest as\n * `X-Webhook-Signature: sha256=<hex>`, and POSTs that exact same string as the\n * body (`webhook_service.ts`, `sendWebhook` and `generateSignature`). So the raw\n * request text is verifiable byte for byte, and the `timestamp` inside it is\n * authenticated.\n *\n * That is why the handler reads `await request.text()` and never `request.json()`:\n * parsing and re-serialising would reorder no keys today, but it is not a\n * property JSON guarantees, and a signature checked against a re-serialisation\n * is a signature that fails for reasons nobody can reproduce.\n *\n * Returns `true` only when every check passes, and `false` for anything else,\n * including an empty secret, a missing or malformed header, a body that is not\n * JSON, and a stale or future timestamp. It never throws on request input, so a\n * crafted header is a rejection rather than a 500. The result deliberately does\n * not say which check failed.\n *\n * It is safe to call on its own: it does not rely on the caller having checked\n * that a secret is configured.\n */\nexport function verifySignature(rawBody: string, header: string | null, secret: string): boolean {\n // An empty key still produces a valid HMAC, one anybody can compute. A\n // verifier that accepted it would authenticate every request.\n if (typeof secret !== 'string' || secret.length === 0) return false\n if (typeof rawBody !== 'string') return false\n if (!hasSignatureShape(header)) return false\n\n const received = header.slice(PREFIX.length)\n const expected = createHmac('sha256', secret).update(rawBody).digest()\n if (!timingSafeEqual(Buffer.from(received, 'hex'), expected)) return false\n\n // Only a body the signature vouches for is parsed.\n return isFresh(rawBody, Date.now())\n}\n\n/**\n * Whether a header has the shape of a Lynkow signature, `sha256=` and 64 hex\n * digits, which a handler can check before reading the body.\n *\n * The FORMAT is validated before anything is compared. A string length counts\n * UTF-16 code units and a UTF-8 buffer counts bytes, so comparing text lets 64\n * non-ASCII characters through a length check and into `timingSafeEqual`,\n * which throws on a byte-length mismatch. Decoding validated hex yields exactly\n * 32 bytes on both sides, so the comparison cannot throw. The shape of a\n * SHA-256 hex digest is public, so this test leaks nothing.\n */\nexport function hasSignatureShape(header: string | null | undefined): header is string {\n return (\n typeof header === 'string' &&\n header.startsWith(PREFIX) &&\n HEX_DIGEST.test(header.slice(PREFIX.length))\n )\n}\n\n/**\n * How far the signed timestamp may sit from this server's clock, in either\n * direction. Without it, one captured request replays forever, and replaying a\n * revalidation is a free cache eviction lever.\n *\n * The API stamps every delivery ATTEMPT afresh, retries included, so this window\n * does not have to absorb the retry schedule, only one attempt's delay plus\n * clock skew between the API and this site. That delay is not capped by the\n * API's 30-second abort: the stamp is taken BEFORE the destination's DNS lookup,\n * which has no timeout of its own, and the abort covers only the 30 seconds\n * after the address resolves. A lookup slow enough to push an attempt past the\n * window fails closed: this site answers 401. That 401 is not always retried.\n * The API retries an event only when EVERY webhook subscribed to it failed, so\n * a retry, with a fresh stamp, follows only when no other webhook on that event\n * accepted it. With several webhooks on one event, a stale 401 here while\n * another one answered 2xx loses this revalidation, and the page stays stale\n * until its `REVALIDATE` ceiling runs out.\n */\nconst FRESHNESS_WINDOW_MS = 5 * 60 * 1000\n\n/** Fail closed: a missing, non-string or unparseable timestamp is not fresh. */\nfunction isFresh(rawBody: string, now: number): boolean {\n let envelope: unknown\n try {\n envelope = JSON.parse(rawBody)\n } catch {\n return false\n }\n if (typeof envelope !== 'object' || envelope === null) return false\n\n const timestamp = (envelope as { timestamp?: unknown }).timestamp\n if (typeof timestamp !== 'string') return false\n\n const signedAt = Date.parse(timestamp)\n if (!Number.isFinite(signedAt)) return false\n\n return Math.abs(now - signedAt) <= FRESHNESS_WINDOW_MS\n}\n","import { revalidateTag } from 'next/cache'\nimport { TAGS } from '../cache.js'\nimport { hasSignatureShape, verifySignature } from './signature.js'\n\n/**\n * The largest body the handler reads. A delivery carries a summary of one\n * resource (the API leaves article bodies out of it) and the API itself refuses\n * a JSON body over 1 MiB, so every real delivery fits with room to spare, while\n * an unauthenticated caller cannot make this route buffer more.\n */\nconst MAX_BODY_BYTES = 5 * 1024 * 1024\n\n/** The body as text, or null past {@link MAX_BODY_BYTES}, declared or streamed. */\nasync function readBody(request: Request): Promise<string | null> {\n if (Number(request.headers.get('content-length')) > MAX_BODY_BYTES) return null\n if (!request.body) return ''\n\n const reader = request.body.getReader()\n const decoder = new TextDecoder()\n let text = ''\n let size = 0\n for (;;) {\n const { done, value } = await reader.read()\n if (done) return text + decoder.decode()\n size += value.byteLength\n if (size > MAX_BODY_BYTES) {\n await reader.cancel()\n return null\n }\n text += decoder.decode(value, { stream: true })\n }\n}\n\n/** The envelope the Lynkow API POSTs to a webhook. */\nexport interface WebhookEnvelope {\n event: string\n timestamp: string\n data: Record<string, unknown>\n}\n\n/**\n * Which cache tags one resource drops.\n *\n * Keyed on the RESOURCE half of the event name rather than on the full name,\n * because the read side does not distinguish them: a page that reads the\n * article list is equally stale after a `content.created` and after a\n * `content.archived`.\n *\n * Eviction is deliberately generous. A tag dropped for nothing costs one API\n * round trip on the next request; a tag NOT dropped serves stale content until\n * the `REVALIDATE` ceiling expires, which is up to an hour.\n *\n * `slug` is present on the `content.*`, `form.*`, `category.*`, `tag.*` and\n * `site_block.*` payloads, and absent on `media.*`, `seo.updated` and\n * `site.updated`, which ask for no slug-scoped tag. A `site_block.*` payload\n * carries `slug` and `locale`, never `path`: a page read by path is evicted by\n * the collective `TAGS.pages`, and `TAGS.page(slug)` exists for pages read by\n * slug.\n */\nfunction tagsForResource(resource: string, slug: string | null): string[] {\n switch (resource) {\n case 'content':\n return [\n TAGS.contents,\n TAGS.paths,\n TAGS.seo,\n TAGS.search,\n ...(slug ? [TAGS.content(slug)] : []),\n ]\n\n case 'category':\n // A category archive is resolved through the same `paths.resolve` call as\n // an article, under `TAGS.content(<last segment>)`, so a renamed category\n // has to drop that key too.\n return [\n TAGS.categories,\n TAGS.contents,\n TAGS.paths,\n TAGS.seo,\n TAGS.search,\n ...(slug ? [TAGS.content(slug)] : []),\n ]\n\n case 'tag':\n return [TAGS.tags, TAGS.contents, TAGS.search]\n\n case 'site_block':\n return [TAGS.pages, TAGS.paths, TAGS.seo, ...(slug ? [TAGS.page(slug)] : [])]\n\n case 'variable':\n // Variables are interpolated inside block data, so a change is visible on\n // any page, not on one identifiable page.\n return [TAGS.pages, TAGS.site]\n\n case 'form':\n return [TAGS.forms, ...(slug ? [TAGS.form(slug)] : [])]\n\n case 'review':\n return [TAGS.reviews]\n\n case 'media':\n // `media.replaced` swaps the file behind a URL that is embedded in block\n // data and in article bodies, and neither payload says where.\n return [TAGS.pages, TAGS.contents, TAGS.search]\n\n case 'redirect':\n return [TAGS.paths]\n\n case 'sitemap_entry':\n case 'sitemap_rule':\n case 'seo':\n return [TAGS.seo]\n\n case 'site':\n return [TAGS.site, TAGS.seo]\n\n case 'search_profile':\n case 'search_config':\n return [TAGS.search]\n\n default:\n return []\n }\n}\n\n/**\n * The tags one delivered event invalidates, deduplicated.\n *\n * An unknown event returns an empty list rather than throwing. Lynkow adds\n * events without asking its consumers, and a site that fails on an event it has\n * never heard of turns a new CMS feature into an outage.\n */\nexport function tagsForEvent(envelope: WebhookEnvelope): string[] {\n // `form.submitted` fires on every visitor submission and changes nothing a\n // site reads: the form schema is untouched and submissions are never listed.\n // Left in the table it would evict the schema of every form on a spam wave.\n if (envelope.event === 'form.submitted') return []\n\n const resource = envelope.event.split('.')[0] ?? ''\n const slug = envelope.data?.['slug']\n\n return [...new Set(tagsForResource(resource, typeof slug === 'string' ? slug : null))]\n}\n\n/** Options of {@link createRevalidateHandler}. */\nexport interface RevalidateHandlerOptions {\n /**\n * The webhook's signing secret, read by the application from a server-only\n * environment variable. Absent or empty, the endpoint stays closed.\n */\n secret: string | undefined\n}\n\n/**\n * The `POST` handler of a revalidation webhook.\n *\n * Point a Lynkow webhook at the route that exports it and give it the secret\n * passed here. Every event the webhook subscribes to drops the cache tags that\n * event can invalidate, so an edit is live in seconds instead of waiting out\n * the `REVALIDATE` ceiling. The route must run on Node, since the signature\n * check needs `node:crypto`:\n *\n * @example\n * // src/app/api/revalidate/route.ts\n * import { createRevalidateHandler } from '@lynkow/next/server'\n *\n * export const runtime = 'nodejs'\n * export const POST = createRevalidateHandler({ secret: process.env.LYNKOW_WEBHOOK_SECRET })\n */\nexport function createRevalidateHandler({ secret }: RevalidateHandlerOptions) {\n return async function POST(request: Request): Promise<Response> {\n if (!secret) {\n // Fail closed. An unauthenticated revalidation endpoint is a free cache\n // eviction lever: anyone who finds the URL can force every page of the\n // site back to the origin on every request.\n return Response.json({ error: 'Revalidation webhook is not configured' }, { status: 503 })\n }\n\n // One answer for a missing or bad signature and for a stale or replayed body\n // alike: the caller learns that the request was refused, never which check\n // refused it. A header without the shape of a signature is refused before\n // the body is read at all.\n const signature = request.headers.get('x-webhook-signature')\n if (!hasSignatureShape(signature)) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n // The signature covers the bytes, so read the bytes. See `verifySignature`.\n const rawBody = await readBody(request)\n if (rawBody === null) {\n return Response.json({ error: 'Body too large' }, { status: 413 })\n }\n\n if (!verifySignature(rawBody, signature, secret)) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n // The verifier has already parsed this body to read its timestamp, so this\n // cannot fail today. It stays guarded anyway: an unhandled throw here would\n // be a 500, which is exactly the failure a crafted request must never reach.\n let envelope: WebhookEnvelope\n try {\n envelope = JSON.parse(rawBody) as WebhookEnvelope\n } catch {\n return Response.json({ error: 'Body is not JSON' }, { status: 400 })\n }\n\n if (typeof envelope?.event !== 'string') {\n return Response.json({ error: 'Body carries no event name' }, { status: 400 })\n }\n\n const tags = tagsForEvent(envelope)\n for (const tag of tags) {\n // Next 16 made the second argument mandatory: it is the staleness a reader\n // that already holds the entry is still allowed. `{ expire: 0 }` grants\n // none, which is the only setting that matches what this route means. A\n // named profile such as `'max'` would say the opposite, and `updateTag`,\n // which expires immediately by definition, throws outside a Server Action.\n revalidateTag(tag, { expire: 0 })\n }\n\n // 200 with an empty `revalidated` is the right answer for an event this site\n // does not read, not an error: the webhook is working, the event just has no\n // consequence here. A 4xx would fill the delivery log with failures and\n // teach whoever reads it to ignore the log.\n return Response.json({ event: envelope.event, revalidated: tags })\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/server/json-ld.tsx","../../src/server/metadata.ts","../../src/server/origin.ts","../../src/server/create-server.ts","../../src/server/signature.ts","../../src/server/revalidation.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import type { BodyRewriter } from './origin.js'\n\n/** Props of the `JsonLdScript` component {@link createLynkowServer} returns. */\nexport interface JsonLdScriptProps {\n /** The schema.org nodes, such as a page's `structuredData.graph`. Nothing renders when empty. */\n nodes: object[] | null | undefined\n locale: string\n}\n\n/**\n * The payload of one `<script type=\"application/ld+json\">`: the nodes as a\n * single `@graph` under one `@context`, which is what lets nodes reference each\n * other by `@id`, with URLs on this site's public form: on this deployment's\n * origin, and without the default locale's prefix when it is served unprefixed.\n *\n * Every `<` is written `<`, which JSON reads back as the same character.\n * The HTML parser then never sees a tag inside the script: escaping only\n * `</script` would still let a `<!--` from CMS content change how the parser\n * reads the rest of the element.\n */\nexport function jsonLdPayload(\n nodes: object[],\n siteDomain: string | null | undefined,\n rewrite: BodyRewriter\n): string {\n const stripped = nodes.map((node) => {\n const copy = { ...(node as Record<string, unknown>) }\n delete copy['@context']\n return copy\n })\n const payload = JSON.stringify({ '@context': 'https://schema.org', '@graph': stripped })\n return rewrite(payload, siteDomain).replace(/</g, '\\\\u003c')\n}\n\nexport function createJsonLdScript(\n rewrite: BodyRewriter,\n getSiteDomain: (locale: string) => Promise<string | null | undefined>\n) {\n /**\n * A list of schema.org nodes as ONE `<script type=\"application/ld+json\">`.\n * An async server component: the site's domain is read to put the graph's\n * URLs on this site's public form, so they agree with the canonical tag.\n */\n return async function JsonLdScript({ nodes, locale }: JsonLdScriptProps) {\n if (!nodes || nodes.length === 0) return null\n const html = jsonLdPayload(nodes, await getSiteDomain(locale), rewrite)\n return <script type=\"application/ld+json\" dangerouslySetInnerHTML={{ __html: html }} />\n }\n}\n","import type { Metadata } from 'next'\nimport type { Page } from 'lynkow'\nimport type { LocalePaths } from '../locale-paths.js'\n\n/** The two metadata builders {@link createLynkowServer} returns. */\nexport interface MetadataBuilders {\n /**\n * Next metadata from a Lynkow page's SEO block.\n *\n * The hreflang set is built from `page.alternates`, so a page translated into\n * two of five locales advertises two alternates rather than five dead ones.\n * The fallbacks walk the same order the API does: an explicit SEO field, then\n * the Open Graph twin, then the page name.\n *\n * An editor's `metaTitle` is the whole `<title>`, brand included, so it is\n * set `absolute` and a layout's `%s | <site name>` template is not appended\n * to it. Only the page name, the fallback, goes through the template.\n *\n * @param fallbackPath - The locale-agnostic path of the route, used for the\n * canonical URL when the page advertises no alternate for `locale`\n */\n pageMetadata(page: Page, locale: string, fallbackPath: string): Metadata\n\n /**\n * Metadata for a route the application owns rather than one Lynkow drives,\n * such as `/reviews`. Its hreflang set is every configured locale: the route\n * exists in each of them by construction.\n *\n * @param path - The locale-agnostic path, with a leading slash\n */\n routeMetadata(locale: string, path: string, title: string, description: string): Metadata\n}\n\nexport function createMetadataBuilders(paths: LocalePaths): MetadataBuilders {\n /** A path the API returned, made public and absolute. */\n function apiUrl(path: string, locale: string): string {\n return paths.absoluteUrl(paths.fromApiPath(path, locale))\n }\n\n /** A route this application owns, which the CMS knows nothing about. */\n function routeUrl(locale: string, path: string): string {\n return paths.absoluteUrl(paths.localePath(locale, path))\n }\n\n function pageMetadata(page: Page, locale: string, fallbackPath: string): Metadata {\n const seo = page.seo\n const title = seo?.metaTitle || page.name || undefined\n const description = seo?.metaDescription || undefined\n\n const wanted = paths.toLocale(locale)\n const current = wanted\n ? page.alternates?.find((alt) => paths.toLocale(alt.locale) === wanted)\n : undefined\n const canonical =\n seo?.canonicalUrl ||\n (current ? apiUrl(current.path, locale) : routeUrl(locale, fallbackPath))\n\n const languages: Record<string, string> = {}\n for (const alternate of page.alternates ?? []) {\n const code = paths.toLocale(alternate.locale)\n if (code) languages[code] = apiUrl(alternate.path, code)\n }\n\n return {\n title: seo?.metaTitle ? { absolute: seo.metaTitle } : title,\n description,\n keywords: seo?.keywords?.length ? seo.keywords : undefined,\n alternates: {\n canonical,\n languages: Object.keys(languages).length > 0 ? languages : undefined,\n },\n robots: seo?.noIndex ? { index: false, follow: false } : undefined,\n openGraph: {\n type: 'website',\n locale,\n url: seo?.ogUrl || canonical,\n siteName: seo?.ogSiteName || undefined,\n title: seo?.ogTitle || title,\n description: seo?.ogDescription || description,\n images: seo?.ogImage?.url\n ? [{ url: seo.ogImage.url, alt: seo.ogImage.alt || undefined }]\n : undefined,\n },\n twitter: {\n card: seo?.twitterCard || 'summary_large_image',\n title: seo?.twitterTitle || seo?.ogTitle || title,\n description: seo?.twitterDescription || seo?.ogDescription || description,\n images: seo?.twitterImage?.url ? [seo.twitterImage.url] : undefined,\n },\n }\n }\n\n function routeMetadata(\n locale: string,\n path: string,\n title: string,\n description: string\n ): Metadata {\n const url = routeUrl(locale, path)\n const languages = Object.fromEntries(paths.locales.map((code) => [code, routeUrl(code, path)]))\n\n return {\n title,\n description,\n alternates: {\n canonical: url,\n languages: paths.locales.length > 1 ? languages : undefined,\n },\n openGraph: { type: 'website', locale, url, title, description },\n }\n }\n\n return { pageMetadata, routeMetadata }\n}\n","import type { LynkowNextConfig } from '../config.js'\n\n/**\n * Rewrite the site's own domain to the origin this deployment is served from.\n *\n * Lynkow generates `sitemap.xml`, `robots.txt`, `llms.txt` and the JSON-LD\n * graph with absolute URLs built from the site's configured domain, which is\n * correct in production and wrong everywhere else: on localhost, on a preview\n * deployment or on a staging host, a verbatim copy would point at another site.\n *\n * When the two match, the body passes through untouched.\n *\n * @param body - What the API returned\n * @param siteDomain - The site's configured domain, such as `www.example.com`\n * @param siteUrl - The origin this deployment is served from, as `resolveConfig` returns it\n */\nexport function rewriteOrigin(\n body: string,\n siteDomain: string | null | undefined,\n siteUrl: string\n): string {\n if (!siteDomain) return body\n\n const sources = [`https://${siteDomain}`, `http://${siteDomain}`]\n return sources.reduce(\n (acc, source) => (source === siteUrl ? acc : acc.split(source).join(siteUrl)),\n body\n )\n}\n\n/**\n * Every absolute URL on `siteUrl` whose first segment is the default locale,\n * mapped to its unprefixed public form: the result\n * `absoluteUrl(localePath(defaultLocale, rest))` gives with\n * `prefixDefaultLocale` off.\n *\n * On a multilingual site the API prefixes the default locale like any other\n * (ADR-0060), so without this a sitemap would list `/en/about`, which the\n * proxy answers with a 308 to `/about`.\n *\n * Only an absolute URL on `siteUrl` is touched, never a bare path in prose.\n * The segment is matched as the API writes it, lowercased, and only when a\n * `/`, `?`, `#`, a quote, `<`, `>`, whitespace, `)`, `]`, a backslash (a JSON\n * escape, in a JSON-LD graph) or the end of the body follows it, so `/en`\n * never matches inside `/en-gb/` or `/entry`. The pattern\n * is a literal followed by one lookahead, so its cost stays linear in the body.\n */\nexport function unprefixDefaultLocale(body: string, siteUrl: string, defaultLocale: string): string {\n const pattern = new RegExp(\n `${escapeRegExp(siteUrl)}/${escapeRegExp(defaultLocale.toLowerCase())}(?=[/?#\"'<>\\\\s)\\\\]\\\\\\\\]|$)`,\n 'g'\n )\n // `/en/about` loses `/en`, `/en?x` and `/en` alone become `/?x` and `/`.\n return body.replace(pattern, (match: string, offset: number) =>\n body[offset + match.length] === '/' ? siteUrl : `${siteUrl}/`\n )\n}\n\n/** Puts the absolute URLs of a body the API generated on this site's public form. */\nexport type BodyRewriter = (body: string, siteDomain: string | null | undefined) => string\n\n/**\n * The one place a body the API generated gets its public URLs: the site domain\n * moved to `siteUrl` by {@link rewriteOrigin}, then, with `prefixDefaultLocale`\n * off, the default locale unprefixed by {@link unprefixDefaultLocale}. With the\n * flag on, the result is exactly that of {@link rewriteOrigin}.\n */\nexport function createBodyRewriter(config: LynkowNextConfig): BodyRewriter {\n const { siteUrl, defaultLocale, prefixDefaultLocale } = config\n return (body, siteDomain) => {\n const moved = rewriteOrigin(body, siteDomain, siteUrl)\n return prefixDefaultLocale ? moved : unprefixDefaultLocale(moved, siteUrl, defaultLocale)\n }\n}\n\nfunction escapeRegExp(value: string): string {\n return value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n}\n","import type { BaseRequestOptions, Client } from 'lynkow'\nimport { cached, REVALIDATE, TAGS } from '../cache.js'\nimport type { LynkowNextConfig } from '../config.js'\nimport { createLocalePaths } from '../locale-paths.js'\nimport { createJsonLdScript } from './json-ld.js'\nimport { createMetadataBuilders, type MetadataBuilders } from './metadata.js'\nimport { createBodyRewriter } from './origin.js'\n\n/** `Content-Type` of an XML text route, such as `sitemap.xml`. */\nexport const XML = 'application/xml; charset=utf-8'\n/** `Content-Type` of a plain text route, such as `robots.txt` or `llms.txt`. */\nexport const TEXT = 'text/plain; charset=utf-8'\n\n/**\n * Reads one text file from Lynkow for a route. `options` carries the cache tags\n * and duration; pass it to the SDK call. `locale` is the route's, which a\n * localized file such as `llms.txt` has to pass explicitly, since the SDK reads\n * the locale of that call from its options and not from the client.\n */\nexport type TextReader = (\n client: Client,\n options: BaseRequestOptions,\n locale: string\n) => Promise<string>\n\n/** The route handler {@link LynkowServer.textRoute} returns. */\nexport type TextRouteHandler = (\n request: Request,\n context?: { params?: Promise<{ locale?: string }> }\n) => Promise<Response>\n\n/** Options of {@link createLynkowServer}. */\nexport interface LynkowServerOptions {\n config: LynkowNextConfig\n /** The client of a locale, typically the function `createClients(config)` returns. */\n client: (locale: string) => Client\n /**\n * The site's configured domain, which Lynkow builds the absolute URLs of its\n * generated files from, or null when it has none. Read it from a cached call,\n * such as the site configuration the layout already fetches.\n */\n getSiteDomain: (locale: string) => Promise<string | null | undefined>\n}\n\nexport interface LynkowServer extends MetadataBuilders {\n /**\n * A route handler serving a text file Lynkow generates, such as\n * `sitemap.xml`, `robots.txt` or `llms.txt`.\n *\n * Regenerating those files in the application would mean re-deriving rules\n * the API already computes from the site's own settings, and the two would\n * drift on the first setting change. So the handler does two things: cache\n * the upstream read under `TAGS.seo`, and put its URLs on their public form\n * through {@link LynkowServer.rewriteOrigin}. Under a `[locale]` segment the\n * route's locale is used; anywhere else, or for a locale that is not\n * configured, the default one.\n *\n * @example\n * // src/app/sitemap.xml/route.ts\n * export const revalidate = 3600\n * export const GET = server.textRoute((client, options) => client.seo.sitemap(options), XML)\n */\n textRoute(read: TextReader, contentType: string): TextRouteHandler\n\n /** An async server component rendering schema.org nodes as one JSON-LD script. */\n JsonLdScript: ReturnType<typeof createJsonLdScript>\n\n /**\n * Puts the absolute URLs of a body the API generated, such as a sitemap or a\n * JSON-LD graph, on this site's public form. The site's domain, given as\n * `siteDomain`, is moved to `siteUrl`, so a copy served from localhost or a\n * preview deployment does not point at production. With `prefixDefaultLocale`\n * off, a URL on `siteUrl` under `/<default locale>` then loses that prefix,\n * which the API always writes: `/en/about` becomes `/about`, as the proxy\n * serves it. Bare paths are left alone. `textRoute` and `JsonLdScript` go\n * through this same function.\n */\n rewriteOrigin(body: string, siteDomain: string | null | undefined): string\n}\n\n/**\n * The server-side helpers of a Lynkow site on Next: text routes, JSON-LD and\n * page metadata, bound to one configuration.\n *\n * @example\n * // src/lib/server.ts\n * import { createClients } from '@lynkow/next'\n * import { createLynkowServer } from '@lynkow/next/server'\n * import { config } from './config'\n *\n * export const lynkow = createClients(config)\n * export const server = createLynkowServer({\n * config,\n * client: (locale) => lynkow(locale),\n * getSiteDomain: async (locale) => (await getSiteShell(locale)).domain,\n * })\n */\nexport function createLynkowServer({\n config,\n client,\n getSiteDomain,\n}: LynkowServerOptions): LynkowServer {\n const paths = createLocalePaths(config)\n const rewrite = createBodyRewriter(config)\n\n function textRoute(read: TextReader, contentType: string): TextRouteHandler {\n return async function GET(_request, context) {\n const locale = paths.toLocale((await context?.params)?.locale) ?? config.defaultLocale\n\n const [domain, body] = await Promise.all([\n getSiteDomain(locale),\n read(client(locale), cached([TAGS.seo], REVALIDATE.seo), locale),\n ])\n\n return new Response(rewrite(body, domain), {\n headers: {\n 'content-type': contentType,\n 'cache-control': `public, max-age=0, s-maxage=${REVALIDATE.seo}, stale-while-revalidate=86400`,\n },\n })\n }\n }\n\n return {\n textRoute,\n JsonLdScript: createJsonLdScript(rewrite, getSiteDomain),\n rewriteOrigin: rewrite,\n ...createMetadataBuilders(paths),\n }\n}\n","import { createHmac, timingSafeEqual } from 'node:crypto'\n\nconst PREFIX = 'sha256='\nconst HEX_DIGEST = /^[0-9a-f]{64}$/i\n\n/**\n * Verify a Lynkow webhook: its HMAC first, then the freshness of the timestamp\n * that HMAC covers.\n *\n * The API builds `{ event, timestamp, data }` once per delivery attempt, signs\n * `JSON.stringify` of it with the webhook's secret, sends the digest as\n * `X-Webhook-Signature: sha256=<hex>`, and POSTs that exact same string as the\n * body (`webhook_service.ts`, `sendWebhook` and `generateSignature`). So the raw\n * request text is verifiable byte for byte, and the `timestamp` inside it is\n * authenticated.\n *\n * That is why the handler reads `await request.text()` and never `request.json()`:\n * parsing and re-serialising would reorder no keys today, but it is not a\n * property JSON guarantees, and a signature checked against a re-serialisation\n * is a signature that fails for reasons nobody can reproduce.\n *\n * Returns `true` only when every check passes, and `false` for anything else,\n * including an empty secret, a missing or malformed header, a body that is not\n * JSON, and a stale or future timestamp. It never throws on request input, so a\n * crafted header is a rejection rather than a 500. The result deliberately does\n * not say which check failed.\n *\n * It is safe to call on its own: it does not rely on the caller having checked\n * that a secret is configured.\n */\nexport function verifySignature(rawBody: string, header: string | null, secret: string): boolean {\n // An empty key still produces a valid HMAC, one anybody can compute. A\n // verifier that accepted it would authenticate every request.\n if (typeof secret !== 'string' || secret.length === 0) return false\n if (typeof rawBody !== 'string') return false\n if (!hasSignatureShape(header)) return false\n\n const received = header.slice(PREFIX.length)\n const expected = createHmac('sha256', secret).update(rawBody).digest()\n if (!timingSafeEqual(Buffer.from(received, 'hex'), expected)) return false\n\n // Only a body the signature vouches for is parsed.\n return isFresh(rawBody, Date.now())\n}\n\n/**\n * Whether a header has the shape of a Lynkow signature, `sha256=` and 64 hex\n * digits, which a handler can check before reading the body.\n *\n * The FORMAT is validated before anything is compared. A string length counts\n * UTF-16 code units and a UTF-8 buffer counts bytes, so comparing text lets 64\n * non-ASCII characters through a length check and into `timingSafeEqual`,\n * which throws on a byte-length mismatch. Decoding validated hex yields exactly\n * 32 bytes on both sides, so the comparison cannot throw. The shape of a\n * SHA-256 hex digest is public, so this test leaks nothing.\n */\nexport function hasSignatureShape(header: string | null | undefined): header is string {\n return (\n typeof header === 'string' &&\n header.startsWith(PREFIX) &&\n HEX_DIGEST.test(header.slice(PREFIX.length))\n )\n}\n\n/**\n * How far the signed timestamp may sit from this server's clock, in either\n * direction. Without it, one captured request replays forever, and replaying a\n * revalidation is a free cache eviction lever.\n *\n * The API stamps every delivery ATTEMPT afresh, retries included, so this window\n * does not have to absorb the retry schedule, only one attempt's delay plus\n * clock skew between the API and this site. That delay is not capped by the\n * API's 30-second abort: the stamp is taken BEFORE the destination's DNS lookup,\n * which has no timeout of its own, and the abort covers only the 30 seconds\n * after the address resolves. A lookup slow enough to push an attempt past the\n * window fails closed: this site answers 401. That 401 is not always retried.\n * The API retries an event only when EVERY webhook subscribed to it failed, so\n * a retry, with a fresh stamp, follows only when no other webhook on that event\n * accepted it. With several webhooks on one event, a stale 401 here while\n * another one answered 2xx loses this revalidation, and the page stays stale\n * until its `REVALIDATE` ceiling runs out.\n */\nconst FRESHNESS_WINDOW_MS = 5 * 60 * 1000\n\n/** Fail closed: a missing, non-string or unparseable timestamp is not fresh. */\nfunction isFresh(rawBody: string, now: number): boolean {\n let envelope: unknown\n try {\n envelope = JSON.parse(rawBody)\n } catch {\n return false\n }\n if (typeof envelope !== 'object' || envelope === null) return false\n\n const timestamp = (envelope as { timestamp?: unknown }).timestamp\n if (typeof timestamp !== 'string') return false\n\n const signedAt = Date.parse(timestamp)\n if (!Number.isFinite(signedAt)) return false\n\n return Math.abs(now - signedAt) <= FRESHNESS_WINDOW_MS\n}\n","import { revalidateTag } from 'next/cache'\nimport { TAGS } from '../cache.js'\nimport { hasSignatureShape, verifySignature } from './signature.js'\n\n/**\n * The largest body the handler reads. A delivery carries a summary of one\n * resource (the API leaves article bodies out of it) and the API itself refuses\n * a JSON body over 1 MiB, so every real delivery fits with room to spare, while\n * an unauthenticated caller cannot make this route buffer more.\n */\nconst MAX_BODY_BYTES = 5 * 1024 * 1024\n\n/** The body as text, or null past {@link MAX_BODY_BYTES}, declared or streamed. */\nasync function readBody(request: Request): Promise<string | null> {\n if (Number(request.headers.get('content-length')) > MAX_BODY_BYTES) return null\n if (!request.body) return ''\n\n const reader = request.body.getReader()\n const decoder = new TextDecoder()\n let text = ''\n let size = 0\n for (;;) {\n const { done, value } = await reader.read()\n if (done) return text + decoder.decode()\n size += value.byteLength\n if (size > MAX_BODY_BYTES) {\n await reader.cancel()\n return null\n }\n text += decoder.decode(value, { stream: true })\n }\n}\n\n/** The envelope the Lynkow API POSTs to a webhook. */\nexport interface WebhookEnvelope {\n event: string\n timestamp: string\n data: Record<string, unknown>\n}\n\n/**\n * Which cache tags one resource drops.\n *\n * Keyed on the RESOURCE half of the event name rather than on the full name,\n * because the read side does not distinguish them: a page that reads the\n * article list is equally stale after a `content.created` and after a\n * `content.archived`.\n *\n * Eviction is deliberately generous. A tag dropped for nothing costs one API\n * round trip on the next request; a tag NOT dropped serves stale content until\n * the `REVALIDATE` ceiling expires, which is up to an hour.\n *\n * `slug` is present on the `content.*`, `form.*`, `category.*`, `tag.*` and\n * `site_block.*` payloads, and absent on `media.*`, `seo.updated` and\n * `site.updated`, which ask for no slug-scoped tag. A `site_block.*` payload\n * carries `slug` and `locale`, never `path`: a page read by path is evicted by\n * the collective `TAGS.pages`, and `TAGS.page(slug)` exists for pages read by\n * slug.\n */\nfunction tagsForResource(resource: string, slug: string | null): string[] {\n switch (resource) {\n case 'content':\n return [\n TAGS.contents,\n TAGS.paths,\n TAGS.seo,\n TAGS.search,\n ...(slug ? [TAGS.content(slug)] : []),\n ]\n\n case 'category':\n // A category archive is resolved through the same `paths.resolve` call as\n // an article, under `TAGS.content(<last segment>)`, so a renamed category\n // has to drop that key too.\n return [\n TAGS.categories,\n TAGS.contents,\n TAGS.paths,\n TAGS.seo,\n TAGS.search,\n ...(slug ? [TAGS.content(slug)] : []),\n ]\n\n case 'tag':\n return [TAGS.tags, TAGS.contents, TAGS.search]\n\n case 'site_block':\n return [TAGS.pages, TAGS.paths, TAGS.seo, ...(slug ? [TAGS.page(slug)] : [])]\n\n case 'variable':\n // Variables are interpolated inside block data, so a change is visible on\n // any page, not on one identifiable page.\n return [TAGS.pages, TAGS.site]\n\n case 'form':\n return [TAGS.forms, ...(slug ? [TAGS.form(slug)] : [])]\n\n case 'review':\n return [TAGS.reviews]\n\n case 'media':\n // `media.replaced` swaps the file behind a URL that is embedded in block\n // data and in article bodies, and neither payload says where.\n return [TAGS.pages, TAGS.contents, TAGS.search]\n\n case 'redirect':\n return [TAGS.paths]\n\n case 'sitemap_entry':\n case 'sitemap_rule':\n case 'seo':\n return [TAGS.seo]\n\n case 'site':\n return [TAGS.site, TAGS.seo]\n\n case 'search_profile':\n case 'search_config':\n return [TAGS.search]\n\n default:\n return []\n }\n}\n\n/**\n * The tags one delivered event invalidates, deduplicated.\n *\n * An unknown event returns an empty list rather than throwing. Lynkow adds\n * events without asking its consumers, and a site that fails on an event it has\n * never heard of turns a new CMS feature into an outage.\n */\nexport function tagsForEvent(envelope: WebhookEnvelope): string[] {\n // `form.submitted` fires on every visitor submission and changes nothing a\n // site reads: the form schema is untouched and submissions are never listed.\n // Left in the table it would evict the schema of every form on a spam wave.\n if (envelope.event === 'form.submitted') return []\n\n const resource = envelope.event.split('.')[0] ?? ''\n const slug = envelope.data?.['slug']\n\n return [...new Set(tagsForResource(resource, typeof slug === 'string' ? slug : null))]\n}\n\n/** Options of {@link createRevalidateHandler}. */\nexport interface RevalidateHandlerOptions {\n /**\n * The webhook's signing secret, read by the application from a server-only\n * environment variable. Absent or empty, the endpoint stays closed.\n */\n secret: string | undefined\n}\n\n/**\n * The `POST` handler of a revalidation webhook.\n *\n * Point a Lynkow webhook at the route that exports it and give it the secret\n * passed here. Every event the webhook subscribes to drops the cache tags that\n * event can invalidate, so an edit is live in seconds instead of waiting out\n * the `REVALIDATE` ceiling. The route must run on Node, since the signature\n * check needs `node:crypto`:\n *\n * @example\n * // src/app/api/revalidate/route.ts\n * import { createRevalidateHandler } from '@lynkow/next/server'\n *\n * export const runtime = 'nodejs'\n * export const POST = createRevalidateHandler({ secret: process.env.LYNKOW_WEBHOOK_SECRET })\n */\nexport function createRevalidateHandler({ secret }: RevalidateHandlerOptions) {\n return async function POST(request: Request): Promise<Response> {\n if (!secret) {\n // Fail closed. An unauthenticated revalidation endpoint is a free cache\n // eviction lever: anyone who finds the URL can force every page of the\n // site back to the origin on every request.\n return Response.json({ error: 'Revalidation webhook is not configured' }, { status: 503 })\n }\n\n // One answer for a missing or bad signature and for a stale or replayed body\n // alike: the caller learns that the request was refused, never which check\n // refused it. A header without the shape of a signature is refused before\n // the body is read at all.\n const signature = request.headers.get('x-webhook-signature')\n if (!hasSignatureShape(signature)) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n // The signature covers the bytes, so read the bytes. See `verifySignature`.\n const rawBody = await readBody(request)\n if (rawBody === null) {\n return Response.json({ error: 'Body too large' }, { status: 413 })\n }\n\n if (!verifySignature(rawBody, signature, secret)) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n // The verifier has already parsed this body to read its timestamp, so this\n // cannot fail today. It stays guarded anyway: an unhandled throw here would\n // be a 500, which is exactly the failure a crafted request must never reach.\n let envelope: WebhookEnvelope\n try {\n envelope = JSON.parse(rawBody) as WebhookEnvelope\n } catch {\n return Response.json({ error: 'Body is not JSON' }, { status: 400 })\n }\n\n if (typeof envelope?.event !== 'string') {\n return Response.json({ error: 'Body carries no event name' }, { status: 400 })\n }\n\n const tags = tagsForEvent(envelope)\n for (const tag of tags) {\n // Next 16 made the second argument mandatory: it is the staleness a reader\n // that already holds the entry is still allowed. `{ expire: 0 }` grants\n // none, which is the only setting that matches what this route means. A\n // named profile such as `'max'` would say the opposite, and `updateTag`,\n // which expires immediately by definition, throws outside a Server Action.\n revalidateTag(tag, { expire: 0 })\n }\n\n // 200 with an empty `revalidated` is the right answer for an event this site\n // does not read, not an error: the webhook is working, the event just has no\n // consequence here. A 4xx would fill the delivery log with failures and\n // teach whoever reads it to ignore the log.\n return Response.json({ event: envelope.event, revalidated: tags })\n }\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lynkow/next",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Next.js adapter for Lynkow: configuration, locale routing, caching, the preview and Markdown proxies and the visual editor, for a site built on the Lynkow SDK",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "Lynkow",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"prepublishOnly": "npm run build"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"lynkow": "
|
|
52
|
+
"lynkow": "~1.60.0",
|
|
53
53
|
"next": "^16.0.0",
|
|
54
54
|
"react": "^18.2.0 || ^19.0.0"
|
|
55
55
|
},
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"eslint": "^10.11.0",
|
|
64
64
|
"globals": "^15.14.0",
|
|
65
65
|
"jsdom": "^27.4.0",
|
|
66
|
-
"lynkow": "
|
|
66
|
+
"lynkow": "~1.60.0",
|
|
67
67
|
"next": "^16.3.5",
|
|
68
68
|
"react": "^19.3.0",
|
|
69
69
|
"react-dom": "^19.3.0",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/locale-paths.ts"],"names":[],"mappings":"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","file":"chunk-4QTGOTFV.js","sourcesContent":["import type { LynkowNextConfig } from './config.js'\n\n/** An href that names its own scheme (`https:`, `mailto:`), or a protocol-relative one. */\nexport const ABSOLUTE_HREF = /^([a-z][a-z0-9+.-]*:|\\/\\/)/i\n\n/**\n * The one place a public path gets its locale, bound to a configuration.\n *\n * Every localized path of a site goes through {@link LocalePaths.localePath}\n * or {@link LocalePaths.fromApiPath}. That is what makes `prefixDefaultLocale`\n * hold everywhere: a path built by hand as `/${locale}/...` is right with the\n * flag on and wrong with it off.\n */\nexport interface LocalePaths {\n readonly locales: readonly string[]\n readonly defaultLocale: string\n readonly prefixDefaultLocale: boolean\n\n /** The configured spelling of a locale, matched case-insensitively, or null. */\n toLocale(value: string | null | undefined): string | null\n\n /** Whether a value names a configured locale, in any case. */\n isLocale(value: string | null | undefined): value is string\n\n /**\n * The locale a path belongs to. A path whose first segment names a locale\n * belongs to it. Any other path belongs to the default locale when the\n * default locale is served unprefixed, and to none otherwise. That includes\n * paths that are not pages at all, such as `/_next/...`, so a caller that\n * routes requests filters those first.\n */\n localeFromPath(pathname: string): string | null\n\n /**\n * The public path of `path` in `locale`: `/fr/blog`, or `/blog` for the\n * default locale when it is served unprefixed. The locale segment is written\n * lowercased, as the API writes it. A query or fragment is kept. A leading\n * run of slashes or backslashes collapses to one, so `//evil.example` stays a\n * path once a default locale is stripped. Dot segments are left as written:\n * `/.//evil.example` resolves to `//evil.example` in `new URL()`, so a caller\n * that resolves the result checks the resolved path, as the proxy does.\n *\n * @throws {RangeError} When `locale` is not a configured locale.\n */\n localePath(locale: string, path?: string): string\n\n /**\n * The public path of a path the API returned.\n *\n * On a multilingual site the API prefixes every path with its row's locale,\n * the default one included, so `/en/blog/post` is mapped to `/blog/post`\n * when the default locale is served unprefixed. A path with no locale\n * segment, as on a single-locale site, belongs to `locale`. Anything that is\n * not a root-relative path is returned unchanged.\n */\n fromApiPath(path: string, locale: string): string\n\n /**\n * An href from CMS content, made to lead to `locale`.\n *\n * Navigation blocks store locale-agnostic hrefs (`/blog`, `/contact`), which\n * is right for an editor, so the locale is added at render: without it every\n * internal link goes through a redirect. An href that already names a locale\n * keeps it, normalized like {@link fromApiPath}. Absolute URLs, `mailto:` and\n * `tel:` links, anchors, query-only and relative hrefs are left untouched.\n */\n localizeHref(href: string, locale: string): string\n\n /**\n * The locale a link leads to on this site, or null when it leads elsewhere:\n * another origin, a `mailto:`, an anchor, a relative path. An absolute URL\n * on the site's own origin counts as this site, which catches an editor's\n * pasted `https://<site>/fr/...`.\n */\n hrefLocale(href: string): string | null\n\n /**\n * The best match between an `Accept-Language` header and the configured\n * locales, falling back to the default locale rather than to a 404.\n */\n negotiateLocale(acceptLanguage: string | null | undefined): string\n\n /** `path` on the site's origin. */\n absoluteUrl(path: string): string\n}\n\n/** Binds the locale path helpers to a configuration from {@link resolveConfig}. */\nexport function createLocalePaths(config: LynkowNextConfig): LocalePaths {\n const { locales, defaultLocale, prefixDefaultLocale, siteUrl } = config\n const byKey = new Map(locales.map((locale) => [locale.toLowerCase(), locale]))\n const site = new URL(siteUrl)\n\n function toLocale(value: string | null | undefined): string | null {\n return value ? (byKey.get(value.toLowerCase()) ?? null) : null\n }\n\n /** The locale named by the first segment of a pathname, and what follows it. */\n function leadingLocale(pathname: string): { locale: string; rest: string } | null {\n const end = pathname.indexOf('/', 1)\n const locale = toLocale(end === -1 ? pathname.slice(1) : pathname.slice(1, end))\n if (!locale) return null\n return { locale, rest: end === -1 ? '/' : pathname.slice(end) }\n }\n\n function localeFromPath(pathname: string): string | null {\n return leadingLocale(pathname)?.locale ?? (prefixDefaultLocale ? null : defaultLocale)\n }\n\n function localePath(locale: string, path = '/'): string {\n const canonical = toLocale(locale)\n if (!canonical) {\n throw new RangeError(\n `${JSON.stringify(locale)} is not one of the configured locales (${locales.join(', ')})`\n )\n }\n const { pathname: raw, suffix } = splitSuffix(path.startsWith('/') ? path : `/${path}`)\n const pathname = onSite(raw)\n if (!prefixDefaultLocale && canonical === defaultLocale) return pathname + suffix\n const segment = `/${canonical.toLowerCase()}`\n return (pathname === '/' ? segment : segment + pathname) + suffix\n }\n\n function fromApiPath(path: string, locale: string): string {\n if (!path.startsWith('/') || path.startsWith('//')) return path\n const { pathname, suffix } = splitSuffix(path)\n const leading = leadingLocale(pathname)\n return leading\n ? localePath(leading.locale, leading.rest + suffix)\n : localePath(locale, pathname + suffix)\n }\n\n function localizeHref(href: string, locale: string): string {\n if (!href || ABSOLUTE_HREF.test(href) || !href.startsWith('/')) return href\n return fromApiPath(href, locale)\n }\n\n function hrefLocale(href: string): string | null {\n if (!ABSOLUTE_HREF.test(href) && !href.startsWith('/')) return null\n try {\n const url = new URL(href, site)\n return url.origin === site.origin ? localeFromPath(url.pathname) : null\n } catch {\n return null\n }\n }\n\n function negotiateLocale(acceptLanguage: string | null | undefined): string {\n if (!acceptLanguage) return defaultLocale\n\n const ranked = acceptLanguage\n .split(',')\n .map((part) => {\n const [tag = '', ...params] = part.trim().split(';')\n const q = params.find((param) => param.trim().startsWith('q='))\n return { tag: tag.trim().toLowerCase(), q: q ? Number.parseFloat(q.split('=')[1] ?? '') : 1 }\n })\n .filter((entry) => entry.tag.length > 0 && !Number.isNaN(entry.q))\n .sort((a, b) => b.q - a.q)\n\n for (const { tag } of ranked) {\n const exact = byKey.get(tag)\n if (exact) return exact\n\n const base = tag.split('-')[0]\n const partial = locales.find((code) => code.toLowerCase().split('-')[0] === base)\n if (partial) return partial\n }\n\n return defaultLocale\n }\n\n function absoluteUrl(path: string): string {\n return `${siteUrl}${path.startsWith('/') ? path : `/${path}`}`\n }\n\n return {\n locales,\n defaultLocale,\n prefixDefaultLocale,\n toLocale,\n isLocale: (value): value is string => toLocale(value) !== null,\n localeFromPath,\n localePath,\n fromApiPath,\n localizeHref,\n hrefLocale,\n negotiateLocale,\n absoluteUrl,\n }\n}\n\n/**\n * A root-relative pathname that a browser cannot read as another host. The URL\n * parser drops tabs and newlines anywhere and reads a backslash as a slash, so\n * `/\\t/evil.example` and `/\\evil.example` are both `//evil.example` to it.\n */\nfunction onSite(pathname: string): string {\n return pathname.replace(/[\\t\\n\\r]/g, '').replace(/^[/\\\\]+/, '/')\n}\n\n/** A path split before its query string or fragment. */\nfunction splitSuffix(path: string): { pathname: string; suffix: string } {\n const index = path.search(/[?#]/)\n return index === -1\n ? { pathname: path, suffix: '' }\n : { pathname: path.slice(0, index), suffix: path.slice(index) }\n}\n"]}
|