폼과 리드 수집: 코드 또는 관리자에서 생성, 스팸 방지, GDPR 동의, 이메일 알림, 서명된 웹훅, CSV
설치
genpm add @core/forms포함 내용
- src/lib/forms/에 소스 코드, 파일 11개. (32.3kB)
- src/lib/forms/AGENTS.md에 AI 규칙, 그리고 IDE 규칙 파일.
- .env.example에 추가되는 환경 변수: FORMS_NOTIFY_TO, FORMS_WEBHOOK_SECRET.
- @core/antispam, @core/content, @core/contracts, @core/db, @core/email, @core/jobs을(를) 자동으로 해결합니다.
README
이 패키지에는 README가 없습니다.
이것이 AI가 src/lib/forms에서 작업할 때 읽는 내용 그대로입니다. 그 외에는 컨텍스트에 아무것도 추가되지 않습니다.
@core/forms — rules for AI agents
Purpose
Contact and lead forms end to end: forms defined in code (defineForm with zod) or built by editors in the admin
(forms content collection), anti-spam (@core/antispam), GDPR consent with a versioned text, stored submissions with
source page and campaign parameters, and background processing (@core/jobs): team alert email, auto-reply,
HMAC-signed webhook and onSubmission handlers. CSV export, retention and erasure helpers. Table: form_submissions.
Map
index.ts— public API:defineForm,submitForm,onSubmission,formsAdminResources,exportSubmissionsCsv,pruneSubmissions,deleteSubmissionsByEmail.definitions.ts— code and admin-built definitions.submit.ts— submission and theforms.processjob.admin.ts— admin, CSV, privacy.react.ts—<Form formKey searchParams>(async server component; works without JavaScript).adapters/hono.ts—formRoutes().adapters/next.ts—formRoute(POST; HTML posts get a 303 back to the page).
Integration
- Env:
EMAIL_FROM(@core/email), optionalFORMS_NOTIFY_TO(comma-separated default recipients) andFORMS_WEBHOOK_SECRET(≥ 32 chars, only with webhooks). Migrations as insrc/lib/db/AGENTS.md; the @core/jobs cron must run. - Define code forms in a module imported at startup (e.g.
src/genpm/forms.ts):defineForm({ key: 'contact', label: 'Contact', schema: z.object({ name: z.string().min(1), email: z.email(), message: z.string().min(5) }), emailField: 'email', consent: { required: true, version: '2026-10', text: '…' } }). - Mount
formRoutes()orformRouteat/api/forms/<key>and render<Form formKey="contact" searchParams={await searchParams} />: it includes the anti-spam fields, the consent checkbox (never pre-checked) and shows the result after the 303. Custom markup: post to the same endpoint withhoneypotProps,_tsandconsent; JSON callers get{ ok, reason, errors }. In Next.js Server Actions callsubmitForm(key, formData, { headers: await headers() })instead. - Translate the messages with
labels(keys:formLabels). - Add
...formsAdminResources()tosrc/genpm/admin.ts. - Verify: submit the form, run the cron, the team receives the email and the submission appears in the admin.
Conventions
- Field names are the keys stored in
data; keep them stable (exports and webhooks depend on them). forms.processrecords each finished step inform_submissions.processed(notify,auto_reply,webhook,handler:<n>): a retry skips them, so emails and webhooks aren't repeated. KeeponSubmissionhandlers in a stable order.- Webhook receivers must verify
x-genpm-signature(t=<unix>,v1=<hmac>over"<t>.<body>") and reject old timestamps. - Ask only for the data you need; define a retention period and schedule
pruneSubmissions(days). - Permissions:
submissions:read|update|delete|export,forms:*for admin-built forms.
Don't
- Don't send emails or call webhooks inside the request;
submitFormqueues them. - Don't put submission values into HTML without escaping, or open CSV exports from other tools without the formula guard.
- Don't add hidden fields that collect data the user didn't type.
# @core/forms — rules for AI agents
## Purpose
Contact and lead forms end to end: forms defined in code (`defineForm` with zod) or built by editors in the admin
(`forms` content collection), anti-spam (@core/antispam), GDPR consent with a versioned text, stored submissions with
source page and campaign parameters, and background processing (@core/jobs): team alert email, auto-reply,
HMAC-signed webhook and `onSubmission` handlers. CSV export, retention and erasure helpers. Table: `form_submissions`.
## Map
- `index.ts` — public API: `defineForm`, `submitForm`, `onSubmission`, `formsAdminResources`, `exportSubmissionsCsv`, `pruneSubmissions`, `deleteSubmissionsByEmail`.
- `definitions.ts` — code and admin-built definitions. `submit.ts` — submission and the `forms.process` job. `admin.ts` — admin, CSV, privacy.
- `react.ts` — `<Form formKey searchParams>` (async server component; works without JavaScript).
- `adapters/hono.ts` — `formRoutes()`. `adapters/next.ts` — `formRoute` (POST; HTML posts get a 303 back to the page).
## Integration
1. Env: `EMAIL_FROM` (@core/email), optional `FORMS_NOTIFY_TO` (comma-separated default recipients) and `FORMS_WEBHOOK_SECRET` (≥ 32 chars, only with webhooks). Migrations as in `src/lib/db/AGENTS.md`; the @core/jobs cron must run.
2. Define code forms in a module imported at startup (e.g. `src/genpm/forms.ts`):
`defineForm({ key: 'contact', label: 'Contact', schema: z.object({ name: z.string().min(1), email: z.email(), message: z.string().min(5) }), emailField: 'email', consent: { required: true, version: '2026-10', text: '…' } })`.
3. Mount `formRoutes()` or `formRoute` at `/api/forms/<key>` and render `<Form formKey="contact" searchParams={await searchParams} />`:
it includes the anti-spam fields, the consent checkbox (never pre-checked) and shows the result after the 303.
Custom markup: post to the same endpoint with `honeypotProps`, `_ts` and `consent`; JSON callers get `{ ok, reason, errors }`.
In Next.js Server Actions call `submitForm(key, formData, { headers: await headers() })` instead.
4. Translate the messages with `labels` (keys: `formLabels`).
5. Add `...formsAdminResources()` to `src/genpm/admin.ts`.
6. Verify: submit the form, run the cron, the team receives the email and the submission appears in the admin.
## Conventions
- Field names are the keys stored in `data`; keep them stable (exports and webhooks depend on them).
- `forms.process` records each finished step in `form_submissions.processed` (`notify`, `auto_reply`, `webhook`,
`handler:<n>`): a retry skips them, so emails and webhooks aren't repeated. Keep `onSubmission` handlers in a stable order.
- Webhook receivers must verify `x-genpm-signature` (`t=<unix>,v1=<hmac>` over `"<t>.<body>"`) and reject old timestamps.
- Ask only for the data you need; define a retention period and schedule `pruneSubmissions(days)`.
- Permissions: `submissions:read|update|delete|export`, `forms:*` for admin-built forms.
## Don't
- Don't send emails or call webhooks inside the request; `submitForm` queues them.
- Don't put submission values into HTML without escaping, or open CSV exports from other tools without the formula guard.
- Don't add hidden fields that collect data the user didn't type.
.genpmignore 적용 후 주입될 정확한 트리입니다. 고정 대상:
// `<Form formKey>`: formulario HTML que funciona sin JavaScript (POST a /api/forms/<key> y vuelta con 303), con
// honeypot, token de tiempo y casilla de consentimiento. Componente de servidor (async): carga la definición.
import { createElement as h, type ReactNode } from 'react';
import type { z } from 'zod';
import { AntispamFields } from '../antispam/react.ts';
import { type BuilderField, type FormDefinition, getForm } from './definitions.ts';
import { CONSENT_FIELD } from './submit.ts';
export const formLabels = {
submit: 'Send',
sent: 'Thank you! We have received your message.',
invalid: 'Please check the highlighted fields.',
spam: 'Your message could not be sent. Please try again.',
rate_limited: 'Too many attempts. Please try again later.',
consent_required: 'Please accept the consent checkbox.',
not_found: 'This form is not available.',
required: 'required',
choose: 'Choose…',
};
export type FormLabels = typeof formLabels;
/** Campos de un formulario de código deducidos de su esquema zod (texto, email, número, select, casilla). */
export function fieldsFromSchema(schema: z.ZodObject): BuilderField[] {
const out: BuilderField[] = [];
for (const [name, t] of Object.entries(schema.shape as Record<string, z.ZodType>)) {
let def = (t as unknown as { def: Record<string, unknown> }).def;
const required = !(t as z.ZodType).safeParse(undefined).success;
while (def && ['optional', 'nullable', 'default', 'pipe'].includes(def.type as string))
def = ((def.innerType ?? def.in) as { def: Record<string, unknown> } | undefined)?.def ?? (undefined as never);
const checks = (def?.checks as Array<{ _zod?: { def?: { check?: string; maximum?: number } } }> | undefined) ?? [];
const max = checks.map((c) => c._zod?.def).find((d) => d?.check === 'max_length')?.maximum;
const label = name.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase());
const base = { name, label, required, ...(max && { maxLength: max }) };
if (def?.type === 'boolean') out.push({ ...base, type: 'checkbox' });
else if (def?.type === 'number') out.push({ ...base, type: 'number' });
else if (def?.type === 'enum') out.push({ ...base, type: 'select', options: Object.values(def.entries as Record<string, string>) });
else if (def?.type === 'string' && def.format === 'email') out.push({ ...base, type: 'email' });
else out.push({ ...base, type: max && max > 300 ? 'textarea' : 'text' });
}
return out;
}
const fieldsOf = (form: FormDefinition) => form.fields ?? fieldsFromSchema(form.schema);
function control(f: BuilderField, id: string, invalid: boolean, describedBy: string | undefined, choose: string): ReactNode {
const common = { id, name: f.name, required: f.required || undefined, 'aria-invalid': invalid || undefined, 'aria-describedby': describedBy };
if (f.type === 'textarea') return h('textarea', { ...common, className: 'ui-textarea', rows: 5, maxLength: f.maxLength ?? 5000 });
if (f.type === 'select')
return h('select', { ...common, className: 'ui-select', defaultValue: '' }, h('option', { value: '', disabled: f.required }, choose), (f.options ?? []).map((o) => h('option', { key: o, value: o }, o)));
if (f.type === 'checkbox') return h('input', { ...common, type: 'checkbox' });
const type = f.type === 'email' ? 'email' : f.type === 'tel' ? 'tel' : f.type === 'number' ? 'number' : 'text';
const auto = f.type === 'email' ? 'email' : f.type === 'tel' ? 'tel' : /name/i.test(f.name) ? 'name' : undefined;
return h('input', { ...common, type, className: 'ui-input', maxLength: f.type === 'number' ? undefined : (f.maxLength ?? 300), autoComplete: auto });
}
export type FormProps = {
formKey: string;
/** `searchParams` de la página: muestra el resultado del envío sin JavaScript (`form`, `form_status`, `form_errors`). */
searchParams?: Record<string, string | string[] | undefined>;
/** Ruta del endpoint (por defecto `/api/forms/<key>`). */
action?: string;
labels?: Partial<FormLabels>;
};
/** `<Form formKey="contact" searchParams={await searchParams} />` */
export async function Form(p: FormProps): Promise<ReactNode> {
const L = { ...formLabels, ...p.labels };
const form = await getForm(p.formKey);
if (!form) return null;
const sp = p.searchParams ?? {};
const mine = sp.form === p.formKey;
const status = mine && typeof sp.form_status === 'string' ? sp.form_status : null;
const bad = new Set(mine && typeof sp.form_errors === 'string' ? sp.form_errors.split(',') : []);
const id = `form-${p.formKey}`;
const message = status && status in L ? L[status as keyof FormLabels] : null;
return h(
'form',
{ id, className: 'gp-form', method: 'post', action: p.action ?? `/api/forms/${encodeURIComponent(p.formKey)}`, 'aria-label': form.label },
message && h('p', { role: status === 'sent' ? 'status' : 'alert', className: `ui-alert ${status === 'sent' ? 'ui-alert--success' : 'ui-alert--error'}` }, message as string),
fieldsOf(form).map((f) => {
const fid = `${id}-${f.name}`;
const invalid = bad.has(f.name);
const errId = invalid ? `${fid}-error` : undefined;
const label = h('label', { htmlFor: fid, className: 'ui-field__label' }, f.label, f.required && h('span', { 'aria-hidden': true }, ' *'));
return f.type === 'checkbox'
? h('div', { key: f.name, className: 'ui-field ui-checkbox' }, control(f, fid, invalid, errId, L.choose), label, invalid && h('span', { id: errId, className: 'ui-field__error' }, L.invalid))
: h('div', { key: f.name, className: 'ui-field' }, label, control(f, fid, invalid, errId, L.choose), invalid && h('span', { id: errId, className: 'ui-field__error' }, L.invalid));
}),
form.consent &&
h(
'div',
{ className: 'ui-field ui-checkbox' },
h('input', { id: `${id}-consent`, type: 'checkbox', name: CONSENT_FIELD, required: form.consent.required || undefined }),
h('label', { htmlFor: `${id}-consent` }, form.consent.text),
),
// Trampa para bots: fuera de pantalla, no `type=hidden`.
h(AntispamFields),
h('button', { type: 'submit', className: 'ui-button ui-button--primary' }, L.submit),
);
}
이 패키지는 MCP 서버를 선언하지 않습니다.
| 버전 | 커밋 | 게시일 | 검사 |
|---|---|---|---|
| 1.1.0 | 76db1c0 | 3시간 전 | 검사 통과 |
- genpm
- @core/antispam ^1.0.0@core/content ^1.0.0@core/contracts ^1.0.0@core/db ^1.0.0@core/email ^1.0.1@core/jobs ^1.0.0
- npm
- zod ^4.0.0
- 제안됨
- GenPM은 npm 명령을 제안하고, 동의한 경우에만 실행합니다.
- 사용하는 패키지 (1)
- @core/kit-cms ^1.0.0
- 검사
- 검사 통과 · 문제 0건
- 커밋
- v1.1.0 → 76db1c0f7e6244a5ee1cab65a5d4e23826f56190 · 가져온 뒤 검증됨
- 스크립트
- 없음. GenPM은 패키지 코드를 절대 실행하지 않습니다.
- 라이선스
- MIT
- 품질
- 100/100
- 인정된 라이선스충족
- AGENTS.md에 목적 설명충족
- AGENTS.md에 통합 단계충족
- AGENTS.md에 규칙 또는 금지 사항충족
- 테스트 포함충족
- 보안 검사 통과충족
- 최근 6개월 내 게시충족
- 인증된 게시자충족
- 요약과 키워드충족
- 신고
- 문제가 있나요?