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
5 changes: 5 additions & 0 deletions .changeset/two-spoons-wonder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@codama/spec": minor
---

Add v1/docs documentation and `@codama/spec/docs` docs generator. Define shape and enrich nodes with typescript examples.
3 changes: 3 additions & 0 deletions generators/docs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
* Runs the docs generator over the v1 spec, then writes the emitted mdx tree to
* `v1/docs/` (plain page bodies, relative `.mdx` links, `index` basenames). CI re-runs this and fails if
* the result differs from what is committed, keeping the docs artifact in lockstep with the spec source.
*
* The same model also feeds the Fumadocs app tree under `docs/content/spec/<major>/`, which adds YAML
* frontmatter per page plus the `meta.json` sidecars the sidebar needs.
*/

import path from 'node:path';
Expand Down
8 changes: 6 additions & 2 deletions src/api/defineNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* meta-model itself just stores a flat list.
*/

import type { DocExamples } from './example';
import type { AttributeSpec, NodeSpec } from './types';

export interface DefineNodeOptions {
Expand All @@ -24,8 +25,11 @@ export interface DefineNodeOptions {
* via `attribute(...)` or `optionalAttribute(...)`.
*/
readonly attributes: readonly AttributeSpec[];
/** Free-form examples (shape defined per spec major version). */
readonly examples?: readonly unknown[];
/**
* Documentation examples for the node.
* Construct each via `example(...)`.
* */
readonly examples?: DocExamples;
}

export function defineNode(kind: string, options: DefineNodeOptions): NodeSpec {
Expand Down
126 changes: 126 additions & 0 deletions src/api/example.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* Examples for spec nodes - the shape plus the helpers used to author and validate it.
*
* Each node may carry a list of worked examples rendered into its docs page.
* Authors write a snippet as a single template literal.
* `code()` dedents it and splits it into one entry per source line,
* so the serialized `spec.json` stores `content` as a line array.
* That keeps committed diffs readable line-by-line.
* Renderers rejoin the lines with `\n` before emitting a fenced block.
*
* ## The `<NodeName>.examples.ts` file convention:
*
* Examples live in a sibling file next to each node definition.
* Each file:
*
* - is named after the node file with an `.examples.ts` suffix
* (`AmountTypeNode.ts` -> `AmountTypeNode.examples.ts`)
* - writes each snippet as a template literal passed straight to `code()`, so the
* snippet reads like real code and `dedent()` strips any source indentation
* - exports a single `DocExamples` array named `examples`
*
* The node file imports that array and hands it to `defineNode`:
*
* ```ts
* // AmountTypeNode.examples.ts
* import { code, example, type DocExamples } from '../../../api';
*
* export const examples: DocExamples = [
* example('2-decimals USD amount', code('typescript', `
* amountTypeNode(numberTypeNode('u32'), 2, 'USD');
*
* // 0.01 USD => 0x01000000
* `)),
* ];
* ```
*
* ```ts
* // AmountTypeNode.ts
* import { examples } from './AmountTypeNode.examples';
*
* export const amountTypeNode = defineNode('amountTypeNode', {
* docs: ['...'],
* attributes: ['...'],
* examples,
* });
* ```
*/

/** Languages the spec ships example snippets for. */
export type CodeLanguage = 'typescript' | 'rust';

/** A single code snippet for one language. */
export interface CodeBlock {
readonly language: CodeLanguage;
readonly content: readonly string[];
}

/**
* One documented case for a node.
* `docs` mirrors the node and attribute `docs` convention.
*/
export interface DocExample {
readonly title: string;
readonly docs?: readonly string[];
readonly code: readonly CodeBlock[];
}

/** The example collection attached to a node through `NodeSpec.examples`. */
export type DocExamples = readonly DocExample[];

/** Options for `example()`. */
export interface ExampleOptions {
/** Free-form prose paragraphs shown under the example title. */
readonly docs?: readonly string[];
}

/**
* Build a `CodeBlock` from a template-literal snippet.
*
* The content is dedented then split into one entry per line.
* Dedent removes the common leading indentation shared by all non-blank lines.
* Use spaces, not tabs, for indentation.
*/
export function code(language: CodeLanguage, content: string): CodeBlock {
return Object.freeze({
language,
content: Object.freeze(dedent(content).split('\n')),
});
}

/**
* Build a `DocExample` from a title and one or more code blocks.
*
* Pass a single `CodeBlock` for the common one-language case,
* or an array to show the same case in several languages.
* Optional `docs` render as prose under the title.
*/
export function example(
title: string,
blocks: CodeBlock | readonly CodeBlock[],
options: ExampleOptions = {},
): DocExample {
const list = 'language' in blocks ? [blocks] : blocks;
return Object.freeze({
title,
...(options.docs !== undefined ? { docs: Object.freeze([...options.docs]) } : {}),
code: Object.freeze([...list]),
});
}

/**
* Remove the common leading indentation shared by all non-blank lines.
* Trim a single leading newline plus any trailing whitespace.
* Relative indentation is kept.
* Assumes space indentation - mixing tabs and spaces makes the common prefix ambiguous.
*/
function dedent(text: string): string {
// Drop the leading newline and trailing whitespace, then split into lines.
const lines = text.replace(/^\n/, '').replace(/\s+$/, '').split('\n');
// Measure the leading-whitespace width of each non-blank line (blanks would falsely force a 0 common indent).
const indents = lines.filter(line => line.trim().length > 0).map(line => line.match(/^[ \t]*/)?.[0].length ?? 0);
// The shared indentation is the smallest of those widths (0 when every line is blank).
const common = indents.length > 0 ? Math.min(...indents) : 0;
// Strip that shared prefix from every line, keeping relative indentation intact.
return lines.map(line => line.slice(common)).join('\n');
}
1 change: 1 addition & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export * from './defineEnumeration';
export * from './defineNestedUnion';
export * from './defineNode';
export * from './defineUnion';
export * from './example';
export * from './primitives';
export * from './semanticAliases';
export * from './validate';
Expand Down
1 change: 1 addition & 0 deletions src/api/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* included here — they live in `./index.ts` for internal use only.
*/

export type { CodeBlock, CodeLanguage, DocExample, DocExamples } from './example';
export type {
AttributeSpec,
CategorySpec,
Expand Down
6 changes: 4 additions & 2 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
* enumerations, nested unions, categories).
*/

import type { DocExamples } from './example';

export type IntegerWidth = 'i8' | 'i16' | 'i32' | 'i64' | 'i128' | 'u8' | 'u16' | 'u32' | 'u64' | 'u128';

export type FloatWidth = 'f32' | 'f64';
Expand Down Expand Up @@ -102,8 +104,8 @@ export interface NodeSpec {
readonly kind: string;
readonly docs?: readonly string[];
readonly attributes: readonly AttributeSpec[];
/** Free-form examples (shape defined per spec major version). */
readonly examples: readonly unknown[];
/** Worked documentation examples for this node - see `DocExample`. */
readonly examples: DocExamples;
}

/** A member of a union — either a node by name, or another union by name. */
Expand Down
25 changes: 25 additions & 0 deletions src/docs/render/renderPages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { pascalCase } from '@codama/fragments';
import type {
AttributeSpec,
CategorySpec,
DocExample,
DocExamples,
EnumerationSpec,
NestedUnionSpec,
NodeSpec,
Expand Down Expand Up @@ -58,6 +60,8 @@ export function renderNodePage(node: NodeSpec, ctx: RenderCtx): DocPage {
childRows.length
? `${markup.heading(3, 'Children')}${BLOCK_SEPARATOR}${markup.table(cols, childRows)}`
: undefined,
// Examples section
renderExamples(node.examples, markup),
];
return {
ref,
Expand All @@ -66,6 +70,27 @@ export function renderNodePage(node: NodeSpec, ctx: RenderCtx): DocPage {
};
}

/** Render all Node examples. Every language carried by the spec is rendered. */
function renderExamples(examples: DocExamples, markup: MarkupRenderer): string | undefined {
const rendered = examples.map(example => renderExample(example, markup)).filter(block => block !== undefined);
if (!rendered.length) return undefined;
return [markup.heading(2, 'Examples'), ...rendered].join(BLOCK_SEPARATOR);
}

/** Render each example. An example carrying no code block renders nothing rather than a bare heading. */
function renderExample(example: DocExample, markup: MarkupRenderer): string | undefined {
if (!example.code.length) return undefined;
const parts: (string | undefined)[] = [
// header
markup.heading(3, example.title),
// description
renderSpecDocs(example.docs, markup),
// code blocks
...example.code.map(code => markup.codeBlock(code.language, code.content.join('\n'))),
];
return parts.filter(Boolean).join(BLOCK_SEPARATOR);
}

export function renderUnionPage(union: UnionSpec, ctx: RenderCtx): DocPage {
const { markup } = ctx;
const ref: DocRef = { kind: 'union', name: union.name };
Expand Down
49 changes: 49 additions & 0 deletions src/v1/nodes/AccountNode.examples.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { code, example, type DocExamples } from '../../api';

export const examples: DocExamples = [
example(
'A fixed-size account',
code(
'typescript',
`
const node = accountNode({
name: 'token',
data: structTypeNode([
structFieldTypeNode({ name: 'mint', type: publicKeyTypeNode() }),
structFieldTypeNode({ name: 'owner', type: publicKeyTypeNode() }),
structFieldTypeNode({ name: 'amount', type: numberTypeNode('u64') }),
]),
discriminators: [sizeDiscriminatorNode(72)],
size: 72,
});
`,
),
),
example(
'An account with a linked PDA',
code(
'typescript',
`
programNode({
name: 'myProgram',
accounts: [
accountNode({
name: 'token',
data: structTypeNode([structFieldTypeNode({ name: 'authority', type: publicKeyTypeNode() })]),
pda: pdaLinkNode('myPda'),
}),
],
pdas: [
pdaNode({
name: 'myPda',
seeds: [
constantPdaSeedNodeFromString('utf8', 'token'),
variablePdaSeedNode('authority', publicKeyTypeNode()),
],
}),
],
});
`,
),
),
];
2 changes: 2 additions & 0 deletions src/v1/nodes/AccountNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
stringIdentifier,
union,
} from '../../api';
import { examples } from './AccountNode.examples';

export const accountNode = defineNode('accountNode', {
docs: [
Expand Down Expand Up @@ -38,4 +39,5 @@ export const accountNode = defineNode('accountNode', {
],
}),
],
examples,
});
33 changes: 33 additions & 0 deletions src/v1/nodes/ConstantNode.examples.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { code, example, type DocExamples } from '../../api';

export const examples: DocExamples = [
example(
'Numeric Constant',
code(
'typescript',
`
const node = constantNode('maxSize', numberTypeNode('u32'), numberValueNode(100));
`,
),
),
example(
'Bytes Constant',
code(
'typescript',
`
const node = constantNode('seedPrefix', bytesTypeNode(), bytesValueNode('base16', '74657374'));
`,
),
),
example(
'With Documentation',
code(
'typescript',
`
const node = constantNode('maxItems', numberTypeNode('u64'), numberValueNode(1000), [
'The maximum number of items allowed.',
]);
`,
),
),
];
2 changes: 2 additions & 0 deletions src/v1/nodes/ConstantNode.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { attribute, defineNode, docs, optionalAttribute, stringIdentifier, union } from '../../api';
import { examples } from './ConstantNode.examples';

export const constantNode = defineNode('constantNode', {
docs: ['A named constant exposed by the program: a typed value associated with a name.'],
Expand All @@ -16,4 +17,5 @@ export const constantNode = defineNode('constantNode', {
docs: ['The concrete value of the constant.'],
}),
],
examples,
});
20 changes: 20 additions & 0 deletions src/v1/nodes/DefinedTypeNode.examples.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { code, example, type DocExamples } from '../../api';

export const examples: DocExamples = [
example(
'Create a defined type node from an input object',
code(
'typescript',
`
const node = definedTypeNode({
name: 'person',
docs: ['This type describes a Person.'],
type: structTypeNode([
structFieldTypeNode({ name: 'name', type: stringTypeNode('utf8') }),
structFieldTypeNode({ name: 'age', type: numberTypeNode('u8') }),
]),
});
`,
),
),
];
2 changes: 2 additions & 0 deletions src/v1/nodes/DefinedTypeNode.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { attribute, defineNode, docs, optionalAttribute, stringIdentifier, union } from '../../api';
import { examples } from './DefinedTypeNode.examples';

export const definedTypeNode = defineNode('definedTypeNode', {
docs: ['A reusable named type that can be referenced by `definedTypeLinkNode` from elsewhere in the IDL.'],
Expand All @@ -13,4 +14,5 @@ export const definedTypeNode = defineNode('definedTypeNode', {
docs: ['The type definition.'],
}),
],
examples,
});
Loading
Loading