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).
The search
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.