Skip to content
Merged
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
2 changes: 1 addition & 1 deletion STYLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ Use a component for what it's for, and only where it helps the reader.
| `Tabs` | Alternatives that aren't code, such as two ways to claim an instance |
| `Accordion` | Entries a reader opens one at a time, such as troubleshooting. Never a reference list: a list a reader looks up is a plain table on its Catalog or Reference page |
| `Files` | A directory a reader will see on disk |
| `Callout` | Something that must interrupt: an action that can't be undone, a step that loses data, or the Overview's one-line warning that Marfa is early |
| `Callout` | Something that must interrupt: an action that can't be undone, a step that loses data, or the Overview's one-line warning that Marfa is early. Every callout has a bold `title` that says what happens, and a short body under it. It draws no icon. `type="warn"` is for a loss or something that can't be undone; a neutral note takes the default, `info` |
| `Cards` | Where to go next: the Overview, an index of the docs, and the end of a tutorial or of the Introduction |

Write an object's fields as a Markdown table (field, type, description), not with `TypeTable`: a table reads the same on the page and in the Markdown copy.
Expand Down
2 changes: 1 addition & 1 deletion content/docs/(overview)/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Marfa is a shared data layer for your apps, and you run it yourself

Your notes, tasks, bookmarks and files live in one instance, as typed items. Your apps, scripts and AI agents read and write them through one API, each reaching only what you allow, and apps built on the Swift SDK keep working offline. These docs cover running an instance and building on it.

<Callout type="warn">Marfa is early, so expect breaking changes until version 1.0.</Callout>
<Callout type="warn" title="Marfa is early">Expect breaking changes until version 1.0.</Callout>

## Start here

Expand Down
2 changes: 1 addition & 1 deletion content/docs/guides/connector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ key 01a15f19-6c20-7c68-a0b3-40bd8a431442 (readlist); the plaintext is shown once

</Terminal>

<Callout type="warn">Copy the key now. Marfa shows it only once.</Callout>
<Callout type="warn" title="Copy the key now">Marfa shows it once. If you lose it, make a new one.</Callout>

The key writes the connector's one type and can register it, and its source matches the connector's `source`. **The connector refuses a key that reaches further**, such as your own. [Manage keys](/guides/manage-keys) explains each flag.

Expand Down
2 changes: 1 addition & 1 deletion content/docs/guides/define-a-type.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ Otherwise it refuses with `409 type_in_use` when items hold the type or a settin

A `type_in_use` refusal counts the items in `details.item_count` and names each folder, view and webhook setting in `details.settings`, without the `id` of a setting your key can't read. **[Move the items to another type](/guides/move-items-to-another-type) first:** the move changes those settings too. `details.grants` lists the keys and apps whose type map names the type, if your key may manage them. They don't stop a delete, so change them through [Manage keys](/guides/manage-keys) when you're ready.

