Configurações do site, menus aninhados editáveis e redirecionamentos sem loops nem open redirect
Instalar
genpm add @core/siteO que você recebe
- Código em src/lib/site/, 10 arquivos. (19,3 kB)
- Regras de IA em src/lib/site/AGENTS.md, mais arquivos de regras da IDE.
- Variáveis de ambiente adicionadas ao .env.example: SITE_URL, SITE_REDIRECT_HOSTS.
- Resolve @core/content, @core/contracts, @core/db para você.
README
Este pacote não tem README.
Isto é exatamente o que sua IA lê quando trabalha em src/lib/site. Nada mais é adicionado ao contexto dela.
@core/site — rules for AI agents
Purpose
What every site has: settings (name, logo, contact, social profiles, legal page paths) as a @core/content singleton,
editable menus up to 3 levels as a content collection (slug = location: header, footer), and redirects managed
from the admin with loop detection, safe targets (site paths or https to allowed hosts) and hit counters.
No page content, no SEO tags (@core/seo reads these settings).
Map
index.ts— public API:getSiteSettings,getMenu,upsertRedirect(byfrom),updateRedirect(by id),resolveRedirect,redirectResponse,siteAdminResources.content.ts—siteSettings,menusand their schemas.redirects.ts— redirect logic.links.ts— link validation.adapters/hono.ts—redirectMiddleware.adapters/next.ts—redirectFor(req).
Integration
- Env:
SITE_URL(https://example.com), optionalSITE_REDIRECT_HOSTS(comma-separated external hosts allowed as redirect targets). Migrations as insrc/lib/db/AGENTS.md. - Import this module once at startup (it registers the
site_settingsandmenuscontent definitions). - Seed initial values with
seedEntry(@core/content): settings and theheader/footermenus, using the site's current ones. - Render:
const settings = await getSiteSettings({ locale }),const items = await getMenu('header', { locale }). - Redirects: Hono
app.use(redirectMiddleware); Next.jsmiddleware.tswithruntime: 'nodejs'callingredirectFor(req). - Add
...siteAdminResources()tosrc/genpm/admin.ts. - Verify: create a redirect
/old → /newand request/old(301 to/new).
Conventions
- Menu links are site paths,
https://,mailto:ortel:; open external links withrel="noopener". - Redirect sources are exact paths without query; the incoming query string is preserved.
- Permissions:
site_settings:*,menus:*,redirects:read|create|update|delete.
Don't
- Don't hardcode the site name, logo or menus in components once this module is installed.
- Don't build redirects from user input; only from the admin through
upsertRedirect/updateRedirect. Editing changes that row by id and fails withinvalid_path(422) if the newfrombelongs to another redirect. - Don't put legal text here; only the paths of the legal pages.
# @core/site — rules for AI agents
## Purpose
What every site has: settings (name, logo, contact, social profiles, legal page paths) as a @core/content singleton,
editable menus up to 3 levels as a content collection (slug = location: `header`, `footer`), and redirects managed
from the admin with loop detection, safe targets (site paths or https to allowed hosts) and hit counters.
No page content, no SEO tags (@core/seo reads these settings).
## Map
- `index.ts` — public API: `getSiteSettings`, `getMenu`, `upsertRedirect` (by `from`), `updateRedirect` (by id), `resolveRedirect`, `redirectResponse`, `siteAdminResources`.
- `content.ts` — `siteSettings`, `menus` and their schemas. `redirects.ts` — redirect logic. `links.ts` — link validation.
- `adapters/hono.ts` — `redirectMiddleware`. `adapters/next.ts` — `redirectFor(req)`.
## Integration
1. Env: `SITE_URL` (`https://example.com`), optional `SITE_REDIRECT_HOSTS` (comma-separated external hosts allowed as redirect targets). Migrations as in `src/lib/db/AGENTS.md`.
2. Import this module once at startup (it registers the `site_settings` and `menus` content definitions).
3. Seed initial values with `seedEntry` (@core/content): settings and the `header`/`footer` menus, using the site's current ones.
4. Render: `const settings = await getSiteSettings({ locale })`, `const items = await getMenu('header', { locale })`.
5. Redirects: Hono `app.use(redirectMiddleware)`; Next.js `middleware.ts` with `runtime: 'nodejs'` calling `redirectFor(req)`.
6. Add `...siteAdminResources()` to `src/genpm/admin.ts`.
7. Verify: create a redirect `/old → /new` and request `/old` (301 to `/new`).
## Conventions
- Menu links are site paths, `https://`, `mailto:` or `tel:`; open external links with `rel="noopener"`.
- Redirect sources are exact paths without query; the incoming query string is preserved.
- Permissions: `site_settings:*`, `menus:*`, `redirects:read|create|update|delete`.
## Don't
- Don't hardcode the site name, logo or menus in components once this module is installed.
- Don't build redirects from user input; only from the admin through `upsertRedirect` / `updateRedirect`. Editing changes that row by id and fails with `invalid_path` (422) if the new `from` belongs to another redirect.
- Don't put legal text here; only the paths of the legal pages.
A árvore exata que será injetada, após o .genpmignore. Fixada em
// Redirecciones gestionables desde el panel, con detección de bucles y caché en memoria para el middleware.
import { and, eq, ne, sql } from 'drizzle-orm';
import { type Executor, getDb } from '../db/index.ts';
import { allowedHosts, assertRedirectTarget, normalizePath, SiteError } from './links.ts';
import { type Redirect, redirects } from './schema.ts';
export const REDIRECT_STATUSES = [301, 302, 307, 308] as const;
export type RedirectStatus = (typeof REDIRECT_STATUSES)[number];
let cache: { at: number; map: Map<string, { to: string; status: number }> } | null = null;
const TTL_MS = 60_000;
export const invalidateRedirectCache = () => {
cache = null;
};
/** `exceptId`: la redirección que se está editando (su `from` anterior deja de contar). */
async function assertNoLoop(from: string, to: string, db: Executor, exceptId?: string): Promise<void> {
if (!to.startsWith('/')) return;
const rows = await db.select().from(redirects);
const all = new Map(rows.filter((r) => r.id !== exceptId).map((r) => [r.fromPath, r.to]));
all.set(from, to);
let current: string | undefined = from;
for (let i = 0; i < 20 && current; i++) {
const next = all.get(current);
if (!next?.startsWith('/')) return;
current = normalizePath(next.split('?')[0]!);
if (current === from) throw new SiteError('redirect_loop', `redirect loop: ${from} → … → ${from}`);
}
}
export async function upsertRedirect(
input: { from: string; to: string; status?: RedirectStatus },
db: Executor = getDb(),
): Promise<Redirect> {
const fromPath = normalizePath(input.from);
const to = assertRedirectTarget(input.to, allowedHosts());
const status = input.status ?? 301;
if (!REDIRECT_STATUSES.includes(status)) throw new SiteError('invalid_link', `invalid status ${status}`);
await assertNoLoop(fromPath, to, db);
const [row] = await db
.insert(redirects)
.values({ fromPath, to, status })
.onConflictDoUpdate({ target: redirects.fromPath, set: { to, status } })
.returning();
invalidateRedirectCache();
return row!;
}
/**
* Edita la redirección `id` (puede cambiar también su `from`). Si el nuevo `from` ya es de otra redirección, falla con
* `invalid_path` en vez de pisarla.
*/
export async function updateRedirect(
id: string,
input: { from: string; to: string; status?: RedirectStatus },
db: Executor = getDb(),
): Promise<Redirect> {
const fromPath = normalizePath(input.from);
const to = assertRedirectTarget(input.to, allowedHosts());
const status = input.status ?? 301;
if (!REDIRECT_STATUSES.includes(status)) throw new SiteError('invalid_link', `invalid status ${status}`);
const [taken] = await db.select({ id: redirects.id }).from(redirects).where(and(eq(redirects.fromPath, fromPath), ne(redirects.id, id)));
if (taken) throw new SiteError('invalid_path', `a redirect from ${fromPath} already exists`);
await assertNoLoop(fromPath, to, db, id);
let row: Redirect | undefined;
try {
[row] = await db.update(redirects).set({ fromPath, to, status }).where(eq(redirects.id, id)).returning();
} catch {
// Carrera con otra edición que acaba de ocupar ese `from` (índice único).
throw new SiteError('invalid_path', `a redirect from ${fromPath} already exists`);
}
if (!row) throw new SiteError('not_found', `redirect ${id} not found`);
invalidateRedirectCache();
return row;
}
export async function deleteRedirect(id: string, db: Executor = getDb()): Promise<void> {
await db.delete(redirects).where(eq(redirects.id, id));
invalidateRedirectCache();
}
/** Destino para una ruta, o null. Usa una caché de 60 s (se invalida al editar en este proceso). */
export async function resolveRedirect(pathname: string, db: Executor = getDb()): Promise<{ to: string; status: number } | null> {
let path: string;
try {
path = normalizePath(pathname);
} catch {
return null;
}
if (!cache || Date.now() - cache.at > TTL_MS) {
const rows = await db.select({ fromPath: redirects.fromPath, to: redirects.to, status: redirects.status }).from(redirects);
cache = { at: Date.now(), map: new Map(rows.map((r) => [r.fromPath, { to: r.to, status: r.status }])) };
}
const hit = cache.map.get(path);
if (!hit) return null;
// Contador sin bloquear la respuesta.
void db
.update(redirects)
.set({ hits: sql`${redirects.hits} + 1`, lastHitAt: new Date() })
.where(eq(redirects.fromPath, path))
.catch(() => {});
return hit;
}
/** Respuesta de redirección para una petición (conserva la query de origen si el destino no trae la suya). */
export async function redirectResponse(req: Request, db: Executor = getDb()): Promise<Response | null> {
const url = new URL(req.url);
const hit = await resolveRedirect(url.pathname, db);
if (!hit) return null;
const target = hit.to.startsWith('/') ? new URL(hit.to, url.origin) : new URL(hit.to);
if (!target.search && url.search) target.search = url.search;
return new Response(null, { status: hit.status, headers: { location: target.toString() } });
}
Este pacote não declara servidores MCP.
| Versão | Commit | Publicado | Análise |
|---|---|---|---|
| 1.1.0 | b7c13fa | há 3 horas | análise aprovada |
- npm
- zod ^4.0.0
- proposto
- O GenPM propõe o comando npm e só o executa se você disser sim.
- Usado por (2)
- @core/kit-cms ^1.0.0@core/seo ^1.0.0
- análise
- análise aprovada · 0 achados
- commit
- v1.1.0 → b7c13fa9b316c9880bceb62d510ee9661a76ae08 · verificado após o download
- scripts
- Nenhum. O GenPM nunca executa código de pacotes.
- licença
- MIT
- Qualidade
- 100/100
- Licença reconhecidacumprido
- AGENTS.md explica o propósitocumprido
- AGENTS.md tem passos de integraçãocumprido
- AGENTS.md lista convenções ou proibiçõescumprido
- Inclui testescumprido
- Escaneamento de segurança aprovadocumprido
- Publicado nos últimos 6 mesescumprido
- Publicador verificadocumprido
- Resumo e palavras-chavecumprido
- denúncia
- Viu algo errado?