Add Document Generator SDK app + step-by-step tutorial (#22522)

## What & why

This adds a **guided tutorial** that teaches the Twenty SDK by building
one real, useful app end to end — plus the finished app itself, ready
for the marketplace.

The app, **Document Generator**, turns reusable templates into
personalized documents using CRM data: write a template once with
`{{placeholders}}`, then generate a filled-in document for any Person or
Company from the command menu, an AI agent, or a workflow.

## Two parts

**1. The app — `packages/twenty-apps/public/document-generator`**

Each capability maps to one tutorial chapter:
- **Data:** `documentTemplate` + `document` objects, fields, and a
bidirectional relation
- **Logic:** a single `generate-document` handler exposed as an **AI
tool**, a **workflow action**, and an **HTTP POST route**; plus a public
**HTML view route**
- **UI:** two views + sidebar navigation, a **command-menu item** (on
Person selection) that opens a **React front component**
- **AI:** an agent + skill; a default application role; marketplace
metadata + logo
- **Tests:** unit tests for the template renderer + an install
integration test

**2. The tutorial —
`packages/twenty-docs/.../apps/tutorials/document-generator/`**

A six-chapter series under **Developers › Apps › Tutorial** (Overview →
Data model → Generating documents → HTTP routes → Building the UI → AI
agent → Publishing). Minimal prose, paste-ready code, inline links to
the matching reference pages, and real screenshots. Registers a new
"Tutorial" nav group and regenerates `docs.json` + the navigation
template.

## Verification

Validated against a running Twenty instance (`twenty-app-dev` on
`:2020`):
- `twenty dev --once` installs cleanly (28 metadata objects created)
- Generated a real document from a Person — placeholders resolved (name,
job title, `company.name`, email), zero missing tokens
- Command menu → front component → generate flow works in the UI
- Public HTML view route renders the document
- App gates green: `yarn lint` (0/0), `yarn typecheck`, `yarn test:unit`
(7/7)

All screenshots in the tutorial are captured from this run.

## Notes
- Left out per-app CI workflows (`.github/workflows`) to keep scope
tight — happy to add them if wanted.

https://claude.ai/code/session_012FoC76zPbPmpgtN7MXMPwy

---
_Generated by [Claude
Code](https://claude.ai/code/session_012FoC76zPbPmpgtN7MXMPwy)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22522?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: github-actions <github-actions@twenty.com>
This commit is contained in:
martmull
2026-07-07 11:13:34 +02:00
committed by GitHub
parent 2a495c3477
commit 07a921f8ca
73 changed files with 7344 additions and 0 deletions
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { documentContentToHtml, documentHtmlPage } from '../render-document';
describe('documentContentToHtml', () => {
it('should render markdown to html', () => {
const html = documentContentToHtml('# Hi\n\n**bold** and a list:\n\n- one\n- two');
expect(html).toContain('<h1>Hi</h1>');
expect(html).toContain('<strong>bold</strong>');
expect(html).toContain('<li>one</li>');
});
it('should drop raw html to prevent injection', () => {
const html = documentContentToHtml('Hello <script>alert(1)</script>');
expect(html).not.toContain('<script>');
});
it('should neutralize unsafe link protocols', () => {
const html = documentContentToHtml('[x](javascript:alert(1))');
expect(html).not.toContain('javascript:');
});
it('should drop images so no external <img> is emitted', () => {
const html = documentContentToHtml('![alt](https://evil.example/x.png)');
expect(html).not.toContain('<img');
expect(html).not.toContain('evil.example');
});
it('should escape link href and text', () => {
const html = documentContentToHtml('[a"b](https://example.com/?x="y"&z=1)');
expect(html).not.toContain('="y"');
expect(html).toContain('&quot;');
expect(html).toContain('&amp;');
});
});
describe('documentHtmlPage', () => {
it('should wrap content in a titled, styled page', () => {
const page = documentHtmlPage('Proposal', 'Body text');
expect(page).toContain('<!doctype html>');
expect(page).toContain('<title>Proposal</title>');
expect(page).toContain('doc-paper');
});
it('should escape the title', () => {
const page = documentHtmlPage('<script>', 'x');
expect(page).toContain('&lt;script&gt;');
});
});
@@ -0,0 +1,161 @@
import { type CSSProperties, type ReactNode } from 'react';
import { marked, type Token, type Tokens } from 'marked';
// Front components run in a remote-DOM sandbox: raw HTML injection is not
// allowed, so Markdown is rendered as real React elements with inline styles.
const INK = '#1f2430';
const HEADING = '#10152a';
const ACCENT = '#1961ed';
const styles: Record<string, CSSProperties> = {
h1: { fontSize: '22px', fontWeight: 700, color: HEADING, margin: '28px 0 12px', lineHeight: 1.3 },
h2: { fontSize: '18px', fontWeight: 700, color: HEADING, margin: '24px 0 10px', lineHeight: 1.3 },
h3: { fontSize: '15px', fontWeight: 700, color: HEADING, margin: '20px 0 8px', lineHeight: 1.3 },
p: { margin: '0 0 14px', lineHeight: 1.7, color: INK },
ul: { margin: '0 0 14px', paddingLeft: '22px' },
ol: { margin: '0 0 14px', paddingLeft: '22px' },
li: { margin: '4px 0', lineHeight: 1.6, color: INK },
a: { color: ACCENT, textDecoration: 'none' },
code: {
fontFamily: "'SFMono-Regular', Menlo, monospace",
fontSize: '0.9em',
background: '#f1f3f9',
padding: '2px 6px',
borderRadius: '4px',
},
blockquote: {
margin: '14px 0',
padding: '4px 18px',
borderLeft: `3px solid ${ACCENT}`,
background: '#f6f8fd',
color: '#47506a',
},
hr: { border: 0, borderTop: '1px solid #e6e9f2', margin: '24px 0' },
pre: {
background: '#f1f3f9',
padding: '14px 16px',
borderRadius: '6px',
overflowX: 'auto',
fontFamily: "'SFMono-Regular', Menlo, monospace",
fontSize: '12px',
lineHeight: 1.5,
margin: '0 0 14px',
},
};
const BLOCK_TOKEN_TYPES = new Set([
'list',
'paragraph',
'code',
'blockquote',
'heading',
'hr',
'space',
]);
const renderInline = (tokens: Token[] | undefined, keyPrefix: string): ReactNode[] => {
if (!tokens) return [];
return tokens.map((token, index): ReactNode => {
const key = `${keyPrefix}-${index}`;
switch (token.type) {
case 'strong':
return <strong key={key}>{renderInline((token as Tokens.Strong).tokens, key)}</strong>;
case 'em':
return <em key={key}>{renderInline((token as Tokens.Em).tokens, key)}</em>;
case 'codespan':
return <code key={key} style={styles.code}>{(token as Tokens.Codespan).text}</code>;
case 'br':
return <br key={key} />;
case 'link': {
const link = token as Tokens.Link;
const safe = /^(https?:|mailto:)/i.test(link.href ?? '');
return safe ? (
<a key={key} href={link.href} style={styles.a} target="_blank" rel="noopener noreferrer">
{renderInline(link.tokens, key)}
</a>
) : (
<span key={key}>{renderInline(link.tokens, key)}</span>
);
}
default: {
const nested = (token as Tokens.Text).tokens;
if (Array.isArray(nested) && nested.length > 0) {
return <span key={key}>{renderInline(nested, key)}</span>;
}
return <span key={key}>{(token as Tokens.Text).text ?? ''}</span>;
}
}
});
};
const renderBlocks = (tokens: Token[]): ReactNode[] =>
tokens.map((token, index): ReactNode => {
const key = `b-${index}`;
switch (token.type) {
case 'heading': {
const heading = token as Tokens.Heading;
const Tag = (['h1', 'h2', 'h3', 'h3'][heading.depth - 1] ?? 'h3') as 'h1' | 'h2' | 'h3';
return (
<Tag key={key} style={styles[Tag]}>
{renderInline(heading.tokens, key)}
</Tag>
);
}
case 'paragraph':
return <p key={key} style={styles.p}>{renderInline((token as Tokens.Paragraph).tokens, key)}</p>;
case 'list': {
const list = token as Tokens.List;
const items = list.items.map((item, itemIndex) => {
// List items can hold block-level tokens (nested lists, extra
// paragraphs, code). Render those as blocks; keep simple items inline.
const hasBlock = item.tokens?.some((child) => BLOCK_TOKEN_TYPES.has(child.type));
return (
<li key={`${key}-${itemIndex}`} style={styles.li}>
{hasBlock
? renderBlocks(item.tokens)
: renderInline(item.tokens, `${key}-${itemIndex}`)}
</li>
);
});
return list.ordered ? (
<ol key={key} style={styles.ol}>{items}</ol>
) : (
<ul key={key} style={styles.ul}>{items}</ul>
);
}
case 'blockquote':
return (
<blockquote key={key} style={styles.blockquote}>
{renderBlocks((token as Tokens.Blockquote).tokens)}
</blockquote>
);
case 'code':
return <pre key={key} style={styles.pre}>{(token as Tokens.Code).text}</pre>;
case 'hr':
return <hr key={key} style={styles.hr} />;
case 'space':
return null;
case 'text': {
// Loose list items expose their content as a `text` block token with
// nested inline tokens — render those so bold/italic/links survive.
const textToken = token as Tokens.Text;
return (
<span key={key}>
{textToken.tokens
? renderInline(textToken.tokens, key)
: textToken.text}
</span>
);
}
default: {
const text = (token as { text?: string }).text;
return text ? <p key={key} style={styles.p}>{text}</p> : null;
}
}
});
export const Markdown = ({ content }: { content: string }): ReactNode => (
<div>{renderBlocks(marked.lexer(content))}</div>
);
@@ -0,0 +1,108 @@
import { Marked } from 'marked';
// A Markdown renderer hardened for untrusted template content: raw HTML is
// dropped and only http(s)/mailto links survive, so the output is safe to
// inject into the page or the front-end viewer.
const escapeHtml = (value: string): string =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
const markdown = new Marked({ async: false, gfm: true, breaks: true });
markdown.use({
renderer: {
html() {
return '';
},
// Drop images entirely — they would emit <img> tags pointing at arbitrary
// external URLs, which the "no raw HTML" contract is meant to prevent.
image() {
return '';
},
link({ href, text }: { href: string; text: string }) {
const isSafe = /^(https?:|mailto:)/i.test(href ?? '');
// Escape both values: href lands in an attribute and text in element
// content, so unescaped input would otherwise inject markup.
const safeText = escapeHtml(text ?? '');
return isSafe
? `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${safeText}</a>`
: safeText;
},
},
});
export const documentContentToHtml = (content: string): string =>
markdown.parse(content) as string;
// The "paper" styling only (no `body` rules), so it is safe to inject inside a
// front component without leaking styles onto the host page.
export const DOCUMENT_PAPER_CSS = `
.doc-paper {
color: #1f2430;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif;
line-height: 1.7;
max-width: 720px;
margin: 48px auto;
background: #ffffff;
border-radius: 10px;
box-shadow: 0 10px 40px rgba(24, 39, 75, 0.08);
overflow: hidden;
}
.doc-body { padding: 64px 72px 72px; }
.doc-body h1, .doc-body h2, .doc-body h3 { color: #10152a; line-height: 1.3; margin: 32px 0 12px; }
.doc-body h1 { font-size: 24px; } .doc-body h2 { font-size: 20px; } .doc-body h3 { font-size: 16px; }
.doc-body p { margin: 0 0 16px; }
.doc-body ul, .doc-body ol { margin: 0 0 16px; padding-left: 24px; }
.doc-body li { margin: 4px 0; }
.doc-body a { color: #1961ed; text-decoration: none; }
.doc-body a:hover { text-decoration: underline; }
.doc-body blockquote {
margin: 16px 0; padding: 4px 20px; border-left: 3px solid #1961ed;
color: #47506a; background: #f6f8fd;
}
.doc-body code {
font-family: 'SFMono-Regular', Menlo, monospace; font-size: 0.9em;
background: #f1f3f9; padding: 2px 6px; border-radius: 4px;
}
.doc-body hr { border: 0; border-top: 1px solid #e6e9f2; margin: 32px 0; }
@media print {
body { background: #fff; }
.doc-paper { box-shadow: none; margin: 0; border-radius: 0; max-width: none; }
}
`;
// Inner markup for the styled "paper": renders the template content only, with
// no title header or footer chrome.
export const documentPaperHtml = (content: string): string =>
`<article class="doc-paper">
<div class="doc-body">
${documentContentToHtml(content)}
</div>
</article>`;
const PAGE_BODY_CSS = `
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0; background: #eef1f6;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif;
}
`;
// A complete, printable HTML page for the public view route.
export const documentHtmlPage = (title: string, content: string): string => `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(title)}</title>
<style>${PAGE_BODY_CSS}${DOCUMENT_PAPER_CSS}</style>
</head>
<body>
${documentPaperHtml(content)}
</body>
</html>`;