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:
+56
@@ -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('');
|
||||
|
||||
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('"');
|
||||
expect(html).toContain('&');
|
||||
});
|
||||
});
|
||||
|
||||
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('<script>');
|
||||
});
|
||||
});
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
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>`;
|
||||
Reference in New Issue
Block a user