Signature - A Documentation Toolkit
This notebook ports a notebook by Fabian Iwand [@mootari] called [Signature - A Documentation Toolkit](https://observablehq.com/@mootari/signature). All mistakes and deviations from the original are my own.
+--------------------------------------------------------------+ | — The following text/narrative is from the original — | +--------------------------------------------------------------+
This notebook offers a set of documentation helpers.
~~~js
import {signature, getPinnedSlug} from '${PINNED_LIB}'
~~~
Features:
- automatic formatting of function signatures
- simple configuration for descriptions and examples
- collapsible sections, optionally collapsed by default
- optional test runner
- theming support
For more examples in the wild please see Toolbox.
const Markdown = new markdownit({html: true});
function md(strings) {
let string = strings[0];
for (let i = 1; i < arguments.length; ++i) {
string += String(arguments[i]);
string += strings[i];
}
const template = document.createElement("template");
template.innerHTML = Markdown.render(string);
return template.content.cloneNode(true);
}
signature(signature, {
description: md`
Documentation template for functions. Extracts the function head from the function passed to **\`fn\`**:
- If \`fn\` is a named function, the head is returned as written. If \`name\` was set, it will replace the function name.
- If \`fn\` is an arrow function, the declaration will be reformatted and the \`function\` keyword injected. If \`name\` was set, it will be injected as the function name.
- Any other argument type is passed through unaltered.
**Note:** Javascript may infer the function name from the variable to which a function was first assigned.
All settings are optional. Available **\`options\`**:
${Object.entries({
description: 'Rendered as Markdown if a string, otherwise passed through.',
example: 'Single value or array of values. String values are formatted as Javascript code, everything else gets passed through.',
name: 'Anchor name to link to. Defaults to function name.',
scope: 'Class name to scope CSS selectors. Defaults to unique string.',
css: 'The theme CSS. If \`scope\` is used, the CSS should start every selector with \`:scope\`. Defaults to \`signature_theme\`.',
open: 'Boolean that controls the toggle state of the details wrapper. Set to \`null\` to disable collapsing.',
tests: 'Object map of test functions, keyed by test name. Each function receives \`assert(condition, assertion)\` as argument. Async functions are supported.',
runTests: 'Boolean or Promise, controls test execution. Set to \`false\` to disable test output.',
testRunner: 'Executes tests and builds results markup. See [\`defaultTestRunner()\`](#defaultTestRunner) for details.',
}).map(([k,v]) => `- \`${k}:\` ${v}\n`)}`,
example: [`
// Basic use
signature(myUsefulFunc, {
description: "It's hard to describe how useful myUsefulFunc is. I use it all the time!"
})
`, `
// Tests
signature(myTestedFunc, {
tests: {
'can retrieve data': async (assert) => {
const data = await myTestedFunc().getData();
assert(Array.isArray(data), 'data is array');
assert(data.length, 'data is not empty');
},
'is this finished?': assert => {
assert(myTestedFunc() !== 'todo: implement', 'actually implemented');
},
'Look, ma! No assert!': () => {
try { myTestedFunc().notImplemented() }
catch(e) { throw Error(\`Hey signature(), catch this! \${e}\`) };
}
}})
`,],
tests: {
'signature parsing': assert => {
const test = (expect, name, fn) => {
const sig = signature(fn, {name, formatter: ({signature:s}) => s.textContent.trim()});
assert(expect === sig, `expected "${expect}", got "${sig}"`);
};
{test('function()', undefined, function(){})}
{test('function foo1()', undefined, function foo1(){})}
{test('function foo2()', 'foo2', function(){})}
{test('function()', undefined, ()=>{})}
{test('function foo3()', 'foo3', ()=>{})}
{test('async function()', undefined, async ()=>{})}
{test('async function foo4(a)', 'foo4', async a=>{})}
{test('async function foo5()', undefined, async function foo5(){})}
{test('async function foo5a()', 'foo5a', async function foo5(){})}
{test('async function* foo6()', 'foo6', async function*(){})}
{test('function*()', undefined, function * (){})}
{test('function(a,b=({foo:"bar"}))', undefined, (a,b=({foo:"bar"}))=>{})}
}
}
})
function signature(fn, options = {}) {
const {
name = typeof fn === 'function' && fn.name.length ? fn.name : null,
description = null,
example = null,
open = true,
tests = {},
runTests = RUN_TESTS.promise,
testRunner = defaultTestRunner,
scope = DOM.uid('css-scope').id,
css = signature_theme,
formatter = defaultFormatter,
signatureParser = defaultSignatureParser,
} = options;
const sig = typeof fn !== 'function' ? fn : signatureParser(fn, name);
let testList = null;
if(runTests && tests && Object.keys(tests).length) {
const {list, run} = testRunner(tests);
const button = html`<button>Run tests`, cta = html`<p class=cta>${button} to view results`;
testList = html`<div class=tests>${[md`Test results:`, cta]}`;
Promise.race([Promise.resolve(runTests), new Promise(r => button.onclick = r)])
.then(() => (cta.replaceWith(list), run()));
}
return formatter({
signature: typeof sig === 'string' ? code(sig) : sig,
description: typeof description === 'string' ? md`${description}` : description,
examples: (example == null ? [] : Array.isArray(example) ? example : [example])
.map(v => typeof v === 'string' ? code(v) : v),
testList,
}, {name, open, css, scope});
}
signature(getPinnedSlug, {
description: 'Retrieves the currently shared or published version of the given notebook identifier.',
example: [
`// Notebook slug
getPinnedSlug('@mootari/signature')`,
`// Notebook ID
getPinnedSlug('3d9d1394d858ca97')`,
],
tests: {
'custom slug': async assert => {
assert((await getPinnedSlug('@mootari/signature')).match(/@\d+$/))
},
'notebook id': async assert => {
assert((await getPinnedSlug('3d9d1394d858ca97')).match(/@\d+$/))
},
'pinned': async assert => {
assert((await getPinnedSlug('@mootari/signature@545')).match(/@545$/))
},
'fallback unpublished': async assert => {
assert((await getPinnedSlug('@mootari/signature@544')) === '@mootari/signature')
},
},
})
async function getPinnedSlug(identifier) {
const {groups} = identifier.match(regIdentifier) || {};
if(!groups) return null;
const {id, user, slug, version} = groups;
const name = id || `@${user}/${slug}`;
const path = `${id ? `d/${id}` : name}${version ? `@${version}` : ''}`;
return fetch(`https://api.observablehq.com/${path}.js?v=1`)
.then(r => r.text())
.catch(e => '')
.then(t => parseFrontmatter(t) || {})
.then(({version: v}) => name + (v ? `@${v}` : ''));
}
signature('PINNED', {
name: 'PINNED',
description: `Notebook slug, automatically pointing to the most recent version of the importing notebook.
If the notebook identifier cannot be derived from the current URL, the string \`(error: name not detectable)\` will be set instead.`,
example: `md\`
~~~js
import {foo} from "\${PINNED}"
~~~
`
})
const PINNED = (() => {
const match = document.baseURI.match(regIdentifier);
if(!match) return '(error: name not detectable)';
const {id, user, slug} = match.groups;
return getPinnedSlug(id || `@${user}/${slug}`);
})();
signature(code, {
description: `Creates syntax-highlighted output.`,
example: `
const myCss = \`.container { background: red; }\`;
return code(myCss, {
// Optional, defaults to Javascript. Supported languages:
//
language: 'css',
// Removes leading and trailing empty lines.
trim: false
});`,
})
function code(text, {type = 'javascript', trim = true} = {}) {
return md`\`\`\`${type}
${!trim ? text : text.replace(/^\s*\n|\s+?$/g, '')}
\`\`\``;
}
signature('RUN_TESTS', {
name: 'RUN_TESTS',
description: `Button that triggers all tests that have not run yet.`,
example: `
import {RUN_TESTS} from "${PINNED_LIB}"
// In another cell:
RUN_TESTS
`
})
const RUN_TESTS = view((() => {
const s = createStepper();
const view = html`<div><button>Run all tests`;
view.onclick = e => { e.stopPropagation(); s.next(); };
view.value = s;
return view;
})());
Internals
Old docs - under construction
Superstylin'
Note: This section is currently being reworked.
To extend the base theme, first import it:
To override the CSS for a single instance, pass the default CSS along with your custom CSS:
signature(demoFunction, {
description: `Scales dimensions proportionally to fit into the given width and/or height.`,
example: `const [width, height, scale] = scaleContain(img.naturalWidth, img.naturalHeight, 500, 500);`,
css: `
${signature_theme}
:scope {
background: LightYellow;
box-shadow: 1px 2px 5px -3px;
font-family: sans-serif;
}
:scope .description {
font-size: 1.2rem;
font-style: italic;
}
:scope .examples .code {
background: NavajoWhite;
}
`,
})
If you want to override the CSS globally (and also have shared instead of scoped styles), use the following steps:
// Scope is applied as a class - note the dot in the scope argument.
scopedStyle('.my-shared-scope', `
${signature_theme}
// Adds
:scope { border: 10px solid #888 }
`)
function myCustomSig(fn, options = {}) {
return signature(fn, {scope: 'my-shared-scope', css: null, ...options});
}
myCustomSig(demoFunction, {
description: `Scales dimensions proportionally to fit into the given width and/or height.`,
example: `const [width, height, scale] = scaleContain(img.naturalWidth, img.naturalHeight, 500, 500);`,
})
Testing
Tests can be incorporated into the documentation and executed
- by passing `{runTests: true}`, which will execute tests immediately,
- by clicking the `Run tests` button inside the documentation cell,
- or by passing a promise for `runTests`. This promise can be used to execute all tests on the page.
Select how tests should be run for the following example:
const runType = view(DOM.select([
'wait for interaction',
'run immediately',
'hide tests',
]))
signature(demoFunction, {
description: `Scales dimensions proportionally to fit into the given width and/or height.`,
example: `
const [width, height, scale] = scaleContain(img.naturalWidth, img.naturalHeight, 500, 500);
img.width = width;
img.height = height;
`,
runTests: {
'run immediately': true,
'hide tests': false,
'wait for interaction': RUN_TESTS.promise,
}[runType],
// Note: Tests contain deliberate errors to showcase the various states.
tests: {
'no target dimensions': assert => {
({}).callUndefined();
const [w, h, s] = demoFunction(200, 150);
assert(w === 200, 'width'); assert(h === 150, 'height'); assert(s === 1, 'scale');
},
'target width only': async assert => {
await Promises.delay(3000);
const [w, h, s] = demoFunction(200, 150, 2*200);
assert(w === 2*200, 'width'); assert(h === 2*150, 'height'); assert(s === 2, 'scale');
},
'target height only': assert => {
const [w, h, s] = demoFunction(200, 150, null, 2*150);
assert(w === 2*200, 'width'); assert(h === 2*150, 'height'); assert(s === 2, 'scale');
},
'same aspect': assert => {
const [w, h, s] = demoFunction(200, 150, 2*200, 2*150);
assert(w === 2*200, 'width'); assert(h === 2*150, 'height'); assert(s === 2, 'scale');
},
'smaller aspect ratio': async assert => {
await Promises.delay(2000);
const [w, h, s] = demoFunction(200, 150, 3*200, 2*150);
assert(w === 2*200, 'width'); assert(h === 2*150, 'height'); assert(s === 0, 'scale');
},
'greater aspect ratio': assert => {
const [w, h, s] = demoFunction(200, 150, 3*200, 2*150);
assert(w === 2*200, 'width'); assert(h === 2*150, 'height'); assert(s === 2, 'scale');
},
}
})
Contributions
- Thanks to Fati Chen for discovering badly handled cases in the signature parsing, and for suggesting improvements regarding name overrides.