@lynkow/next 0.1.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/LICENSE +29 -0
- package/README.md +257 -0
- package/dist/chunk-4QTGOTFV.js +95 -0
- package/dist/chunk-4QTGOTFV.js.map +1 -0
- package/dist/chunk-XNN4SECG.js +55 -0
- package/dist/chunk-XNN4SECG.js.map +1 -0
- package/dist/client/index.d.ts +152 -0
- package/dist/client/index.js +170 -0
- package/dist/client/index.js.map +1 -0
- package/dist/config-CwDV7jFS.d.ts +80 -0
- package/dist/image-loader.d.ts +34 -0
- package/dist/image-loader.js +21 -0
- package/dist/image-loader.js.map +1 -0
- package/dist/index.d.ts +216 -0
- package/dist/index.js +169 -0
- package/dist/index.js.map +1 -0
- package/dist/proxy/index.d.ts +236 -0
- package/dist/proxy/index.js +219 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/server/index.d.ts +196 -0
- package/dist/server/index.js +263 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +85 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
export { REVALIDATE, TAGS, cached, uncached } from './chunk-XNN4SECG.js';
|
|
2
|
+
import { createLocalePaths } from './chunk-4QTGOTFV.js';
|
|
3
|
+
export { ABSOLUTE_HREF, createLocalePaths } from './chunk-4QTGOTFV.js';
|
|
4
|
+
import { createClient } from 'lynkow';
|
|
5
|
+
|
|
6
|
+
// src/config.ts
|
|
7
|
+
var DEFAULT_API_URL = "https://api.lynkow.com";
|
|
8
|
+
var HOST = /^(?:[a-z0-9_-]+(?:\.[a-z0-9_-]+)*\.?|\[[0-9a-f:.]+\])$/i;
|
|
9
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
10
|
+
var LOCALE = /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
|
|
11
|
+
function resolveConfig(input, labels = {}) {
|
|
12
|
+
const problems = [];
|
|
13
|
+
const name = (field) => `\`${labels[field] ?? field}\``;
|
|
14
|
+
const siteId = input.siteId?.trim() ?? "";
|
|
15
|
+
if (!siteId) problems.push(`${name("siteId")} is required`);
|
|
16
|
+
else if (!UUID.test(siteId)) problems.push(`${name("siteId")} is not a site UUID: ${quote(siteId)}`);
|
|
17
|
+
const rawSiteUrl = input.siteUrl?.trim() ?? "";
|
|
18
|
+
if (!rawSiteUrl) problems.push(`${name("siteUrl")} is required`);
|
|
19
|
+
const siteUrl = rawSiteUrl ? parseOrigin(name("siteUrl"), rawSiteUrl, problems) : void 0;
|
|
20
|
+
const rawApiUrl = input.apiUrl?.trim() ?? "";
|
|
21
|
+
const apiUrl = rawApiUrl ? parseOrigin(name("apiUrl"), rawApiUrl, problems) : DEFAULT_API_URL;
|
|
22
|
+
const locales = parseLocales(name("locales"), input.locales, problems);
|
|
23
|
+
const wanted = input.defaultLocale?.trim() ?? "";
|
|
24
|
+
const defaultLocale = wanted ? locales.find((locale) => locale.toLowerCase() === wanted.toLowerCase()) : locales[0];
|
|
25
|
+
if (wanted && locales.length > 0 && !defaultLocale) {
|
|
26
|
+
problems.push(
|
|
27
|
+
`${name("defaultLocale")} is ${quote(wanted)}, which is not one of the locales (${locales.join(", ")})`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const prefixDefaultLocale = parseFlag(
|
|
31
|
+
name("prefixDefaultLocale"),
|
|
32
|
+
input.prefixDefaultLocale,
|
|
33
|
+
true,
|
|
34
|
+
problems
|
|
35
|
+
);
|
|
36
|
+
const publishableKey = input.publishableKey?.trim() || void 0;
|
|
37
|
+
if (publishableKey && !publishableKey.startsWith("lkw_pk_")) {
|
|
38
|
+
problems.push(
|
|
39
|
+
`${name("publishableKey")} is not a publishable key (lkw_pk_...). This configuration reaches the browser, so it must never hold a secret API key`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
if (problems.length > 0 || !siteUrl || !apiUrl || !defaultLocale) {
|
|
43
|
+
throw new TypeError(`Invalid Lynkow configuration:
|
|
44
|
+
- ${problems.join("\n - ")}`);
|
|
45
|
+
}
|
|
46
|
+
return Object.freeze({
|
|
47
|
+
siteId: siteId.toLowerCase(),
|
|
48
|
+
siteUrl,
|
|
49
|
+
apiUrl,
|
|
50
|
+
publishableKey,
|
|
51
|
+
locales: Object.freeze([...locales]),
|
|
52
|
+
defaultLocale,
|
|
53
|
+
prefixDefaultLocale
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function parseOrigin(field, raw, problems) {
|
|
57
|
+
if (hasUnsafeCharacter(raw)) {
|
|
58
|
+
problems.push(`${field} contains whitespace or a control character`);
|
|
59
|
+
return void 0;
|
|
60
|
+
}
|
|
61
|
+
let url;
|
|
62
|
+
try {
|
|
63
|
+
url = new URL(raw);
|
|
64
|
+
} catch {
|
|
65
|
+
problems.push(`${field} is not an absolute URL${echoUrl(raw)}`);
|
|
66
|
+
return void 0;
|
|
67
|
+
}
|
|
68
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
69
|
+
problems.push(`${field} must use http or https${echoUrl(raw)}`);
|
|
70
|
+
} else if (url.username || url.password) {
|
|
71
|
+
problems.push(`${field} must not carry credentials`);
|
|
72
|
+
} else if (!HOST.test(url.hostname)) {
|
|
73
|
+
problems.push(`${field} has a host with characters no domain carries${echoUrl(raw)}`);
|
|
74
|
+
} else if (!/^\/+$/.test(url.pathname) || url.search || url.hash) {
|
|
75
|
+
problems.push(`${field} must be an origin, with no path, query or fragment${echoUrl(raw)}`);
|
|
76
|
+
} else {
|
|
77
|
+
return url.origin;
|
|
78
|
+
}
|
|
79
|
+
return void 0;
|
|
80
|
+
}
|
|
81
|
+
function parseLocales(field, input, problems) {
|
|
82
|
+
const list = typeof input === "string" ? input.split(",") : [...input ?? []];
|
|
83
|
+
const locales = list.map((locale) => String(locale).trim()).filter(Boolean);
|
|
84
|
+
if (locales.length === 0) {
|
|
85
|
+
problems.push(`${field} must name at least one locale`);
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
const seen = /* @__PURE__ */ new Set();
|
|
89
|
+
for (const locale of locales) {
|
|
90
|
+
if (!LOCALE.test(locale)) {
|
|
91
|
+
problems.push(`${field} has an invalid locale ${quote(locale)}, expected a code such as "en" or "pt-BR"`);
|
|
92
|
+
}
|
|
93
|
+
const key = locale.toLowerCase();
|
|
94
|
+
if (seen.has(key)) problems.push(`${field} lists ${quote(locale)} twice`);
|
|
95
|
+
seen.add(key);
|
|
96
|
+
}
|
|
97
|
+
return locales;
|
|
98
|
+
}
|
|
99
|
+
function parseFlag(field, value, fallback, problems) {
|
|
100
|
+
if (typeof value === "boolean") return value;
|
|
101
|
+
const normalized = value?.trim().toLowerCase() ?? "";
|
|
102
|
+
if (normalized === "") return fallback;
|
|
103
|
+
if (normalized === "true") return true;
|
|
104
|
+
if (normalized === "false") return false;
|
|
105
|
+
problems.push(`${field} must be true or false, not ${quote(String(value))}`);
|
|
106
|
+
return fallback;
|
|
107
|
+
}
|
|
108
|
+
function hasUnsafeCharacter(value) {
|
|
109
|
+
if (/\s/.test(value)) return true;
|
|
110
|
+
for (let index = 0; index < value.length; index++) {
|
|
111
|
+
const code = value.charCodeAt(index);
|
|
112
|
+
if (code < 32 || code === 127) return true;
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
function echoUrl(raw) {
|
|
117
|
+
return raw.includes("@") ? "" : `: ${quote(raw)}`;
|
|
118
|
+
}
|
|
119
|
+
function quote(value) {
|
|
120
|
+
return JSON.stringify(value.length > 120 ? `${value.slice(0, 120)}...` : value);
|
|
121
|
+
}
|
|
122
|
+
function createClients(config) {
|
|
123
|
+
const { toLocale } = createLocalePaths(config);
|
|
124
|
+
const clients = /* @__PURE__ */ new Map();
|
|
125
|
+
return function lynkow(locale = config.defaultLocale, { retry = true } = {}) {
|
|
126
|
+
const canonical = toLocale(locale);
|
|
127
|
+
if (!canonical) {
|
|
128
|
+
throw new RangeError(
|
|
129
|
+
`${JSON.stringify(locale)} is not one of the configured locales (${config.locales.join(", ")})`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
const key = retry ? canonical : `${canonical}:no-retry`;
|
|
133
|
+
const existing = clients.get(key);
|
|
134
|
+
if (existing) return existing;
|
|
135
|
+
const client = createClient({
|
|
136
|
+
siteId: config.siteId,
|
|
137
|
+
locale: canonical,
|
|
138
|
+
baseUrl: config.apiUrl,
|
|
139
|
+
...config.publishableKey ? { publishableKey: config.publishableKey } : {},
|
|
140
|
+
cache: false,
|
|
141
|
+
retry
|
|
142
|
+
});
|
|
143
|
+
clients.set(key, client);
|
|
144
|
+
return client;
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// src/analytics/events.ts
|
|
149
|
+
function tracker() {
|
|
150
|
+
if (typeof window === "undefined") return null;
|
|
151
|
+
const global = window.LynkowAnalytics;
|
|
152
|
+
return global && typeof global.track === "function" ? global : null;
|
|
153
|
+
}
|
|
154
|
+
function trackEvent(type, data) {
|
|
155
|
+
const global = tracker();
|
|
156
|
+
if (!global) return false;
|
|
157
|
+
global.track({
|
|
158
|
+
event_type: type,
|
|
159
|
+
page_path: window.location.pathname,
|
|
160
|
+
page_url: window.location.href,
|
|
161
|
+
page_title: document.title,
|
|
162
|
+
...data
|
|
163
|
+
});
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export { createClients, resolveConfig, trackEvent };
|
|
168
|
+
//# sourceMappingURL=index.js.map
|
|
169
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +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"]}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { NextMiddleware, NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { Client, Redirect } from 'lynkow';
|
|
3
|
+
import { L as LynkowNextConfig } from '../config-CwDV7jFS.js';
|
|
4
|
+
export { DEFAULT_CMS_ORIGIN } from 'lynkow/visual-editor';
|
|
5
|
+
|
|
6
|
+
/** Options of {@link createLynkowProxy}. */
|
|
7
|
+
interface LynkowProxyOptions {
|
|
8
|
+
config: LynkowNextConfig;
|
|
9
|
+
/** The client of a locale, typically the function `createClients(config)` returns. */
|
|
10
|
+
client: (locale: string) => Client;
|
|
11
|
+
/**
|
|
12
|
+
* The origin allowed to frame the site on a preview request. Defaults to the
|
|
13
|
+
* Lynkow dashboard. Give the visual editor the same value, or neither one.
|
|
14
|
+
*/
|
|
15
|
+
cmsOrigin?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The proxy of a Lynkow site: redirects, then locale routing, with the preview
|
|
19
|
+
* headers the visual editor needs.
|
|
20
|
+
*
|
|
21
|
+
* 1. **Redirects**, first, on the path as the visitor typed it. The CMS stores
|
|
22
|
+
* sources that way, a mix of `/old` and `/fr/blog/old` on a multilingual
|
|
23
|
+
* site, so matching after a locale prefix is added would silently break
|
|
24
|
+
* every unprefixed rule. The status code is the one the editor chose, which
|
|
25
|
+
* `redirect()` from `next/navigation` could not give (307 or 308 only). A
|
|
26
|
+
* target an editor wrote without a locale gets the visitor's, so the rule
|
|
27
|
+
* and the locale step cost one hop rather than two.
|
|
28
|
+
* 2. **Locale routing.** With `prefixDefaultLocale` on, a path without a locale
|
|
29
|
+
* is sent (307) to the visitor's best match: the `NEXT_LOCALE` cookie, then
|
|
30
|
+
* `Accept-Language`, then the default. With it off, a path without a locale
|
|
31
|
+
* IS the default locale's page and is served from its `[locale]` route under
|
|
32
|
+
* the same URL, while `/<default>/...` answers 308 to that URL. Either way a
|
|
33
|
+
* locale segment written in another case answers 308 to its lowercase form,
|
|
34
|
+
* the form the API writes, so each page has one URL.
|
|
35
|
+
*
|
|
36
|
+
* The application keeps its own `config.matcher`, which Next requires to be a
|
|
37
|
+
* literal in `src/proxy.ts`. It must exclude everything that is not a page:
|
|
38
|
+
* Next internals, API routes and files.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* // src/proxy.ts
|
|
42
|
+
* import { createClients } from '@lynkow/next'
|
|
43
|
+
* import { createLynkowProxy } from '@lynkow/next/proxy'
|
|
44
|
+
* import { config as site } from './lib/config'
|
|
45
|
+
*
|
|
46
|
+
* export default createLynkowProxy({ config: site, client: createClients(site) })
|
|
47
|
+
*
|
|
48
|
+
* export const config = {
|
|
49
|
+
* matcher: ['/((?!_next/|api/|.*\\.[\\w]+$).*)'],
|
|
50
|
+
* }
|
|
51
|
+
*/
|
|
52
|
+
declare function createLynkowProxy({ config, client, cmsOrigin }: LynkowProxyOptions): NextMiddleware;
|
|
53
|
+
|
|
54
|
+
interface LynkowPreviewOptions {
|
|
55
|
+
/**
|
|
56
|
+
* Origin allowed to frame this site, defaulting to {@link DEFAULT_CMS_ORIGIN}.
|
|
57
|
+
*
|
|
58
|
+
* Note the deliberate divergence from `VisualEditorConfig.cmsOrigin`: an EMPTY
|
|
59
|
+
* value here takes the default too, because the safe direction for a CSP is a
|
|
60
|
+
* header that names the real dashboard rather than one that names nothing.
|
|
61
|
+
* The editor half instead refuses to start, since an editor pointed nowhere is
|
|
62
|
+
* inert rather than unsafe.
|
|
63
|
+
*/
|
|
64
|
+
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
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Creates a Next.js middleware that lets the Lynkow dashboard frame the site in
|
|
78
|
+
* preview mode. It is {@link withLynkowPreview} around a middleware that returns
|
|
79
|
+
* nothing, so every response starts from `NextResponse.next()`.
|
|
80
|
+
*
|
|
81
|
+
* On a request carrying the `lynkow-preview` query parameter, the response gets
|
|
82
|
+
* `Content-Security-Policy: frame-ancestors 'self' <cmsOrigin>` and loses any
|
|
83
|
+
* `X-Frame-Options`. Every other response gets `frame-ancestors 'self'`.
|
|
84
|
+
*/
|
|
85
|
+
declare function lynkowPreviewMiddleware(options?: LynkowPreviewOptions): NextMiddleware;
|
|
86
|
+
/**
|
|
87
|
+
* Wraps an existing Next.js middleware so its responses let the Lynkow dashboard
|
|
88
|
+
* frame the site in preview mode.
|
|
89
|
+
*
|
|
90
|
+
* The wrapped middleware's own `Content-Security-Policy` is kept and merged at
|
|
91
|
+
* the directive level. In preview mode (the `lynkow-preview` query parameter),
|
|
92
|
+
* its `frame-ancestors` is replaced by one allowing `cmsOrigin` and
|
|
93
|
+
* `X-Frame-Options` is removed. Outside preview mode, a `frame-ancestors` it
|
|
94
|
+
* already set is left as is, and `frame-ancestors 'self'` is added only when it
|
|
95
|
+
* set none. A middleware returning nothing is treated as `NextResponse.next()`.
|
|
96
|
+
*/
|
|
97
|
+
declare function withLynkowPreview(existingMiddleware: NextMiddleware, options?: LynkowPreviewOptions): NextMiddleware;
|
|
98
|
+
|
|
99
|
+
/** Returns the redirect rule matching a pathname, or null. */
|
|
100
|
+
type RedirectMatcher = (pathname: string) => Promise<Redirect | null>;
|
|
101
|
+
/**
|
|
102
|
+
* A redirect lookup with its own in-process cache.
|
|
103
|
+
*
|
|
104
|
+
* Why its own cache, when every other read hands caching to Next. Two facts,
|
|
105
|
+
* both measured against Next 16.3.4 and the live API on 2026-09-08:
|
|
106
|
+
*
|
|
107
|
+
* 1. **Next's fetch cache does not reach the proxy.** `next: { revalidate,
|
|
108
|
+
* tags }` is accepted and ignored there: five identical requests each
|
|
109
|
+
* produced a real network call. So `cached()` would be decoration here,
|
|
110
|
+
* and `revalidateTag()` from the revalidation webhook can never reach this
|
|
111
|
+
* code, since the proxy is a separate bundle with its own module scope.
|
|
112
|
+
* 2. **A miss is uncacheable at the edge.** A path with no redirect answers
|
|
113
|
+
* 404 with `Cache-Control: no-store`, so a CDN bypasses it. Almost every
|
|
114
|
+
* request is a miss, so an uncached lookup would send every page view of
|
|
115
|
+
* the site to the API.
|
|
116
|
+
*
|
|
117
|
+
* The cache is per process, so a multi-instance deployment holds one copy per
|
|
118
|
+
* instance, and nothing invalidates it early: `TTL_MS` is the whole contract.
|
|
119
|
+
*
|
|
120
|
+
* Concurrent lookups of one path share one request, so a link posted somewhere
|
|
121
|
+
* busy costs one call rather than one per simultaneous visitor. A failed lookup
|
|
122
|
+
* answers `null` and is never cached: a transient API error would otherwise
|
|
123
|
+
* blind every redirect on the site for a full TTL, while answering "no
|
|
124
|
+
* redirect" for one request gives the visitor exactly the page they would have
|
|
125
|
+
* got without this module.
|
|
126
|
+
*
|
|
127
|
+
* `pathname` must carry no query string: the API compares the stored source
|
|
128
|
+
* against it verbatim, so `/old?page=2` matches nothing.
|
|
129
|
+
*/
|
|
130
|
+
declare function createRedirectMatcher(client: () => Client): RedirectMatcher;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Options for the {@link markdownProxy} helper.
|
|
134
|
+
*/
|
|
135
|
+
interface MarkdownProxyOptions {
|
|
136
|
+
/**
|
|
137
|
+
* URL prefix where the Markdown route handler is mounted. The helper
|
|
138
|
+
* rewrites `Accept: text/markdown` requests from `<path>` to
|
|
139
|
+
* `<mdPathPrefix><path>`. Defaults to `'/md'`.
|
|
140
|
+
*
|
|
141
|
+
* If your route handler lives at `app/markdown/[...path]/route.ts`,
|
|
142
|
+
* set this to `'/markdown'`.
|
|
143
|
+
*/
|
|
144
|
+
mdPathPrefix?: string;
|
|
145
|
+
/**
|
|
146
|
+
* Whether to set `Vary: Accept` on the rewritten response. Defaults
|
|
147
|
+
* to `true`. Set to `false` only if your edge or CDN already sets the
|
|
148
|
+
* header and you want to avoid duplication.
|
|
149
|
+
*/
|
|
150
|
+
setVary?: boolean;
|
|
151
|
+
/**
|
|
152
|
+
* Whether to set `X-Forwarded-Accept: text/markdown` on the rewritten
|
|
153
|
+
* request so the route handler can confirm the request originated
|
|
154
|
+
* from a Markdown negotiation. Defaults to `true`.
|
|
155
|
+
*/
|
|
156
|
+
setForwardedAccept?: boolean;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Parses an `Accept` header per RFC 9110 §12.5.1 and returns the
|
|
160
|
+
* highest q-value for a target media type. Supports exact match
|
|
161
|
+
* (`text/markdown`), type wildcard (`text/*`), full wildcard (`*\/*`),
|
|
162
|
+
* and q-values.
|
|
163
|
+
*
|
|
164
|
+
* Returns `0` if the type is not present or has `q=0`. Returns `1` if
|
|
165
|
+
* no q value is given (per RFC, missing q means q=1). Out-of-range or
|
|
166
|
+
* non-numeric q values fall back to the default `1`.
|
|
167
|
+
*
|
|
168
|
+
* @param acceptHeader - The raw `Accept` header value, or `null` if absent
|
|
169
|
+
* @param targetType - The target media type, e.g. `'text/markdown'`
|
|
170
|
+
* @returns The highest q-value for the target type (0 to 1)
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* ```typescript
|
|
174
|
+
* acceptQuality('text/html;q=0.9, text/markdown;q=0.8', 'text/markdown') // 0.8
|
|
175
|
+
* acceptQuality('text/markdown', 'text/markdown') // 1
|
|
176
|
+
* acceptQuality(null, 'text/markdown') // 0
|
|
177
|
+
* ```
|
|
178
|
+
*/
|
|
179
|
+
declare function acceptQuality(acceptHeader: string | null, targetType: string): number;
|
|
180
|
+
/**
|
|
181
|
+
* Decides whether the request prefers Markdown over HTML based on its
|
|
182
|
+
* `Accept` header.
|
|
183
|
+
*
|
|
184
|
+
* Returns `true` when `Accept` contains `text/markdown` with q strictly
|
|
185
|
+
* higher than `text/html`. Ties break in favor of HTML (the default
|
|
186
|
+
* browsers expect).
|
|
187
|
+
*
|
|
188
|
+
* @param acceptHeader - The raw `Accept` header value, or `null` if absent
|
|
189
|
+
* @returns `true` when Markdown is the preferred representation
|
|
190
|
+
*
|
|
191
|
+
* @example
|
|
192
|
+
* ```typescript
|
|
193
|
+
* prefersMarkdown('text/markdown;q=0.9, text/html;q=0.8') // true
|
|
194
|
+
* prefersMarkdown('text/markdown, text/html') // false (tie -> HTML)
|
|
195
|
+
* prefersMarkdown('text/html') // false
|
|
196
|
+
* ```
|
|
197
|
+
*/
|
|
198
|
+
declare function prefersMarkdown(acceptHeader: string | null): boolean;
|
|
199
|
+
/**
|
|
200
|
+
* Next.js proxy/middleware helper that translates
|
|
201
|
+
* `Accept: text/markdown` requests into rewrites to a dedicated
|
|
202
|
+
* Markdown route handler.
|
|
203
|
+
*
|
|
204
|
+
* Returns the rewritten `NextResponse` when the request preferred
|
|
205
|
+
* Markdown, or `null` when the request did not (so the caller can pass
|
|
206
|
+
* through to the next middleware step).
|
|
207
|
+
*
|
|
208
|
+
* Sets `Vary: Accept` on the rewritten response so intermediate caches
|
|
209
|
+
* keep separate variants for HTML and Markdown.
|
|
210
|
+
*
|
|
211
|
+
* The helper is Edge runtime compatible (no `node:` imports).
|
|
212
|
+
*
|
|
213
|
+
* @param request - The incoming `NextRequest`
|
|
214
|
+
* @param options - Optional {@link MarkdownProxyOptions} overrides
|
|
215
|
+
* @returns A `NextResponse.rewrite(...)` when Markdown was preferred, or `null` otherwise
|
|
216
|
+
*
|
|
217
|
+
* @example
|
|
218
|
+
* ```typescript
|
|
219
|
+
* // Next 16, proxy.ts
|
|
220
|
+
* import { markdownProxy } from '@lynkow/next/proxy'
|
|
221
|
+
* import { NextResponse, type NextRequest } from 'next/server'
|
|
222
|
+
*
|
|
223
|
+
* export function proxy(request: NextRequest) {
|
|
224
|
+
* const md = markdownProxy(request)
|
|
225
|
+
* if (md) return md
|
|
226
|
+
* return NextResponse.next()
|
|
227
|
+
* }
|
|
228
|
+
*
|
|
229
|
+
* export const config = {
|
|
230
|
+
* matcher: ['/((?!_next|favicon.ico|md|api).*)'],
|
|
231
|
+
* }
|
|
232
|
+
* ```
|
|
233
|
+
*/
|
|
234
|
+
declare function markdownProxy(request: NextRequest, options?: MarkdownProxyOptions): NextResponse | null;
|
|
235
|
+
|
|
236
|
+
export { type LynkowProxyOptions, type MarkdownProxyOptions, type RedirectMatcher, acceptQuality, createLynkowProxy, createRedirectMatcher, lynkowPreviewMiddleware, markdownProxy, prefersMarkdown, withLynkowPreview };
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { createLocalePaths } from '../chunk-4QTGOTFV.js';
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
import { DEFAULT_CMS_ORIGIN, PREVIEW_PARAM } from 'lynkow/visual-editor';
|
|
4
|
+
export { DEFAULT_CMS_ORIGIN } from 'lynkow/visual-editor';
|
|
5
|
+
|
|
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
|
+
function lynkowPreviewMiddleware(options) {
|
|
16
|
+
return withLynkowPreview(() => void 0, options);
|
|
17
|
+
}
|
|
18
|
+
function isFrameAncestors(directive) {
|
|
19
|
+
const [name = ""] = directive.split(/\s+/, 1);
|
|
20
|
+
return name.toLowerCase() === "frame-ancestors";
|
|
21
|
+
}
|
|
22
|
+
function parseCspList(value) {
|
|
23
|
+
if (!value) return [];
|
|
24
|
+
return value.split(",").map(
|
|
25
|
+
(policy) => policy.split(";").map((directive) => directive.trim()).filter((directive) => directive.length > 0)
|
|
26
|
+
).filter((policy) => policy.length > 0);
|
|
27
|
+
}
|
|
28
|
+
function appendToFirstPolicy(policies, directive) {
|
|
29
|
+
const [first = [], ...rest] = policies;
|
|
30
|
+
return [[...first, directive], ...rest];
|
|
31
|
+
}
|
|
32
|
+
function serializeCspList(policies) {
|
|
33
|
+
return policies.filter((policy) => policy.length > 0).map((policy) => policy.join("; ")).join(", ");
|
|
34
|
+
}
|
|
35
|
+
function addCspHeaders(response, request, cmsOrigin) {
|
|
36
|
+
const isPreview = request.nextUrl.searchParams.has(PREVIEW_PARAM);
|
|
37
|
+
const policies = parseCspList(response.headers.get(CSP_HEADER));
|
|
38
|
+
if (isPreview) {
|
|
39
|
+
const withoutFraming = policies.map(
|
|
40
|
+
(policy) => policy.filter((directive) => !isFrameAncestors(directive))
|
|
41
|
+
);
|
|
42
|
+
const merged = appendToFirstPolicy(withoutFraming, `frame-ancestors 'self' ${cmsOrigin}`);
|
|
43
|
+
response.headers.set(CSP_HEADER, serializeCspList(merged));
|
|
44
|
+
response.headers.delete("X-Frame-Options");
|
|
45
|
+
} else if (!policies.some((policy) => policy.some(isFrameAncestors))) {
|
|
46
|
+
const merged = appendToFirstPolicy(policies, "frame-ancestors 'self'");
|
|
47
|
+
response.headers.set(CSP_HEADER, serializeCspList(merged));
|
|
48
|
+
}
|
|
49
|
+
return response;
|
|
50
|
+
}
|
|
51
|
+
function withLynkowPreview(existingMiddleware, options) {
|
|
52
|
+
const cmsOrigin = options?.cmsOrigin || DEFAULT_CMS_ORIGIN;
|
|
53
|
+
warnIgnoredPreviewParam(options);
|
|
54
|
+
return function middleware(request, event) {
|
|
55
|
+
const response = existingMiddleware(request, event);
|
|
56
|
+
const finalResponse = response instanceof Promise ? response.then((res) => addCspHeaders(res || NextResponse.next(), request, cmsOrigin)) : addCspHeaders(response || NextResponse.next(), request, cmsOrigin);
|
|
57
|
+
return finalResponse;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/proxy/redirects.ts
|
|
62
|
+
var TTL_MS = 6e4;
|
|
63
|
+
var MAX_ENTRIES = 500;
|
|
64
|
+
function createRedirectMatcher(client) {
|
|
65
|
+
const cache = /* @__PURE__ */ new Map();
|
|
66
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
67
|
+
function remember(path, value) {
|
|
68
|
+
if (cache.size >= MAX_ENTRIES && !cache.has(path)) {
|
|
69
|
+
const oldest = cache.keys().next().value;
|
|
70
|
+
if (oldest !== void 0) cache.delete(oldest);
|
|
71
|
+
}
|
|
72
|
+
cache.set(path, { value, expiresAt: Date.now() + TTL_MS });
|
|
73
|
+
}
|
|
74
|
+
return async function matchRedirect(pathname) {
|
|
75
|
+
const entry = cache.get(pathname);
|
|
76
|
+
if (entry && entry.expiresAt > Date.now()) return entry.value;
|
|
77
|
+
if (entry) cache.delete(pathname);
|
|
78
|
+
const pending = inFlight.get(pathname);
|
|
79
|
+
if (pending) return pending;
|
|
80
|
+
const lookup = Promise.resolve().then(() => client().paths.matchRedirect(pathname)).then((value) => {
|
|
81
|
+
remember(pathname, value);
|
|
82
|
+
return value;
|
|
83
|
+
}).catch((error) => {
|
|
84
|
+
console.error(`[lynkow] redirect lookup failed for ${pathname}`, error);
|
|
85
|
+
return null;
|
|
86
|
+
}).finally(() => {
|
|
87
|
+
inFlight.delete(pathname);
|
|
88
|
+
});
|
|
89
|
+
inFlight.set(pathname, lookup);
|
|
90
|
+
return lookup;
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/proxy/lynkow-proxy.ts
|
|
95
|
+
var LOCALE_COOKIE = "NEXT_LOCALE";
|
|
96
|
+
function createLynkowProxy({ config, client, cmsOrigin }) {
|
|
97
|
+
const paths = createLocalePaths(config);
|
|
98
|
+
const { defaultLocale, prefixDefaultLocale } = config;
|
|
99
|
+
const defaultSegment = `/${defaultLocale.toLowerCase()}`;
|
|
100
|
+
const matchRedirect = createRedirectMatcher(() => client(defaultLocale));
|
|
101
|
+
function prefixOf(pathname) {
|
|
102
|
+
const segment = pathname.split("/")[1] ?? "";
|
|
103
|
+
const locale = paths.toLocale(segment);
|
|
104
|
+
return locale ? { locale, rest: pathname.slice(segment.length + 1) || "/" } : null;
|
|
105
|
+
}
|
|
106
|
+
function resolveLocale(request) {
|
|
107
|
+
return paths.localeFromPath(request.nextUrl.pathname) ?? paths.toLocale(request.cookies.get(LOCALE_COOKIE)?.value) ?? paths.negotiateLocale(request.headers.get("accept-language"));
|
|
108
|
+
}
|
|
109
|
+
async function findRedirect(pathname) {
|
|
110
|
+
if (prefixDefaultLocale || prefixOf(pathname)) return matchRedirect(pathname);
|
|
111
|
+
const [typed, prefixed] = await Promise.all([
|
|
112
|
+
matchRedirect(pathname),
|
|
113
|
+
matchRedirect(pathname === "/" ? defaultSegment : defaultSegment + pathname)
|
|
114
|
+
]);
|
|
115
|
+
return typed ?? prefixed;
|
|
116
|
+
}
|
|
117
|
+
function redirectTo(request, redirect, locale) {
|
|
118
|
+
const from = request.nextUrl;
|
|
119
|
+
let url;
|
|
120
|
+
try {
|
|
121
|
+
url = new URL(paths.localizeHref(redirect.target, locale), from.href);
|
|
122
|
+
} catch {
|
|
123
|
+
console.error(`[lynkow] unparseable redirect target ${redirect.target} for ${redirect.source}`);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
if (url.origin === from.origin) url.pathname = url.pathname.replace(/^\/{2,}/, "/");
|
|
127
|
+
if (redirect.preserveQueryString) {
|
|
128
|
+
for (const [key, value] of from.searchParams) {
|
|
129
|
+
if (!url.searchParams.has(key)) url.searchParams.append(key, value);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (url.origin === from.origin && url.pathname === from.pathname && url.search === from.search) {
|
|
133
|
+
console.error(`[lynkow] ignoring self-referential redirect on ${redirect.source}`);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return NextResponse.redirect(url, redirect.statusCode);
|
|
137
|
+
}
|
|
138
|
+
function redirectToPath(request, pathname, status) {
|
|
139
|
+
const url = request.nextUrl.clone();
|
|
140
|
+
url.pathname = pathname;
|
|
141
|
+
return NextResponse.redirect(url, status);
|
|
142
|
+
}
|
|
143
|
+
function route(request, locale) {
|
|
144
|
+
const { pathname } = request.nextUrl;
|
|
145
|
+
const prefix = prefixOf(pathname);
|
|
146
|
+
if (prefix) {
|
|
147
|
+
const canonical = paths.localePath(prefix.locale, prefix.rest);
|
|
148
|
+
return canonical === pathname ? NextResponse.next() : redirectToPath(request, canonical, 308);
|
|
149
|
+
}
|
|
150
|
+
if (!prefixDefaultLocale) {
|
|
151
|
+
const url = request.nextUrl.clone();
|
|
152
|
+
url.pathname = pathname === "/" ? defaultSegment : defaultSegment + pathname;
|
|
153
|
+
return NextResponse.rewrite(url);
|
|
154
|
+
}
|
|
155
|
+
const response = redirectToPath(request, paths.localePath(locale, pathname), 307);
|
|
156
|
+
response.cookies.set(LOCALE_COOKIE, locale, {
|
|
157
|
+
path: "/",
|
|
158
|
+
maxAge: 60 * 60 * 24 * 365,
|
|
159
|
+
sameSite: "lax"
|
|
160
|
+
});
|
|
161
|
+
return response;
|
|
162
|
+
}
|
|
163
|
+
async function site(request) {
|
|
164
|
+
const locale = resolveLocale(request);
|
|
165
|
+
const redirect = await findRedirect(request.nextUrl.pathname);
|
|
166
|
+
return redirect && redirectTo(request, redirect, locale) || route(request, locale);
|
|
167
|
+
}
|
|
168
|
+
return withLynkowPreview(site, { cmsOrigin });
|
|
169
|
+
}
|
|
170
|
+
var DEFAULT_MD_PREFIX = "/md";
|
|
171
|
+
function acceptQuality(acceptHeader, targetType) {
|
|
172
|
+
if (!acceptHeader) return 0;
|
|
173
|
+
const [targetMain, targetSub] = targetType.toLowerCase().split("/");
|
|
174
|
+
let best = 0;
|
|
175
|
+
for (const raw of acceptHeader.split(",")) {
|
|
176
|
+
const parts = raw.trim().split(";").map((p) => p.trim()).filter(Boolean);
|
|
177
|
+
const mediaType = parts[0]?.toLowerCase();
|
|
178
|
+
if (!mediaType || !mediaType.includes("/")) continue;
|
|
179
|
+
const [main, sub] = mediaType.split("/");
|
|
180
|
+
const matches = main === targetMain && sub === targetSub || main === targetMain && sub === "*" || main === "*" && sub === "*";
|
|
181
|
+
if (!matches) continue;
|
|
182
|
+
let q = 1;
|
|
183
|
+
for (const param of parts.slice(1)) {
|
|
184
|
+
if (param.startsWith("q=")) {
|
|
185
|
+
const parsed = Number.parseFloat(param.slice(2));
|
|
186
|
+
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 1) q = parsed;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (q > best) best = q;
|
|
190
|
+
}
|
|
191
|
+
return best;
|
|
192
|
+
}
|
|
193
|
+
function prefersMarkdown(acceptHeader) {
|
|
194
|
+
const md = acceptQuality(acceptHeader, "text/markdown");
|
|
195
|
+
if (md === 0) return false;
|
|
196
|
+
const html = acceptQuality(acceptHeader, "text/html");
|
|
197
|
+
return md > html;
|
|
198
|
+
}
|
|
199
|
+
function markdownProxy(request, options) {
|
|
200
|
+
const accept = request.headers.get("accept");
|
|
201
|
+
if (!prefersMarkdown(accept)) return null;
|
|
202
|
+
const prefix = options?.mdPathPrefix ?? DEFAULT_MD_PREFIX;
|
|
203
|
+
const setVary = options?.setVary ?? true;
|
|
204
|
+
const setForwardedAccept = options?.setForwardedAccept ?? true;
|
|
205
|
+
const url = request.nextUrl.clone();
|
|
206
|
+
if (url.pathname.startsWith(`${prefix}/`) || url.pathname === prefix) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
url.pathname = `${prefix}${url.pathname}`;
|
|
210
|
+
const headers = new Headers(request.headers);
|
|
211
|
+
if (setForwardedAccept) headers.set("x-forwarded-accept", "text/markdown");
|
|
212
|
+
const response = NextResponse.rewrite(url, { request: { headers } });
|
|
213
|
+
if (setVary) response.headers.set("vary", "accept");
|
|
214
|
+
return response;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export { acceptQuality, createLynkowProxy, createRedirectMatcher, lynkowPreviewMiddleware, markdownProxy, prefersMarkdown, withLynkowPreview };
|
|
218
|
+
//# sourceMappingURL=index.js.map
|
|
219
|
+
//# sourceMappingURL=index.js.map
|