Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ Build [Airtable Custom Interface Extensions](https://airtable.com/developers/int

## What's in the box

### AI Skill (`skill/SKILL.md`)
### AI Skill (`SKILL.md`)

A comprehensive reference that teaches AI how to build Airtable Interface Extensions correctly. Covers the entire SDK — reading data, writing data, custom properties, dark mode, every field type, styling with Tailwind or MUI, and 14 common mistakes to avoid.

Upload it to your AI tool of choice and describe what you want to build:

> *"Build me a dashboard that shows tasks grouped by status with a bar chart of completion rates"*

### Reusable Helpers (`src/`)
### Reusable Helpers (`frontend/`)

Drop-in utilities for patterns every extension needs:

Expand All @@ -29,14 +29,14 @@ Cursor-compatible rules file that guides the AI when editing extension code.

### With Claude Projects (claude.ai)

1. Upload `skill/SKILL.md` to **Project Knowledge**
1. Upload `SKILL.md` to **Project Knowledge**
2. Describe the interface you want
3. Claude writes working code using the SDK patterns

### With Claude Code (CLI)

```bash
cp skill/SKILL.md your-project/.claude/skills/airtable-extensions/SKILL.md
cp SKILL.md your-project/.claude/skills/airtable-extensions/SKILL.md
```

### With Cursor
Expand All @@ -48,10 +48,27 @@ cp interface-extensions.mdc your-project/.cursor/rules/

### Using the helpers

Copy `src/` into your extension project. Then use the helpers in `index.js`
Copy `frontend/` into your extension project. Then use the helpers in `index.js`

See [`examples/basic-usage.js`](examples/basic-usage.js) for a complete working extension.

## Security model

Components in this toolkit render content that originates from Airtable cells, which is *not* a trusted boundary — base collaborators, automations, and AI workflows can write attack payloads into long-text fields.

What the toolkit handles for you:

- **`<Markdown>`** renders to React nodes (no `dangerouslySetInnerHTML`). URL schemes in links are restricted to `http:`, `https:`, and `mailto:` — `javascript:` and `data:` URLs render as plain text.
- **`<AttachmentPreview>`** restricts `<img src>` to `http:`/`https:` and sets `referrerPolicy="no-referrer"`.

What you, the consumer, are responsible for:

- **Permission gating on writes.** `<InlineFieldEdit>` and the editable components call `onSave` directly; you must run `table.checkPermissionsForUpdateRecord(...)` in your `onSave` handler before calling `updateRecordAsync` (see [`examples/basic-usage.js`](examples/basic-usage.js)).
- **Sanitizing AI-generated content.** If you render LLM output through `<Markdown>`, treat it as adversarial input — the renderer is hardened against XSS, but business-logic prompt injection is your call.
- **Origin trust.** These components assume they run inside Airtable's extension iframe sandbox. If you embed them elsewhere, re-evaluate the threat model.

Report security issues privately to the maintainer rather than via public issue.

## Acknowledgments

- Built from [Airtable's official documentation](https://airtable.com/developers/interface-extensions) and example repos
Expand Down
24 changes: 22 additions & 2 deletions frontend/components/AttachmentPreview.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import React from 'react';

const SAFE_IMG_SCHEMES = new Set(['http:', 'https:']);

function safeImageUrl(raw) {
if (!raw) return null;
try {
const parsed = new URL(String(raw).trim(), 'https://example.invalid');
if (parsed.origin === 'https://example.invalid') return null;
return SAFE_IMG_SCHEMES.has(parsed.protocol) ? parsed.href : null;
} catch {
return null;
}
}

/**
* Renders a thumbnail preview of an Airtable attachment field value.
*
Expand All @@ -12,7 +25,14 @@ export default function AttachmentPreview({ attachments, className = '', index =
if (!attachments || attachments.length === 0) return null;
const att = attachments[index];
if (!att) return null;
const url = att.thumbnails?.large?.url || att.url;
const url = safeImageUrl(att.thumbnails?.large?.url || att.url);
if (!url) return null;
return <img src={url} alt={att.filename || ''} className={`rounded-md object-cover ${className}`} />;
return (
<img
src={url}
alt={att.filename || ''}
referrerPolicy="no-referrer"
className={`rounded-md object-cover ${className}`}
/>
);
}
196 changes: 163 additions & 33 deletions frontend/components/Markdown.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,162 @@
import React from 'react';

// Lightweight markdown renderer — zero dependencies.
// Falls back to plain whitespace-pre-wrap text if content doesn't look like markdown.
// Renders to React nodes (no dangerouslySetInnerHTML, no string HTML synthesis),
// so attribute escaping is handled by React. Falls back to plain
// whitespace-pre-wrap text if content doesn't look like markdown.

function escapeHtml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
const SAFE_SCHEMES = new Set(['http:', 'https:', 'mailto:']);

function safeUrl(raw) {
if (!raw) return null;
const trimmed = String(raw).trim();
try {
const parsed = new URL(trimmed, 'https://example.invalid');
if (parsed.origin === 'https://example.invalid' && !trimmed.startsWith('/')) {
return null;
}
return SAFE_SCHEMES.has(parsed.protocol) ? parsed.href : null;
} catch {
return null;
}
}

function renderInline(text) {
let html = escapeHtml(text);
html = html.replace(/`([^`]+)`/g, '<code class="md-code-inline">$1</code>');
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
html = html.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
'<a href="$2" target="_blank" rel="noopener noreferrer" class="md-link">$1</a>'
);
html = html.replace(
/(?<!["=])(https?:\/\/[^\s<]+)/g,
'<a href="$1" target="_blank" rel="noopener noreferrer" class="md-link">$1</a>'
);
return html;
// ── Inline tokenizer ─────────────────────────────────────────────────────────────
// Parses inline markdown into an array of React-renderable tokens.
// Order matters: code spans first (their content is opaque), then links, then
// emphasis. Each pass slices the string into plain segments + token segments
// and recurses into the plain segments for the next pass.

function tokenizeCode(text) {
const out = [];
const re = /`([^`]+)`/g;
let last = 0;
let m;
while ((m = re.exec(text)) !== null) {
if (m.index > last) out.push({ type: 'text', value: text.slice(last, m.index) });
out.push({ type: 'code', value: m[1] });
last = m.index + m[0].length;
}
if (last < text.length) out.push({ type: 'text', value: text.slice(last) });
return out;
}

function tokenizeLinks(text) {
const out = [];
const explicit = /\[([^\]]+)\]\(([^)]+)\)/g;
let last = 0;
let m;
while ((m = explicit.exec(text)) !== null) {
if (m.index > last) out.push({ type: 'text', value: text.slice(last, m.index) });
const href = safeUrl(m[2]);
if (href) {
out.push({ type: 'link', label: m[1], href });
} else {
out.push({ type: 'text', value: m[1] });
}
last = m.index + m[0].length;
}
if (last < text.length) out.push({ type: 'text', value: text.slice(last) });

const withAuto = [];
const auto = /(https?:\/\/[^\s)]+)/g;
for (const tok of out) {
if (tok.type !== 'text') { withAuto.push(tok); continue; }
let l = 0;
let mm;
while ((mm = auto.exec(tok.value)) !== null) {
if (mm.index > l) withAuto.push({ type: 'text', value: tok.value.slice(l, mm.index) });
const href = safeUrl(mm[1]);
if (href) {
withAuto.push({ type: 'link', label: mm[1], href });
} else {
withAuto.push({ type: 'text', value: mm[1] });
}
l = mm.index + mm[0].length;
}
if (l < tok.value.length) withAuto.push({ type: 'text', value: tok.value.slice(l) });
}
return withAuto;
}

function tokenizeEmphasis(text) {
const patterns = [
{ re: /\*\*\*(.+?)\*\*\*/g, type: 'boldItalic' },
{ re: /\*\*(.+?)\*\*/g, type: 'bold' },
{ re: /\*(.+?)\*/g, type: 'italic' },
];
let tokens = [{ type: 'text', value: text }];
for (const { re, type } of patterns) {
const next = [];
for (const tok of tokens) {
if (tok.type !== 'text') { next.push(tok); continue; }
let last = 0;
let m;
re.lastIndex = 0;
while ((m = re.exec(tok.value)) !== null) {
if (m.index > last) next.push({ type: 'text', value: tok.value.slice(last, m.index) });
next.push({ type, value: m[1] });
last = m.index + m[0].length;
}
if (last < tok.value.length) next.push({ type: 'text', value: tok.value.slice(last) });
}
tokens = next;
}
return tokens;
}

