@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 ADDED
@@ -0,0 +1,29 @@
1
+ Lynkow SDK - Proprietary License
2
+
3
+ Copyright (c) 2026 Lynkow. All rights reserved.
4
+
5
+ This software and associated documentation files (the "Software") are the
6
+ proprietary property of Lynkow and are protected by copyright law.
7
+
8
+ PERMITTED USE:
9
+ - You may use this Software solely in connection with Lynkow services
10
+ - You may install and use the Software in your applications that integrate
11
+ with the Lynkow platform
12
+
13
+ RESTRICTIONS:
14
+ - You may NOT copy, modify, merge, publish, distribute, sublicense, or sell
15
+ copies of the Software
16
+ - You may NOT reverse engineer, decompile, or disassemble the Software
17
+ - You may NOT use the Software for any purpose other than integrating with
18
+ Lynkow services
19
+ - You may NOT remove or alter any proprietary notices or labels on the Software
20
+
21
+ NO WARRANTY:
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
25
+ LYNKOW BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
26
+ ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
27
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
28
+
29
+ For licensing inquiries, contact: contact@lynkow.com
package/README.md ADDED
@@ -0,0 +1,257 @@
1
+ # @lynkow/next
2
+
3
+ The Next.js adapter for [Lynkow](https://lynkow.com). It holds the code every
4
+ Lynkow site on Next needs and no site should have to maintain by hand, so a fix
5
+ reaches an existing site as a version bump.
6
+
7
+ It builds on the [`lynkow`](https://www.npmjs.com/package/lynkow) SDK, which
8
+ stays framework-agnostic.
9
+
10
+ ```bash
11
+ npm install @lynkow/next lynkow
12
+ ```
13
+
14
+ It requires Next.js 16, React 18.2 or 19, and `lynkow` 1.60 or later.
15
+
16
+ | Entry | For |
17
+ |---|---|
18
+ | `@lynkow/next` | Configuration, locale paths, caching and `trackEvent`, safe to import anywhere |
19
+ | `@lynkow/next/proxy` | The site's `src/proxy.ts`: redirects, locale routing, preview and Markdown helpers |
20
+ | `@lynkow/next/server` | Route handlers and server components: text routes, JSON-LD, metadata, revalidation |
21
+ | `@lynkow/next/client` | Client components: the visual editor, `Editable`, the analytics tracker |
22
+ | `@lynkow/next/image-loader` | The `next/image` loader for Lynkow media |
23
+
24
+ ## Configuration
25
+
26
+ The adapter reads no environment variable. Your application reads its own and
27
+ passes the values to `resolveConfig`, which validates them and throws one error
28
+ listing every problem:
29
+
30
+ ```ts
31
+ // src/lib/config.ts
32
+ import { createClients, createLocalePaths, resolveConfig } from '@lynkow/next'
33
+
34
+ export const config = resolveConfig({
35
+ siteId: process.env.NEXT_PUBLIC_LYNKOW_SITE_ID,
36
+ siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
37
+ locales: process.env.NEXT_PUBLIC_LOCALES,
38
+ defaultLocale: process.env.NEXT_PUBLIC_DEFAULT_LOCALE,
39
+ prefixDefaultLocale: process.env.NEXT_PUBLIC_PREFIX_DEFAULT_LOCALE,
40
+ apiUrl: process.env.NEXT_PUBLIC_LYNKOW_API_URL,
41
+ publishableKey: process.env.NEXT_PUBLIC_LYNKOW_PUBLISHABLE_KEY,
42
+ })
43
+
44
+ export const paths = createLocalePaths(config)
45
+ export const lynkow = createClients(config)
46
+ ```
47
+
48
+ `createClients` returns one SDK client per configured locale, created on first
49
+ use and reused, with the SDK's own cache off so Next's data cache is the only
50
+ one. A locale that is not configured throws instead of creating a client.
51
+
52
+ Every value in that configuration reaches the browser. Keep server secrets,
53
+ such as the webhook signing secret, out of it.
54
+
55
+ ## Locale paths
56
+
57
+ `createLocalePaths(config)` is the one place a public path gets its locale.
58
+ With `prefixDefaultLocale: true` (the default) every locale is prefixed, so
59
+ `/en/about` and `/fr/a-propos`. With `false`, the default locale is served
60
+ without its prefix, so `/about` and `/fr/a-propos`.
61
+
62
+ ```ts
63
+ paths.localePath('fr', '/blog') // '/fr/blog'
64
+ paths.fromApiPath('/en/blog/post', 'en') // '/blog/post' when the default locale is unprefixed
65
+ paths.localizeHref('/contact', 'fr') // '/fr/contact'
66
+ ```
67
+
68
+ Build every localized path through it: a path written by hand as
69
+ `/${locale}/...` is right with one setting and wrong with the other.
70
+
71
+ ## Caching
72
+
73
+ `cached(tags, revalidate)` puts one SDK read into Next's data cache, with the
74
+ tags the revalidation webhook invalidates. `TAGS` and `REVALIDATE` hold the tag
75
+ names and the default durations.
76
+
77
+ ```ts
78
+ import { cached, REVALIDATE, TAGS } from '@lynkow/next'
79
+
80
+ const page = await client.pages.getBySlug('home', cached([TAGS.page('home')], REVALIDATE.page))
81
+ ```
82
+
83
+ ## Server
84
+
85
+ ```ts
86
+ // src/lib/server.ts
87
+ import { cached, REVALIDATE, TAGS } from '@lynkow/next'
88
+ import { createLynkowServer } from '@lynkow/next/server'
89
+ import { config, lynkow } from './config'
90
+
91
+ export const server = createLynkowServer({
92
+ config,
93
+ client: (locale) => lynkow(locale),
94
+ getSiteDomain: async (locale) => {
95
+ const site = await lynkow(locale).blocks.siteConfig(cached([TAGS.site], REVALIDATE.site))
96
+ return site.data.site.domain
97
+ },
98
+ })
99
+ ```
100
+
101
+ - `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 moved to
103
+ this deployment's origin.
104
+ - `server.JsonLdScript` renders schema.org nodes as one JSON-LD script.
105
+ - `server.pageMetadata(page, locale, path)` and `server.routeMetadata(...)`
106
+ build Next metadata, canonical and hreflang URLs included, through the locale
107
+ paths.
108
+
109
+ The revalidation webhook takes its secret from a server-only variable, never
110
+ from the configuration above:
111
+
112
+ ```ts
113
+ // src/app/api/revalidate/route.ts
114
+ import { createRevalidateHandler } from '@lynkow/next/server'
115
+
116
+ export const runtime = 'nodejs'
117
+ export const POST = createRevalidateHandler({ secret: process.env.LYNKOW_WEBHOOK_SECRET })
118
+ ```
119
+
120
+ Without a secret it answers 503. A request whose `X-Webhook-Signature` does not
121
+ match the raw body, or whose signed timestamp is more than five minutes from
122
+ this server's clock, answers 401.
123
+
124
+ ## Proxy
125
+
126
+ ```ts
127
+ // src/proxy.ts
128
+ import { createClients } from '@lynkow/next'
129
+ import { createLynkowProxy } from '@lynkow/next/proxy'
130
+ import { config as site } from './lib/config'
131
+
132
+ export default createLynkowProxy({ config: site, client: createClients(site) })
133
+
134
+ export const config = {
135
+ matcher: ['/((?!_next/|api/|.*\\.[\\w]+$).*)'],
136
+ }
137
+ ```
138
+
139
+ `createLynkowProxy` runs three things on every page request, in this order:
140
+
141
+ 1. **Redirects** created in the Lynkow admin, matched on the path as the
142
+ visitor typed it, with the status code the editor chose. A target written
143
+ without a locale gets the visitor's. Answers are cached in the process for
144
+ one minute, at most 500 paths, and a failed lookup is never cached.
145
+ 2. **Locale routing.** With `prefixDefaultLocale` on, a path without a locale
146
+ answers 307 to the visitor's locale (the `NEXT_LOCALE` cookie, then
147
+ `Accept-Language`, then the default) and sets the cookie. With it off, a
148
+ path without a locale is the default locale's page, served under the same
149
+ URL, and `/<default>/...` answers 308 to it. A locale segment in another
150
+ case, such as `/FR/`, answers 308 to its lowercase form.
151
+ 3. **Preview headers**, through `withLynkowPreview`.
152
+
153
+ The `matcher` stays in your `src/proxy.ts`, because Next requires it to be a
154
+ literal there. It must exclude everything that is not a page.
155
+
156
+ `withLynkowPreview` lets the Lynkow dashboard frame the site on a preview
157
+ request (`?lynkow-preview=true`): it rewrites the `frame-ancestors` directive of
158
+ the response's `Content-Security-Policy` and removes `X-Frame-Options`, keeping
159
+ the rest of the policy. `lynkowPreviewMiddleware()` does the same on its own.
160
+ `markdownProxy` rewrites a request that prefers Markdown to `/md/...`, for a
161
+ site that composes its own proxy:
162
+
163
+ ```ts
164
+ import { NextResponse, type NextRequest } from 'next/server'
165
+ import { markdownProxy, withLynkowPreview } from '@lynkow/next/proxy'
166
+
167
+ export default withLynkowPreview((request: NextRequest) => markdownProxy(request) ?? NextResponse.next())
168
+ ```
169
+
170
+ ## Visual editor
171
+
172
+ ```tsx
173
+ // src/app/layout.tsx
174
+ import { LynkowVisualEditor } from '@lynkow/next/client'
175
+
176
+ <LynkowVisualEditor>{children}</LynkowVisualEditor>
177
+ ```
178
+
179
+ `useBlockData`, `useLynkowField` and `useIsPreviewMode` read the live values
180
+ while an editor works on the page. The module starts with `'use client'`, so a
181
+ server component can render the provider directly.
182
+
183
+ `EditableBlock` marks the element that renders one site block, and `Editable`
184
+ marks a field inside it, so an editor can click it on the page and edit it in
185
+ place. They render `data-lynkow-*` attributes on the element named by `as`,
186
+ and add no wrapper of their own. A field outside any `EditableBlock` is logged
187
+ in development and skipped by the editor.
188
+
189
+ ```tsx
190
+ import { Editable, EditableBlock } from '@lynkow/next/client'
191
+
192
+ <EditableBlock slug="header" as="header">
193
+ <Editable field="title" as="h1">{data.title}</Editable>
194
+ </EditableBlock>
195
+ ```
196
+
197
+ Mark the element that holds the text and nothing else: the editor edits its
198
+ `textContent` in place.
199
+
200
+ `<Editable html={value}>` renders a richtext field as HTML. Pass it only
201
+ richtext the Lynkow API returned, which the API sanitizes; in a preview the
202
+ value comes from the Lynkow dashboard instead, and only from the origin the
203
+ visual editor was configured with.
204
+
205
+ ## Analytics
206
+
207
+ ```tsx
208
+ // src/app/[locale]/layout.tsx
209
+ import { LynkowTracker } from '@lynkow/next/client'
210
+
211
+ <LynkowTracker config={config} consent={consent} />
212
+ ```
213
+
214
+ `LynkowTracker` loads Lynkow's analytics script once, and only when `consent`
215
+ allows it. `consent` describes the site's consent banner and the visitor's
216
+ answer:
217
+
218
+ | `consent` | Tracker |
219
+ |---|---|
220
+ | `{ mode: null, state: 'not-required' }` | Loads: the site has no consent gate |
221
+ | `{ mode: 'notice-only', state }` | Loads: the banner informs rather than asks |
222
+ | `{ mode: 'opt-out', state: 'pending' \| 'granted' }` | Loads |
223
+ | `{ mode: 'opt-out', state: 'denied' }` | Does not load |
224
+ | `{ mode: 'opt-in', state: 'granted' }` | Loads |
225
+ | `{ mode: 'opt-in', state: 'pending' \| 'denied' \| 'unavailable' }` | Does not load |
226
+
227
+ `unavailable` means the consent configuration could not be read, so an opt-in
228
+ site never loads the tracker on a guess. Any other `mode` does not load it
229
+ either. `trackerAllowed(consent)` returns the same decision.
230
+
231
+ This decides whether the script is added. Once it runs, the tracker follows the
232
+ visitor's later answers through Lynkow's own consent banner. With another
233
+ banner, a refusal given after the script loaded takes effect on the next full
234
+ page load.
235
+
236
+ `trackEvent(type, data)` sends one custom event through the tracker already on
237
+ the page. `type` is the SDK's closed `AnalyticsEventType` enum. It returns
238
+ `false`, never throws, when no tracker has loaded.
239
+
240
+ ## Images
241
+
242
+ Next wants a file for a custom image loader, so re-export this one from yours:
243
+
244
+ ```ts
245
+ // src/image-loader.ts
246
+ export { default } from '@lynkow/next/image-loader'
247
+ ```
248
+
249
+ ```ts
250
+ // next.config.ts
251
+ images: { loader: 'custom', loaderFile: './src/image-loader.ts' }
252
+ ```
253
+
254
+ Lynkow media is resized at the Lynkow CDN, at the width `next/image` asks for,
255
+ never upscaled, in the best format the browser accepts, at quality 80 unless
256
+ the `quality` prop says otherwise. A relative URL, such as a file in `/public`,
257
+ comes back unchanged, and so does an absolute URL that is not Lynkow media.
@@ -0,0 +1,95 @@
1
+ // src/locale-paths.ts
2
+ var ABSOLUTE_HREF = /^([a-z][a-z0-9+.-]*:|\/\/)/i;
3
+ function createLocalePaths(config) {
4
+ const { locales, defaultLocale, prefixDefaultLocale, siteUrl } = config;
5
+ const byKey = new Map(locales.map((locale) => [locale.toLowerCase(), locale]));
6
+ const site = new URL(siteUrl);
7
+ function toLocale(value) {
8
+ return value ? byKey.get(value.toLowerCase()) ?? null : null;
9
+ }
10
+ function leadingLocale(pathname) {
11
+ const end = pathname.indexOf("/", 1);
12
+ const locale = toLocale(end === -1 ? pathname.slice(1) : pathname.slice(1, end));
13
+ if (!locale) return null;
14
+ return { locale, rest: end === -1 ? "/" : pathname.slice(end) };
15
+ }
16
+ function localeFromPath(pathname) {
17
+ return leadingLocale(pathname)?.locale ?? (prefixDefaultLocale ? null : defaultLocale);
18
+ }
19
+ function localePath(locale, path = "/") {
20
+ const canonical = toLocale(locale);
21
+ if (!canonical) {
22
+ throw new RangeError(
23
+ `${JSON.stringify(locale)} is not one of the configured locales (${locales.join(", ")})`
24
+ );
25
+ }
26
+ const { pathname: raw, suffix } = splitSuffix(path.startsWith("/") ? path : `/${path}`);
27
+ const pathname = onSite(raw);
28
+ if (!prefixDefaultLocale && canonical === defaultLocale) return pathname + suffix;
29
+ const segment = `/${canonical.toLowerCase()}`;
30
+ return (pathname === "/" ? segment : segment + pathname) + suffix;
31
+ }
32
+ function fromApiPath(path, locale) {
33
+ if (!path.startsWith("/") || path.startsWith("//")) return path;
34
+ const { pathname, suffix } = splitSuffix(path);
35
+ const leading = leadingLocale(pathname);
36
+ return leading ? localePath(leading.locale, leading.rest + suffix) : localePath(locale, pathname + suffix);
37
+ }
38
+ function localizeHref(href, locale) {
39
+ if (!href || ABSOLUTE_HREF.test(href) || !href.startsWith("/")) return href;
40
+ return fromApiPath(href, locale);
41
+ }
42
+ function hrefLocale(href) {
43
+ if (!ABSOLUTE_HREF.test(href) && !href.startsWith("/")) return null;
44
+ try {
45
+ const url = new URL(href, site);
46
+ return url.origin === site.origin ? localeFromPath(url.pathname) : null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+ function negotiateLocale(acceptLanguage) {
52
+ if (!acceptLanguage) return defaultLocale;
53
+ const ranked = acceptLanguage.split(",").map((part) => {
54
+ const [tag = "", ...params] = part.trim().split(";");
55
+ const q = params.find((param) => param.trim().startsWith("q="));
56
+ return { tag: tag.trim().toLowerCase(), q: q ? Number.parseFloat(q.split("=")[1] ?? "") : 1 };
57
+ }).filter((entry) => entry.tag.length > 0 && !Number.isNaN(entry.q)).sort((a, b) => b.q - a.q);
58
+ for (const { tag } of ranked) {
59
+ const exact = byKey.get(tag);
60
+ if (exact) return exact;
61
+ const base = tag.split("-")[0];
62
+ const partial = locales.find((code) => code.toLowerCase().split("-")[0] === base);
63
+ if (partial) return partial;
64
+ }
65
+ return defaultLocale;
66
+ }
67
+ function absoluteUrl(path) {
68
+ return `${siteUrl}${path.startsWith("/") ? path : `/${path}`}`;
69
+ }
70
+ return {
71
+ locales,
72
+ defaultLocale,
73
+ prefixDefaultLocale,
74
+ toLocale,
75
+ isLocale: (value) => toLocale(value) !== null,
76
+ localeFromPath,
77
+ localePath,
78
+ fromApiPath,
79
+ localizeHref,
80
+ hrefLocale,
81
+ negotiateLocale,
82
+ absoluteUrl
83
+ };
84
+ }
85
+ function onSite(pathname) {
86
+ return pathname.replace(/[\t\n\r]/g, "").replace(/^[/\\]+/, "/");
87
+ }
88
+ function splitSuffix(path) {
89
+ const index = path.search(/[?#]/);
90
+ return index === -1 ? { pathname: path, suffix: "" } : { pathname: path.slice(0, index), suffix: path.slice(index) };
91
+ }
92
+
93
+ export { ABSOLUTE_HREF, createLocalePaths };
94
+ //# sourceMappingURL=chunk-4QTGOTFV.js.map
95
+ //# sourceMappingURL=chunk-4QTGOTFV.js.map
@@ -0,0 +1 @@
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"]}
@@ -0,0 +1,55 @@
1
+ // src/cache.ts
2
+ var REVALIDATE = {
3
+ /** Site config and global blocks: header, footer, branding. */
4
+ site: 3600,
5
+ /** Structural pages driven by site blocks. */
6
+ page: 300,
7
+ /** Blog articles, categories, tags. */
8
+ blog: 300,
9
+ /** Approved reviews. */
10
+ reviews: 900,
11
+ /** Form schemas. */
12
+ forms: 3600,
13
+ /** The cookie consent banner configuration. */
14
+ consent: 3600,
15
+ /** sitemap.xml, robots.txt, llms.txt. */
16
+ seo: 3600,
17
+ /** The path table used for static generation and redirect matching. */
18
+ paths: 600,
19
+ /**
20
+ * One search result page.
21
+ *
22
+ * Short on purpose. Next keys its fetch cache on the full URL, so every
23
+ * distinct query is its own entry and a long ceiling would pin a large,
24
+ * unbounded set of them. The tag is what actually keeps results honest: a
25
+ * publish evicts every cached query at once.
26
+ */
27
+ search: 60
28
+ };
29
+ var TAGS = {
30
+ site: "lynkow:site",
31
+ pages: "lynkow:pages",
32
+ page: (slug) => `lynkow:page:${slug}`,
33
+ contents: "lynkow:contents",
34
+ content: (slug) => `lynkow:content:${slug}`,
35
+ categories: "lynkow:categories",
36
+ tags: "lynkow:tags",
37
+ reviews: "lynkow:reviews",
38
+ forms: "lynkow:forms",
39
+ form: (slug) => `lynkow:form:${slug}`,
40
+ consent: "lynkow:consent",
41
+ seo: "lynkow:seo",
42
+ paths: "lynkow:paths",
43
+ search: "lynkow:search"
44
+ };
45
+ function cached(tags, revalidate) {
46
+ const fetchOptions = { next: { tags, revalidate } };
47
+ return { fetchOptions };
48
+ }
49
+ function uncached() {
50
+ return { fetchOptions: { cache: "no-store" } };
51
+ }
52
+
53
+ export { REVALIDATE, TAGS, cached, uncached };
54
+ //# sourceMappingURL=chunk-XNN4SECG.js.map
55
+ //# sourceMappingURL=chunk-XNN4SECG.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cache.ts"],"names":[],"mappings":";AA4BO,IAAM,UAAA,GAAa;AAAA;AAAA,EAExB,IAAA,EAAM,IAAA;AAAA;AAAA,EAEN,IAAA,EAAM,GAAA;AAAA;AAAA,EAEN,IAAA,EAAM,GAAA;AAAA;AAAA,EAEN,OAAA,EAAS,GAAA;AAAA;AAAA,EAET,KAAA,EAAO,IAAA;AAAA;AAAA,EAEP,OAAA,EAAS,IAAA;AAAA;AAAA,EAET,GAAA,EAAK,IAAA;AAAA;AAAA,EAEL,KAAA,EAAO,GAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASP,MAAA,EAAQ;AACV;AAEO,IAAM,IAAA,GAAO;AAAA,EAClB,IAAA,EAAM,aAAA;AAAA,EACN,KAAA,EAAO,cAAA;AAAA,EACP,IAAA,EAAM,CAAC,IAAA,KAAiB,CAAA,YAAA,EAAe,IAAI,CAAA,CAAA;AAAA,EAC3C,QAAA,EAAU,iBAAA;AAAA,EACV,OAAA,EAAS,CAAC,IAAA,KAAiB,CAAA,eAAA,EAAkB,IAAI,CAAA,CAAA;AAAA,EACjD,UAAA,EAAY,mBAAA;AAAA,EACZ,IAAA,EAAM,aAAA;AAAA,EACN,OAAA,EAAS,gBAAA;AAAA,EACT,KAAA,EAAO,cAAA;AAAA,EACP,IAAA,EAAM,CAAC,IAAA,KAAiB,CAAA,YAAA,EAAe,IAAI,CAAA,CAAA;AAAA,EAC3C,OAAA,EAAS,gBAAA;AAAA,EACT,GAAA,EAAK,YAAA;AAAA,EACL,KAAA,EAAO,cAAA;AAAA,EACP,MAAA,EAAQ;AACV;AAQO,SAAS,MAAA,CAAO,MAAgB,UAAA,EAAwC;AAC7E,EAAA,MAAM,eAAgC,EAAE,IAAA,EAAM,EAAE,IAAA,EAAM,YAAW,EAAE;AACnE,EAAA,OAAO,EAAE,YAAA,EAAa;AACxB;AAMO,SAAS,QAAA,GAA+B;AAC7C,EAAA,OAAO,EAAE,YAAA,EAAc,EAAE,KAAA,EAAO,YAAW,EAAE;AAC/C","file":"chunk-XNN4SECG.js","sourcesContent":["/**\n * The caching strategy of a Lynkow site on Next, in one file.\n *\n * Next 15 changed `fetch()` to be uncached by default, so nothing here is\n * inherited: a read that does not pass one of these option bags hits the Lynkow\n * API on every single request. Every data read in this app therefore goes\n * through `cached()`. The durations are defaults tuned for a content site; a\n * site that wants others passes its own number to `cached()`.\n *\n * Two mechanisms, deliberately combined:\n *\n * - `revalidate` is the ceiling. Even with no webhook wired, content is never\n * more than this many seconds stale.\n * - `tags` are the floor. The revalidation webhook calls `revalidateTag()` so\n * an edit in the admin appears in seconds rather than minutes.\n *\n * A tag is only useful if the webhook actually sends the matching event, so the\n * names here and the ones the revalidation handler maps MUST stay in step.\n */\nimport type { BaseRequestOptions } from 'lynkow'\n\n/**\n * `RequestInit` as Next extends it. Spelled out here because this entry\n * imports nothing from `next`, whose global types carry that extension.\n */\ntype NextRequestInit = RequestInit & { next?: { tags?: string[]; revalidate?: number | false } }\n\n/** Seconds. Tuned for a content site: rarely-changing structure lives longer. */\nexport const REVALIDATE = {\n /** Site config and global blocks: header, footer, branding. */\n site: 3600,\n /** Structural pages driven by site blocks. */\n page: 300,\n /** Blog articles, categories, tags. */\n blog: 300,\n /** Approved reviews. */\n reviews: 900,\n /** Form schemas. */\n forms: 3600,\n /** The cookie consent banner configuration. */\n consent: 3600,\n /** sitemap.xml, robots.txt, llms.txt. */\n seo: 3600,\n /** The path table used for static generation and redirect matching. */\n paths: 600,\n /**\n * One search result page.\n *\n * Short on purpose. Next keys its fetch cache on the full URL, so every\n * distinct query is its own entry and a long ceiling would pin a large,\n * unbounded set of them. The tag is what actually keeps results honest: a\n * publish evicts every cached query at once.\n */\n search: 60,\n} as const\n\nexport const TAGS = {\n site: 'lynkow:site',\n pages: 'lynkow:pages',\n page: (slug: string) => `lynkow:page:${slug}`,\n contents: 'lynkow:contents',\n content: (slug: string) => `lynkow:content:${slug}`,\n categories: 'lynkow:categories',\n tags: 'lynkow:tags',\n reviews: 'lynkow:reviews',\n forms: 'lynkow:forms',\n form: (slug: string) => `lynkow:form:${slug}`,\n consent: 'lynkow:consent',\n seo: 'lynkow:seo',\n paths: 'lynkow:paths',\n search: 'lynkow:search',\n} as const\n\n/**\n * Build the per-request options that put one Lynkow read into the Next cache.\n *\n * @example\n * const page = await lynkow(locale).pages.getBySlug('home', cached([TAGS.page('home')], REVALIDATE.page))\n */\nexport function cached(tags: string[], revalidate: number): BaseRequestOptions {\n const fetchOptions: NextRequestInit = { next: { tags, revalidate } }\n return { fetchOptions }\n}\n\n/**\n * For a read that must never be served from cache: a preview render, or a\n * form schema fetched at submit time.\n */\nexport function uncached(): BaseRequestOptions {\n return { fetchOptions: { cache: 'no-store' } }\n}\n"]}
@@ -0,0 +1,152 @@
1
+ import * as react from 'react';
2
+ import react__default, { ElementType, ReactNode } from 'react';
3
+ import { VisualEditorConfig, VisualEditorInstance } from 'lynkow/visual-editor';
4
+
5
+ /**
6
+ * Props of {@link LynkowVisualEditor}.
7
+ *
8
+ * Extends {@link VisualEditorConfig}, so `cmsOrigin` is documented once, on the
9
+ * config the core reads, and means the same thing here.
10
+ */
11
+ interface LynkowVisualEditorProps extends VisualEditorConfig {
12
+ /** Your application. Rendered unchanged; the provider adds no wrapper element. */
13
+ children: react__default.ReactNode;
14
+ }
15
+ declare function LynkowVisualEditor({ children, ...config }: LynkowVisualEditorProps): react__default.JSX.Element;
16
+
17
+ declare function useBlockData<T extends Record<string, any>>(blockSlug: string, initialData: T): T;
18
+ declare function useLynkowField<T>(blockSlug: string, fieldKey: string, initialValue: T): T;
19
+ declare function useIsPreviewMode(): boolean;
20
+
21
+ interface LynkowVisualEditorContextValue {
22
+ instance: VisualEditorInstance | null;
23
+ isPreviewMode: boolean;
24
+ blocksData: Record<string, Record<string, any>>;
25
+ }
26
+
27
+ /**
28
+ * Names the site block this subtree renders.
29
+ *
30
+ * `as` exists so this adds no DOM node: pass the element the block already had
31
+ * (`as="header"`, `as="section"`) rather than nesting a div inside it.
32
+ */
33
+ declare function EditableBlock({ slug, as: As, className, children, }: {
34
+ slug: string;
35
+ as?: ElementType;
36
+ className?: string;
37
+ children: ReactNode;
38
+ }): react.JSX.Element;
39
+ type EditableCommon = {
40
+ /** The field key, exactly as the site block schema spells it. */
41
+ field: string;
42
+ /** What the editor's badge shows. Defaults to the field key. */
43
+ label?: string;
44
+ /** Picks the badge icon and tells the admin which control to open. */
45
+ type?: 'text' | 'richtext' | 'url' | 'image' | 'number' | 'date' | 'select' | 'boolean';
46
+ as?: ElementType;
47
+ className?: string;
48
+ };
49
+ /**
50
+ * `children` for a plain text field, `html` for a richtext one. Never both:
51
+ * a richtext field is injected rather than rendered, so the two cannot share a
52
+ * code path without also sharing the injection.
53
+ */
54
+ type EditableProps = (EditableCommon & {
55
+ children: string;
56
+ html?: never;
57
+ }) | (EditableCommon & {
58
+ html: string;
59
+ children?: never;
60
+ });
61
+ /**
62
+ * One editable field.
63
+ *
64
+ * The value passed in is the SERVER-rendered one and stays the value in
65
+ * production: `useLynkowField` returns its third argument whenever the page is
66
+ * not inside the editor. Inside it, the hook returns whatever the admin has
67
+ * typed, so a change made in the dashboard's side panel appears here without a
68
+ * reload.
69
+ *
70
+ * FLAT KEYS ONLY. The hook indexes the block data with `key in data`, so a
71
+ * repeater row (`services[0].title`) has no addressable key and cannot be
72
+ * marked. Those fields stay editable from the dashboard; they are simply not
73
+ * clickable on the page. Marking the repeater's container would be worse than
74
+ * not marking it, because inline editing would flatten the whole list to text.
75
+ */
76
+ declare function Editable({ field, label, type, as: As, className, children, html, }: EditableProps): react.JSX.Element;
77
+
78
+ /** A validated configuration, frozen. Every field is safe to ship to the browser. */
79
+ interface LynkowNextConfig {
80
+ readonly siteId: string;
81
+ /** The site's origin, with no trailing slash, so `${siteUrl}${path}` is always well formed. */
82
+ readonly siteUrl: string;
83
+ /** The API origin, with no trailing slash. */
84
+ readonly apiUrl: string;
85
+ readonly publishableKey: string | undefined;
86
+ /** The locales as configured, in their configured case. URLs carry them lowercased. */
87
+ readonly locales: readonly string[];
88
+ /** One of `locales`, in its configured case. */
89
+ readonly defaultLocale: string;
90
+ readonly prefixDefaultLocale: boolean;
91
+ }
92
+
93
+ /**
94
+ * What the site's consent state lets the analytics tracker do.
95
+ *
96
+ * - `mode: null`: the site has no consent gate, because the banner is off in
97
+ * the admin or the consent code was removed. Loading the tracker is not this
98
+ * component's decision to refuse.
99
+ * - `notice-only`: the banner informs rather than asks, so the tracker loads.
100
+ * - `opt-out`: the tracker loads unless the visitor refused.
101
+ * - `opt-in`: the tracker loads only once the visitor granted analytics.
102
+ * `unavailable` means the consent configuration could not be read, so there
103
+ * is no choice to read against: the tracker never loads from here then.
104
+ *
105
+ * A mode outside these four never loads it: a value this package does not know
106
+ * cannot be read as consent.
107
+ *
108
+ * This decides whether the script is ADDED. Once it runs, the tracker follows
109
+ * later answers through Lynkow's own consent banner, which it listens to; a
110
+ * refusal given through another banner reaches it on the next full page load.
111
+ */
112
+ type TrackerConsent = {
113
+ mode: null;
114
+ state: 'not-required';
115
+ } | {
116
+ mode: 'opt-out' | 'notice-only';
117
+ state: 'pending' | 'granted' | 'denied';
118
+ } | {
119
+ mode: 'opt-in';
120
+ state: 'pending' | 'granted' | 'denied' | 'unavailable';
121
+ };
122
+ /** Props of {@link LynkowTracker}. */
123
+ interface LynkowTrackerProps {
124
+ config: Pick<LynkowNextConfig, 'siteId' | 'apiUrl'>;
125
+ consent: TrackerConsent;
126
+ }
127
+ /** Whether the tracker may load under this consent state. */
128
+ declare function trackerAllowed(consent: TrackerConsent): boolean;
129
+ /**
130
+ * Loads Lynkow's analytics tracker when the consent state allows it, unless a
131
+ * copy is already on the page.
132
+ *
133
+ * The tracker is a self-contained script the API serves. It records pageviews,
134
+ * client-side navigations included, scroll depth, engaged time, outbound and
135
+ * file links, rage and dead clicks, form events, Core Web Vitals and click
136
+ * heatmaps on its own.
137
+ *
138
+ * ONE TRACKER, NEVER TWO. A browser client of the SDK loads `tracker.js` on its
139
+ * own, under the id `lynkow-tracker`, and two copies each send every event.
140
+ * This component therefore injects the script only when no copy exists, under
141
+ * that same id, which the SDK's loader checks and waits on instead of adding
142
+ * its own.
143
+ *
144
+ * The consent mode is passed to the script as `data-consent-mode`. Without it
145
+ * the tracker works the mode out from the page, and on a site whose banner is
146
+ * not the SDK's own it finds none and tracks before the visitor answers.
147
+ *
148
+ * Renders nothing.
149
+ */
150
+ declare function LynkowTracker({ config, consent }: LynkowTrackerProps): null;
151
+
152
+ export { Editable, EditableBlock, LynkowTracker, type LynkowTrackerProps, LynkowVisualEditor, type LynkowVisualEditorContextValue, type LynkowVisualEditorProps, type TrackerConsent, trackerAllowed, useBlockData, useIsPreviewMode, useLynkowField };