리치 텍스트: Tiptap 에디터, 허용 목록 JSON 문서, XSS 안전한 HTML/React 렌더링, 일반 텍스트, 목차
설치
genpm add @core/rich-text포함 내용
- src/lib/rich-text/에 소스 코드, 파일 8개. (23.6kB)
- src/lib/rich-text/AGENTS.md에 AI 규칙, 그리고 IDE 규칙 파일.
README
이 패키지에는 README가 없습니다.
이것이 AI가 src/lib/rich-text에서 작업할 때 읽는 내용 그대로입니다. 그 외에는 컨텍스트에 아무것도 추가되지 않습니다.
@core/rich-text — rules for AI agents
Purpose
Rich text stored as JSON (Tiptap/ProseMirror format), never as HTML. RichTextSchema allowlists nodes (paragraphs,
headings, lists, quotes, code, images from @core/media, YouTube/Vimeo embeds), marks and links (site paths, #,
http(s), mailto, tel). Renders to HTML or React with full escaping, plain text, excerpts, reading time and a table of
contents with stable heading ids. Includes a Tiptap editor component. No tables, no collaboration.
Map
index.ts— server API:RichTextSchema,parseDoc,toHtml,RichText(React),toPlainText,excerpt,readingTime,headings.editor.ts—RichTextEditor(client component,'use client'); import it from.../rich-text/editor.jsonly in client code.doc.ts— schema and link rules.html.ts,react.ts— renderers.text.ts— text helpers.
Integration
- Install the npm deps if GenPM did not (
@tiptap/*,react). - In content schemas use
body: RichTextSchema(with @core/content:defineCollection('posts', { schema: z.object({ title: z.string(), body: RichTextSchema }) })). - Render:
<RichText doc={post.body} resolveImage={(a) => a.mediaId ? imgAttrs(mediaById[a.mediaId]) : null} siteHost="example.com" />(load the media rows first; the resolver is synchronous). Non-React:toHtml(doc, options). - Edit:
<RichTextEditor value={doc} onChange={setDoc} onPickImage={openMediaPicker} labels={…} />in a client component. - Search/SEO:
toPlainText(doc)for @core/search bodies andexcerpt(doc)for descriptions. - Verify: a link
javascript:alert(1)is rejected byparseDoc; rendered HTML has no<script>.
Conventions
- Validate every document coming from a client with
parseDoc(or the collection schema) before saving. - Embeds render as plain links by default; use
embeds: 'iframe'only where the site has consent for third-party video. - Pass
labels/embedLabelwith translated texts; defaults are English.
Don't
- Don't store or render HTML from users; don't use
dangerouslySetInnerHTMLwith document content. - Don't add nodes or marks without extending
NodeSchemaand both renderers. - Don't import
editor.tsin server code (it needs the browser).
# @core/rich-text — rules for AI agents
## Purpose
Rich text stored as JSON (Tiptap/ProseMirror format), never as HTML. `RichTextSchema` allowlists nodes (paragraphs,
headings, lists, quotes, code, images from @core/media, YouTube/Vimeo embeds), marks and links (site paths, `#`,
http(s), mailto, tel). Renders to HTML or React with full escaping, plain text, excerpts, reading time and a table of
contents with stable heading ids. Includes a Tiptap editor component. No tables, no collaboration.
## Map
- `index.ts` — server API: `RichTextSchema`, `parseDoc`, `toHtml`, `RichText` (React), `toPlainText`, `excerpt`, `readingTime`, `headings`.
- `editor.ts` — `RichTextEditor` (client component, `'use client'`); import it from `.../rich-text/editor.js` only in client code.
- `doc.ts` — schema and link rules. `html.ts`, `react.ts` — renderers. `text.ts` — text helpers.
## Integration
1. Install the npm deps if GenPM did not (`@tiptap/*`, `react`).
2. In content schemas use `body: RichTextSchema` (with @core/content: `defineCollection('posts', { schema: z.object({ title: z.string(), body: RichTextSchema }) })`).
3. Render: `<RichText doc={post.body} resolveImage={(a) => a.mediaId ? imgAttrs(mediaById[a.mediaId]) : null} siteHost="example.com" />`
(load the media rows first; the resolver is synchronous). Non-React: `toHtml(doc, options)`.
4. Edit: `<RichTextEditor value={doc} onChange={setDoc} onPickImage={openMediaPicker} labels={…} />` in a client component.
5. Search/SEO: `toPlainText(doc)` for @core/search bodies and `excerpt(doc)` for descriptions.
6. Verify: a link `javascript:alert(1)` is rejected by `parseDoc`; rendered HTML has no `<script>`.
## Conventions
- Validate every document coming from a client with `parseDoc` (or the collection schema) before saving.
- Embeds render as plain links by default; use `embeds: 'iframe'` only where the site has consent for third-party video.
- Pass `labels`/`embedLabel` with translated texts; defaults are English.
## Don't
- Don't store or render HTML from users; don't use `dangerouslySetInnerHTML` with document content.
- Don't add nodes or marks without extending `NodeSchema` and both renderers.
- Don't import `editor.ts` in server code (it needs the browser).
.genpmignore 적용 후 주입될 정확한 트리입니다. 고정 대상:
// Documento de texto enriquecido (JSON de Tiptap/ProseMirror) validado con lista blanca de nodos, marcas y enlaces.
import { z } from 'zod';
export type Mark =
| { type: 'bold' | 'italic' | 'strike' | 'code' | 'underline' }
| { type: 'link'; attrs: { href: string; target?: '_blank' | null } };
export type Node =
| { type: 'text'; text: string; marks?: Mark[] }
| { type: 'paragraph'; content?: Node[] }
| { type: 'heading'; attrs: { level: 1 | 2 | 3 | 4 | 5 | 6 }; content?: Node[] }
| { type: 'bulletList'; content: Node[] }
| { type: 'orderedList'; attrs?: { start?: number }; content: Node[] }
| { type: 'listItem'; content: Node[] }
| { type: 'blockquote'; content: Node[] }
| { type: 'codeBlock'; attrs?: { language?: string | null }; content?: Node[] }
| { type: 'horizontalRule' }
| { type: 'hardBreak' }
| { type: 'image'; attrs: { mediaId?: string | null; src?: string | null; alt?: string | null; title?: string | null } }
| { type: 'embed'; attrs: { provider: 'youtube' | 'vimeo'; id: string } };
export type Doc = { type: 'doc'; content: Node[] };
/** Enlaces permitidos: rutas del sitio, anclas, http(s), mailto y tel. Nunca `javascript:` ni `data:`. */
export function isSafeHref(href: string): boolean {
if (/^\/(?![/\\])/.test(href) || href.startsWith('#')) return !/\s/.test(href);
if (/^mailto:[^\s]+$/i.test(href) || /^tel:\+?[0-9 ()-]{3,30}$/i.test(href)) return true;
try {
const u = new URL(href);
return u.protocol === 'https:' || u.protocol === 'http:';
} catch {
return false;
}
}
const Href = z.string().max(2000).refine(isSafeHref, 'unsafe link');
const MarkSchema: z.ZodType<Mark> = z.union([
z.strictObject({ type: z.enum(['bold', 'italic', 'strike', 'code', 'underline']) }),
z.strictObject({ type: z.literal('link'), attrs: z.object({ href: Href, target: z.enum(['_blank']).nullish() }) }),
]);
const inline = () => z.array(z.lazy(() => NodeSchema)).max(2000).optional();
export const NodeSchema: z.ZodType<Node> = z.lazy(() =>
z.discriminatedUnion('type', [
z.strictObject({ type: z.literal('text'), text: z.string().max(20_000), marks: z.array(MarkSchema).max(6).optional() }),
z.strictObject({ type: z.literal('paragraph'), content: inline(), attrs: z.object({}).passthrough().optional() }),
z.strictObject({ type: z.literal('heading'), attrs: z.object({ level: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5), z.literal(6)]) }), content: inline() }),
z.strictObject({ type: z.literal('bulletList'), content: z.array(NodeSchema).max(500) }),
z.strictObject({ type: z.literal('orderedList'), attrs: z.object({ start: z.number().int().min(0).max(100_000).optional() }).passthrough().optional(), content: z.array(NodeSchema).max(500) }),
z.strictObject({ type: z.literal('listItem'), content: z.array(NodeSchema).max(200) }),
z.strictObject({ type: z.literal('blockquote'), content: z.array(NodeSchema).max(500) }),
z.strictObject({ type: z.literal('codeBlock'), attrs: z.object({ language: z.string().regex(/^[a-z0-9+#-]{0,20}$/).nullish() }).optional(), content: inline() }),
z.strictObject({ type: z.literal('horizontalRule') }),
z.strictObject({ type: z.literal('hardBreak') }),
z.strictObject({
type: z.literal('image'),
attrs: z.object({
mediaId: z.string().max(64).nullish(),
src: Href.nullish(),
alt: z.string().max(500).nullish(),
title: z.string().max(300).nullish(),
}),
}),
z.strictObject({ type: z.literal('embed'), attrs: z.object({ provider: z.enum(['youtube', 'vimeo']), id: z.string().regex(/^[A-Za-z0-9_-]{6,20}$/) }) }),
]),
) as z.ZodType<Node>;
/** Esquema para campos de contenido: `body: RichTextSchema`. */
export const RichTextSchema: z.ZodType<Doc> = z.strictObject({ type: z.literal('doc'), content: z.array(NodeSchema).max(5000) });
export const emptyDoc = (): Doc => ({ type: 'doc', content: [] });
/** Valida un documento de origen no fiable (formularios, API). Lanza ZodError si no cumple la lista blanca. */
export const parseDoc = (input: unknown): Doc => RichTextSchema.parse(input);
/** Documento a partir de texto plano (párrafos separados por líneas en blanco). */
export function docFromText(text: string): Doc {
return {
type: 'doc',
content: text
.split(/\n{2,}/)
.map((p) => p.trim())
.filter(Boolean)
.map((p) => ({ type: 'paragraph', content: [{ type: 'text', text: p }] })),
};
}
이 패키지는 MCP 서버를 선언하지 않습니다.
| 버전 | 커밋 | 게시일 | 검사 |
|---|---|---|---|
| 1.0.1 | c21aca7 | 6시간 전 | 검사 통과 |
- genpm
- 없음
- npm
- @tiptap/extension-image ^3.0.0@tiptap/extension-link ^3.0.0@tiptap/pm ^3.0.0@tiptap/react ^3.0.0@tiptap/starter-kit ^3.0.0react ^19.0.0zod ^4.0.0
- 제안됨
- GenPM은 npm 명령을 제안하고, 동의한 경우에만 실행합니다.
- 검사
- 검사 통과 · 문제 0건
- 커밋
- v1.0.1 → c21aca7ae36b2292a82698adfcc11126377ccc9c · 가져온 뒤 검증됨
- 스크립트
- 없음. GenPM은 패키지 코드를 절대 실행하지 않습니다.
- 라이선스
- MIT
- 품질
- 100/100
- 인정된 라이선스충족
- AGENTS.md에 목적 설명충족
- AGENTS.md에 통합 단계충족
- AGENTS.md에 규칙 또는 금지 사항충족
- 테스트 포함충족
- 보안 검사 통과충족
- 최근 6개월 내 게시충족
- 인증된 게시자충족
- 요약과 키워드충족
- 신고
- 문제가 있나요?