function tokenizeInline(text) {
const codeStage = tokenizeCode(text);
const linkStage = [];
for (const tok of codeStage) {
if (tok.type !== 'text') { linkStage.push(tok); continue; }
linkStage.push(...tokenizeLinks(tok.value));
}
const finalStage = [];
for (const tok of linkStage) {
if (tok.type === 'text') {
finalStage.push(...tokenizeEmphasis(tok.value));
} else if (tok.type === 'link') {
finalStage.push({ ...tok, children: tokenizeEmphasis(tok.label) });
} else {
finalStage.push(tok);
}
}
return finalStage;
}

function renderTokens(tokens, keyPrefix = '') {
return tokens.map((tok, i) => {
const key = `${keyPrefix}${i}`;
switch (tok.type) {
case 'text': return <React.Fragment key={key}>{tok.value}</React.Fragment>;
case 'code': return <code key={key} className="md-code-inline">{tok.value}</code>;
case 'bold': return <strong key={key}>{tok.value}</strong>;
case 'italic': return <em key={key}>{tok.value}</em>;
case 'boldItalic': return <strong key={key}><em>{tok.value}</em></strong>;
case 'link':
return (
<a
key={key}
href={tok.href}
target="_blank"
rel="noopener noreferrer"
className="md-link"
>
{renderTokens(tok.children || [{ type: 'text', value: tok.label }], `${key}-`)}
</a>
);
default: return null;
}
});
}

