Pitch: Support for custom scripts in DocC
- Authors: Lucca de Mello (@Lucca-mito) & mentor Jim Haungs (@jhaungs) for the 2024 Swift Mentorship Program.
- Status: implemented.
- Implementation: swift-docc, swift-docc-render.
Introduction
We propose first-class support for adding custom scripts to a DocC-generated website. These may be local scripts, in which case the website will continue to work offline, or they may be external scripts at a user-specified URL. This support will be in the form of a custom-scripts.json
file (spec), the scripting analog of theme-settings.json
.
For a demo of this proposal and the accompanying implementation, see here and here. Notice that the LaTeX expressions in the documentation text are dynamically rendered into equations. In this pitch, we’ll show how you can use the proposed feature to achieve this in your documentation pages.
Motivation
The Single-Page Applications generated by DocC already come with a design and behavior well suited for most documentation websites. While theme-settings.json
allows DocC users to customize the design, there is an unmet demand to customize the behavior.
There are at least two use cases with documented community demand for customizing the website behavior: rendering LaTeX and diagramming.
Rendering LaTeX
Documentation pages often need to include mathematical expressions. For example, Apple’s documentation pages for Accelerate (which are made with DocC) are enriched by equations and matrices that help clarify the documentation's text. But the current approaches for adding math to documentation websites — images and Unicode math — are insufficient.
When I pitched support for MathML in DocC as a potential solution — the preceding paragraph is adapted from, and elaborated in, that pitch — the main bit of feedback I received is that some (if not most) documentation writers would prefer to write their math in LaTeX, not MathML, rendered dynamically using a JavaScript library like MathJax or KaTeX.
There is community demand for MathJax/KaTeX support in DocC. And documentation authors would be more inclined to augment their documentation with explanatory math if it were as easy as importing a popular library and writing the math inline, in the standard language for doing so (LaTeX).
Diagramming
Similarly, an open issue in swift-docc-render requests support for rendering diagrams using a JS library like Mermaid or D2.
Diagrams are useful in documentation pages for clarifying the behavior and component relationships of a software package. But, as with math, the current solution for displaying diagrams — externally-generating and including an image — leads to a cumbersome workflow, having to keep multiple files in sync, and subpar results.
Playground
One could also use custom scripts to demonstrate the unique features of your package by embedding, into its documentation website, both:
- A web-based Swift-to-Wasm compiler (if the documentation website is for a Swift package, analogously if otherwise).
- An interactive coding playground (loaded with your package).
Then readers can easily try out your package as soon as they learn about it, or experiment with it while consulting its docs.
Whatever you want
The principal motivation for this proposed feature is so that documentation authors can tailor their documentation website to the specific needs of their package. For example, if your package is used for physics simulation, is a game engine, or does low-level rendering, you could add a 3D scene to your website dynamically rendered or simulated with your package (if the package supports the Wasm target). You could embed a WebXR experience, a drawable canvas, or any other technology that may be useful for explaining, demonstrating, or promoting your package.
You could add your own quality-of-life enhancements to the documentation website, or make changes to the stylesheet or page structure that are not yet directly supported. You could have your documentation website show confetti on its release anniversary or add fun easter eggs. Custom scripts remove the limitations on customization.
Proposed solution
Users can add a custom-scripts.json
file to their documentation catalog. The file consists of an array of “custom script” objects that specify where the source code of each script is; and, optionally, how it should be loaded and when it should be run. The source code for each custom script can be in one of three locations:
- At an external URL specified by a
url
property. - In a local file specified by a
name
property. While the file names of local scripts must have the “.js” extension, the extension may be omitted from the value of thename
property. - Inline with the declaration of the script; that is, in the
custom-scripts.json
file itself. Write inline scripts as a string in a custom script’scode
property.
#1 (scripts at an external URL) are easiest to add, so they’re suitable for developers who are ok with their documentation website requiring an internet connection.
However, some developers will want their documentation website to continue to work offline even if they add custom scripts. This is useful for previewing the documentation website while developing the package without an internet connection, and it’s essential for developers who want to distribute a documentation archive that can be opened offline. So, in this scenario, the best way to add custom scripts is via #2 (local script files).
For one-line scripts that don’t warrant their own file, inline scripts (#3) are a convenient alternative to #2.
Example usage: adding MathJax
Before jumping into the detailed design: as an illustrative example, below is a custom-scripts.json
(with an accompanying local configuration script) that adds the MathJax library to your documentation website.
// custom-scripts.json
[
{
"name": "mathjax-config",
"defer": true
},
{
"url": "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js",
"defer": true,
"integrity": "sha384-KKWa9jJ1MZvssLeOoXG6FiOAZfAgmzsIIfw8BXwI9+kYm0lPCbC6yTQPBC00F1/L"
},
{
"code": "MathJax.typeset();",
"run": "on-navigate"
}
]
// mathjax-config.js
MathJax = {
tex: { inlineMath: [['$', '$']] }
};
The first object in the example custom-scripts.json
above tells the website to run a local script called mathjax-config.js
. This script, which can be placed anywhere in the documentation catalog, sets a global configuration variable that the MathJax library will read immediately after it’s loaded. We could have also written "mathjax-config.js"
— that is, including the “.js” extension — as the script’s name
.
The second object in the example tells the documentation website to load the MathJax library from a CDN. (We could have instead downloaded the library to the documentation catalog and included it as a local script.) Note that we’ve marked both scripts with defer
so that they run in order, without blocking the rest of the page while the files are being downloaded.
With these first two scripts, MathJax will render all LaTeX on the page immediately after the library is loaded. But if the reader navigates to a different subpage, any LaTeX there will not be rendered. The solution is to call MathJax.typeset
, a function that re-runs the LaTeX renderer, after each navigation. This is what the third “custom script” object does: it’s an inline MathJax.typeset();
script that runs when the reader navigates to a subpage (but not on the initial page load) since its run
property is set to "on-navigate"
. The first two scripts, which do run on the initial page load, have an implicit run
of "on-load"
. If we wanted to run a script on the initial page load and on each navigation, which is not the case in this example, we would set the script’s run
property to "on-load-and-navigate"
.
Detailed design
Spec
The OpenAPI spec for custom-scripts.json
is here.
As described in Proposed solution above, each “custom script” object in the array must have exactly one of the following string properties:
url
for external scriptsname
for local scriptscode
for inline scripts
The type
, integrity
, async
, and defer
properties — the last two are booleans, not strings — are all optional, and correspond to the identically-named attributes of the HTML script element.
-
The
type
property of custom scripts, like its HTML namesake, behaves as if it were set to"text/javascript"
when omitted. Setting this property is rarely necessary. One use case is importing MathJax 2, for which the configuration script must sometimes have a type of "text/x-mathjax-config"
, but this is never necessary in MathJax 3. -
The
integrity
string is a hash that the browser will use to verify that the script has not been unexpectedly manipulated. It can be used on both external and local scripts.- The
custom-scripts.json
schema intentionally does not have an explicit “cross-origin” (or similar) property. If theintegrity
property is set and the custom script is external, our implementation will automatically use CORS when loading the script.
- The
-
Inline scripts cannot have the
integrity
,async
, ordefer
properties.
All three kinds of custom scripts may have the run
property, which is an enum string admitting the following values:
-
"on-load"
, which runs the script when the website is first loaded and the DOM is ready — following the usual rules forasync
anddefer
scripts — even if the first loaded page is not the landing page. -
"on-navigate"
, which runs the script each time the reader navigates in any way (including via the browser’s forward/backward button) within the documentation website.on-navigate
scripts don’t run on the initial page load, even if the first loaded page is not the landing page. -
"on-load-and-navigate"
, which runs the script when the website is first loaded and on each navigation.
Technically, on-load
scripts are not run immediately after the initial page load, but only after the dynamic HTML for the current route has been added to the DOM. This applies similarly to on-navigate
and on-load-and-navigate
scripts. That is, any custom scripts that should be run for the current routing event — either the initial page load or a subsequent navigation — are only run after the HTML for the current documentation topic (or landing page, tutorial, etc.) is available. This allows custom scripts, including on-load
scripts, to customize the dynamic content of documentation pages.
Folder structure in the catalog
DocC is intentionally agnostic to the folder structure of the documentation catalog.
As a result, the custom-scripts.json
file and all local script files can be added anywhere in the documentation catalog. If you group all local scripts under the same parent subdirectory of the catalog, which is not required, you can name that directory whatever you want.
But to keep things consistent within the DocC community, I recommend keeping all local custom scripts under one directory called CustomScripts
. This name communicates the directory’s connection to custom-scripts.json
while following the convention of catalog subdirectories having upper-case names.
Here’s an example documentation catalog containing custom scripts:
MyModule.docc/
theme-settings.json
custom-scripts.json
CustomScripts/
script1.js
script2.js
script3.js
The file names of local scripts, like all files in a documentation catalog, must be unique per catalog. If two scripts have the same name in the catalog, which one will be copied to the documentation archive is undefined.
Folder structure in the archive
All files in the catalog with the “.js” extension will be copied to a subdirectory of the documentation archive called custom-scripts
.
This is intentionally separate from the existing js
subdirectory of the archive, which contains the scripts common to all swift-docc-render
-based websites (like the ones for syntax highlighting). Separating custom scripts from standard DocC scripts matches how custom images are separated from DocC’s “built-in” images:
Archive subdirectory created by swift-docc containing custom files |
Archive subdirectory copied from the prebuilt Vue website, common to all swift-docc-render websites |
|
---|---|---|
Images | images/ |
img/ |
Scripts | custom-scripts/ |
js/ |
The separation also makes it impossible for the filenames of custom scripts to collide with those of built-in scripts (though the latter are suffixed with hashes, so collisions would be highly unlikely regardless).
Privacy and security
In the accompanying implementation, custom scripts can make arbitrary network calls and changes to the DOM — by design. So custom scripts could be used to ping trackers, show ads, or otherwise harass the reader, possibly without the knowledge of the documentation authors who added those scripts.
This raises two questions:
- Does this proposed feature make it meaningfully easier for malicious documentation authors to embed malware into documentation websites?
- With this proposed feature, could non-malicious documentation authors unintentionally expose their readers to security risks?
The answer to #1 is no: it is already possible, and easy, for a malicious documentation author to embed malware into their documentation website by directly adding a malicious script to the index.html
files in the archive. This proposal does not make that meaningfully easier. And, ultimately, authors are responsible for using DocC ethically.
The answer to #2 is also no — if the proposed feature is used responsibly. Only add scripts written by you, your team, or a third party that’s reputable and trusted. If you import scripts from an external URL, take the additional precaution of computing the script’s hash (using this tool, for example) and setting that as the value of the integrity
property. Finally, in the unlikely event that you do unintentionally add a malicious script to your documentation website, it’s unlikely that serious harm will be done to your readers given the security protections of modern browsers. Still, if that happens, remove the library and re-deploy your documentation website.
Alternatives considered
First-party, out-of-the-box support for rendering LaTeX, diagrams, etc.
Instead of having users import JS libraries for the rendering needs of their package, we could go through each popular rendering need and add out-of-the-box support for it in DocC. But this idea is not mutually exclusive with our proposed “custom scripts” feature, and implementing the former would not completely remove the demand for the latter.
It may still be a good idea to add first-party support for rendering math and diagrams, and for any other popular use cases of custom scripts. And as new features are added to DocC, the need for custom scripts will decrease.
But DocC can’t have first-party support for the behavior customization needs of every single documentation website. This is chiefly because there are simply too many different packages with different documentation needs, so there will always be some demand for deeper customization of the documentation website’s behavior. And adding first-party support for more and more increasingly-niche rendering features could lead to unmanageable complexity in DocC if we implement those rendering features ourselves, or dependency creep if we use existing libraries instead.
Finally, different documentation authors may have incompatible preferences for how a given rendering feature should work. For example, suppose that some developers prefer Mermaid over D2 for rendering diagrams, and vice-versa. Instead of forcing all documentation authors to use the same library, custom scripts would allow developers to use whichever one they prefer.
Future directions
Import local scripts automatically, without having to add them to custom-scripts.json
In the proposed design, adding a “.js” file to the documentation catalog is not sufficient for adding a local script to a documentation website: the documentation author must also require the script by its name
in custom-scripts.json
. This aspect of the design is an artificial restriction; however:
- It greatly simplifies the implementation. The documentation website only has to look in one place (
custom-scripts.json
) for scripts, as opposed to also having to look in thecustom-scripts
directory. And client-side JavaScript cannot access file systems, so iterating through the files in thecustom-scripts
directory may not be trivial. - Even if local scripts were imported automatically, adding them to
custom-scripts.json
would still be necessary when execution order matters, or when the local script’srun
property should be anything other than"on-load"
. - Adding local scripts to
custom-scripts.json
is a good idea regardless because it’s useful to have a centralized view of all scripts being included in the documentation website, whether they’re external, local, or inline.
Still, if there is community demand for omitting local scripts from custom-scripts.json
, this could be a future direction for the feature.