@searkin/next 0.0.0-stage → 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Searkin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,99 @@
1
- # Temporary Holding Version
1
+ # @searkin/next
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Ce paquet relie un site **Next.js sans CMS** à [Searkin](https://app.searkin.com). Searkin conserve les titres, descriptions, canonicals, consignes d'indexation et redirections de chaque page, et le site les lit au moment du rendu. Quand une valeur change dans Searkin, Searkin envoie au site une demande de rafraîchissement signée, si bien qu'aucun redéploiement n'est nécessaire.
4
+
5
+ Compatible avec Next.js 13.4 à 16, en App Router comme en Pages Router.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @searkin/next
11
+ npx @searkin/next init --key sks_…
12
+ ```
13
+
14
+ `init` commence par détecter si le projet utilise l'App Router ou le Pages Router, et s'il a un dossier `src/`. Il effectue ensuite les opérations suivantes :
15
+
16
+ - il écrit `SEARKIN_KEY` dans `.env.local`, ainsi que `SEARKIN_SITE_URL` s'il peut la déduire de `NEXT_PUBLIC_SERVER_URL` / `NEXT_PUBLIC_SITE_URL` (ou de l'option `--site-url`) ;
17
+ - il crée la route `app/api/searkin/[...searkin]/route.ts`, ou `pages/api/searkin/[...searkin].ts` en Pages Router ;
18
+ - il crée `middleware.ts` (`proxy.ts` à partir de Next 16). Si ce fichier existe déjà, il n'y touche pas et affiche les deux lignes à ajouter ;
19
+ - il affiche un exemple de `generateMetadata`, sans modifier vos pages.
20
+
21
+ Vous pouvez relancer `init` sans risque. Tout fichier modifié est d'abord sauvegardé en `*.searkin-<date>.bak`. Pensez aussi à déclarer `SEARKIN_KEY` dans les variables d'environnement de votre hébergeur.
22
+
23
+ ## Variables d'environnement
24
+
25
+ | Variable | Rôle |
26
+ |---|---|
27
+ | `SEARKIN_KEY` | Clé de site `sks_…` générée par Searkin. Elle sert seulement à lire les valeurs de ce site et à vérifier les rafraîchissements |
28
+ | `SEARKIN_SITE_URL` | URL publique, pour rendre les canonicals absolus (à défaut : `NEXT_PUBLIC_SERVER_URL`, puis `NEXT_PUBLIC_SITE_URL`) |
29
+ | `SEARKIN_API` | Facultatif (par défaut : `https://app.searkin.com/api/proxy`) |
30
+ | `SEARKIN_OUTPUT` | `export` si le site est exporté de manière statique (valeur renvoyée par le ping) |
31
+
32
+ ## App Router
33
+
34
+ ```ts
35
+ // app/[slug]/page.tsx
36
+ import type { Metadata } from 'next'
37
+ import { searkinMetadata } from '@searkin/next'
38
+
39
+ export async function generateMetadata({ params }): Promise<Metadata> {
40
+ const { slug } = await params
41
+ return searkinMetadata(`/${slug}`, { title: 'Titre par défaut', description: '…' })
42
+ }
43
+ ```
44
+
45
+ `searkinMetadata(path, base?)` lit `GET /site-api/v1/seo?path=…`. La lecture est mise en cache 300 s avec le tag `searkin`. Chaque valeur que Searkin renvoie remplace celle de `base` :
46
+
47
+ - `title` et `description` ;
48
+ - `alternates.canonical`, rendu absolu ;
49
+ - `robots` : `noindex` donne `{index:false, follow:true}`, `index` donne `{index:true, follow:true}`.
50
+
51
+ Si la clé est absente ou si Searkin est injoignable, `base` est renvoyé tel quel et un message est journalisé une seule fois.
52
+
53
+ `getSearkinSeo(path)` renvoie les valeurs brutes. On peut l'appeler partout, y compris dans `getServerSideProps` et `getStaticProps`.
54
+
55
+ ## Pages Router
56
+
57
+ ```tsx
58
+ import { SearkinHead, getSearkinSeo } from '@searkin/next/pages'
59
+
60
+ export async function getServerSideProps({ resolvedUrl }) {
61
+ return { props: { seo: await getSearkinSeo(resolvedUrl), path: resolvedUrl } }
62
+ }
63
+
64
+ export default function Page({ seo, path }) {
65
+ return (<><SearkinHead seo={seo} path={path} />…</>)
66
+ }
67
+ ```
68
+
69
+ La clé ne doit jamais atteindre le navigateur. `SearkinHead` reçoit donc les valeurs déjà lues côté serveur, au lieu de les lire lui-même. Si Searkin ne définit pas de canonical, c'est `path` qui sert de canonical.
70
+
71
+ ## Redirections
72
+
73
+ - **Sans redéploiement**, avec le middleware créé par `init` : `searkinMiddleware(req)` renvoie une redirection quand une règle correspond, sinon `undefined`. Les règles viennent de `/site-api/v1/redirects` et restent 60 s en mémoire. Le middleware fonctionne en Edge (Web Crypto, aucun module Node).
74
+ - **Au build**, avec `searkinRedirects()` :
75
+
76
+ ```js
77
+ // next.config.mjs
78
+ import { searkinRedirects } from '@searkin/next'
79
+ export default { async redirects() { return searkinRedirects() } }
80
+ ```
81
+
82
+ Next ne connaît que `permanent` : avec `searkinRedirects()`, les codes 301/308 deviennent 308 et les codes 302/307 deviennent 307. Le middleware, lui, conserve le code exact.
83
+
84
+ ## Rafraîchissement par Searkin
85
+
86
+ Les deux routes ci-dessous sont installées par `init`. Searkin les appelle avec l'en-tête `X-Searkin-Signature: t=<unix>,v1=<hex HMAC-SHA256(SEARKIN_KEY, t + "." + corps)>`.
87
+
88
+ - `GET /api/searkin/ping`. Sans signature, la réponse est `{ok, version}`. Avec une signature valide, elle devient `{ok, version, framework:'next', router, output}`.
89
+ - `POST /api/searkin/revalidate` `{tags:['searkin'], paths:[…]}`. Le site appelle alors `revalidateTag` et `revalidatePath` (en Pages Router, `res.revalidate`) et répond `{ok, revalidated}`.
90
+
91
+ Les signatures invalides, de plus de 5 minutes ou déjà utilisées sont refusées avec une erreur 401. Seuls les tags `searkin` et `searkin:*` sont acceptés.
92
+
93
+ ## Export statique (`output: 'export'`)
94
+
95
+ Les valeurs sont figées au moment du build, et il n'y a ni route `/api` ni middleware. Pour que les nouvelles valeurs soient prises en compte, Searkin déclenche le *deploy hook* configuré dans ses Réglages (Vercel, Netlify, Cloudflare…). Les redirections, elles, sont à gérer chez l'hébergeur.
96
+
97
+ ## Licence
98
+
99
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * npx @searkin/next init --key sks_… [--dir <projet>] [--site-url https://…] [--dry-run]
4
+ */
5
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { dirname, join, relative, resolve } from 'node:path';
7
+ import { VERSION } from './shared.js';
8
+ function parseArgs(argv) {
9
+ const a = { dir: process.cwd(), dryRun: false };
10
+ for (let i = 0; i < argv.length; i++) {
11
+ const v = argv[i];
12
+ if (v === '--key')
13
+ a.key = argv[++i];
14
+ else if (v.startsWith('--key='))
15
+ a.key = v.slice(6);
16
+ else if (v === '--site-url')
17
+ a.siteUrl = argv[++i];
18
+ else if (v === '--dir')
19
+ a.dir = resolve(argv[++i] || '.');
20
+ else if (v === '--dry-run')
21
+ a.dryRun = true;
22
+ else if (v === '--version' || v === '-v')
23
+ a.cmd = 'version';
24
+ else if (!v.startsWith('-') && !a.cmd)
25
+ a.cmd = v;
26
+ }
27
+ return a;
28
+ }
29
+ const log = (s = '') => {
30
+ process.stdout.write(s + '\n');
31
+ };
32
+ function backup(file) {
33
+ const dest = `${file}.searkin-${new Date().toISOString().replace(/[:.]/g, '-')}.bak`;
34
+ copyFileSync(file, dest);
35
+ return dest;
36
+ }
37
+ function write(root, file, content, dryRun) {
38
+ if (dryRun) {
39
+ log(` (simulation) ${relative(root, file)}`);
40
+ return;
41
+ }
42
+ mkdirSync(dirname(file), { recursive: true });
43
+ if (existsSync(file))
44
+ log(` sauvegarde : ${relative(root, backup(file))}`);
45
+ writeFileSync(file, content);
46
+ }
47
+ /** Met à jour (ou ajoute) des variables dans un fichier .env, sans toucher aux autres lignes. */
48
+ function upsertEnv(root, file, vars, dryRun) {
49
+ const current = existsSync(file) ? readFileSync(file, 'utf8') : '';
50
+ let next = current;
51
+ const changed = [];
52
+ for (const [k, v] of Object.entries(vars)) {
53
+ const line = `${k}=${v}`;
54
+ const re = new RegExp(`^${k}=.*$`, 'm');
55
+ if (next.split(/\r?\n/).includes(line))
56
+ continue;
57
+ if (re.test(next))
58
+ next = next.replace(re, line);
59
+ else
60
+ next = next + (next && !next.endsWith('\n') ? '\n' : '') + line + '\n';
61
+ changed.push(k);
62
+ }
63
+ if (changed.length)
64
+ write(root, file, next, dryRun);
65
+ return changed;
66
+ }
67
+ function readEnvValue(root, names) {
68
+ for (const f of ['.env.local', '.env.production', '.env']) {
69
+ const p = join(root, f);
70
+ if (!existsSync(p))
71
+ continue;
72
+ for (const name of names) {
73
+ const m = new RegExp(`^${name}=["']?([^"'\\n]+)`, 'm').exec(readFileSync(p, 'utf8'));
74
+ if (m && /^https?:\/\//.test(m[1]))
75
+ return m[1].trim().replace(/\/+$/, '');
76
+ }
77
+ }
78
+ return undefined;
79
+ }
80
+ function nextMajor(root) {
81
+ try {
82
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
83
+ const v = pkg.dependencies?.next || pkg.devDependencies?.next || '';
84
+ const m = /(\d+)/.exec(v);
85
+ if (m)
86
+ return Number(m[1]);
87
+ }
88
+ catch {
89
+ /* inconnu */
90
+ }
91
+ try {
92
+ return Number(JSON.parse(readFileSync(join(root, 'node_modules/next/package.json'), 'utf8')).version.split('.')[0]);
93
+ }
94
+ catch {
95
+ return 15;
96
+ }
97
+ }
98
+ function outputExport(root) {
99
+ for (const f of ['next.config.ts', 'next.config.mjs', 'next.config.js', 'next.config.cjs']) {
100
+ const p = join(root, f);
101
+ if (existsSync(p) && /output\s*:\s*['"]export['"]/.test(readFileSync(p, 'utf8')))
102
+ return true;
103
+ }
104
+ return false;
105
+ }
106
+ const MIDDLEWARE = (fn) => `import type { NextRequest } from 'next/server.js'
107
+ import { searkinMiddleware } from '@searkin/next/middleware'
108
+
109
+ // Redirections Searkin actives sans redéploiement (règles gardées 60 s en mémoire).
110
+ export async function ${fn}(req: NextRequest) {
111
+ return searkinMiddleware(req)
112
+ }
113
+
114
+ export const config = {
115
+ matcher: ['/((?!api|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)'],
116
+ }
117
+ `;
118
+ const METADATA_EXAMPLE = `// app/[slug]/page.tsx
119
+ import type { Metadata } from 'next'
120
+ import { searkinMetadata } from '@searkin/next'
121
+
122
+ export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
123
+ const { slug } = await params
124
+ return searkinMetadata(\`/\${slug}\`, { title: 'Titre par défaut', description: 'Description par défaut' })
125
+ }`;
126
+ const PAGES_EXAMPLE = `// pages/[slug].tsx
127
+ import { SearkinHead, getSearkinSeo } from '@searkin/next/pages'
128
+
129
+ export async function getServerSideProps({ resolvedUrl }) {
130
+ return { props: { seo: await getSearkinSeo(resolvedUrl) } }
131
+ }
132
+
133
+ export default function Page({ seo }) {
134
+ return <><SearkinHead seo={seo} />…</>
135
+ }`;
136
+ function init(args) {
137
+ const root = args.dir;
138
+ log(`@searkin/next ${VERSION} — init${args.dryRun ? ' (simulation)' : ''}`);
139
+ if (!existsSync(join(root, 'package.json'))) {
140
+ log('package.json introuvable : lancez la commande à la racine du projet Next (ou --dir).');
141
+ return 1;
142
+ }
143
+ const base = existsSync(join(root, 'src', 'app')) || existsSync(join(root, 'src', 'pages')) ? join(root, 'src') : root;
144
+ const hasApp = existsSync(join(base, 'app'));
145
+ const hasPages = existsSync(join(base, 'pages'));
146
+ const ts = existsSync(join(root, 'tsconfig.json'));
147
+ const ext = ts ? 'ts' : 'js';
148
+ const isExport = outputExport(root);
149
+ const major = nextMajor(root);
150
+ log(`Projet : ${hasApp ? 'App Router' : ''}${hasApp && hasPages ? ' + ' : ''}${hasPages ? 'Pages Router' : ''}${!hasApp && !hasPages ? 'aucun dossier app/ ni pages/' : ''}${base !== root ? ' (src/)' : ''}, Next ${major}${isExport ? ', output: export' : ''}`);
151
+ if (!hasApp && !hasPages)
152
+ return 1;
153
+ // 1. Variables d'environnement.
154
+ if (args.key) {
155
+ if (!/^sks_[A-Za-z0-9_-]{16,}$/.test(args.key))
156
+ log('Attention : la clé ne ressemble pas à une clé de site Searkin (sks_…).');
157
+ const vars = { SEARKIN_KEY: args.key };
158
+ const siteUrl = args.siteUrl || readEnvValue(root, ['SEARKIN_SITE_URL', 'NEXT_PUBLIC_SERVER_URL', 'NEXT_PUBLIC_SITE_URL', 'SITE_URL']);
159
+ if (siteUrl)
160
+ vars.SEARKIN_SITE_URL = siteUrl;
161
+ const changed = upsertEnv(root, join(root, '.env.local'), vars, args.dryRun);
162
+ log(changed.length ? `.env.local : ${changed.join(', ')} écrit(s).` : '.env.local : déjà à jour.');
163
+ if (!siteUrl)
164
+ log(' SEARKIN_SITE_URL non déduit : ajoutez l’URL publique du site (canonicals absolus).');
165
+ }
166
+ // 2. Route /api/searkin/*.
167
+ if (isExport) {
168
+ log('output: export : pas de route /api (les valeurs sont figées au build ; Searkin déclenchera votre deploy hook).');
169
+ }
170
+ else if (hasApp) {
171
+ const file = join(base, 'app', 'api', 'searkin', '[...searkin]', `route.${ext}`);
172
+ const content = "export { GET, POST } from '@searkin/next/route'\n";
173
+ if (existsSync(file) && readFileSync(file, 'utf8').includes('@searkin/next/route'))
174
+ log(`Route déjà présente : ${relative(root, file)}`);
175
+ else {
176
+ write(root, file, content, args.dryRun);
177
+ log(`Route créée : ${relative(root, file)}`);
178
+ }
179
+ }
180
+ else {
181
+ const file = join(base, 'pages', 'api', 'searkin', `[...searkin].${ext}`);
182
+ const content = "export { default, config } from '@searkin/next/pages'\n";
183
+ if (existsSync(file) && readFileSync(file, 'utf8').includes('@searkin/next/pages'))
184
+ log(`Route déjà présente : ${relative(root, file)}`);
185
+ else {
186
+ write(root, file, content, args.dryRun);
187
+ log(`Route créée : ${relative(root, file)}`);
188
+ }
189
+ }
190
+ // 3. Middleware (proxy à partir de Next 16).
191
+ if (!isExport) {
192
+ const name = major >= 16 ? 'proxy' : 'middleware';
193
+ const existing = [`${name}.ts`, `${name}.js`, 'middleware.ts', 'middleware.js'].map((f) => join(base, f)).find((f) => existsSync(f));
194
+ if (existing && readFileSync(existing, 'utf8').includes('searkinMiddleware')) {
195
+ log(`Middleware déjà branché : ${relative(root, existing)}`);
196
+ }
197
+ else if (existing) {
198
+ log(`${relative(root, existing)} existe déjà : ajoutez à la main, en tête de la fonction :`);
199
+ log(" import { searkinMiddleware } from '@searkin/next/middleware'");
200
+ log(' const redirect = await searkinMiddleware(req); if (redirect) return redirect');
201
+ }
202
+ else {
203
+ const file = join(base, `${name}.${ext}`);
204
+ write(root, file, MIDDLEWARE(name), args.dryRun);
205
+ log(`${name === 'proxy' ? 'Proxy' : 'Middleware'} créé : ${relative(root, file)}`);
206
+ }
207
+ }
208
+ // 4. Exemple d'intégration (jamais écrit dans vos pages).
209
+ log();
210
+ log('Exemple à intégrer :');
211
+ log(hasApp ? METADATA_EXAMPLE : PAGES_EXAMPLE);
212
+ if (isExport) {
213
+ log();
214
+ log("output: 'export' : ni middleware ni redirects() de next.config ne s'appliquent ; configurez les redirections chez l'hébergeur.");
215
+ }
216
+ log();
217
+ log('Ensuite : redéployez avec SEARKIN_KEY dans les variables d’environnement de l’hébergeur.');
218
+ return 0;
219
+ }
220
+ function main() {
221
+ const args = parseArgs(process.argv.slice(2));
222
+ if (args.cmd === 'version') {
223
+ log(VERSION);
224
+ return 0;
225
+ }
226
+ if (!args.cmd || args.cmd === 'init')
227
+ return init(args);
228
+ log('Usage : npx @searkin/next init --key sks_… [--site-url https://…] [--dir <projet>] [--dry-run]');
229
+ return 1;
230
+ }
231
+ process.exitCode = main();
@@ -0,0 +1,22 @@
1
+ import type { Metadata } from 'next';
2
+ import { type SearkinSeo } from './shared.js';
3
+ export type { SearkinSeo, SearkinRobots, SearkinRedirectRule } from './shared.js';
4
+ export { VERSION, CACHE_TAG } from './shared.js';
5
+ /**
6
+ * Valeurs SEO brutes de Searkin pour un chemin ({} si rien n'est défini, null si l'API est
7
+ * indisponible ou la clé absente). Utilisable partout : generateMetadata, getServerSideProps, getStaticProps…
8
+ */
9
+ export declare function getSearkinSeo(path: string): Promise<SearkinSeo | null>;
10
+ /** Fusionne les valeurs Searkin dans un objet Metadata ; les valeurs Searkin présentes l'emportent. */
11
+ export declare function applySearkinSeo(seo: SearkinSeo | null | undefined, base?: Metadata): Metadata;
12
+ /**
13
+ * App Router, côté serveur : à appeler dans generateMetadata.
14
+ * En cas d'erreur réseau ou de clé absente, `base` est renvoyé intact.
15
+ */
16
+ export declare function searkinMetadata(path: string, base?: Metadata): Promise<Metadata>;
17
+ /** Redirections pour `async redirects()` de next.config (lues au build). */
18
+ export declare function searkinRedirects(): Promise<{
19
+ source: string;
20
+ destination: string;
21
+ permanent: boolean;
22
+ }[]>;
package/dist/index.js ADDED
@@ -0,0 +1,41 @@
1
+ import { absoluteUrl, CACHE_TAG, config, fetchRedirectRules, isPermanent, normalizePath, robotsFor, searkinGet, } from './shared.js';
2
+ export { VERSION, CACHE_TAG } from './shared.js';
3
+ /**
4
+ * Valeurs SEO brutes de Searkin pour un chemin ({} si rien n'est défini, null si l'API est
5
+ * indisponible ou la clé absente). Utilisable partout : generateMetadata, getServerSideProps, getStaticProps…
6
+ */
7
+ export async function getSearkinSeo(path) {
8
+ const p = normalizePath(path);
9
+ return searkinGet(`/site-api/v1/seo?path=${encodeURIComponent(p)}`, {
10
+ next: { revalidate: 300, tags: [CACHE_TAG] },
11
+ });
12
+ }
13
+ /** Fusionne les valeurs Searkin dans un objet Metadata ; les valeurs Searkin présentes l'emportent. */
14
+ export function applySearkinSeo(seo, base = {}) {
15
+ if (!seo)
16
+ return base;
17
+ const out = { ...base };
18
+ if (seo.title)
19
+ out.title = seo.title;
20
+ if (seo.description)
21
+ out.description = seo.description;
22
+ if (seo.canonical) {
23
+ out.alternates = { ...(base.alternates || {}), canonical: absoluteUrl(seo.canonical, config().siteUrl) };
24
+ }
25
+ const robots = robotsFor(seo.robots);
26
+ if (robots)
27
+ out.robots = robots;
28
+ return out;
29
+ }
30
+ /**
31
+ * App Router, côté serveur : à appeler dans generateMetadata.
32
+ * En cas d'erreur réseau ou de clé absente, `base` est renvoyé intact.
33
+ */
34
+ export async function searkinMetadata(path, base = {}) {
35
+ return applySearkinSeo(await getSearkinSeo(path), base);
36
+ }
37
+ /** Redirections pour `async redirects()` de next.config (lues au build). */
38
+ export async function searkinRedirects() {
39
+ const rules = await fetchRedirectRules({ cache: 'no-store' });
40
+ return (rules || []).map((r) => ({ source: r.source, destination: r.destination, permanent: isPermanent(r.code) }));
41
+ }
@@ -0,0 +1,8 @@
1
+ import { NextResponse, type NextRequest } from 'next/server.js';
2
+ /**
3
+ * Redirige si une règle Searkin correspond au chemin demandé, sinon renvoie undefined.
4
+ * Règles lues sur /site-api/v1/redirects, gardées 60 s en mémoire.
5
+ */
6
+ export declare function searkinMiddleware(req: NextRequest): Promise<NextResponse | undefined>;
7
+ /** Vide le cache des règles (tests). */
8
+ export declare const resetSearkinRedirectCache: () => void;
@@ -0,0 +1,52 @@
1
+ // Edge compatible : aucun module Node.
2
+ import { NextResponse } from 'next/server.js';
3
+ import { fetchRedirectRules, normalizePath } from './shared.js';
4
+ const TTL_MS = 60_000;
5
+ let cache = null;
6
+ let pending = null;
7
+ async function load() {
8
+ if (cache && Date.now() - cache.at < TTL_MS)
9
+ return cache.rules;
10
+ if (!pending) {
11
+ pending = (async () => {
12
+ const rules = await fetchRedirectRules({ cache: 'no-store' });
13
+ if (rules) {
14
+ cache = { at: Date.now(), rules: new Map(rules.map((r) => [normalizePath(r.source), r])) };
15
+ }
16
+ else if (cache) {
17
+ cache = { ...cache, at: Date.now() }; // garde les dernières règles connues
18
+ }
19
+ else {
20
+ cache = { at: Date.now(), rules: new Map() };
21
+ }
22
+ })().finally(() => {
23
+ pending = null;
24
+ });
25
+ }
26
+ await pending;
27
+ return cache.rules;
28
+ }
29
+ const CODES = new Set([301, 302, 303, 307, 308]);
30
+ /**
31
+ * Redirige si une règle Searkin correspond au chemin demandé, sinon renvoie undefined.
32
+ * Règles lues sur /site-api/v1/redirects, gardées 60 s en mémoire.
33
+ */
34
+ export async function searkinMiddleware(req) {
35
+ const path = normalizePath(req.nextUrl.pathname);
36
+ if (path.startsWith('/_next') || path.startsWith('/api/searkin'))
37
+ return undefined;
38
+ const rules = await load();
39
+ const rule = rules.get(path);
40
+ if (!rule)
41
+ return undefined;
42
+ const destination = new URL(rule.destination, req.nextUrl.origin);
43
+ if (!/^https?:\/\//i.test(rule.destination) && req.nextUrl.search && !destination.search)
44
+ destination.search = req.nextUrl.search;
45
+ if (destination.toString() === req.nextUrl.toString())
46
+ return undefined;
47
+ return NextResponse.redirect(destination, CODES.has(rule.code) ? rule.code : 301);
48
+ }
49
+ /** Vide le cache des règles (tests). */
50
+ export const resetSearkinRedirectCache = () => {
51
+ cache = null;
52
+ };
@@ -0,0 +1,26 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { type ReactElement } from 'react';
3
+ import { type SearkinSeo } from './shared.js';
4
+ export { getSearkinSeo } from './index.js';
5
+ export type SearkinHeadProps = {
6
+ /** Valeurs lues par getSearkinSeo() dans getServerSideProps / getStaticProps. */
7
+ seo: SearkinSeo | null | undefined;
8
+ /** Chemin de la page : sert de canonical quand Searkin n'en définit pas. */
9
+ path?: string;
10
+ };
11
+ /** Balises <title>, description, canonical et robots dans next/head. */
12
+ export declare function SearkinHead({ seo, path }: SearkinHeadProps): ReactElement | null;
13
+ type ApiReq = IncomingMessage & {
14
+ query?: Record<string, string | string[]>;
15
+ url?: string;
16
+ };
17
+ type ApiRes = ServerResponse & {
18
+ revalidate?: (path: string) => Promise<void>;
19
+ };
20
+ /** Le corps brut est nécessaire pour vérifier la signature. */
21
+ export declare const config: {
22
+ api: {
23
+ bodyParser: boolean;
24
+ };
25
+ };
26
+ export default function searkinPagesHandler(req: ApiReq, res: ApiRes): Promise<void>;
package/dist/pages.js ADDED
@@ -0,0 +1,78 @@
1
+ import HeadModule from 'next/head.js';
2
+ import { createElement, Fragment } from 'react';
3
+ import { absoluteUrl, config as env, outputMode, parseRevalidateBody, verifySignature, VERSION } from './shared.js';
4
+ export { getSearkinSeo } from './index.js';
5
+ // next/head est un module CommonJS : selon le chargeur, le composant est l'export par défaut ou son .default.
6
+ const Head = (HeadModule.default ?? HeadModule);
7
+ /** Balises <title>, description, canonical et robots dans next/head. */
8
+ export function SearkinHead({ seo, path }) {
9
+ if (!seo)
10
+ return null;
11
+ const siteUrl = env().siteUrl;
12
+ const children = [];
13
+ if (seo.title)
14
+ children.push(createElement('title', { key: 'searkin-title' }, seo.title));
15
+ if (seo.description)
16
+ children.push(createElement('meta', { key: 'searkin-description', name: 'description', content: seo.description }));
17
+ const canonical = seo.canonical || (path ? path : null);
18
+ if (canonical)
19
+ children.push(createElement('link', { key: 'searkin-canonical', rel: 'canonical', href: absoluteUrl(canonical, siteUrl) }));
20
+ if (seo.robots === 'noindex')
21
+ children.push(createElement('meta', { key: 'searkin-robots', name: 'robots', content: 'noindex, follow' }));
22
+ else if (seo.robots === 'index')
23
+ children.push(createElement('meta', { key: 'searkin-robots', name: 'robots', content: 'index, follow' }));
24
+ if (!children.length)
25
+ return null;
26
+ return createElement(Head, null, createElement(Fragment, null, ...children));
27
+ }
28
+ const send = (res, status, data) => {
29
+ res.statusCode = status;
30
+ res.setHeader('Content-Type', 'application/json');
31
+ res.setHeader('Cache-Control', 'no-store');
32
+ res.end(JSON.stringify(data));
33
+ };
34
+ const readRaw = async (req) => {
35
+ const chunks = [];
36
+ for await (const c of req)
37
+ chunks.push(typeof c === 'string' ? Buffer.from(c) : c);
38
+ return Buffer.concat(chunks).toString('utf8');
39
+ };
40
+ /** Le corps brut est nécessaire pour vérifier la signature. */
41
+ export const config = { api: { bodyParser: false } };
42
+ export default async function searkinPagesHandler(req, res) {
43
+ const action = /\/api\/searkin\/([^/?#]+)/.exec(req.url || '')?.[1] || '';
44
+ try {
45
+ if (req.method === 'GET' && action === 'ping') {
46
+ const header = req.headers['x-searkin-signature'];
47
+ if (!header)
48
+ return send(res, 200, { ok: true, version: VERSION });
49
+ const check = await verifySignature(header, '');
50
+ if (!check.ok)
51
+ return send(res, 401, { ok: false, message: check.reason });
52
+ return send(res, 200, { ok: true, version: VERSION, framework: 'next', router: 'pages', output: outputMode() });
53
+ }
54
+ if (req.method === 'POST' && action === 'revalidate') {
55
+ const raw = await readRaw(req);
56
+ const check = await verifySignature(req.headers['x-searkin-signature'], raw);
57
+ if (!check.ok)
58
+ return send(res, 401, { ok: false, message: check.reason });
59
+ const { tags, paths } = parseRevalidateBody(raw);
60
+ const done = [];
61
+ const failed = {};
62
+ for (const path of paths) {
63
+ try {
64
+ await res.revalidate?.(path);
65
+ done.push(path);
66
+ }
67
+ catch (err) {
68
+ failed[path] = err.message;
69
+ }
70
+ }
71
+ return send(res, 200, { ok: true, revalidated: { tags, paths: done }, ...(Object.keys(failed).length ? { failed } : {}) });
72
+ }
73
+ return send(res, 404, { ok: false, message: 'Route inconnue.' });
74
+ }
75
+ catch (err) {
76
+ return send(res, 500, { ok: false, message: err.message });
77
+ }
78
+ }
@@ -0,0 +1,3 @@
1
+ export declare const dynamic = "force-dynamic";
2
+ export declare function GET(req: Request): Promise<Response>;
3
+ export declare function POST(req: Request): Promise<Response>;
package/dist/route.js ADDED
@@ -0,0 +1,35 @@
1
+ // Gestionnaire App Router : `export { GET, POST } from '@searkin/next/route'`
2
+ // dans app/api/searkin/[...searkin]/route.ts
3
+ import { revalidatePath, revalidateTag } from 'next/cache.js';
4
+ import { outputMode, parseRevalidateBody, verifySignature, VERSION } from './shared.js';
5
+ const json = (data, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } });
6
+ const action = (req) => {
7
+ const m = /\/api\/searkin\/([^/?#]+)/.exec(new URL(req.url).pathname);
8
+ return m ? m[1] : '';
9
+ };
10
+ export const dynamic = 'force-dynamic';
11
+ export async function GET(req) {
12
+ if (action(req) !== 'ping')
13
+ return json({ ok: false, message: 'Route inconnue.' }, 404);
14
+ const header = req.headers.get('x-searkin-signature');
15
+ if (!header)
16
+ return json({ ok: true, version: VERSION });
17
+ const check = await verifySignature(header, '');
18
+ if (!check.ok)
19
+ return json({ ok: false, message: check.reason }, 401);
20
+ return json({ ok: true, version: VERSION, framework: 'next', router: 'app', output: outputMode() });
21
+ }
22
+ export async function POST(req) {
23
+ if (action(req) !== 'revalidate')
24
+ return json({ ok: false, message: 'Route inconnue.' }, 404);
25
+ const raw = await req.text();
26
+ const check = await verifySignature(req.headers.get('x-searkin-signature'), raw);
27
+ if (!check.ok)
28
+ return json({ ok: false, message: check.reason }, 401);
29
+ const { tags, paths } = parseRevalidateBody(raw);
30
+ for (const tag of tags)
31
+ revalidateTag(tag);
32
+ for (const path of paths)
33
+ revalidatePath(path);
34
+ return json({ ok: true, revalidated: { tags, paths } });
35
+ }
@@ -0,0 +1,60 @@
1
+ export declare const VERSION = "0.1.0";
2
+ export declare const CACHE_TAG = "searkin";
3
+ export type SearkinRobots = 'default' | 'index' | 'noindex' | null;
4
+ export type SearkinSeo = {
5
+ path?: string;
6
+ title?: string | null;
7
+ description?: string | null;
8
+ canonical?: string | null;
9
+ robots?: SearkinRobots;
10
+ updated_at?: string | null;
11
+ };
12
+ export type SearkinRedirectRule = {
13
+ source: string;
14
+ destination: string;
15
+ code: number;
16
+ };
17
+ export declare const config: () => {
18
+ key: string | undefined;
19
+ api: string;
20
+ siteUrl: string | undefined;
21
+ };
22
+ export declare function logOnce(message: string): void;
23
+ /** Chemin normalisé : commence par /, sans requête ni ancre, sans / final (sauf racine). */
24
+ export declare function normalizePath(path: string): string;
25
+ type FetchInit = RequestInit & {
26
+ next?: {
27
+ revalidate?: number | false;
28
+ tags?: string[];
29
+ };
30
+ };
31
+ /** GET authentifié vers l'API site de Searkin. Renvoie null (et journalise une fois) en cas d'échec. */
32
+ export declare function searkinGet<T>(path: string, init?: FetchInit, missingKey?: string): Promise<T | null>;
33
+ export declare const absoluteUrl: (value: string, siteUrl?: string) => string;
34
+ export declare const robotsFor: (robots: SearkinRobots | undefined) => {
35
+ index: boolean;
36
+ follow: boolean;
37
+ } | undefined;
38
+ export declare function fetchRedirectRules(init?: FetchInit): Promise<SearkinRedirectRule[] | null>;
39
+ export declare const isPermanent: (code: number | undefined) => code is 301 | 308 | undefined;
40
+ export declare const SIGNATURE_WINDOW_S = 300;
41
+ export declare function hmacHex(key: string, message: string): Promise<string>;
42
+ export type SignatureCheck = {
43
+ ok: true;
44
+ } | {
45
+ ok: false;
46
+ reason: string;
47
+ };
48
+ /**
49
+ * Vérifie `X-Searkin-Signature: t=<unix>,v1=<hex HMAC-SHA256(SEARKIN_KEY, t + "." + corps brut)>`.
50
+ * Fenêtre de 5 minutes, rejeux refusés (mémoire du processus).
51
+ */
52
+ export declare function verifySignature(header: string | null | undefined, rawBody: string, now?: number): Promise<SignatureCheck>;
53
+ export type RevalidatePayload = {
54
+ tags: string[];
55
+ paths: string[];
56
+ };
57
+ /** Valide le corps de /revalidate : tags préfixés « searkin », chemins commençant par /. */
58
+ export declare function parseRevalidateBody(raw: string): RevalidatePayload;
59
+ export declare const outputMode: () => "server" | "export";
60
+ export {};
package/dist/shared.js ADDED
@@ -0,0 +1,163 @@
1
+ // Code commun, sans dépendance Node : utilisable côté serveur, en Edge (middleware) et au build.
2
+ export const VERSION = '0.1.0';
3
+ export const CACHE_TAG = 'searkin';
4
+ const DEFAULT_API = 'https://app.searkin.com/api/proxy';
5
+ const env = (name) => {
6
+ try {
7
+ const v = process.env[name];
8
+ return v && v.trim() ? v.trim() : undefined;
9
+ }
10
+ catch {
11
+ return undefined;
12
+ }
13
+ };
14
+ export const config = () => ({
15
+ key: env('SEARKIN_KEY'),
16
+ api: (env('SEARKIN_API') || DEFAULT_API).replace(/\/+$/, ''),
17
+ siteUrl: (env('SEARKIN_SITE_URL') || env('NEXT_PUBLIC_SERVER_URL') || env('NEXT_PUBLIC_SITE_URL') || '').replace(/\/+$/, '') || undefined,
18
+ });
19
+ const logged = new Set();
20
+ export function logOnce(message) {
21
+ if (logged.has(message))
22
+ return;
23
+ logged.add(message);
24
+ console.warn(`[searkin] ${message}`);
25
+ }
26
+ /** Chemin normalisé : commence par /, sans requête ni ancre, sans / final (sauf racine). */
27
+ export function normalizePath(path) {
28
+ let p = (path || '/').split('#')[0].split('?')[0];
29
+ if (/^https?:\/\//i.test(p)) {
30
+ try {
31
+ p = new URL(p).pathname;
32
+ }
33
+ catch {
34
+ /* garde tel quel */
35
+ }
36
+ }
37
+ if (!p.startsWith('/'))
38
+ p = '/' + p;
39
+ p = p.replace(/\/{2,}/g, '/');
40
+ if (p.length > 1)
41
+ p = p.replace(/\/+$/, '');
42
+ return p;
43
+ }
44
+ /** GET authentifié vers l'API site de Searkin. Renvoie null (et journalise une fois) en cas d'échec. */
45
+ export async function searkinGet(path, init = {}, missingKey = 'SEARKIN_KEY absent : les valeurs Searkin ne sont pas appliquées.') {
46
+ const { key, api } = config();
47
+ if (!key) {
48
+ logOnce(missingKey);
49
+ return null;
50
+ }
51
+ try {
52
+ const res = await fetch(api + path, {
53
+ ...init,
54
+ headers: { Accept: 'application/json', Authorization: `Bearer ${key}`, ...(init.headers || {}) },
55
+ });
56
+ if (!res.ok) {
57
+ logOnce(`API Searkin : HTTP ${res.status} sur ${path.split('?')[0]}${res.status === 401 ? ' (clé refusée)' : ''}.`);
58
+ return null;
59
+ }
60
+ return (await res.json());
61
+ }
62
+ catch (err) {
63
+ logOnce(`API Searkin injoignable (${err.message}).`);
64
+ return null;
65
+ }
66
+ }
67
+ export const absoluteUrl = (value, siteUrl) => {
68
+ if (/^https?:\/\//i.test(value))
69
+ return value;
70
+ if (!siteUrl)
71
+ return value;
72
+ try {
73
+ return new URL(value, siteUrl + '/').toString();
74
+ }
75
+ catch {
76
+ return value;
77
+ }
78
+ };
79
+ export const robotsFor = (robots) => robots === 'noindex' ? { index: false, follow: true } : robots === 'index' ? { index: true, follow: true } : undefined;
80
+ export async function fetchRedirectRules(init = {}) {
81
+ const data = await searkinGet('/site-api/v1/redirects', init, 'SEARKIN_KEY absent : les redirections Searkin sont inactives.');
82
+ if (!data)
83
+ return null;
84
+ return (data.items || []).filter((r) => r && typeof r.source === 'string' && typeof r.destination === 'string');
85
+ }
86
+ export const isPermanent = (code) => code === 301 || code === 308 || code === undefined;
87
+ // ---------- Signature HMAC (Web Crypto, Edge et Node) ----------
88
+ const enc = new TextEncoder();
89
+ const seen = new Map();
90
+ export const SIGNATURE_WINDOW_S = 300;
91
+ async function subtle() {
92
+ const c = globalThis.crypto;
93
+ if (c?.subtle)
94
+ return c.subtle;
95
+ // Node 18 sans globalThis.crypto : on passe par webcrypto, chargé dynamiquement (jamais en Edge).
96
+ const mod = 'node:crypto';
97
+ const nodeCrypto = (await import(/* webpackIgnore: true */ mod));
98
+ return nodeCrypto.webcrypto.subtle;
99
+ }
100
+ const toHex = (buf) => Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, '0')).join('');
101
+ export async function hmacHex(key, message) {
102
+ const s = await subtle();
103
+ const k = await s.importKey('raw', enc.encode(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
104
+ return toHex(await s.sign('HMAC', k, enc.encode(message)));
105
+ }
106
+ const safeEqual = (a, b) => {
107
+ if (a.length !== b.length)
108
+ return false;
109
+ let diff = 0;
110
+ for (let i = 0; i < a.length; i++)
111
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
112
+ return diff === 0;
113
+ };
114
+ /**
115
+ * Vérifie `X-Searkin-Signature: t=<unix>,v1=<hex HMAC-SHA256(SEARKIN_KEY, t + "." + corps brut)>`.
116
+ * Fenêtre de 5 minutes, rejeux refusés (mémoire du processus).
117
+ */
118
+ export async function verifySignature(header, rawBody, now = Date.now()) {
119
+ const { key } = config();
120
+ if (!key)
121
+ return { ok: false, reason: 'SEARKIN_KEY absent sur le site' };
122
+ if (!header)
123
+ return { ok: false, reason: 'signature absente' };
124
+ const parts = Object.fromEntries(header.split(',').map((p) => {
125
+ const i = p.indexOf('=');
126
+ return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
127
+ }));
128
+ const t = Number(parts.t);
129
+ const v1 = (parts.v1 || '').toLowerCase();
130
+ if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(v1))
131
+ return { ok: false, reason: 'signature mal formée' };
132
+ if (Math.abs(now / 1000 - t) > SIGNATURE_WINDOW_S)
133
+ return { ok: false, reason: 'signature expirée' };
134
+ const expected = await hmacHex(key, `${t}.${rawBody}`);
135
+ if (!safeEqual(expected, v1))
136
+ return { ok: false, reason: 'signature invalide' };
137
+ for (const [sig, exp] of seen)
138
+ if (exp < now)
139
+ seen.delete(sig);
140
+ if (seen.has(v1))
141
+ return { ok: false, reason: 'signature déjà utilisée (rejeu)' };
142
+ seen.set(v1, now + SIGNATURE_WINDOW_S * 1000 * 2);
143
+ return { ok: true };
144
+ }
145
+ /** Valide le corps de /revalidate : tags préfixés « searkin », chemins commençant par /. */
146
+ export function parseRevalidateBody(raw) {
147
+ let body = {};
148
+ if (raw.trim()) {
149
+ try {
150
+ body = JSON.parse(raw);
151
+ }
152
+ catch {
153
+ body = {};
154
+ }
155
+ }
156
+ const tags = (Array.isArray(body.tags) ? body.tags : [CACHE_TAG]).filter((t) => typeof t === 'string' && (t === CACHE_TAG || t.startsWith(CACHE_TAG + ':')));
157
+ const paths = (Array.isArray(body.paths) ? body.paths : [])
158
+ .filter((p) => typeof p === 'string' && p.startsWith('/'))
159
+ .slice(0, 500)
160
+ .map(normalizePath);
161
+ return { tags: tags.length ? Array.from(new Set(tags)) : [CACHE_TAG], paths: Array.from(new Set(paths)) };
162
+ }
163
+ export const outputMode = () => (process.env.SEARKIN_OUTPUT === 'export' ? 'export' : 'server');
package/package.json CHANGED
@@ -1,6 +1,79 @@
1
1
  {
2
2
  "name": "@searkin/next",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Searkin pour les sites Next.js sans CMS : métadonnées SEO, redirections et rafraîchissement signé, pilotés depuis Searkin.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "author": "Searkin",
8
+ "homepage": "https://app.searkin.com",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/Zakaru-Studio/searkin.git",
12
+ "directory": "packages/next-plugin"
13
+ },
14
+ "keywords": [
15
+ "next",
16
+ "nextjs",
17
+ "seo",
18
+ "searkin",
19
+ "metadata",
20
+ "redirects"
21
+ ],
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js",
26
+ "default": "./dist/index.js"
27
+ },
28
+ "./route": {
29
+ "types": "./dist/route.d.ts",
30
+ "import": "./dist/route.js",
31
+ "default": "./dist/route.js"
32
+ },
33
+ "./pages": {
34
+ "types": "./dist/pages.d.ts",
35
+ "import": "./dist/pages.js",
36
+ "default": "./dist/pages.js"
37
+ },
38
+ "./middleware": {
39
+ "types": "./dist/middleware.d.ts",
40
+ "import": "./dist/middleware.js",
41
+ "default": "./dist/middleware.js"
42
+ },
43
+ "./package.json": "./package.json"
44
+ },
45
+ "main": "./dist/index.js",
46
+ "types": "./dist/index.d.ts",
47
+ "bin": {
48
+ "searkin-next": "dist/cli.js"
49
+ },
50
+ "files": [
51
+ "dist",
52
+ "README.md",
53
+ "LICENSE"
54
+ ],
55
+ "publishConfig": {
56
+ "access": "public"
57
+ },
58
+ "sideEffects": false,
59
+ "scripts": {
60
+ "build": "tsc -p tsconfig.json",
61
+ "typecheck": "tsc -p tsconfig.json --noEmit",
62
+ "prepack": "npm run build"
63
+ },
64
+ "engines": {
65
+ "node": ">=18.18.0"
66
+ },
67
+ "peerDependencies": {
68
+ "next": ">=13.4.0 <17",
69
+ "react": ">=18.2.0"
70
+ },
71
+ "devDependencies": {
72
+ "@types/node": "^22.10.0",
73
+ "@types/react": "^19.0.0",
74
+ "next": "15.5.29",
75
+ "react": "19.1.1",
76
+ "react-dom": "19.1.1",
77
+ "typescript": "~5.7.3"
78
+ }
79
+ }