function Inline({ text }) {
return <>{renderTokens(tokenizeInline(text))}</>;
}

// ── Block parser ─────────────────────────────────────────────────────────────────

function parseMarkdown(source) {
const lines = source.split('\n');
const blocks = [];
Expand Down Expand Up @@ -118,10 +248,6 @@ function parseMarkdown(source) {
return blocks;
}

function InlineHtml({ text }) {
return <span dangerouslySetInnerHTML={{ __html: renderInline(text) }} />;
}

/** Heuristic: does this text contain markdown-like syntax? */
export function looksLikeMarkdown(text) {
if (!text) return false;
Expand All @@ -144,14 +270,18 @@ export function looksLikeMarkdown(text) {
*/
export default function Markdown({ children, className = '' }) {
const source = typeof children === 'string' ? children : '';

const blocks = React.useMemo(
() => (source && looksLikeMarkdown(source) ? parseMarkdown(source) : null),
[source],
);

if (!source) return null;

if (!looksLikeMarkdown(source)) {
if (!blocks) {
return <div className={`text-sm whitespace-pre-wrap leading-relaxed ${className}`}>{source}</div>;
}

const blocks = React.useMemo(() => parseMarkdown(source), [source]);

return (
<div className={`md-root ${className}`}>
{blocks.map((block, idx) => {
Expand All @@ -168,14 +298,14 @@ export default function Markdown({ children, className = '' }) {
const Tag = `h${block.level}`;
return (
<Tag key={idx} className={`${classes[block.level]} text-gray-gray700 dark:text-gray-gray200`}>
<InlineHtml text={block.content} />
<Inline text={block.content} />
</Tag>
);
}
case 'paragraph':
return (
<p key={idx} className="mb-2 text-sm leading-relaxed text-gray-gray700 dark:text-gray-gray200">
<InlineHtml text={block.content} />
<Inline text={block.content} />
</p>
);
case 'code':
Expand All @@ -188,22 +318,22 @@ export default function Markdown({ children, className = '' }) {
return (
<ul key={idx} className="mb-2 pl-5 list-disc text-sm text-gray-gray700 dark:text-gray-gray200 space-y-0.5">
{block.items.map((item, j) => (
<li key={j} className="leading-relaxed"><InlineHtml text={item} /></li>
<li key={j} className="leading-relaxed"><Inline text={item} /></li>
))}
</ul>
);
case 'ol':
return (
<ol key={idx} className="mb-2 pl-5 list-decimal text-sm text-gray-gray700 dark:text-gray-gray200 space-y-0.5">
{block.items.map((item, j) => (
<li key={j} className="leading-relaxed"><InlineHtml text={item} /></li>
<li key={j} className="leading-relaxed"><Inline text={item} /></li>
))}
</ol>
);
case 'blockquote':
return (
<blockquote key={idx} className="mb-2 pl-3 border-l-2 border-gray-gray300 dark:border-gray-gray500 text-sm text-gray-gray500 dark:text-gray-gray400 italic">
<InlineHtml text={block.content} />
<Inline text={block.content} />
</blockquote>
);
case 'hr':
Expand Down
Loading