Skip to content

omnisense/omnidocs

Omnidocs: one model of a manual, whatever wrote it - Markdown, Sphinx, Doxygen, JSDoc: its versions, its pages (title, contents, headings, the file to edit), its API entries and their anchors, read through one source contract; a search index a static page searches, and the HTML conventions of symfony.com/doc (callouts, versionadded, code with captions and tabs) every format renders to.

Floor
Family
Version
1.x
Conformance suite
runs, CI red
Verified for real
Verified in part
Licence
MIT
composer require omnisense/omnidocs:1.x-dev

The HTML conventions

Every source writes the same HTML (Html\Conventions), the conventions of symfony.com/doc:

What HTML Markdown (omnidocs/markdown)
a callout <blockquote class="doc-callout doc-callout-note" data-label="Note"> > [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION], [!SEEALSO]
new in a version <blockquote class="doc-callout doc-callout-versionadded" data-label="New in version 1.4" data-version="1.4"> > [!VERSIONADDED] 1.4
deprecated ... doc-callout-deprecated ... data-version="2.0" > [!DEPRECATED] 2.0
a version badge <span class="doc-version doc-version-versionadded" data-version="1.1"> a heading ending with a known version: ### Keys (1.1)
a code frame <div class="doc-code" data-lang="js"><div class="doc-code-caption">app.js</div><pre><code class="language-js"> ```js caption="app.js"
code tabs <div class="doc-tabs" data-doc-tabs>, a tab list, a panel each blocks that follow each other, each ```yaml tab="YAML"