<Callout type="warn">
<Callout type="warn" title="A forced delete blocks writes to the items">
`force=true` (the CLI's `--force`), sent with a key, deletes a type that items still hold or a setting names. It doesn't override a subtype or a `compatible_with` claim. The items and settings stay as they are, but until the type is registered again, Marfa refuses any write to the items' properties, tier or time. You can still tag them, archive them or move them to the trash.
</Callout>

Expand Down
2 changes: 1 addition & 1 deletion content/docs/guides/housekeeping.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ The server refuses `409 housekeeping_job_running` while a run of the same housek

An item stays in the bin until it is purged. Left alone, `trash-purge` purges each item once it has been in the bin longer than `TRASH_RETENTION_DAYS`, which is 60 days unless you change it. `trash_retention_days` in the [configuration](/guides/settings#change-settings-while-it-runs) overrides that while the server runs, and a retention of `0` keeps every item. A run removes a limited number of items, and while items past the retention remain after a run that removed some, the job runs again at once.

<Callout type="warn">A purge deletes items and their history for good. The owner’s empty-trash operation has no dry run. Read the trash before purging it.</Callout>
<Callout type="warn" title="A purge deletes items for good">It removes their history too. The owner's empty-trash operation has no dry run, so read the trash before you purge it.</Callout>

To empty the trash through the private socket, use the owner's summary operation. **Each call attempts up to 200 selected items** and returns the committed purge count, per-item failures and a cursor for the next batch. Send `--cursor` or the HTTP `cursor` query parameter with the returned value until `next_cursor` is `null`. If a write outcome is unknown, read the trash again before retrying. The owner can also empty it from a recent browser session.

Expand Down
4 changes: 2 additions & 2 deletions content/docs/guides/manage-keys.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ The owner makes keys in a browser or through the private socket. A key holding `

Open **API keys** from [Home](/guides/sign-in-as-the-owner#start-from-home) and choose **Create key**. **Details** holds the name and when the key ends. **Access starts with everything off**, so turn on only what the key needs. **Advanced** holds its sources and where its items land.

<Callout type="warn">The page shows the key once, under **Copy your key**. Copy it before you leave the page.</Callout>
<Callout type="warn" title="Copy the key now">The page shows it once, under **Copy your key**. Copy it before you leave the page.</Callout>

A saved key can only get less access or an earlier end. **Creating a key, saving a change to it and revoking it need a sign-in from the last five minutes**: the page asks the owner to [confirm it's them](/guides/sign-in-as-the-owner#confirm-its-you) when theirs is older. The socket, and a key with the permission for the operation, aren't held to it.

Expand Down Expand Up @@ -101,7 +101,7 @@ An update can say it must only narrow. Add `"narrow_only": true` to `PATCH /keys

Replace a key when its holder changes.

<Callout type="warn">If a key may have leaked, revoke it first, then make a new one. Revoking leaves the keys it made working, so if it held `keys.mint`, find them in the [audit log](/guides/audit-log) and revoke them too. The steps below are for a planned change: they keep the old key working until the new one is in place.</Callout>
<Callout type="warn" title="If a key may have leaked, revoke it first">Then make a new one. Revoking leaves the keys it made working, so if it held `keys.mint`, find them in the [audit log](/guides/audit-log) and revoke them too. The steps below are for a planned change: they keep the old key working until the new one is in place.</Callout>

1. Make a key with the same access and a new `--source`, and give it to the app.
2. Check that the new key works. An hour later, `marfa keys list` should show the old key's last use before the switch.
Expand Down
3 changes: 1 addition & 2 deletions source.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,7 @@ const llms: LLMsOptions = {
return `- [${attribute(node, 'title') ?? ''}](${attribute(node, 'href') ?? ''})${text ? `: ${text}` : ''}`;
}
case 'Callout': {
const title = attribute(node, 'title');
const body = `${title ? `**${title}.** ` : ''}${inner()}`;
const body = `**${attribute(node, 'title') ?? ''}.** ${inner()}`;
return body
.split('\n')
.map((line) => (line ? `> ${line}` : '>'))
Expand Down
49 changes: 30 additions & 19 deletions src/components/callout.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
import type { ComponentProps, ReactNode } from 'react';
import { cn } from '../lib/cn';

// Fumadocs' Callout, drawn as a faint wash of its type's color with no border,
// shadow or icon: the tint says what kind of note it is, and the title, when
// there is one, says the rest. `icon` is accepted and not drawn.
// A callout is a soft tinted panel: a bold title in the tint's dark color, the
// body under it, no border, shadow or icon. The tint says what kind of note it
// is and the title says what happens. The colors are the `--marfa-callout-*`
// tokens in app.css, which other components reuse.

type CalloutType = 'info' | 'warn' | 'warning' | 'error' | 'success' | 'tip' | 'idea';

// Written out whole, so Tailwind finds each class.
const TINTS = {
info: 'bg-fd-info/6',
warning: 'bg-fd-warning/8',
error: 'bg-fd-error/6',
success: 'bg-fd-success/6',
info: {
panel: 'bg-(--marfa-callout-info-bg)',
title: 'text-(--marfa-callout-info-title)',
body: 'text-(--marfa-callout-info-body)',
},
warning: {
panel: 'bg-(--marfa-callout-warning-bg)',
title: 'text-(--marfa-callout-warning-title)',
body: 'text-(--marfa-callout-warning-body)',
},
error: {
panel: 'bg-(--marfa-callout-error-bg)',
title: 'text-(--marfa-callout-error-title)',
body: 'text-(--marfa-callout-error-body)',
},
success: {
panel: 'bg-(--marfa-callout-success-bg)',
title: 'text-(--marfa-callout-success-title)',
body: 'text-(--marfa-callout-success-body)',
},
};

function resolve(type: CalloutType) {
Expand All @@ -23,28 +41,21 @@ function resolve(type: CalloutType) {
export function Callout({
type = 'info',
title,
icon: _icon,
children,
className,
...props
}: Omit<ComponentProps<'div'>, 'title'> & {
type?: CalloutType;
title?: ReactNode;
icon?: ReactNode;
title: ReactNode;
}) {
const tint = TINTS[resolve(type)];
return (
<div
{...props}
className={cn(
'my-6 flex flex-col gap-1 rounded-xl px-4 py-3 text-sm leading-relaxed',
TINTS[resolve(type)],
className,
)}
className={cn('my-6 flex flex-col gap-0.5 rounded-xl px-4 py-3 text-sm leading-relaxed', tint.panel, className)}
>
{title ? <p className="my-0! font-medium text-fd-foreground">{title}</p> : null}
<div className={cn('prose-no-margin', title ? 'text-fd-foreground/75' : 'text-fd-foreground/85')}>
{children}
</div>
<p className={cn('my-0! font-semibold', tint.title)}>{title}</p>
<div className={cn('[&>:first-child]:mt-0 [&>:last-child]:mb-0', tint.body)}>{children}</div>
</div>
);
}
39 changes: 39 additions & 0 deletions src/lib/content-lint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type Rule =
| 'statement-number'
| 'frontmatter'
| 'coming-soon'
| 'callout-title'
| 'link'
| 'include';

Expand Down Expand Up @@ -250,6 +251,23 @@ export function checkComingSoon(file: string, text: string): Violation[] {
return out;
}

/** Every `<Callout` opening tag, up to its closing `>`, outside code. */
const CALLOUT_TAG = /<Callout(?=[\s>/])((?:"[^"]*"|'[^']*'|\{[^}]*\}|[^>"'{])*)>/g;

export function checkCalloutTitles(file: string, text: string): Violation[] {
const out: Violation[] = [];
for (const match of stripCode(text).matchAll(CALLOUT_TAG)) {
if (/\stitle=("[^"]*[^"\s][^"]*"|'[^']*[^'\s][^']*'|\{[^}]*\S[^}]*\})/.test(match[1])) continue;
out.push({
file,
line: lineOf(text, match.index),
rule: 'callout-title',
detail: 'a Callout needs a title that says what happens, such as title="Copy the key now"',
});
}
return out;
}

/** Routes that serve text rather than a docs page, and are fine to link to. */
const NON_PAGE_ROUTES = new Set(['/', '/llms.txt', '/llms-full.txt', '/docs-map.md', '/mcp']);

Expand Down Expand Up @@ -526,6 +544,10 @@ describe('content lint: the corpus', () => {
it('links only to pages that exist', () => {
expect(lintAll((file, text) => checkLinks(file, text, PAGE_URLS))).toEqual([]);
});

it('gives every callout a title', () => {
expect(lintAll(checkCalloutTitles)).toEqual([]);
});
});

// ── Witnesses: each rule fires on a small bad sample ─────────────────────
Expand Down Expand Up @@ -626,6 +648,23 @@ describe('content lint: each rule fires on a bad sample', () => {
expect(rules(checkComingSoon('(overview)/run-an-instance.mdx', 'Coming soon.'))).toEqual(['coming-soon']);
});

it('callout-title', () => {
const bad = [
'<Callout type="warn">Copy the key now.</Callout>',
'<Callout>\nBody.\n</Callout>',
'<Callout type="warn" title="">Body.</Callout>',
'<Callout type="info" notitle="x">Body.</Callout>',
];
for (const sample of bad) expect(rules(checkCalloutTitles('x.mdx', sample))).toEqual(['callout-title']);
expect(checkCalloutTitles('x.mdx', 'Fine.\n\n<Callout type="warn">No title.</Callout>\n')[0].line).toBe(3);
});

it('callout-title accepts a title and ignores code', () => {
expect(checkCalloutTitles('x.mdx', '<Callout title="Copy the key now">Body.</Callout>')).toEqual([]);
expect(checkCalloutTitles('x.mdx', '<Callout type="warn" title={"Copy"}>Body.</Callout>')).toEqual([]);
expect(checkCalloutTitles('x.mdx', 'Write `<Callout>` like this.\n\n```mdx\n<Callout>Body</Callout>\n```\n')).toEqual([]);
});

describe('link', () => {
const urls = new Set(['/overview', '/api-reference/items/createItem']);

Expand Down
28 changes: 28 additions & 0 deletions src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@
--color-fd-secondary: #f4f4f3;
--color-fd-accent: #efefee;
--color-fd-popover: #ffffff;

/* Callout tints, the Soft family: a panel, a title and a body color for each
kind of note, built from one hue and chroma. Other components that carry a
kind of note, such as a status chip, use the same tokens. */
--marfa-callout-warning-bg: oklch(96.3% calc(.16 * 0.14) 60);
--marfa-callout-warning-title: oklch(38% calc(.16 * 0.55) 60);
--marfa-callout-warning-body: oklch(38% calc(.16 * 0.3) 60 / 0.85);
--marfa-callout-info-bg: oklch(96.3% calc(.17 * 0.14) 255);
--marfa-callout-info-title: oklch(38% calc(.17 * 0.55) 255);
--marfa-callout-info-body: oklch(38% calc(.17 * 0.3) 255 / 0.85);
--marfa-callout-error-bg: oklch(96.3% calc(.17 * 0.14) 25);
--marfa-callout-error-title: oklch(38% calc(.17 * 0.55) 25);
--marfa-callout-error-body: oklch(38% calc(.17 * 0.3) 25 / 0.85);
--marfa-callout-success-bg: oklch(96.3% calc(.12 * 0.14) 165);
--marfa-callout-success-title: oklch(38% calc(.12 * 0.55) 165);
--marfa-callout-success-body: oklch(38% calc(.12 * 0.3) 165 / 0.85);
}
.dark {
--marfa-shadow-near: rgb(0 0 0 / 0.3);
Expand All @@ -62,6 +78,18 @@
--color-fd-secondary: #1e1e1e;
--color-fd-accent: #242424;
--color-fd-popover: #181818;
--marfa-callout-warning-bg: oklch(26% calc(.16 * 0.12) 60);
--marfa-callout-warning-title: oklch(86% calc(.16 * 0.4) 60);
--marfa-callout-warning-body: oklch(86% calc(.16 * 0.22) 60 / 0.8);
--marfa-callout-info-bg: oklch(26% calc(.17 * 0.12) 255);
--marfa-callout-info-title: oklch(86% calc(.17 * 0.4) 255);
--marfa-callout-info-body: oklch(86% calc(.17 * 0.22) 255 / 0.8);
--marfa-callout-error-bg: oklch(26% calc(.17 * 0.12) 25);
--marfa-callout-error-title: oklch(86% calc(.17 * 0.4) 25);
--marfa-callout-error-body: oklch(86% calc(.17 * 0.22) 25 / 0.8);
--marfa-callout-success-bg: oklch(26% calc(.12 * 0.12) 165);
--marfa-callout-success-title: oklch(86% calc(.12 * 0.4) 165);
--marfa-callout-success-body: oklch(86% calc(.12 * 0.22) 165 / 0.8);
}

/* ── Type ──────────────────────────────────────────────────────────────────
Expand Down
Loading