リッチテキスト:Tiptap エディタ、許可リスト方式の JSON 文書、XSS 安全な HTML/React 描画、プレーンテキスト、目次
インストール
genpm add @core/rich-text含まれるもの
- src/lib/rich-text/ にソースコード(8 ファイル)。 (23.6 KB)
- 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 適用後に組み込まれる正確なツリーです。固定先:
'use client';
// Editor (Tiptap) que produce documentos válidos para `RichTextSchema`. Solo en el navegador.
// <RichTextEditor value={doc} onChange={setDoc} onPickImage={async () => ({ mediaId, alt })} />
// Los estilos los pone la app (clases `.rich-text-editor` y `.ProseMirror`).
import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { createElement as h, type ReactNode, useEffect } from 'react';
import { type Doc, isSafeHref, RichTextSchema } from './doc.ts';
export type EditorLabels = Partial<Record<'bold' | 'italic' | 'link' | 'h2' | 'h3' | 'bulletList' | 'orderedList' | 'quote' | 'image' | 'undo' | 'redo' | 'linkPrompt', string>>;
const DEFAULT_LABELS: Required<EditorLabels> = {
bold: 'Bold', italic: 'Italic', link: 'Link', h2: 'Heading 2', h3: 'Heading 3', bulletList: 'Bullet list',
orderedList: 'Numbered list', quote: 'Quote', image: 'Image', undo: 'Undo', redo: 'Redo', linkPrompt: 'Link URL (https://…, /page, mailto:…)',
};
const ImageWithMedia = Image.extend({
addAttributes() {
return { ...this.parent?.(), mediaId: { default: null } };
},
});
export function RichTextEditor(props: {
value: Doc;
onChange: (doc: Doc) => void;
/** Abre el selector de medios y devuelve la imagen elegida (o null). */
onPickImage?: () => Promise<{ mediaId?: string; src?: string; alt: string } | null>;
labels?: EditorLabels;
editable?: boolean;
}): ReactNode {
const labels = { ...DEFAULT_LABELS, ...props.labels };
const editor = useEditor({
extensions: [
StarterKit.configure({ link: false, heading: { levels: [2, 3, 4] } }),
Link.configure({ openOnClick: false, autolink: true, protocols: ['mailto', 'tel'], isAllowedUri: (url) => isSafeHref(url) }),
ImageWithMedia,
],
content: props.value,
editable: props.editable ?? true,
immediatelyRender: false,
onUpdate: ({ editor: e }) => {
// Solo emite documentos válidos (lista blanca); lo que no cumpla se ignora.
const parsed = RichTextSchema.safeParse(e.getJSON());
if (parsed.success) props.onChange(parsed.data);
},
});
useEffect(() => {
if (editor && JSON.stringify(editor.getJSON()) !== JSON.stringify(props.value)) editor.commands.setContent(props.value, { emitUpdate: false });
}, [editor, props.value]);
if (!editor) return null;
const btn = (label: string, active: boolean, run: () => void) =>
h('button', { key: label, type: 'button', 'aria-label': label, 'aria-pressed': active, onClick: run }, label);
const chain = () => editor.chain().focus();
return h(
'div',
{ className: 'rich-text-editor' },
h(
'div',
{ role: 'toolbar', className: 'rich-text-toolbar' },
btn(labels.bold, editor.isActive('bold'), () => chain().toggleBold().run()),
btn(labels.italic, editor.isActive('italic'), () => chain().toggleItalic().run()),
btn(labels.h2, editor.isActive('heading', { level: 2 }), () => chain().toggleHeading({ level: 2 }).run()),
btn(labels.h3, editor.isActive('heading', { level: 3 }), () => chain().toggleHeading({ level: 3 }).run()),
btn(labels.bulletList, editor.isActive('bulletList'), () => chain().toggleBulletList().run()),
btn(labels.orderedList, editor.isActive('orderedList'), () => chain().toggleOrderedList().run()),
btn(labels.quote, editor.isActive('blockquote'), () => chain().toggleBlockquote().run()),
btn(labels.link, editor.isActive('link'), () => {
const href = window.prompt(labels.linkPrompt, editor.getAttributes('link').href ?? '');
if (href === null) return;
if (!href) chain().unsetLink().run();
else if (isSafeHref(href)) chain().extendMarkRange('link').setLink({ href }).run();
}),
props.onPickImage &&
btn(labels.image, false, async () => {
const img = await props.onPickImage?.();
if (img) chain().setImage({ src: img.src ?? '', alt: img.alt, ...({ mediaId: img.mediaId ?? null } as object) }).run();
}),
btn(labels.undo, false, () => chain().undo().run()),
btn(labels.redo, false, () => chain().redo().run()),
),
h(EditorContent, { editor }),
);
}
このパッケージは MCP サーバーを宣言していません。
| バージョン | コミット | 公開日 | スキャン |
|---|---|---|---|
| 1.0.1 | c21aca7 | 4 時間前 | スキャン合格 |
- 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 か月以内に公開達成
- 認証済みの公開者達成
- 概要とキーワード達成
- 報告
- 問題を見つけましたか?