searvia-next 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +16 -0
- package/README.md +74 -2
- package/dist/client.d.ts +8 -0
- package/dist/client.js +65 -0
- package/dist/feed.d.ts +26 -0
- package/dist/feed.js +55 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +59 -0
- package/dist/middleware.d.ts +14 -0
- package/dist/middleware.js +53 -0
- package/package.json +55 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Searvia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
|
6
|
+
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
|
7
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
|
8
|
+
persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
|
11
|
+
Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
|
14
|
+
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
15
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
16
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,75 @@
|
|
|
1
|
-
#
|
|
1
|
+
# searvia-next
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Apply the SEO fixes you approve in [Searvia](https://searvia.com) to a Next.js (App Router) site: titles, meta
|
|
4
|
+
descriptions, canonical tags, robots, hreflang alternates and JSON-LD are rendered on the server, so Google and AI
|
|
5
|
+
crawlers see them; image alt text and internal links are applied in the browser.
|
|
6
|
+
|
|
7
|
+
The fix list is signed by Searvia (HMAC-SHA256) and cached by Next.js for five minutes. If Searvia cannot be reached or
|
|
8
|
+
the signature does not match, your pages render as they are.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install searvia-next
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
In Searvia open **SEO › Site fixes**, choose **Next.js** and copy the site key. Then set:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
SEARVIA_SITE_KEY=xxxxxxxxxxxxxxxxxxxxxxxx.yyyyyyyy # the site key from Searvia
|
|
20
|
+
SEARVIA_SITE_URL=https://www.example.com # optional, shown in Searvia
|
|
21
|
+
SEARVIA_WEBHOOK_URL=https://www.example.com/api/searvia # optional, instant refresh (see below)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Use
|
|
25
|
+
|
|
26
|
+
**1. Middleware** (passes the current path to your layout). `middleware.ts`:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
export { middleware } from 'searvia-next/middleware';
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Already have a middleware? Wrap your response: `return withSearviaPath(request, response)`, and to apply approved redirects first add `const redirect = await searviaRedirect(request); if (redirect) return redirect;`.
|
|
33
|
+
|
|
34
|
+
**2. Root layout** (`app/layout.tsx`):
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
import { SearviaHead, searviaMetadata, getSearviaFixes, currentPath } from 'searvia-next';
|
|
38
|
+
import { SearviaClientFixes } from 'searvia-next/client';
|
|
39
|
+
|
|
40
|
+
export async function generateMetadata() {
|
|
41
|
+
return searviaMetadata({ title: 'My site', description: 'Default description' });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
|
45
|
+
const { pathname, search } = await currentPath();
|
|
46
|
+
const fixes = await getSearviaFixes(pathname, search);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<html lang="en">
|
|
50
|
+
<body>
|
|
51
|
+
{children}
|
|
52
|
+
<SearviaHead pathname={pathname} search={search} />
|
|
53
|
+
<SearviaClientFixes fixes={fixes} />
|
|
54
|
+
</body>
|
|
55
|
+
</html>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Pages that set their own `generateMetadata` can wrap it the same way: `return searviaMetadata(myMetadata)`.
|
|
61
|
+
|
|
62
|
+
The connection turns active in Searvia on the first page your site renders.
|
|
63
|
+
|
|
64
|
+
**3. Instant refresh (optional).** `app/api/searvia/route.ts`:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
export { searviaWebhook as POST } from 'searvia-next';
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Set `SEARVIA_WEBHOOK_URL` to that address on your own domain. Searvia calls it (signed) whenever you approve or roll
|
|
71
|
+
back a fix, and the list refreshes at once instead of within five minutes.
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Fix } from './feed';
|
|
2
|
+
/**
|
|
3
|
+
* Image alt text and internal link fixes, applied in the browser after the page renders (they change the page body,
|
|
4
|
+
* which Next.js renders from your components). Pass the fixes from `getSearviaFixes()`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function SearviaClientFixes({ fixes }: {
|
|
7
|
+
fixes: Fix[];
|
|
8
|
+
}): null;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useEffect } from 'react';
|
|
3
|
+
const SKIP = ['A', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BUTTON', 'SCRIPT', 'STYLE', 'NAV', 'HEADER', 'FOOTER', 'FIGCAPTION'];
|
|
4
|
+
function inline(href, anchor) {
|
|
5
|
+
const escaped = anchor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
|
|
6
|
+
const pattern = new RegExp('(^|[^\\p{L}\\p{N}])(' + escaped + ')(?![\\p{L}\\p{N}])', 'iu');
|
|
7
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
8
|
+
let node;
|
|
9
|
+
while ((node = walker.nextNode())) {
|
|
10
|
+
let inText = false;
|
|
11
|
+
let blocked = false;
|
|
12
|
+
for (let element = node.parentElement; element; element = element.parentElement) {
|
|
13
|
+
if (SKIP.includes(element.tagName)) {
|
|
14
|
+
blocked = true;
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
if (element.tagName === 'P' || element.tagName === 'LI')
|
|
18
|
+
inText = true;
|
|
19
|
+
}
|
|
20
|
+
const match = !blocked && inText ? pattern.exec(node.nodeValue ?? '') : null;
|
|
21
|
+
if (match) {
|
|
22
|
+
const word = node.splitText(match.index + match[1].length);
|
|
23
|
+
word.splitText(match[2].length);
|
|
24
|
+
const link = document.createElement('a');
|
|
25
|
+
link.href = href;
|
|
26
|
+
word.parentNode?.replaceChild(link, word);
|
|
27
|
+
link.appendChild(word);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Image alt text and internal link fixes, applied in the browser after the page renders (they change the page body,
|
|
34
|
+
* which Next.js renders from your components). Pass the fixes from `getSearviaFixes()`.
|
|
35
|
+
*/
|
|
36
|
+
export function SearviaClientFixes({ fixes }) {
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
for (const fix of fixes) {
|
|
39
|
+
if (fix.t === 'image_alt') {
|
|
40
|
+
for (const image of fix.v.images) {
|
|
41
|
+
document.querySelectorAll('img').forEach((img) => {
|
|
42
|
+
const src = img.getAttribute('src') || img.getAttribute('data-src');
|
|
43
|
+
if (src === image.src && !img.hasAttribute('alt'))
|
|
44
|
+
img.setAttribute('alt', image.alt);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else if (fix.t === 'internal_link' && fix.v.mode === 'inline') {
|
|
49
|
+
inline(fix.v.href, fix.v.anchor);
|
|
50
|
+
}
|
|
51
|
+
else if (fix.t === 'internal_link' && !document.querySelector(`a.searvia-related[href="${CSS.escape(fix.v.href)}"]`)) {
|
|
52
|
+
const target = document.querySelector('main') || document.querySelector('article') || document.body;
|
|
53
|
+
const paragraph = document.createElement('p');
|
|
54
|
+
const link = document.createElement('a');
|
|
55
|
+
paragraph.className = 'searvia-related';
|
|
56
|
+
link.className = 'searvia-related';
|
|
57
|
+
link.href = fix.v.href;
|
|
58
|
+
link.textContent = fix.v.anchor;
|
|
59
|
+
paragraph.appendChild(link);
|
|
60
|
+
target.appendChild(paragraph);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}, [fixes]);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
package/dist/feed.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare const VERSION = "0.2.0";
|
|
2
|
+
export type Fix = {
|
|
3
|
+
t: string;
|
|
4
|
+
v: Record<string, any>;
|
|
5
|
+
};
|
|
6
|
+
export type Feed = {
|
|
7
|
+
v: string;
|
|
8
|
+
generated: string;
|
|
9
|
+
fixes: Record<string, Fix[]>;
|
|
10
|
+
};
|
|
11
|
+
type Config = {
|
|
12
|
+
channel: string;
|
|
13
|
+
secret: string;
|
|
14
|
+
base: string;
|
|
15
|
+
site: string;
|
|
16
|
+
webhook: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function config(): Config | null;
|
|
19
|
+
export declare function sign(body: string, secret: string): Promise<string>;
|
|
20
|
+
/** Constant-time comparison of two signatures. */
|
|
21
|
+
export declare function same(a: string, b: string): boolean;
|
|
22
|
+
/** The fix list, cached by Next.js for five minutes (tag "searvia"). Returns null when the key is missing or the signature does not match. */
|
|
23
|
+
export declare function getSearviaFeed(): Promise<Feed | null>;
|
|
24
|
+
/** Fixes for one address: path with query first, then the path alone. */
|
|
25
|
+
export declare function getSearviaFixes(pathname: string, search?: string): Promise<Fix[]>;
|
|
26
|
+
export {};
|
package/dist/feed.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Searvia fix feed: signed (HMAC-SHA256) list of approved fixes keyed by path. Works in the Node.js and Edge runtimes.
|
|
2
|
+
export const VERSION = '0.2.0';
|
|
3
|
+
export function config() {
|
|
4
|
+
const [channel, secret] = (process.env.SEARVIA_SITE_KEY ?? '').split('.', 2);
|
|
5
|
+
if (!channel || !secret || !/^[a-z0-9]{24}$/.test(channel)) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
return {
|
|
9
|
+
channel,
|
|
10
|
+
secret,
|
|
11
|
+
base: (process.env.SEARVIA_URL ?? 'https://searvia.com').replace(/\/$/, ''),
|
|
12
|
+
site: process.env.SEARVIA_SITE_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? '',
|
|
13
|
+
webhook: process.env.SEARVIA_WEBHOOK_URL ?? '',
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export async function sign(body, secret) {
|
|
17
|
+
const encoder = new TextEncoder();
|
|
18
|
+
const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
19
|
+
const digest = new Uint8Array(await crypto.subtle.sign('HMAC', key, encoder.encode(body)));
|
|
20
|
+
return 'sha256=' + Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
21
|
+
}
|
|
22
|
+
/** Constant-time comparison of two signatures. */
|
|
23
|
+
export function same(a, b) {
|
|
24
|
+
if (a.length !== b.length)
|
|
25
|
+
return false;
|
|
26
|
+
let diff = 0;
|
|
27
|
+
for (let i = 0; i < a.length; i++)
|
|
28
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
29
|
+
return diff === 0;
|
|
30
|
+
}
|
|
31
|
+
/** The fix list, cached by Next.js for five minutes (tag "searvia"). Returns null when the key is missing or the signature does not match. */
|
|
32
|
+
export async function getSearviaFeed() {
|
|
33
|
+
const settings = config();
|
|
34
|
+
if (!settings)
|
|
35
|
+
return null;
|
|
36
|
+
try {
|
|
37
|
+
const response = await fetch(`${settings.base}/fixes/${settings.channel}/feed`, {
|
|
38
|
+
headers: { 'User-Agent': `SearviaNext/${VERSION}`, 'X-Searvia-Site': settings.site, 'X-Searvia-Version': VERSION, 'X-Searvia-Webhook': settings.webhook },
|
|
39
|
+
next: { revalidate: 300, tags: ['searvia'] },
|
|
40
|
+
});
|
|
41
|
+
if (!response.ok)
|
|
42
|
+
return null;
|
|
43
|
+
const body = await response.text();
|
|
44
|
+
return same(await sign(body, settings.secret), response.headers.get('x-searvia-signature') ?? '') ? JSON.parse(body) : null;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Fixes for one address: path with query first, then the path alone. */
|
|
51
|
+
export async function getSearviaFixes(pathname, search = '') {
|
|
52
|
+
const feed = await getSearviaFeed();
|
|
53
|
+
const query = search && !search.startsWith('?') ? `?${search}` : search;
|
|
54
|
+
return feed?.fixes[pathname + query] ?? feed?.fixes[pathname] ?? [];
|
|
55
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
export { getSearviaFeed, getSearviaFixes, type Fix, type Feed } from './feed';
|
|
3
|
+
/** Current address set by the Searvia middleware ("/" when the middleware is not installed). */
|
|
4
|
+
export declare function currentPath(): Promise<{
|
|
5
|
+
pathname: string;
|
|
6
|
+
search: string;
|
|
7
|
+
}>;
|
|
8
|
+
/**
|
|
9
|
+
* Merges approved fixes into your page metadata: title, description, canonical, robots and hreflang alternates.
|
|
10
|
+
* Use it in `generateMetadata` of the root layout (or a page): `return searviaMetadata({ title: 'My site' })`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function searviaMetadata(base?: Metadata, path?: {
|
|
13
|
+
pathname: string;
|
|
14
|
+
search?: string;
|
|
15
|
+
}): Promise<Metadata>;
|
|
16
|
+
/** Structured data fixes as JSON-LD script tags. Put it once in the root layout's <body>. */
|
|
17
|
+
export declare function SearviaHead({ pathname, search }?: {
|
|
18
|
+
pathname?: string;
|
|
19
|
+
search?: string;
|
|
20
|
+
}): Promise<import("react").JSX.Element>;
|
|
21
|
+
/**
|
|
22
|
+
* Route handler for Searvia's change notice: when you approve or roll back a fix, Searvia calls it and the fix list is
|
|
23
|
+
* refreshed at once. In app/api/searvia/route.ts: `export { searviaWebhook as POST } from 'searvia-next'`, and set
|
|
24
|
+
* SEARVIA_WEBHOOK_URL to that address on your own domain.
|
|
25
|
+
*/
|
|
26
|
+
export declare function searviaWebhook(request: Request): Promise<Response>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { revalidateTag } from 'next/cache';
|
|
3
|
+
import { headers } from 'next/headers';
|
|
4
|
+
import { config, getSearviaFixes, same, sign } from './feed';
|
|
5
|
+
import { PATH_HEADER } from './middleware';
|
|
6
|
+
export { getSearviaFeed, getSearviaFixes } from './feed';
|
|
7
|
+
/** Current address set by the Searvia middleware ("/" when the middleware is not installed). */
|
|
8
|
+
export async function currentPath() {
|
|
9
|
+
const value = (await headers()).get(PATH_HEADER) ?? '/';
|
|
10
|
+
const [pathname, search = ''] = value.split('?', 2);
|
|
11
|
+
return { pathname, search: search ? `?${search}` : '' };
|
|
12
|
+
}
|
|
13
|
+
function first(fixes, type) {
|
|
14
|
+
return fixes.find((fix) => fix.t === type)?.v;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Merges approved fixes into your page metadata: title, description, canonical, robots and hreflang alternates.
|
|
18
|
+
* Use it in `generateMetadata` of the root layout (or a page): `return searviaMetadata({ title: 'My site' })`.
|
|
19
|
+
*/
|
|
20
|
+
export async function searviaMetadata(base = {}, path) {
|
|
21
|
+
const { pathname, search } = path ?? (await currentPath());
|
|
22
|
+
const fixes = await getSearviaFixes(pathname, search ?? '');
|
|
23
|
+
const title = first(fixes, 'title');
|
|
24
|
+
const description = first(fixes, 'meta_description');
|
|
25
|
+
const canonical = first(fixes, 'canonical');
|
|
26
|
+
const robots = first(fixes, 'robots');
|
|
27
|
+
const hreflang = first(fixes, 'hreflang');
|
|
28
|
+
return {
|
|
29
|
+
...base,
|
|
30
|
+
...(title ? { title: { absolute: title.text } } : {}),
|
|
31
|
+
...(description ? { description: description.text } : {}),
|
|
32
|
+
...(robots ? { robots: robots.content } : {}),
|
|
33
|
+
alternates: {
|
|
34
|
+
...(base.alternates ?? {}),
|
|
35
|
+
...(canonical ? { canonical: canonical.href } : {}),
|
|
36
|
+
...(hreflang ? { languages: Object.fromEntries(hreflang.links.map((link) => [link.hreflang, link.href])) } : {}),
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Structured data fixes as JSON-LD script tags. Put it once in the root layout's <body>. */
|
|
41
|
+
export async function SearviaHead({ pathname, search } = {}) {
|
|
42
|
+
const path = pathname ? { pathname, search: search ?? '' } : await currentPath();
|
|
43
|
+
const fixes = (await getSearviaFixes(path.pathname, path.search)).filter((fix) => fix.t === 'json_ld');
|
|
44
|
+
return (_jsx(_Fragment, { children: fixes.map((fix, index) => (_jsx("script", { type: "application/ld+json", "data-searvia": "fix", dangerouslySetInnerHTML: { __html: JSON.stringify(fix.v.data).replace(/</g, '\\u003c') } }, index))) }));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Route handler for Searvia's change notice: when you approve or roll back a fix, Searvia calls it and the fix list is
|
|
48
|
+
* refreshed at once. In app/api/searvia/route.ts: `export { searviaWebhook as POST } from 'searvia-next'`, and set
|
|
49
|
+
* SEARVIA_WEBHOOK_URL to that address on your own domain.
|
|
50
|
+
*/
|
|
51
|
+
export async function searviaWebhook(request) {
|
|
52
|
+
const settings = config();
|
|
53
|
+
const body = await request.text();
|
|
54
|
+
if (!settings || !same(await sign(body, settings.secret), request.headers.get('x-searvia-signature') ?? '')) {
|
|
55
|
+
return new Response(null, { status: 401 });
|
|
56
|
+
}
|
|
57
|
+
revalidateTag('searvia');
|
|
58
|
+
return Response.json({ ok: true });
|
|
59
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
|
+
/** Request header that carries the current address to Server Components (App Router layouts do not know the path). */
|
|
3
|
+
export declare const PATH_HEADER = "x-searvia-path";
|
|
4
|
+
/**
|
|
5
|
+
* A redirect approved in Searvia (a broken or merged page) for this request, or null. Call it from your own middleware:
|
|
6
|
+
* `const redirect = await searviaRedirect(request); if (redirect) return redirect;`
|
|
7
|
+
*/
|
|
8
|
+
export declare function searviaRedirect(request: NextRequest): Promise<NextResponse | null>;
|
|
9
|
+
/**
|
|
10
|
+
* Middleware that passes the path to `searviaMetadata()` and `<SearviaHead />`. Use it as your middleware, or call it from
|
|
11
|
+
* your own: `return withSearviaPath(request, NextResponse.next(...))`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function withSearviaPath(request: NextRequest, response?: NextResponse): NextResponse;
|
|
14
|
+
export declare function middleware(request: NextRequest): Promise<NextResponse>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { config, same, sign, VERSION } from './feed.js';
|
|
3
|
+
/** Request header that carries the current address to Server Components (App Router layouts do not know the path). */
|
|
4
|
+
export const PATH_HEADER = 'x-searvia-path';
|
|
5
|
+
const TTL = 300_000;
|
|
6
|
+
let cached = null;
|
|
7
|
+
/** The fix list for redirects, kept in memory for five minutes (middleware fetches are not cached by Next.js). */
|
|
8
|
+
async function feed() {
|
|
9
|
+
const settings = config();
|
|
10
|
+
if (!settings)
|
|
11
|
+
return null;
|
|
12
|
+
if (cached && Date.now() - cached.at < TTL)
|
|
13
|
+
return cached.feed;
|
|
14
|
+
try {
|
|
15
|
+
const response = await fetch(`${settings.base}/fixes/${settings.channel}/feed`, { headers: { 'User-Agent': `SearviaNext/${VERSION} (middleware)` } });
|
|
16
|
+
const body = response.ok ? await response.text() : '';
|
|
17
|
+
const valid = body !== '' && same(await sign(body, settings.secret), response.headers.get('x-searvia-signature') ?? '');
|
|
18
|
+
cached = { feed: valid ? JSON.parse(body) : (cached?.feed ?? null), at: Date.now() };
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
cached = { feed: cached?.feed ?? null, at: Date.now() };
|
|
22
|
+
}
|
|
23
|
+
return cached.feed;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A redirect approved in Searvia (a broken or merged page) for this request, or null. Call it from your own middleware:
|
|
27
|
+
* `const redirect = await searviaRedirect(request); if (redirect) return redirect;`
|
|
28
|
+
*/
|
|
29
|
+
export async function searviaRedirect(request) {
|
|
30
|
+
if (request.method !== 'GET')
|
|
31
|
+
return null;
|
|
32
|
+
const { pathname, search } = request.nextUrl;
|
|
33
|
+
const list = (await feed())?.fixes;
|
|
34
|
+
const fix = (list?.[pathname + search] ?? list?.[pathname] ?? []).find((item) => item.t === 'redirect');
|
|
35
|
+
if (!fix || typeof fix.v.href !== 'string')
|
|
36
|
+
return null;
|
|
37
|
+
const target = new URL(fix.v.href, request.url);
|
|
38
|
+
return target.href === request.nextUrl.href ? null : NextResponse.redirect(target, [301, 302, 308].includes(fix.v.status) ? fix.v.status : 301);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Middleware that passes the path to `searviaMetadata()` and `<SearviaHead />`. Use it as your middleware, or call it from
|
|
42
|
+
* your own: `return withSearviaPath(request, NextResponse.next(...))`.
|
|
43
|
+
*/
|
|
44
|
+
export function withSearviaPath(request, response) {
|
|
45
|
+
const headers = new Headers(request.headers);
|
|
46
|
+
headers.set(PATH_HEADER, request.nextUrl.pathname + request.nextUrl.search);
|
|
47
|
+
const next = NextResponse.next({ request: { headers } });
|
|
48
|
+
response?.headers.forEach((value, key) => next.headers.set(key, value));
|
|
49
|
+
return next;
|
|
50
|
+
}
|
|
51
|
+
export async function middleware(request) {
|
|
52
|
+
return (await searviaRedirect(request)) ?? withSearviaPath(request);
|
|
53
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "searvia-next",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Apply the SEO fixes you approve in Searvia (titles, descriptions, canonical, robots, hreflang, structured data, alt text, internal links) to a Next.js App Router site.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./client": {
|
|
14
|
+
"types": "./dist/client.d.ts",
|
|
15
|
+
"import": "./dist/client.js"
|
|
16
|
+
},
|
|
17
|
+
"./middleware": {
|
|
18
|
+
"types": "./dist/middleware.d.ts",
|
|
19
|
+
"import": "./dist/middleware.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"keywords": [
|
|
29
|
+
"seo",
|
|
30
|
+
"nextjs",
|
|
31
|
+
"searvia",
|
|
32
|
+
"metadata",
|
|
33
|
+
"json-ld",
|
|
34
|
+
"hreflang"
|
|
35
|
+
],
|
|
36
|
+
"homepage": "https://searvia.com/help/site-fixes",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/searvia/searvia-next.git"
|
|
40
|
+
},
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsc -p tsconfig.json",
|
|
44
|
+
"prepublishOnly": "npm run build"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"next": ">=14.2",
|
|
48
|
+
"react": ">=18.2"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^22.20.5",
|
|
52
|
+
"@types/react": "^19.0.0",
|
|
53
|
+
"next": "^15.0.0",
|
|
54
|
+
"react": "^19.0.0",
|
|
55
|
+
"typescript": "^5.6.0"
|
|
56
|
+
}
|
|
57
|
+
}
|