Texto enriquecido: editor Tiptap, documentos JSON con lista blanca, render HTML/React sin XSS, texto plano e índice
Instalar
genpm add @core/rich-textQué obtienes
- Código en src/lib/rich-text/, 8 archivos. (23,6 kB)
- Reglas de IA en src/lib/rich-text/AGENTS.md, más archivos de reglas para tu IDE.
README
Este paquete no tiene README.
Esto es exactamente lo que lee tu IA cuando trabaja en src/lib/rich-text. No se añade nada más a su contexto.
@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).
El árbol exacto que se inyectará, tras aplicar .genpmignore. Anclado a
// Render a HTML con escape completo: solo salen las etiquetas y atributos que este archivo escribe.
import type { Doc, Mark, Node } from './doc.ts';
import { isSafeHref } from './doc.ts';
import { headingIds } from './text.ts';
export type ResolvedImage = { src: string; alt: string; width?: number; height?: number; srcSet?: string; sizes?: string };
export type RenderOptions = {
/** Resuelve imágenes de @core/media (`mediaId`) a URL y atributos. Sin él, solo se pintan imágenes con `src`. */
resolveImage?: (attrs: { mediaId?: string | null; src?: string | null; alt?: string | null }) => ResolvedImage | null;
/** `link` (por defecto): enlace al vídeo, sin cargar nada de terceros. `iframe`: reproductor sin cookies (requiere consentimiento si aplica). */
embeds?: 'link' | 'iframe';
/** rel para enlaces externos. Default `noopener noreferrer`. */
externalRel?: string;
/** Host del sitio, para no marcar como externos sus propios enlaces absolutos. */
siteHost?: string;
/** Texto del enlace a vídeos (`embeds: 'link'`), traducible. */
embedLabel?: (provider: 'youtube' | 'vimeo') => string;
};
export const defaultEmbedLabel = (p: 'youtube' | 'vimeo') => `Watch on ${p === 'youtube' ? 'YouTube' : 'Vimeo'}`;
export const escapeHtml = (s: string) =>
s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
export function embedUrl(provider: 'youtube' | 'vimeo', id: string, mode: 'link' | 'iframe'): string {
if (provider === 'youtube') return mode === 'iframe' ? `https://www.youtube-nocookie.com/embed/${id}` : `https://www.youtube.com/watch?v=${id}`;
return mode === 'iframe' ? `https://player.vimeo.com/video/${id}?dnt=1` : `https://vimeo.com/${id}`;
}
export function isExternal(href: string, siteHost?: string): boolean {
if (!/^https?:\/\//i.test(href)) return false;
try {
return new URL(href).host !== siteHost;
} catch {
return true;
}
}
function linkAttrs(m: Extract<Mark, { type: 'link' }>, o: RenderOptions): string {
if (!isSafeHref(m.attrs.href)) return '';
const ext = isExternal(m.attrs.href, o.siteHost);
return ` href="${escapeHtml(m.attrs.href)}"${m.attrs.target === '_blank' ? ' target="_blank"' : ''}${ext || m.attrs.target === '_blank' ? ` rel="${escapeHtml(o.externalRel ?? 'noopener noreferrer')}"` : ''}`;
}
const MARK_TAG = { bold: 'strong', italic: 'em', strike: 's', code: 'code', underline: 'u' } as const;
function text(n: Extract<Node, { type: 'text' }>, o: RenderOptions): string {
let html = escapeHtml(n.text);
for (const m of n.marks ?? []) {
if (m.type === 'link') {
const attrs = linkAttrs(m, o);
html = attrs ? `<a${attrs}>${html}</a>` : html;
} else html = `<${MARK_TAG[m.type]}>${html}</${MARK_TAG[m.type]}>`;
}
return html;
}
export function toHtml(doc: Doc, o: RenderOptions = {}): string {
const ids = headingIds(doc);
let h = 0;
const render = (nodes: Node[] | undefined): string => (nodes ?? []).map(node).join('');
const node = (n: Node): string => {
switch (n.type) {
case 'text':
return text(n, o);
case 'paragraph':
return `<p>${render(n.content)}</p>`;
case 'heading':
return `<h${n.attrs.level} id="${escapeHtml(ids[h++] ?? '')}">${render(n.content)}</h${n.attrs.level}>`;
case 'bulletList':
return `<ul>${render(n.content)}</ul>`;
case 'orderedList':
return `<ol${n.attrs?.start && n.attrs.start !== 1 ? ` start="${n.attrs.start}"` : ''}>${render(n.content)}</ol>`;
case 'listItem':
return `<li>${render(n.content)}</li>`;
case 'blockquote':
return `<blockquote>${render(n.content)}</blockquote>`;
case 'codeBlock':
return `<pre><code${n.attrs?.language ? ` class="language-${escapeHtml(n.attrs.language)}"` : ''}>${render(n.content)}</code></pre>`;
case 'horizontalRule':
return '<hr>';
case 'hardBreak':
return '<br>';
case 'image': {
const img = o.resolveImage?.(n.attrs) ?? (n.attrs.src && isSafeHref(n.attrs.src) ? { src: n.attrs.src, alt: n.attrs.alt ?? '' } : null);
if (!img) return '';
const extra = [
img.width && ` width="${img.width}"`,
img.height && ` height="${img.height}"`,
img.srcSet && ` srcset="${escapeHtml(img.srcSet)}"`,
img.sizes && ` sizes="${escapeHtml(img.sizes)}"`,
].filter(Boolean).join('');
return `<figure><img src="${escapeHtml(img.src)}" alt="${escapeHtml(img.alt)}"${extra} loading="lazy" decoding="async">${n.attrs.title ? `<figcaption>${escapeHtml(n.attrs.title)}</figcaption>` : ''}</figure>`;
}
case 'embed': {
const mode = o.embeds ?? 'link';
const url = escapeHtml(embedUrl(n.attrs.provider, n.attrs.id, mode));
return mode === 'iframe'
? `<div class="embed"><iframe src="${url}" title="${n.attrs.provider} video" loading="lazy" allow="encrypted-media; picture-in-picture; fullscreen" referrerpolicy="strict-origin-when-cross-origin" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe></div>`
: `<p class="embed"><a href="${url}" rel="noopener noreferrer" target="_blank">${escapeHtml((o.embedLabel ?? defaultEmbedLabel)(n.attrs.provider))}</a></p>`;
}
}
};
return render(doc.content);
}
Este paquete no declara servidores MCP.
| Versión | Commit | Publicado | Escaneo |
|---|---|---|---|
| 1.0.1 | c21aca7 | hace 5 horas | escaneo superado |
- genpm
- ninguno
- 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
- propuesta
- GenPM propone el comando npm y solo lo ejecuta si dices que sí.
- escaneo
- escaneo superado · 0 hallazgos
- commit
- v1.0.1 → c21aca7ae36b2292a82698adfcc11126377ccc9c · verificado tras la descarga
- scripts
- Ninguno. GenPM nunca ejecuta código del paquete.
- licencia
- MIT
- Calidad
- 100/100
- Licencia reconocidacumplido
- AGENTS.md explica su propósitocumplido
- AGENTS.md tiene pasos de integracióncumplido
- AGENTS.md lista convenciones o prohibicionescumplido
- Incluye testscumplido
- Escaneo de seguridad superadocumplido
- Publicado en los últimos 6 mesescumplido
- Publicador verificadocumplido
- Resumen y palabras clavecumplido
- reporte
- ¿Ves algo raro?