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:

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

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