The labels are English by default; new Conventions(['note' => 'Remarque', 'versionadded' => 'Nouveau en %version%']) (a provider's labels option) says them in the reader's language. Sphinx's admonitions, versionadded and highlighted blocks, Doxygen's \note, \warning, \see, \since and code examples become the same.

Propose a change docs/conventions.md

The Docker harness

cd docker
docker compose run --rm omnidocs test     # every package's suite, BareTest included
docker compose run --rm omnidocs bare     # plain PHP: a manual, its pages, its search index; what PHP loaded
docker compose run --rm js                # assets/omnidocs.js on node's test runner
docker compose run --rm sphinx            # Requests' docs through Sphinx 8.1.3's JSON builder -> real/sphinx
docker compose run --rm doxygen           # TinyXML-2 through Doxygen -> real/doxygen
docker compose run --rm jsdoc             # flowjs through JSDoc 4.0.5 -> real/jsdoc.json
docker compose run --rm omnidocs real     # each provider's conformance suite on the whole of real/
docker compose down -v

The providers come from their checkouts beside the core (OMNIDOCS_PLUGINS, ../.. by default: the workspace), from GitHub for those that are not there. The fixtures the providers' own suites read were cut from the same tools' output: Fixtures/*/COMMIT names the project's commit.

Propose a change docs/harness.md

Installation and a manual in plain PHP

composer require omnisense/omnidocs:1.x-dev omnidocs/markdown:1.x-dev

The core needs PHP 8.2 and nothing else; each provider requires what its format needs (league/commonmark for Markdown, nikic/php-parser for PHP sources, ext-dom for Doxygen).

require __DIR__.'/vendor/autoload.php';

use Omnidocs\Linking\RepositoryLinker;
use Omnidocs\Markdown\MarkdownSourceFactory;
use Omnidocs\Php\PhpSourceFactory;
use Omnidocs\Model\Version;
use Omnidocs\Registry;

// No container: the registry is built by hand, its sources by name.
$docs = new Registry([new MarkdownSourceFactory(), new PhpSourceFactory()], [
    'manual' => ['factory' => 'markdown', 'options' => ['root' => __DIR__, 'docs' => 'docs', 'home' => 'README.md']],
    'api' => ['factory' => 'php', 'options' => ['root' => __DIR__]],
]);

$version = new Version('1.x', $docs->get('manual'), '1.x', 'abc1234');
$links = new RepositoryLinker(fn (string $path, ?string $anchor) => '/docs/'.$path.($anchor ? '#'.$anchor : ''), 'acme/widget', 'abc1234');

foreach ($version->pages() as $node) {
    $page = $version->page($node->path, $links);
    echo $page->title, ': ', count($page->headings()), " headings\n";
}

A page's links are the linker's: page() for another page of the manual, file() for another file of the repository (RepositoryLinker: github.com at the commit read, never a moving branch), image() for a picture. A site gives its own addresses there.

In Symfony, register the factories and the registry as services (autowiring takes them as they are) and Twig's namespace Omnidocs for vendor/omnisense/omnidocs/Bridge/Twig/templates.

Propose a change docs/installation.md

The model and the sources

Class What it is
Model\Manual a key, a title, its versions by name, the default one, its repository
Model\Version a name, a source, the branch it follows, the commit it is read at, its end of life; its pages in reading order (the home, then the tree depth first), previous() / next()
Model\Node a place in the contents: a page (its file in the repository) or a section (isSection()) holding pages
Model\Page a page rendered: path, title, Rendered (HTML, title, headings, metadata, plain text), source file, its API entries
Model\Heading level, id, text
Model\Symbol an API entry: kind ("class", "method", "option", "py:function"...), name, page, anchor, summary, signature, parent, source line

SourceInterface asks three things of a format: home() and tree() (the contents), page($path, $links) (a page rendered, its links through the linker), symbols() (the API entries). A source reads files that exist - a checkout, a tool's output - and never runs the tool nor reaches the network.

A provider's factory extends SourceFactory: its defaults in populate(), its required options in omnidocs.required_options, the source built in build(). Its tests use Testing\SourceContractTests on a real tool's output:

  • the contents are nodes, one path each, the home at "", sections hold pages and no file;
  • every page renders, with a title, no script, and each heading it lists at its anchor;
  • a path that is no page gives none;
  • its links to pages go to pages it has;
  • every API entry is on a page at its anchor (a source that documents an API must give entries);
  • a page read twice is the same.

Propose a change docs/model.md

One page per manual, the sidebar, the search

OnePage reads a version as one page, at one address: the home first, as it is, then every page as a part - a <section class="doc-part"> whose title is an <h2 id="<part>">, its own headings one level down (its h2 an h3), every id of the part prefixed with the part's anchor (keys becomes sticky-keys). A link from a page to another page becomes an anchor of the page (#sticky, #sticky-keys); files and pictures keep the linker's addresses.

$one = new OnePage($version, $links, '/docs/flowjs/1.x');
foreach ($one->parts() as $part) {
    echo $part->html;                 // and an "edit this page" link to $part->source
}
$one->toc();                          // [['heading' => Heading, 'children' => [Heading...]], ...]
$one->redirects();                    // ['sticky' => ['anchor' => 'sticky', 'prefix' => 'sticky-'], ...]
$one->urlOf('sticky', 'keys');        // /docs/flowjs/1.x#sticky-keys

The former address of each page redirects to its part: with omnibridge/static, StaticResponse::redirect('/docs/flowjs/1.x#sticky', 301, fragment: 'sticky-') (or a host response's X-Static-Fragment: sticky- header) writes a page whose script sends /docs/flowjs/1.x/sticky#keys to /docs/flowjs/1.x#sticky-keys.

The sidebar

assets/omnidocs.css lays the page out: .omnidocs-layout holds .omnidocs-sidebar beside .omnidocs-content; the sidebar sticks (position: sticky, --omnidocs-top below the top) with its own scroll when it is taller than the screen; under 64rem it is a drawer opened by [data-omnidocs-toc-toggle]. @Omnidocs/toc.html.twig writes the table of contents ([data-omnidocs-toc]); assets/omnidocs.js marks the heading being read (.is-current, aria-current="location"), opens its section, keeps it in sight in the sidebar, and keeps the address on it with history.replaceState - the back button is not filled with sections.

An entry with entries under it is a disclosure: its dot and its name form a button ([data-omnidocs-toc-disclose], aria-expanded, aria-controls naming its list), and the section itself is reached by the small arrow beside it (a.omnidocs-toc-go, named "Go to …" - the template's go variable gives the words). By default only the entry being read is open, and the one open follows the reading; an entry the reader opened or closed by hand stays so until they change it. Enter and Space toggle it (it is a button), ArrowRight opens it, ArrowLeft closes it; it opens with a short animation, none under prefers-reduced-motion; the phone's drawer is the same list. Without the script every entry stays open.

{% include '@Omnidocs/toc.html.twig' with {toc: one.toc(), label: 'On this page', go: 'Aller à'} %}

Groups. new OnePage($version, $links, $url, groups: true) takes the manual's top-level sections (a folder of Markdown files, a toctree caption, a group of API entries) as groups of its parts: the table of contents lists each group with its pages under it ('group' => true on the entry), and the page puts the group's title before its first part (<div class="doc-group" id="group-navigation"><p class="doc-group-title">Navigation</p></div>). A section of a single page that bears its name is that page alone. groups() lists them: [['id' => 'group-navigation', 'title' => 'Navigation', 'paths' => ['transparent', 'flowframe']], ...].

A site gives its look through custom properties: --omnidocs-bg, --omnidocs-fg, --omnidocs-muted, --omnidocs-line, --omnidocs-soft, --omnidocs-accent, --omnidocs-top, --omnidocs-drawer-top, --omnidocs-sidebar, --omnidocs-mono (the defaults have no specificity: any rule of the site wins).

Search\SearchIndex writes one JSON file the browser downloads whole, the first time the reader searches: onePage() gives a record for the page, one per part, per heading (levels 2 and 3), per API entry - each leading to its anchor - and add() takes the site's own (Entry: kind, title, address, text, headings, extra words, page, signature; short keys k t u d h x p s).

@Omnidocs/search.html.twig writes the button, assets/omnidocs.js the palette: ⌘ K (Ctrl K off a Mac) or / opens it from anywhere, every word must be found (accents and case ignored), the title counts more, an API entry named by its last part leads; arrows move, Enter opens, Escape closes. The browser's find in page (⌘ F, Ctrl F) is never taken: one page holds the whole manual, so it finds everything there too.

Propose a change docs/one-page.md