@leandrose/project-documentation
v0.2.4
Published
Dynamic project documentation server for rendering Markdown content and serving OpenAPI/Swagger specifications with integrated HTML documentation pages.
Maintainers
Readme
Project Documentation System
Reusable Node.js documentation server for project Markdown files and OpenAPI/Swagger/AsyncAPI specifications.
Requirements
- Node.js
>=20(as declared inpackage.json)
Install
npm install --save-dev @leandrose/project-documentationUsage
The package is published as @leandrose/project-documentation, and the executable command remains project-documentation.
Add a script to the consuming project. The first argument is the path to the documentation directory:
{
"scripts": {
"docs": "project-documentation docs/"
}
}The CLI validates that this path exists and is a directory. Run:
npm run docsThe server tries port 4201 first. If it is unavailable, it tries ports from 33000 to 33999 and prints the selected URL in the console.
You can also pass a custom port with --port:
{
"scripts": {
"docs": "project-documentation docs/ --port 5000"
}
}By default, the server binds to the local host. To allow access from another interface, pass --host:
{
"scripts": {
"docs:network": "project-documentation docs/ --port 5000 --host 0.0.0.0"
}
}Security warning: using
--host 0.0.0.0exposes the documentation server to external network access. Only use it in trusted networks and avoid serving sensitive documentation.
Documentation layout
docs/
├── md
│ ├── index.md
│ ├── introduction.md
│ └── guides
│ ├── _category.md
│ ├── index.md
│ └── first-request.md
├── openapi
│ ├── openapi.yaml
│ └── users.yaml
└── websocket
├── asyncapi.yaml
└── module.yamlThe root route selects the first available section in this order: OpenAPI, Documentation, then WebSocket. If the physical openapi/ directory exists, / renders Redoc for /openapi/openapi.yaml. Otherwise it redirects with 302 to /md/ when md/ exists, or to /websocket when websocket/ exists. If none of these directories exists, it returns 404 with Documentation not found. This decision is recalculated on every request. A real directory counts even when empty or missing its expected entrypoint; symbolic links do not count.
Routes
/- Redoc for/openapi/openapi.yamlwhenopenapi/exists; otherwise a302redirect to/md/, then/websocket, according to the first existing section; returns404 Documentation not foundwhen no section exists/swagger.html- Swagger UI for/openapi/openapi.yaml/oauth2-redirect.html- Swagger OAuth redirect page/md/- renders the rootmd/index.mdpage, or the default home page when it is absent/md/<path>.md- renders a root or nested Markdown file as styled HTML;/md/index.mdremains supported/openapi/<name>- Redoc page for a spec underdocs/openapi/openapi/<name>?swagger- Swagger UI for a spec/openapi/<name>.yaml- processed OpenAPI YAML output/websocket- browser-rendered documentation fordocs/websocket/asyncapi.yaml/websocket/<spec>- browser-rendered documentation for an AsyncAPI specification underdocs/websocket/websocket/<spec>.<yaml|yml|json>- AsyncAPI specification output
OpenAPI files can be YAML or JSON. Specifications are processed with @apidevtools/swagger-parser, so local $ref dependencies are bundled before being returned. base.yaml may be used as a shared document with paths: {}.
Browser caching is disabled for OpenAPI pages and responses, and each reload attempts to generate a fresh bundle. A short retry handles partially saved files. If generation still fails, the last valid bundle is served, when available, with the X-OpenAPI-Stale: true header; without a previous bundle, the server returns a temporary 503. This avoids restarting the server while editing, but a stale response means the current files still contain an error.
AsyncAPI specifications belong under docs/websocket. Files may use the .yaml, .yml, or .json extension. AsyncAPI pages use @asyncapi/react-component in the browser. The server does not run AsyncAPI Generator and does not create per-request temporary files or directories. The browser fetches the specification with no-store; after changing a specification, reload the page to see the update—restarting the server is not required.
Automatic Markdown sidebar
The filesystem under <docs-path>/md/** is the exclusive source of truth for Markdown navigation. The server rescans it on every Markdown request, with no sidebar cache or manual navigation configuration. Markdown files directly in md/ are root pages, and directories become recursively nested categories with no depth limit. Effectively empty categories are omitted, but a category containing only a valid index.md remains visible. A root md/index.md is a page whose canonical sidebar URL is /md/; /md/index.md remains valid for compatibility. A nested index.md is a real page node at /md/<directory>/index.md, displayed as the category's first child with its own title and metadata. Its sidebar_position is preserved as metadata but never moves it from that first position.
Markdown pages and optional _category.md category files accept YAML frontmatter:
---
title: Integration guides
sidebar_position: 2
collapsed: false
custom_field: preserved
---
# Integration guidestitle must be a string and sidebar_position a finite number. collapsed, when present, must be a boolean; it controls categories and is validated but ignored on pages. Unknown fields are preserved as metadata but ignored by navigation. Frontmatter uses exact --- delimiter lines and accepts an initial BOM and common line endings. A truly empty or whitespace-only frontmatter block is valid, but an explicit empty YAML mapping ({}) is invalid; other non-empty frontmatter must contain a YAML mapping. The block is removed before Marked renders the body. Titles fall back to a readable filename or directory name, not the first heading.
The root md/index.md may also set the shared HTML topbar's brand:
---
title: Home
project_name: Product Documentation
---project_name must be a string. Only the root index has an effect; an absent, empty, or whitespace-only value uses Documentation. The field is still validated if it appears in another page or category file.
At each level, items with non-negative positions come first in ascending order, followed by items without a position sorted by title, followed by negative positions in -3, -2, -1 order. Titles use case-insensitive, accent-aware, numeric Portuguese (pt-BR) collation after NFC normalization. Final ties use the raw, unnormalized relative path in ordinal order, so canonically equivalent NFC and NFD paths remain distinct. Pages and categories have no type priority.
The sidebar uses an AdminLTE v2-inspired visual language without depending on AdminLTE. Every page link and category button fills the row at its level, so hover and keyboard focus cover the complete row. Nested levels receive cumulative indentation with no fixed depth limit. Categories are closed by default (collapsed: true) and can be expanded independently; collapsed: false initially opens one. The browser does not persist the choice, and all ancestors of the active page open automatically. The current page has aria-current="page", the existing active background, an inset box shadow, and a heavier font weight; page links never receive an .active class.
A category header is one full-row button, not a link, and contains no nested controls. It exposes aria-expanded, aria-controls, and an aria-label of Expand <title> or Collapse <title>. Opening a closed category also activates its first nested index page when one exists. Clicking an open category only collapses it without navigation; a category without an index only toggles. The browser behavior is served by /assets/sidebar.js.
Shared HTML topbar
The documentation topbar appears on rendered Markdown, Redoc, Swagger UI, and AsyncAPI/WebSocket HTML pages. It is not added to YAML or JSON specification responses or /oauth2-redirect.html. Documentation is active on /md/**; OpenAPI is active on /, /swagger.html, /openapi/<name>, and the corresponding ?swagger view; AsyncAPI is active on /websocket and /websocket/<name>.
Topbar sections are recalculated on every applicable documentation HTML request from the physical directories directly under <docs-path>. A section appears whenever its corresponding real directory exists, even if the directory has no entrypoint: md adds Documentation at /md/, openapi adds OpenAPI at /, and websocket adds AsyncAPI at /websocket. Symbolic links do not count as directories for this menu.
Markdown requests use their already scanned navigation tree to obtain project_name. OpenAPI and AsyncAPI pages safely read only md/index.md on each request. If that file is absent, a symlink, or not a regular file, they use Documentation. Invalid frontmatter is logged and also falls back to Documentation without blocking the API page; operational read errors still propagate.
API pages load /assets/documentation.css, use a .documentation-api-shell body with an isolated topbar wrapper and main.documentation-api-content, and keep body scrolling enabled. Their minimum height accounts for the 50 px desktop topbar and 94 px mobile topbar. They do not load /assets/sidebar.js. Swagger UI retains StandaloneLayout; only its native topbar inside .documentation-api-shell #swagger-ui is hidden to avoid a duplicate header.
The right side contains a link to https://mirvo.dev/leandrose/project-documentation that opens in a new tab and displays the remote https://mirvo.dev/assets/img/logo.svg logo at 32 × 32 pixels. The link has an accessible name indicating that it opens in a new tab and sends no referrer.
_category.md configures only its parent category and returns 404 if requested directly. Other names beginning with _ are excluded from navigation, although regular private Markdown files such as _private.md remain directly accessible at their .md URLs for compatibility. Only regular files are served by Markdown routes; symlinks, directories, and other non-regular entries return 404 when requested directly and are ignored by navigation. Path traversal outside md/ continues to return 403.
Invalid frontmatter found while scanning is logged with its relative file, line, column, and cause, and only that navigation item is omitted; an invalid _category.md falls back to category defaults. Directly requesting an invalid Markdown page returns 422 with the same safe diagnostic and no absolute path or stack trace. Markdown responses use Cache-Control: no-store, so filesystem changes are reflected on reload without restarting the server. Because every Markdown request rescans the complete tree, very large trees may have a noticeable request-time cost.
Markdown URLs retain the /md/<path>.md form; clean extensionless routes are not generated. Sidebar labels are HTML-escaped and links are derived from URL-encoded filesystem path segments. Category toggle IDs encode the raw UTF-8 relative path without NFC normalization, keeping NFC and NFD names distinct. Markdown body HTML is not sanitized: raw HTML supported by Marked is rendered as provided. Treat the Markdown tree as trusted content, especially when exposing the server on a network.
The sidebar and documentation-context modules are internal implementation details and do not add a public package API. Existing OpenAPI, AsyncAPI, OAuth, PlantUML, status-code, and routing behavior remains compatible.
Markdown PlantUML support
Markdown files can include PlantUML diagrams using fenced code blocks with the plantuml language:
```plantuml
@startuml
Alice -> Bob: Hello
Bob --> Alice: Hi
@enduml
```The documentation server renders these blocks as images using the public PlantUML server:
https://www.plantuml.com/plantuml/svg/<encoded-diagram>The diagram content is encoded with plantuml-encoder before being added to the image URL.
Privacy note: because the image is loaded from the public PlantUML server, the encoded diagram content is sent through that external service. Avoid using sensitive information in PlantUML diagrams unless this behavior is acceptable for your project.
Development
npm install
npm testStart the server against the repository fixtures with:
npm startThis runs project-documentation with test/fixtures/docs as its documentation path. The server tries port 4201 first and, if unavailable, tries ports 33000 through 33999. Use --port or --host when needed; see the usage examples above.
