Writing Doc Comments
This site is built by tools/docs/generate.py from two independent
sources, so a doc comment lives or dies depending on which one actually reads it:
lua-language-server's--docJSON export. LuaLS parseslua/and exports every doc comment it understands — that means real LuaCATS annotations (--- @param,--- @return,--- @class, ...) attached immediately above a declaration, with no blank line in between. A blank line between the comment and the code it describes means LuaLS never associates the two, and the comment is silently dropped from the JSON entirely.generate.py's own raw-source scan, used only for each file's leading header comment (description,@namespace,@state). This one tolerates a blank line before the code that follows. Write headers as---lines.
Photon's comments were written for LDoc, which this pipeline
replaced. LDoc's tags (@module, @tparam, @treturn, @str, @int, @ent, ...) mean nothing to
LuaLS, so a file that still uses them lands on the site as a page named after its own path, with no
types on anything. Converting a file means rewriting those tags — see
Porting from LDoc at the bottom.
Pipeline
# 1. LuaLS walks lua/ and exports its own understanding of every doc comment.
lua-language-server --doc=lua --doc_out_path=tools/docs/out \
--configpath=tools/docs/luarc-docs.json
# 2. generate.py turns that JSON (plus docs/**/*.md) into this site.
python3 tools/docs/generate.py --json tools/docs/out/doc.json \
--output tools/docs/build \
--style-dir tools/docs/style \
--topics docs
Step 1 needs the base-game type definitions, which are a submodule:
git submodule update --init tools/docs/vendor/glua-api-snippets
Everything under lua/autorun/photon/library/, lua/tests/ and lua/internet_benchmark/ is
excluded from the export. The library tree is content — vehicles, components, sirens and presets
registered as data tables — and listing it would bury the engine's own pages several times over.
Publishing
.github/workflows/docs.yml runs both steps on every push and pull request, and on a push to
development it also publishes the result to the gh-pages branch, which is what
photonle.github.io/Photon serves. A push to master builds
but does not publish: Pages serves one branch, and two publishing into it would overwrite each
other.
The deploy replaces gh-pages outright rather than committing on top of it. The site is a full
rebuild every run, so anything already on the branch that the build did not just produce is stale by
definition — a page whose module was renamed, a topic that moved into a folder, a previous build's
content-hashed stylesheet. That also keeps the branch at one commit, so the repository isn't carrying
a copy of the whole site for every push it has ever had.
Definition-only files
Some of Photon's API is generated at load time — Photon.SNet:Map hangs a Set/Get pair off the
Entity metatable for every variable it registers, and nothing declares those in source. Neither
LuaLS nor your editor can see an API that only exists once the game is running, so those are declared
by hand in lua/autorun/photon/meta/, as --- @meta files full of empty declarations.
Nothing on any load path reads that folder; it exists to be read by tooling. It carries its own
.glualint.json turning off lint_unusedVars, since every parameter in a declaration-only file is
unused by construction — that exception is scoped to the folder, so it can't quietly cover real code.
Seeing every feature at once
tools/docs/fixtures/stubs.lua is a stub file using every tag,
realm, callout, type and section the site renders. It is never loaded by the game, and it sits
outside lua/ so its fake symbols can never reach the published site. Build just that file when
changing generate.py or the stylesheet — it is far quicker than hunting for a real function that
happens to use the feature you touched:
lua-language-server --doc=tools/docs/fixtures --doc_out_path=tools/docs/stub-out \
--configpath=tools/docs/luarc-docs.json
python3 tools/docs/generate.py --json tools/docs/stub-out/doc.json \
--output tools/docs/stub-build \
--style-dir tools/docs/style --topics docs \
--source tools/docs/fixtures
test_generate.py fails if the stub stops covering a callout kind, a realm, or the
example/section/alias tags, so it cannot quietly fall behind the generator.
Topic files and folders
Every markdown file under docs/ is published, and the folder it sits in decides where. Top-level
files are listed directly under the sidebar's Topics heading. A subfolder becomes a single
collapsed entry at the bottom of that same list, linking to the folder's index page —
docs/design/x.md publishes to topics/design/x.html. The folder's pages only unfold in the
sidebar while you're on one of them.
Folders nest to any depth, and each level gets its own index page. There is no per-folder config;
creating the folder is the whole step. Add an index.md to give the folder a name and an intro — its
title becomes the sidebar label. The list of what's in the folder is always generated.
topics/ mirrors docs/, so a relative link between two topic files stays correct on the site as
well as on GitHub — generate.py only swaps the .md for .html. That mirroring cuts both ways:
moving a doc into a folder changes its depth, so its own ../ links and everyone else's links to
it need updating in the same change. A relative link that points above docs/ — ../README.md,
../lua/autorun/photon/shared/sh_simplenet.lua — is rewritten to the repo browser rather than left
to 404.
A page's opening <h1> is dropped from the rendered HTML — the title already sits in the header bar
above it. Keep writing the heading; it's what makes the file readable on GitHub, and it's the
fallback when there's no front-matter title.
Front matter
Any topic may open with a --- block. It's flat key: value lines, not real YAML — the docs build
has no YAML dependency, and a block that isn't in that shape is left in the page as ordinary
markdown rather than silently swallowed.
| Key | Effect |
|---|---|
title |
Sidebar label, page header, and index row. Overrides the first heading. |
status |
Index column. Free text — Proposed, Landed, Implemented. |
date |
Index column. |
description |
Index column. One line on what the doc covers. |
pinned |
true/yes/1 lifts the doc to the top of its list, marked with a pin. |
featured |
true/yes/1 adds the doc to the Important Topics list on the front page. |
index |
true promotes the doc to be its folder's landing page. |
Precedence for a folder's landing page, most deliberate first: a doc with index: true, then
index.md, then a doc named after the folder it sits in, then a generated listing.
Function-level: use real LuaCATS annotations
Write these as --- @tag ... directly above the function/assignment they describe, no blank line.
generate.py reads the native args/returns LuaLS already resolved from the function signature
itself, so the types are accurate, and only borrows the tag's text for the human-readable
description.
Four rules decide whether an annotation counts at all. Break any one and it still looks fine in the diff and is silently dropped:
- Three dashes, always.
--- @paramcounts,-- @paramdoes not. - Directly above the declaration, with no blank line.
- Description first, tags after.
- Names must match the real signature.
--- @param ent Entityonly binds if the function actually has a parameter calledent.
| Annotation | Syntax | Notes |
|---|---|---|
@param |
--- @param <name> <type[?]> [description] |
Name first, then type — the reverse of LDoc's @tparam. ? on the type marks it optional. |
@return |
--- @return <type> [<name> [comment]] |
One @return line per return value, in order. |
@class |
--- @class [(exact)] <name>[: <parent>...] |
Defines a struct/type, optionally with inheritance. |
@field |
--- @field [scope] <name> <type[?]> [description] |
Documents a field of a @class. |
@type |
--- @type <type> |
Declares the type of a variable, e.g. --- @type string[]. |
@alias |
--- @alias <name> <type> |
Names a type (or enum) for reuse elsewhere. |
@enum |
--- @enum [(key)] <name> |
Marks a table as an enum, usable at runtime. |
@overload |
--- @overload fun([param: type...]): [return_type] |
An additional call signature. |
@generic |
--- @generic <name> [:parent_type] |
Generic type placeholder. |
@deprecated |
--- @deprecated [reason] |
Also renders as a callout — see below. |
@nodiscard |
--- @nodiscard |
Warns if the return value is ignored at the call site. |
@see |
--- @see <symbol> |
Cross-reference to another documented symbol. |
@private / @protected / @package |
--- @private |
Restricts where the item is considered accessible. |
@diagnostic |
--- @diagnostic <state>:<diagnostic> |
Suppresses/enables a specific LuaLS diagnostic. |
Two annotations are explicitly not used here:
@module— LuaLS reserves this for its ownrequire()-return-type inference and silently drops any doc comment that uses it. Use@namespaceinstead. Photon's LDoc-era headers all use@module, which is why so many pages are still named after a file path.@vararg— deprecated by LuaLS itself in favour of--- @param ... <type> [description].
Minimal example:
--- Get the latest cached value of a networked variable on an entity.
--- @param ent Entity The entity to read the value from.
--- @param name string The registered variable name.
--- @param default any? Value to return when nothing has been networked yet.
--- @return any value The networked value, or `default`.
function NET:Get(ent, name, default)
...
end
Function-level: this codebase's own tags
These are custom to generate.py, not LuaCATS — but like @param/@return, they're written
directly above the function or table they describe, with no blank line.
Important
Write these with a space after the dashes —
--- @state server, not---@state server.LuaLS parses
---@tagas an annotation and drops any tag it doesn't recognise, so the tag never reachesgenerate.pyand the whole line vanishes with no error.--- @tagis description text as far as LuaLS is concerned, which is exactly how these survive into the export.
| Tag | Purpose |
|---|---|
@section <title> |
Starts a new named subsection on the module page. Every following item in the file (until the next @section) is grouped under it, so it's usually written once per group. The tag's own value is the heading — write it as you want it read. A one-word lowercase slug is title-cased. |
@state <word> |
One of client, server, menu, shared, global, clmenu — drives the realm badge shown next to the item. Falls back to whatever @state the file header declared. |
@example <code> |
Renders an "Example" code block after the parameters and returns. Continuation lines are kept as lines. Not @usage, which is an LDoc tag and renders nothing. |
Callout tags
Seven tags render as callout boxes, in the manner of the GMod wiki's note boxes. They always appear in this order on an entry — whether you should be using this at all, then what is wrong with it, then advisories — regardless of the order they were written in:
| Tag | Box | Stock text when no text is given |
|---|---|---|
@deprecated [text] |
Deprecated | "This function is deprecated and may be removed in a future version." |
@internal [text] |
Internal | "This function is internal. Whilst you can call it, you probably shouldn't." |
@bug <text> |
Bug | — |
@warning <text> |
Warning | — |
@ambig [text] |
Ambiguous | "This entry is unclear or incomplete." |
@validate [text] |
Validate | "This entry has not been checked against the code recently." |
@note <text> |
Note | — |
@bugs, @warns, @notes and @ambiguous are accepted as alternative spellings — @warns is the
LDoc spelling Photon's older comments use, and it still works. Several spellings of the same tag on
one function collapse into a single box. The text is rendered as markdown, so links and code work
inside a callout.
Topics can use any of these as GFM alerts too — > [!BUG], > [!DEPRECATED] — alongside the
standard [!NOTE], [!TIP], [!IMPORTANT], [!WARNING] and [!CAUTION].
--- Send the current value of a networked variable to a recipient.
--- @param ent Entity The entity the value belongs to.
--- @param name string The registered variable name.
--- @internal
--- @warning `name` must already be registered with `Photon.SNet:Map`.
--- @state server
function NET:SendChange(ent, name, val, to)
...
end
Constant families: @alias gathers the constants it names
A page that declares a long run of prefixed constants would otherwise list every one of them as a
top-level entry, interleaved alphabetically with everything else. An @alias over those constants
turns them into a section of its own: the alias becomes the heading, its constants sit beneath it,
and it gets its own line in the page's Contents.
--- The wire types a SimpleNet variable can be registered as.
--- @section Network Types
--- @alias PhotonNetType
---| `Photon.SNet.BOOL`
---| `Photon.SNet.INT`
---| `Photon.SNet.UINT`
---| `Photon.SNet.STR`
The constants must be documented on the same page as the alias, and two is the minimum — below that a heading costs more than it saves. An alias whose members are string literals or other types has no constants to gather, so it stays an ordinary entry, rendered as an Accepted values list.
Class pages: @namespace <ClassName> gathers a class's members
Photon hangs methods off Entity from several files at once, and none of them owns the metatable.
Each member would otherwise land on the page of whichever file happens to declare it, or on a page
derived from a different file's path entirely.
Declaring @namespace <ClassName> on one of the class's files makes that the class's page, and every
<ClassName>.member follows it there, wherever it was written. A @class that exists to describe a
library's own table declares no such namespace, so its fields stay on that library's page next to the
thing they describe.
Classes extended via FindMetaTable("X") are detected from the source and claim a page the same way
without needing to declare a namespace; they're listed under Metatables in the sidebar rather
than under Modules.
File-header tags
The very first comment in a file (--- line comments; a blank line before the code is fine) is
scanned separately for these. They are not LuaCATS and LuaLS doesn't know about them:
| Tag | Purpose |
|---|---|
@namespace <name> |
Not @module. Groups every doc'd item in this file onto one page named <name>, merging with any other file that declares the same namespace (e.g. a cl_*.lua/sv_*.lua pair). Without it, the page falls back to the file's own dotted path (autorun.photon.shared.sh_simplenet). Must not contain ... |
@state <word> |
Same word list as the function-level tag. On a file header this becomes the default realm badge for every item in the file that doesn't declare its own. |
@copyright, @release, @author |
Accepted and parsed, but not currently rendered on the site — keep them for humans reading the source. |
--- Regular Simplified Networking.
--- Easily optimise networking for setting local values on entities.
--- @copyright Photon Team
--- @author Photon Team
--- @namespace Photon.SNet
--- @state shared
Porting from LDoc
Photon's comments predate this pipeline. A file is converted by rewriting its tags; the generator
still understands the LDoc-era callout spellings (@warns, @bugs) and the @state tag, so those
can stay as they are.
| LDoc | LuaCATS |
|---|---|
-- @tag (two dashes) |
--- @tag (three) |
@module Photon.SNet |
@namespace Photon.SNet on the file header |
@tparam Entity ent Desc. |
@param ent Entity Desc. — name first |
@str name Desc. |
@param name string Desc. |
@int n Desc. |
@param n integer Desc. |
@bool b Desc. |
@param b boolean Desc. |
@ent e Desc. / @ply p Desc. / @veh v Desc. |
@param e Entity Desc. / @param p Player Desc. / @param v Vehicle Desc. |
@tab t Desc. |
@param t table Desc. |
@param[opt] x Desc. |
@param x any? Desc. — the ? goes on the type |
@treturn int Desc. |
@return integer Desc. |
@rint Desc. / @rbool Desc. / @rstring Desc. |
@return integer Desc. / @return boolean Desc. / @return string Desc. |
@usage <code> |
@example <code> |
@alias NET (LDoc's "this table is the module") |
Delete it — LuaCATS @alias means something else entirely |
lua/autorun/photon/shared/sh_simplenet.lua is the
worked example: it is fully converted, and its page is what a converted file looks like.