From 2ac2664c692de467e24c550439ada774f40b3408 Mon Sep 17 00:00:00 2001
From: Greg Wilson <greg.wilson@plot.ly>
Date: Mon, 2 Dec 2024 09:31:49 -0500
Subject: [PATCH 1/4] feat: use MkDocs to generate documentation from
 plot-schema.json

1.  Copy `make_ref_pages.py` from graphing-library-docs repository to
    create `bin/make_ref_pages.py` and then refactor to generate
    Markdown stubs for documentation pages.

1.  Write `bin/gen.py` to generate HTML pages from the Markdown stubs
    using the Jinja templates in the `theme` directory for debugging.

1.  Convert Jekyll templates from graphing-library-docs repository to
    Jinja and store in `theme` directory.

1.  Write `requirements.txt` file to install required Python packages.
    -   `beautifulsoup4`: for HTML manipulation
    -   `html5validator`: to validate generated HTML
    -   `jinja2`: for manual template expansion
    -   `mkdocs`: for website generation
    -   `mkdocs-exclude`: to exclude `.jinja` files from generation
    -   `python-frontmatter`: for manual template expansion
    -   `ruff`: for checking `bin` scripts

1.  Write `Makefile` to automate build and test.

1.  Modify documentation comments in some JavaScript files to remove
    stray backticks.

Notes:

1.  As of the time of this commit, the `mkdocs_data_loader` plugin
    must be installed directly from a `.whl` file or similar. It will
    be added to `requirements.txt` once the plugin is on PyPI.

1.  Jinja does not expand directives in Markdown files; it only
    expands those it finds in template `.jinja` files. Because of
    this, the Markdown stubs generated by `bin/make_ref_pages.py` have
    YAML headers but no bodies.

1.  The logic to set the `details` variable in `theme/main.jinja` is
    copied from the original Jekyll templates and modified by trial
    and error to produce (what appears to be) the right answer.  If
    pages contain extra information, or if information is missing, the
    most likely cause is an error here.

1.  At the time of this commit, no styling is applied to the generated
    pages and they do not contain headers, footers, or navigation.
    All of this should be added once a general site theme is developed.

1.  `bin/gen.py` defines a `backtick` filter to convert backtick'd
    pieces of text to `<code>` blocks. This is currently *not* used in
    the Jinja templates because (a) the existing documentation
    includes the backticks as-is and (b) I couldn't be bothered to
    figure out how to add it to MkDocs.
---
 .gitignore                               |   5 +
 Makefile                                 |  95 +++++++++++++
 bin/gen.py                               |  97 +++++++++++++
 bin/make_ref_pages.py                    | 125 +++++++++++++++++
 bin/plugins.py                           |  22 +++
 bin/pretty-html.py                       |   6 +
 mkdocs.yml                               |  18 +++
 requirements.txt                         |   7 +
 src/components/errorbars/attributes.js   |   2 +-
 src/plots/cartesian/layout_attributes.js |   2 +-
 src/plots/polar/layout_attributes.js     |   2 +-
 src/traces/carpet/axis_attributes.js     |   2 +-
 theme/attribute.jinja                    |   7 +
 theme/block.jinja                        | 168 +++++++++++++++++++++++
 theme/global.jinja                       |  14 ++
 theme/main.jinja                         |  30 ++++
 theme/trace.jinja                        |  22 +++
 17 files changed, 620 insertions(+), 4 deletions(-)
 create mode 100644 Makefile
 create mode 100644 bin/gen.py
 create mode 100644 bin/make_ref_pages.py
 create mode 100644 bin/plugins.py
 create mode 100644 bin/pretty-html.py
 create mode 100644 mkdocs.yml
 create mode 100644 requirements.txt
 create mode 100644 theme/attribute.jinja
 create mode 100644 theme/block.jinja
 create mode 100644 theme/global.jinja
 create mode 100644 theme/main.jinja
 create mode 100644 theme/trace.jinja

diff --git a/.gitignore b/.gitignore
index 02e1f10c3e7..25133ea4674 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,8 @@ tags
 !.circleci
 !.gitignore
 !.npmignore
+
+docs
+ref_pages
+__pycache__
+tmp
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000000..eca2626bb5b
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,95 @@
+all: commands
+
+BUNDLE=build/plotly.js
+OUT_DIR=docs
+SCHEMA=test/plot-schema.json
+REF_PAGES=ref_pages
+THEME=theme
+
+## site: rebuild with MkDocs
+site:
+	mkdocs build
+
+## pages: make all the pages
+pages:
+	python bin/gen.py \
+	--out docs \
+	--schema ${SCHEMA} \
+	--stubs ${REF_PAGES} \
+	--theme ${THEME}
+
+## figure: generate docs for a figure (annotations.html)
+figure:
+	python bin/gen.py \
+	--crash \
+	--out docs \
+	--schema ${SCHEMA} \
+	--stubs ${REF_PAGES} \
+	--theme ${THEME} \
+	annotations.md
+
+## global: generate docs for global (global.html)
+global:
+	python bin/gen.py \
+	--crash \
+	--out docs \
+	--schema ${SCHEMA} \
+	--stubs ${REF_PAGES} \
+	--theme ${THEME} \
+	global.md
+
+## subplot: generate docs for a subplot (polar.html)
+subplot:
+	python bin/gen.py \
+	--crash \
+	--out docs \
+	--schema ${SCHEMA} \
+	--stubs ${REF_PAGES} \
+	--theme ${THEME} \
+	polar.md
+
+## trace: generate docs for a trace (violin.html)
+trace:
+	python bin/gen.py \
+	--crash \
+	--out docs \
+	--schema ${SCHEMA} \
+	--stubs ${REF_PAGES} \
+	--theme ${THEME} \
+	violin.md
+
+## stubs: make reference page stubs
+stubs: ${SCHEMA}
+	python bin/make_ref_pages.py \
+	--pages ${REF_PAGES} \
+	--schema ${SCHEMA} \
+	--verbose
+
+## validate: check the generated HTML
+validate:
+	@html5validator --root docs
+
+## regenerate JavaScript schema
+schema:
+	npm run schema dist
+
+## -------- : --------
+## commands: show available commands
+# Note: everything with a leading double '##' and a colon is shown.
+commands:
+	@grep -h -E '^##' ${MAKEFILE_LIST} \
+	| sed -e 's/## //g' \
+	| column -t -s ':'
+
+## find-subplots: use jq to find subplot objects
+find-subplots:
+	@cat tmp/plot-schema-formatted.json | jq 'paths | select(.[length-1] == "_isSubplotObj")'
+
+## lint: check code and project
+lint:
+	@ruff check bin
+
+## clean: erase all generated content
+clean:
+	@find . -name '*~' -exec rm {} \;
+	@rm -rf ${REF_PAGES} ${OUT_DIR}
diff --git a/bin/gen.py b/bin/gen.py
new file mode 100644
index 00000000000..a7db0d1d2fa
--- /dev/null
+++ b/bin/gen.py
@@ -0,0 +1,97 @@
+"""Build plotly.js documentation using jinja template."""
+
+import argparse
+import frontmatter
+from jinja2 import Environment, FileSystemLoader
+from jinja2.exceptions import TemplateError
+import json
+import markdown
+from pathlib import Path
+import sys
+
+import plugins
+
+
+KEYS_TO_IGNORE = {
+    "_isSubplotObj",
+    "editType",
+    "role",
+}
+SUBPLOT = "_isSubplotObj"
+
+
+def main():
+    """Main driver."""
+    opt = parse_args()
+    schema = json.loads(Path(opt.schema).read_text())
+    env = Environment(loader=FileSystemLoader(opt.theme))
+    env.filters["backtick"] = plugins.backtick
+    env.filters["debug"] = plugins.debug
+    all_pages = opt.page if opt.page else [p.name for p in Path(opt.stubs).glob("*.md")]
+    err_count = 0
+    for page in all_pages:
+        if opt.crash:
+            render_page(opt, schema, env, page)
+        else:
+            try:
+                render_page(opt, schema, env, page)
+            except Exception as exc:
+                print(f"ERROR in {page}: {exc}", file=sys.stderr)
+                err_count += 1
+    print(f"ERRORS: {err_count} / {len(all_pages)}")
+
+
+def get_details(schema, page):
+    """Temporary hack to pull details out of schema and page header."""
+    # Trace
+    if "full_name" not in page:
+        return page
+
+    key = page["name"].split(".")[-1]
+    entry = schema["layout"]["layoutAttributes"][key]
+
+    # Subplot
+    if SUBPLOT in entry:
+        return entry
+
+    # Figure
+    return list(entry["items"].values())[0]
+
+
+def parse_args():
+    """Parse command-line arguments."""
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--crash", action="store_true", help="crash on first error")
+    parser.add_argument("--out", help="name of output directory")
+    parser.add_argument("--schema", required=True, help="path to schema JSON")
+    parser.add_argument("--stubs", required=True, help="path to stubs directory")
+    parser.add_argument("--theme", required=True, help="path to theme directory")
+    parser.add_argument("page", nargs="...", help="name(s) of source file in stubs directory")
+    return parser.parse_args()
+
+
+def render_page(opt, schema, env, page_name):
+    """Render a single page."""
+    stem = Path(page_name).stem
+    loaded = frontmatter.load(Path(opt.stubs, page_name))
+    metadata = loaded.metadata
+    assert "template" in metadata, f"page {page_name} does not specify 'template'"
+    content = loaded.content
+    details = get_details(schema, metadata)
+    template = env.get_template(metadata["template"])
+    html = template.render(
+        page={"title": stem, "meta": metadata},
+        config={"data": {"plot-schema": schema}},
+        details=details,
+        keys_to_ignore=KEYS_TO_IGNORE,
+        content=content,
+    )
+    if opt.out:
+        Path(opt.out, stem).mkdir(parents=True, exist_ok=True)
+        Path(opt.out, stem, "index.html").write_text(html)
+    else:
+        print(html)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/bin/make_ref_pages.py b/bin/make_ref_pages.py
new file mode 100644
index 00000000000..228a3b541c9
--- /dev/null
+++ b/bin/make_ref_pages.py
@@ -0,0 +1,125 @@
+"""Generate API reference pages from JSON metadata extracted from JavaScript source."""
+
+import argparse
+import json
+from pathlib import Path
+import sys
+
+
+# Suffix for generated files.
+SUFFIX = "md"
+
+# Attributes to document.
+ATTRIBUTES = [
+    "annotations",
+    "coloraxis",
+    "geo",
+    "images",
+    "map",
+    "mapbox",
+    "polar",
+    "scene",
+    "selections",
+    "shapes",
+    "sliders",
+    "smith",
+    "ternary",
+    "updatemenus",
+    "xaxis",
+    "yaxis",
+]
+
+# Template for documentation of attributes.
+ATTRIBUTE_TEMPLATE = """---
+template: attribute.jinja
+permalink: /javascript/reference/{full_attribute_path}/
+name: {attribute}
+full_name: {full_attribute}
+description: Figure attribute reference for Plotly's JavaScript open-source graphing library.
+parentlink: layout
+block: layout
+parentpath: layout
+---
+"""
+
+# Documenting top-level layout.
+GLOBAL_PAGE = """---
+template: global.jinja
+permalink: /javascript/reference/layout/
+name: layout
+description: Figure attribute reference for Plotly's JavaScript open-source graphing library.
+parentlink: layout
+block: layout
+parentpath: layout
+mustmatch: global
+---
+"""
+
+# Template for documentation of trace.
+TRACE_TEMPLATE = """---
+template: trace.jinja
+permalink: /javascript/reference/{trace}/
+trace: {trace}
+description: Figure attribute reference for Plotly's JavaScript open-source graphing library.
+---
+"""
+
+
+def main():
+    """Main driver."""
+    try:
+        opt = parse_args()
+        schema = json.loads(Path(opt.schema).read_text())
+        make_global(opt)
+        for attribute in ATTRIBUTES:
+            make_attribute(opt, attribute)
+        for trace in schema["traces"]:
+            make_trace(opt, trace)
+    except AssertionError as exc:
+        print(str(exc), file=sys.stderr)
+        sys.exit(1)
+
+
+def make_attribute(opt, attribute):
+    """Write reference pages for attributes."""
+    full_attribute = f"layout.{attribute}"
+    content = ATTRIBUTE_TEMPLATE.format(
+        full_attribute=full_attribute,
+        full_attribute_path=full_attribute.replace(".", "/"),
+        attribute=attribute,
+    )
+    write_page(opt, "attribute", f"{attribute}.{SUFFIX}", content)
+
+
+def make_global(opt):
+    """Make top-level 'global' page."""
+    write_page(opt, "global", f"global.{SUFFIX}", GLOBAL_PAGE)
+    
+
+
+def make_trace(opt, trace):
+    """Write reference page for trace."""
+    content = TRACE_TEMPLATE.format(trace=trace,)
+    write_page(opt, "trace", f"{trace}.{SUFFIX}", content)
+
+
+def parse_args():
+    """Parse command-line arguments."""
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--pages", required=True, help="where to write generated page stubs")
+    parser.add_argument("--schema", required=True, help="path to plot schema file")
+    parser.add_argument("--verbose", action="store_true", help="report progress")
+    return parser.parse_args()
+
+
+def write_page(opt, kind, page_name, content):
+    """Save a page."""
+    output_path = Path(f"{opt.pages}/{page_name}")
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+    output_path.write_text(content)
+    if opt.verbose:
+        print(f"{kind}: {output_path}", file=sys.stderr)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/bin/plugins.py b/bin/plugins.py
new file mode 100644
index 00000000000..da7dced80ae
--- /dev/null
+++ b/bin/plugins.py
@@ -0,0 +1,22 @@
+"""Jinja plugins."""
+
+import re
+import sys
+
+
+BACKTICK_RE = re.compile(r'`(.+?)`')
+def backtick(text):
+    """Regex replacement reordered."""
+    return BACKTICK_RE.sub(r"<code>\1</code>", text)
+
+
+def debug(msg):
+    """Print debugging message during template expansion."""
+    print(msg, file=sys.stderr)
+
+
+# If being loaded by MkDocs, register the filters.
+if "define_env" in globals():
+    def define_env(env):
+        env.filters["backtick"] = backtick
+        env.filters["debug"] = debug
diff --git a/bin/pretty-html.py b/bin/pretty-html.py
new file mode 100644
index 00000000000..a375ec6cad4
--- /dev/null
+++ b/bin/pretty-html.py
@@ -0,0 +1,6 @@
+"""Prettify HTML."""
+
+from bs4 import BeautifulSoup
+import sys
+
+sys.stdout.write(BeautifulSoup(sys.stdin.read(), "html.parser").prettify())
diff --git a/mkdocs.yml b/mkdocs.yml
new file mode 100644
index 00000000000..61c9c4686e7
--- /dev/null
+++ b/mkdocs.yml
@@ -0,0 +1,18 @@
+site_name: Plotly Libraries
+site_description: Documentation for Plotly libraries
+site_url: https://example.com
+copyright: Plotly Inc.
+
+docs_dir: ref_pages
+site_dir: docs
+theme:
+  name: null
+  custom_dir: theme
+
+plugins:
+  - exclude:
+      regex:
+        - '.*\.jinja'
+  - mkdocs-data-loader:
+      dir: dist
+      key: data
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 00000000000..9e8403cb32f
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,7 @@
+beautifulsoup4
+html5validator
+jinja2
+mkdocs
+mkdocs-exclude
+python-frontmatter
+ruff
diff --git a/src/components/errorbars/attributes.js b/src/components/errorbars/attributes.js
index d564fe00562..bd57eb82e40 100644
--- a/src/components/errorbars/attributes.js
+++ b/src/components/errorbars/attributes.js
@@ -16,7 +16,7 @@ module.exports = {
         description: [
             'Determines the rule used to generate the error bars.',
 
-            'If *constant`, the bar lengths are of a constant value.',
+            'If *constant*, the bar lengths are of a constant value.',
             'Set this constant in `value`.',
 
             'If *percent*, the bar lengths correspond to a percentage of',
diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js
index 32afc99457a..4857a26acba 100644
--- a/src/plots/cartesian/layout_attributes.js
+++ b/src/plots/cartesian/layout_attributes.js
@@ -334,7 +334,7 @@ module.exports = {
         description: [
             'If *normal*, the range is computed in relation to the extrema',
             'of the input data.',
-            'If *tozero*`, the range extends to 0,',
+            'If *tozero*, the range extends to 0,',
             'regardless of the input data',
             'If *nonnegative*, the range is non-negative,',
             'regardless of the input data.',
diff --git a/src/plots/polar/layout_attributes.js b/src/plots/polar/layout_attributes.js
index 052749af701..66be8789586 100644
--- a/src/plots/polar/layout_attributes.js
+++ b/src/plots/polar/layout_attributes.js
@@ -73,7 +73,7 @@ var radialAxisAttrs = {
         dflt: 'tozero',
         editType: 'calc',
         description: [
-            'If *tozero*`, the range extends to 0,',
+            'If *tozero*, the range extends to 0,',
             'regardless of the input data',
             'If *nonnegative*, the range is non-negative,',
             'regardless of the input data.',
diff --git a/src/traces/carpet/axis_attributes.js b/src/traces/carpet/axis_attributes.js
index 688f1a11a50..dc84fba4c32 100644
--- a/src/traces/carpet/axis_attributes.js
+++ b/src/traces/carpet/axis_attributes.js
@@ -86,7 +86,7 @@ module.exports = {
         description: [
             'If *normal*, the range is computed in relation to the extrema',
             'of the input data.',
-            'If *tozero*`, the range extends to 0,',
+            'If *tozero*, the range extends to 0,',
             'regardless of the input data',
             'If *nonnegative*, the range is non-negative,',
             'regardless of the input data.'
diff --git a/theme/attribute.jinja b/theme/attribute.jinja
new file mode 100644
index 00000000000..06e27c0ccb4
--- /dev/null
+++ b/theme/attribute.jinja
@@ -0,0 +1,7 @@
+{% extends "main.jinja" %}
+{% block content %}
+<h2>JavaScript Figure Reference: <code>{{page.full_name}}</code></h2>
+{% with toplevel=true, parentlink=page.layout, block=page.layout, parentpath=page.layout, attribute=details %}
+  {% include "block.jinja" %}
+{% endwith %}
+{% endblock %}
diff --git a/theme/block.jinja b/theme/block.jinja
new file mode 100644
index 00000000000..a133124b5d1
--- /dev/null
+++ b/theme/block.jinja
@@ -0,0 +1,168 @@
+{#
+  Document a chunk of the API by iterating through plot schema JSON.
+  Note: this template may call itself recursively for structured objects.
+  - toplevel (bool): is this a top-level documentation entry?
+  - attribute (JSON): data to expand as documentation
+  - keys_to_ignore (set): keys that are *not* to be documented
+  - parentlink: slug for parent of this entry
+  - block: section or "nested" for nested invocations
+  - parentpath: possibly redundant?
+  - attribute: dictionary with details to document
+  - page: information pulled from YAML header of page
+    - name: name of top-level entry (e.g., "annotations")
+    - full_name (unused): qualified name (e.g., "layout.annotations")
+    - description (unused): one-sentence description of this item
+    - permalink (unused): path to output page
+#}
+{% set id=[parentlink, "-", page.name] | join %}
+{% if toplevel %}<a class="attribute-name" id="{{id}}" href="#{{parentlink}}-{{page.name}}">{{page.name}}</a>{% endif %}
+<br>
+<em>Parent:</em> <code>{{parentpath | replace('-', '.')}}</code>
+<ul>
+{% for key, obj in attribute.items() %}
+  {% if key not in keys_to_ignore %}
+  <li><code>{{key}}</code>
+    {% if obj is string %}
+      {{obj}}{# FIXME: backtick #}
+    {% elif obj.valType %}
+      <br>
+      {% if obj.valType == "enumerated" or obj.valType.values %}
+        <em>Type:</em>
+        {{ obj.valType }}
+        {% if obj["arrayOk"] %} or array of {{ obj.valType }}s{% endif %}
+	, one of (
+          {% for value in obj["values"] %}
+            {% if value != false and value != true %}<code>"{{value}}"</code>{% else %}<code>{{value}}</code>{% endif %}
+            {% if not loop.last %}|{% endif %}
+          {% endfor %}
+        )
+
+      {% elif obj.valType == "number" or obj.valType == "integer" %}
+        {% if obj["min"] and obj["max"] %}
+          <em>Type:</em> {{ obj.valType }}{% if obj["arrayOk"] %} or array of {{ obj.valType }}s{% endif %} between or equal to {{obj["min"]}} and {{obj["max"]}}
+        {% elif obj["min"] %}
+          <em>Type:</em> {{ obj.valType }}{% if obj["arrayOk"] %} or array of {{ obj.valType }}s{% endif %} greater than or equal to {{obj["min"]}}
+        {% elif obj["max"] %}
+          <em>Type:</em> {{ obj.valType }}{% if obj["arrayOk"] %} or array of {{ obj.valType }}s{% endif %} less than or equal to {{obj["min"]}}
+        {% else %}
+          <em>Type:</em> {{ obj.valType }}{% if obj["arrayOk"] %} or array of {{ obj.valType }}s{% endif %}
+        {% endif %}
+
+      {% elif obj.valType == "boolean" %}
+        <em>Type:</em> {{ obj.valType }}{% if obj["arrayOk"] %} or array of {{ obj.valType }}s{% endif %}
+
+      {% elif obj.valType == "flaglist" %}
+        <em>Type:</em> {{ obj.valType }} string.
+
+        Any combination of
+        {% for value in obj["flags"] %}
+          {% if value != false and value != true %}
+            <code>"{{value}}"</code>
+          {% else %}
+            <code>{{value}}</code>
+          {% endif %}
+            {% if not loop.last %}, {% endif %}
+	{% endfor %}
+        joined with a <code>"+"</code>
+
+        {% if obj["extras"] %}
+          OR
+          {% for value in obj["extras"] %}
+            {% if value != false and value != true %}
+              <code>"{{value}}"</code>
+            {% else %}
+              <code>{{value}}</code>
+            {% endif %}
+            {% if not loop.last %} or {% endif %}
+          {% endfor %}.
+        {% endif %}
+
+        <br>
+        <em>Examples:</em>
+        <code>"{{obj["flags"][0]}}"</code>,
+        <code>"{{obj["flags"][1]}}"</code>,
+        <code>"{{obj["flags"][0]}}+{{obj["flags"][1]}}"</code>
+        {% if obj["flags"][2] %}, <code>"{{obj["flags"][0]}}+{{obj["flags"][1]}}+{{obj["flags"][2]}}"</code>{% endif %}
+        {% if obj["extras"] %}, <code>"{{obj["extras"][0]}}"</code>{% endif %}
+
+      {% elif obj.valType == "data_array" %}
+          <em>Type:</em> {{obj.valType}}
+
+      {% elif obj.valType == "info_array" %}
+        <em>Type:</em> {array}
+
+      {% elif obj.valType == "color" %}
+        <em>Type:</em> {{ obj.valType }}{% if obj["arrayOk"] %} or array of {{ obj.valType }}s{% endif %}
+
+      {% elif obj.valType == "any" %}
+        <em>Type:</em> number or categorical coordinate string
+
+      {% elif obj.valType == "string" %}
+        <em>Type:</em> string{% if obj["arrayOk"] %} or array of strings{% endif %}
+
+      {% else %}
+        <em>Type:</em> {{ obj.valType }}
+      {% endif %}
+
+      {% if obj["role"] == "object" %}
+        {% if obj["items"] %}
+          <em>Type:</em> array of objects
+        {% else %}
+          <em>Type:</em> object
+        {% endif %}
+      {% endif %}
+    {% endif %}
+
+    {% if obj["dflt"] %}
+      {% if obj["valType"] == "flaglist" %}
+        <br><em>Default:</em> <code>"{{ obj["dflt"] }}"</code>
+      {% else %}
+        <br><em>Default:</em>
+	<code>
+        {%- if obj["dflt"] == "" -%}
+          ""
+        {%- elif obj["valType"] == "colorscale" -%}
+          [{% for d in obj["dflt"] %}[{{d | join(", ")}}], {% endfor %}]
+        {%- elif obj["valType"] == "info_array" or obj["valType"] == "colorlist" -%}
+          [{{obj["dflt"] | join(", ")}}]
+        {%- elif obj["valType"] == "string" or obj["valType"] == "color" or obj["dflt"] == "auto" -%}
+          "{{ obj["dflt"] }}"
+        {%- elif obj["valType"] == "enumerated" and obj["dflt"] != true and obj["dflt"] != false -%}
+          "{{ obj["dflt"] }}"
+        {%- else -%}
+          {{obj["dflt"]}}
+        {%- endif %}
+        </code>
+      {% endif %}
+    {% endif %}
+
+    {% if obj["items"] and obj["valType"] != "info_array" %}
+
+      <br><em>Type:</em> array of object where
+      each object has one or more of the keys listed below.
+      {% if page.name == "annotations" %}
+        {% if not obj["description"] %}
+          <br>An annotation is a text element that can be placed anywhere in the plot. It can be positioned with respect to relative coordinates in the plot or with respect to the actual data coordinates of the graph. Annotations can be shown with or without an arrow.
+        {% endif %}
+      {% endif %}
+    {% elif obj["role"] == "object" %}
+      <br><em>Type:</em> object containing one or more of the keys listed below.
+    {% endif %}
+
+    {% if obj["description"] and obj["description"]!= "" %}
+      <br>
+      {{ obj["description"] | replace("*", '"') | escape}}{# FIXME: backtick #}
+    {% endif %}
+
+    {% if obj["role"] == "object" %}
+      {% set localparentlink=[parentlink, "-", page.name] | join %}
+      {% set localparentpath=[parentpath, "-", page.name] | join %}
+      {% with toplevel=False, parentlink=localparentlink, block="nested", parentpath=localparentpath, attribute=obj %}
+        {% include "block.jinja" %}
+      {% endwith %}
+    {% endif %}
+
+  </li>
+  {% endif %}
+{% endfor %}
+</ul>
diff --git a/theme/global.jinja b/theme/global.jinja
new file mode 100644
index 00000000000..facb3d30d3b
--- /dev/null
+++ b/theme/global.jinja
@@ -0,0 +1,14 @@
+{% extends "main.jinja" %}
+{% block content %}
+<h2>JavaScript Figure Reference: <code>layout</code></h2>
+{% with parentlink=page.layout, block=page.layout, parentpath=page.layout, mustmatch=page.global, attribute=config["data"]["plot-schema"]["layout"]["layoutAttributes"] %}
+  {% include "block.jinja" %}
+{% endwith %}
+{% for trace in config["data"]["plot-schema"]["traces"] %}
+  {% if trace[1].layoutAttributes %}
+    {% with parentlink=page.layout, block=page.layout, parentpath=page.layout, attribute=trace[1].layoutAttributes %}
+      {% include "block.jinja" %}
+    {% endwith %}
+  {% endif %}
+{% endfor %}
+{% endblock %}
diff --git a/theme/main.jinja b/theme/main.jinja
new file mode 100644
index 00000000000..f4485eeb2ff
--- /dev/null
+++ b/theme/main.jinja
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    {% block head_title %}<title>{{page["title"]}}</title>{% endblock %}
+    {% block head_includes %}{% endblock %}
+  </head>
+  <body>
+    <main>
+      {% block page_title %}<h1>{{page["title"]}}</h1>{% endblock %}
+
+      {# reach into the schema data and pull out details #}
+      {% if "full_name" not in page.meta %}
+        {# trace #}
+        {% set details=page.meta %}
+      {% else %}
+        {% set temp_key=page.meta.name.split(".")[-1] %}
+        {% set temp=config["data"]["plot-schema"]["layout"]["layoutAttributes"][temp_key] %}
+        {% if "_isSubplotObj" in temp %}
+          {# subplot #}
+          {% set details=temp %}
+        {% else %}
+          {# everything that isn't trace or subplot #}
+          {% set details=temp["items"].values() | first %}
+        {% endif %}
+      {% endif %}
+
+      {% block content %}{% endblock %}
+    </main>
+  </body>
+</html>
diff --git a/theme/trace.jinja b/theme/trace.jinja
new file mode 100644
index 00000000000..788e0c95367
--- /dev/null
+++ b/theme/trace.jinja
@@ -0,0 +1,22 @@
+{% extends "main.jinja" %}
+{% block content %}
+
+<h2>JavaScript Figure Reference: <code>page.trace</code> Traces</h2>
+{% with trace_name=page.meta.trace, trace_data=config["data"]["plot-schema"].traces[page.meta.trace] %}
+  <div class="description">
+    A <code>{{trace_name}}</code> trace is an object with the key <code>"type"</code> equal to <code>"{{trace_data.attributes.type}}"</code>
+    (i.e. <code>{"type": "{{trace_data.attributes.type}}"}</code>) and any of the keys listed below.
+    <br>
+    {{trace_data.meta.description}}{# FIXME: backtick #}
+  </div>
+
+  {% set localparentlink=trace_name %}
+  {% set localparentpath="FIXME" %}
+  {% set attribute=trace_data.attributes %}
+  {% with parentlink=localparentlink, block="data", parentpath=localparentpath %}
+    {% include "block.jinja" %}
+  {% endwith %}
+
+{% endwith %}
+{% endblock %}
+

From 86f5f55480da6fed94676cddb27ec8b0ffae6780 Mon Sep 17 00:00:00 2001
From: Greg Wilson <greg.wilson@plot.ly>
Date: Mon, 9 Dec 2024 13:15:02 -0500
Subject: [PATCH 2/4] feat: modify to work with mkdocs-material as well

1.  Move configuration `mkdocs.yml` to `mkdocs-vanilla.yml`.
1.  Move logic to set `details` from `theme/main.jinja` to `theme/attribute.jinja`
    where it is actually used.
1.  Create new `mkdocs-material.yml` configuration file with mkdocs-material settings.
1.  Change configuration to load overrides from `overrides` directory.
1.  Copy template files *without* `main.jinja` to `overrides` directory.
1.  Modify `Makefile` to build `mkdocs-vanilla` (our own) or `mkdocs-material`.
---
 Makefile                         |  10 +-
 mkdocs-material.yml              |  18 ++++
 mkdocs.yml => mkdocs-vanilla.yml |   0
 overrides/attribute.jinja        |  23 +++++
 overrides/block.jinja            | 168 +++++++++++++++++++++++++++++++
 overrides/global.jinja           |  16 +++
 overrides/trace.jinja            |  23 +++++
 requirements.txt                 |   1 +
 theme/attribute.jinja            |  26 ++++-
 theme/main.jinja                 |  17 ----
 10 files changed, 278 insertions(+), 24 deletions(-)
 create mode 100644 mkdocs-material.yml
 rename mkdocs.yml => mkdocs-vanilla.yml (100%)
 create mode 100644 overrides/attribute.jinja
 create mode 100644 overrides/block.jinja
 create mode 100644 overrides/global.jinja
 create mode 100644 overrides/trace.jinja

diff --git a/Makefile b/Makefile
index eca2626bb5b..ffcf2fe2cdd 100644
--- a/Makefile
+++ b/Makefile
@@ -6,9 +6,13 @@ SCHEMA=test/plot-schema.json
 REF_PAGES=ref_pages
 THEME=theme
 
-## site: rebuild with MkDocs
-site:
-	mkdocs build
+## material: rebuild with MkDocs using mkdocs-material and 'overrides' directory
+material:
+	mkdocs build -f mkdocs-material.yml
+
+## vanilla: rebuild with MkDocs using 'theme' directory (our own)
+vanilla:
+	mkdocs build -f mkdocs-vanilla.yml
 
 ## pages: make all the pages
 pages:
diff --git a/mkdocs-material.yml b/mkdocs-material.yml
new file mode 100644
index 00000000000..af480177fbd
--- /dev/null
+++ b/mkdocs-material.yml
@@ -0,0 +1,18 @@
+site_name: Plotly Libraries
+site_description: Documentation for Plotly libraries
+site_url: https://example.com
+copyright: Plotly Inc.
+
+docs_dir: ref_pages
+site_dir: docs
+theme:
+  name: material
+  custom_dir: overrides
+
+plugins:
+  - exclude:
+      regex:
+        - '.*\.jinja'
+  - mkdocs-data-loader:
+      dir: dist
+      key: data
diff --git a/mkdocs.yml b/mkdocs-vanilla.yml
similarity index 100%
rename from mkdocs.yml
rename to mkdocs-vanilla.yml
diff --git a/overrides/attribute.jinja b/overrides/attribute.jinja
new file mode 100644
index 00000000000..d74951f21f1
--- /dev/null
+++ b/overrides/attribute.jinja
@@ -0,0 +1,23 @@
+{% extends "base.html" %}
+{% block content %}
+  {{ super() }}
+  {# reach into the schema data and pull out details #}
+  {% if "full_name" not in page.meta %}
+    {# trace #}
+    {% set details=page.meta %}
+  {% else %}
+    {% set temp_key=page.meta.name.split(".")[-1] %}
+    {% set temp=config["data"]["plot-schema"]["layout"]["layoutAttributes"][temp_key] %}
+    {% if "_isSubplotObj" in temp %}
+      {# subplot #}
+      {% set details=temp %}
+    {% else %}
+      {# everything that isn't trace or subplot #}
+      {% set details=temp["items"].values() | first %}
+    {% endif %}
+  {% endif %}
+  <h2>JavaScript Figure Reference: <code>{{page.full_name}}</code></h2>
+  {% with toplevel=true, parentlink=page.layout, block=page.layout, parentpath=page.layout, attribute=details %}
+    {% include "block.jinja" %}
+  {% endwith %}
+{% endblock %}
diff --git a/overrides/block.jinja b/overrides/block.jinja
new file mode 100644
index 00000000000..3ef65aec154
--- /dev/null
+++ b/overrides/block.jinja
@@ -0,0 +1,168 @@
+{#
+  Document a chunk of the API by iterating through plot schema JSON.
+  Note: this template may call itself recursively for structured objects.
+  - toplevel (bool): is this a top-level documentation entry?
+  - attribute (JSON): data to expand as documentation
+  - keys_to_ignore (set): keys that are *not* to be documented
+  - parentlink: slug for parent of this entry
+  - block: section or "nested" for nested invocations
+  - parentpath: possibly redundant?
+  - attribute: dictionary with details to document
+  - page: information pulled from YAML header of page
+    - name: name of top-level entry (e.g., "annotations")
+    - full_name (unused): qualified name (e.g., "layout.annotations")
+    - description (unused): one-sentence description of this item
+    - permalink (unused): path to output page
+#}
+{% set id=[parentlink, "-", page.name] | join %}
+{% if toplevel %}<a class="attribute-name" id="{{id}}" href="#{{parentlink}}-{{page.name}}">{{page.name}}</a>{% endif %}
+<br>
+<em>Parent:</em> <code>{{parentpath | replace('-', '.')}}</code>
+<ul>
+{% for key, obj in attribute.items() %}
+  {% if key not in keys_to_ignore %}
+  <li><code>{{key}}</code>
+    {% if obj is string %}
+      {{obj}}<!-- FIXME: backtick -->
+    {% elif obj.valType %}
+      <br>
+      {% if obj.valType == "enumerated" or obj.valType.values %}
+        <em>Type:</em>
+        {{ obj.valType }}
+        {% if obj["arrayOk"] %} or array of {{ obj.valType }}s{% endif %}
+	, one of (
+          {% for value in obj["values"] %}
+            {% if value != false and value != true %}<code>"{{value}}"</code>{% else %}<code>{{value}}</code>{% endif %}
+            {% if not loop.last %}|{% endif %}
+          {% endfor %}
+        )
+
+      {% elif obj.valType == "number" or obj.valType == "integer" %}
+        {% if obj["min"] and obj["max"] %}
+          <em>Type:</em> {{ obj.valType }}{% if obj["arrayOk"] %} or array of {{ obj.valType }}s{% endif %} between or equal to {{obj["min"]}} and {{obj["max"]}}
+        {% elif obj["min"] %}
+          <em>Type:</em> {{ obj.valType }}{% if obj["arrayOk"] %} or array of {{ obj.valType }}s{% endif %} greater than or equal to {{obj["min"]}}
+        {% elif obj["max"] %}
+          <em>Type:</em> {{ obj.valType }}{% if obj["arrayOk"] %} or array of {{ obj.valType }}s{% endif %} less than or equal to {{obj["min"]}}
+        {% else %}
+          <em>Type:</em> {{ obj.valType }}{% if obj["arrayOk"] %} or array of {{ obj.valType }}s{% endif %}
+        {% endif %}
+
+      {% elif obj.valType == "boolean" %}
+        <em>Type:</em> {{ obj.valType }}{% if obj["arrayOk"] %} or array of {{ obj.valType }}s{% endif %}
+
+      {% elif obj.valType == "flaglist" %}
+        <em>Type:</em> {{ obj.valType }} string.
+
+        Any combination of
+        {% for value in obj["flags"] %}
+          {% if value != false and value != true %}
+            <code>"{{value}}"</code>
+          {% else %}
+            <code>{{value}}</code>
+          {% endif %}
+            {% if not loop.last %}, {% endif %}
+	{% endfor %}
+        joined with a <code>"+"</code>
+
+        {% if obj["extras"] %}
+          OR
+          {% for value in obj["extras"] %}
+            {% if value != false and value != true %}
+              <code>"{{value}}"</code>
+            {% else %}
+              <code>{{value}}</code>
+            {% endif %}
+            {% if not loop.last %} or {% endif %}
+          {% endfor %}.
+        {% endif %}
+
+        <br>
+        <em>Examples:</em>
+        <code>"{{obj["flags"][0]}}"</code>,
+        <code>"{{obj["flags"][1]}}"</code>,
+        <code>"{{obj["flags"][0]}}+{{obj["flags"][1]}}"</code>
+        {% if obj["flags"][2] %}, <code>"{{obj["flags"][0]}}+{{obj["flags"][1]}}+{{obj["flags"][2]}}"</code>{% endif %}
+        {% if obj["extras"] %}, <code>"{{obj["extras"][0]}}"</code>{% endif %}
+
+      {% elif obj.valType == "data_array" %}
+          <em>Type:</em> {{obj.valType}}
+
+      {% elif obj.valType == "info_array" %}
+        <em>Type:</em> {array}
+
+      {% elif obj.valType == "color" %}
+        <em>Type:</em> {{ obj.valType }}{% if obj["arrayOk"] %} or array of {{ obj.valType }}s{% endif %}
+
+      {% elif obj.valType == "any" %}
+        <em>Type:</em> number or categorical coordinate string
+
+      {% elif obj.valType == "string" %}
+        <em>Type:</em> string{% if obj["arrayOk"] %} or array of strings{% endif %}
+
+      {% else %}
+        <em>Type:</em> {{ obj.valType }}
+      {% endif %}
+
+      {% if obj["role"] == "object" %}
+        {% if obj["items"] %}
+          <em>Type:</em> array of objects
+        {% else %}
+          <em>Type:</em> object
+        {% endif %}
+      {% endif %}
+    {% endif %}
+
+    {% if obj["dflt"] %}
+      {% if obj["valType"] == "flaglist" %}
+        <br><em>Default:</em> <code>"{{ obj["dflt"] }}"</code>
+      {% else %}
+        <br><em>Default:</em>
+	<code>
+        {%- if obj["dflt"] == "" -%}
+          ""
+        {%- elif obj["valType"] == "colorscale" -%}
+          [{% for d in obj["dflt"] %}[{{d | join(", ")}}], {% endfor %}]
+        {%- elif obj["valType"] == "info_array" or obj["valType"] == "colorlist" -%}
+          [{{obj["dflt"] | join(", ")}}]
+        {%- elif obj["valType"] == "string" or obj["valType"] == "color" or obj["dflt"] == "auto" -%}
+          "{{ obj["dflt"] }}"
+        {%- elif obj["valType"] == "enumerated" and obj["dflt"] != true and obj["dflt"] != false -%}
+          "{{ obj["dflt"] }}"
+        {%- else -%}
+          {{obj["dflt"]}}
+        {%- endif %}
+        </code>
+      {% endif %}
+    {% endif %}
+
+    {% if obj["items"] and obj["valType"] != "info_array" %}
+
+      <br><em>Type:</em> array of object where
+      each object has one or more of the keys listed below.
+      {% if page.name == "annotations" %}
+        {% if not obj["description"] %}
+          <br>An annotation is a text element that can be placed anywhere in the plot. It can be positioned with respect to relative coordinates in the plot or with respect to the actual data coordinates of the graph. Annotations can be shown with or without an arrow.
+        {% endif %}
+      {% endif %}
+    {% elif obj["role"] == "object" %}
+      <br><em>Type:</em> object containing one or more of the keys listed below.
+    {% endif %}
+
+    {% if obj["description"] and obj["description"]!= "" %}
+      <br>
+      {{ obj["description"] | replace("*", '"') | escape}}<!-- FIXME: backtick -->
+    {% endif %}
+
+    {% if obj["role"] == "object" %}
+      {% set localparentlink=[parentlink, "-", page.name] | join %}
+      {% set localparentpath=[parentpath, "-", page.name] | join %}
+      {% with toplevel=False, parentlink=localparentlink, block="nested", parentpath=localparentpath, attribute=obj %}
+        {% include "block.jinja" %}
+      {% endwith %}
+    {% endif %}
+
+  </li>
+  {% endif %}
+{% endfor %}
+</ul>
diff --git a/overrides/global.jinja b/overrides/global.jinja
new file mode 100644
index 00000000000..ce30284e180
--- /dev/null
+++ b/overrides/global.jinja
@@ -0,0 +1,16 @@
+{% extends "base.html" %}
+{% block content %}
+{{ super() }}
+
+<h2>JavaScript Figure Reference: <code>layout</code></h2>
+{% with parentlink=page.layout, block=page.layout, parentpath=page.layout, mustmatch=page.global, attribute=config["data"]["plot-schema"]["layout"]["layoutAttributes"] %}
+  {% include "block.jinja" %}
+{% endwith %}
+{% for trace in config["data"]["plot-schema"]["traces"] %}
+  {% if trace[1].layoutAttributes %}
+    {% with parentlink=page.layout, block=page.layout, parentpath=page.layout, attribute=trace[1].layoutAttributes %}
+      {% include "block.jinja" %}
+    {% endwith %}
+  {% endif %}
+{% endfor %}
+{% endblock %}
diff --git a/overrides/trace.jinja b/overrides/trace.jinja
new file mode 100644
index 00000000000..b50b0c29c4d
--- /dev/null
+++ b/overrides/trace.jinja
@@ -0,0 +1,23 @@
+{% extends "base.html" %}
+{% block content %}
+{{ super() }}
+
+<h2>JavaScript Figure Reference: <code>page.trace</code> Traces</h2>
+{% with trace_name=page.meta.trace, trace_data=config["data"]["plot-schema"].traces[page.meta.trace] %}
+  <div class="description">
+    A <code>{{trace_name}}</code> trace is an object with the key <code>"type"</code> equal to <code>"{{trace_data.attributes.type}}"</code>
+    (i.e. <code>{"type": "{{trace_data.attributes.type}}"}</code>) and any of the keys listed below.
+    <br>
+    {{trace_data.meta.description}}{# FIXME: backtick #}
+  </div>
+
+  {% set localparentlink=trace_name %}
+  {% set localparentpath="FIXME" %}
+  {% set attribute=trace_data.attributes %}
+  {% with parentlink=localparentlink, block="data", parentpath=localparentpath %}
+    {% include "block.jinja" %}
+  {% endwith %}
+
+{% endwith %}
+{% endblock %}
+
diff --git a/requirements.txt b/requirements.txt
index 9e8403cb32f..a15e73779b7 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,5 +3,6 @@ html5validator
 jinja2
 mkdocs
 mkdocs-exclude
+mkdocs-material
 python-frontmatter
 ruff
diff --git a/theme/attribute.jinja b/theme/attribute.jinja
index 06e27c0ccb4..08e703089c0 100644
--- a/theme/attribute.jinja
+++ b/theme/attribute.jinja
@@ -1,7 +1,25 @@
 {% extends "main.jinja" %}
 {% block content %}
-<h2>JavaScript Figure Reference: <code>{{page.full_name}}</code></h2>
-{% with toplevel=true, parentlink=page.layout, block=page.layout, parentpath=page.layout, attribute=details %}
-  {% include "block.jinja" %}
-{% endwith %}
+
+  {# reach into the schema data and pull out details #}
+  {% if "full_name" not in page.meta %}
+    {# trace #}
+    {% set details=page.meta %}
+  {% else %}
+    {% set temp_key=page.meta.name.split(".")[-1] %}
+    {% set temp=config["data"]["plot-schema"]["layout"]["layoutAttributes"][temp_key] %}
+    {% if "_isSubplotObj" in temp %}
+      {# subplot #}
+      {% set details=temp %}
+    {% else %}
+      {# everything that isn't trace or subplot #}
+      {% set details=temp["items"].values() | first %}
+    {% endif %}
+  {% endif %}
+
+  <h2>JavaScript Figure Reference: <code>{{page.full_name}}</code></h2>
+  {% with toplevel=true, parentlink=page.layout, block=page.layout, parentpath=page.layout, attribute=details %}
+    {% include "block.jinja" %}
+  {% endwith %}
+
 {% endblock %}
diff --git a/theme/main.jinja b/theme/main.jinja
index f4485eeb2ff..604025ee15f 100644
--- a/theme/main.jinja
+++ b/theme/main.jinja
@@ -7,23 +7,6 @@
   <body>
     <main>
       {% block page_title %}<h1>{{page["title"]}}</h1>{% endblock %}
-
-      {# reach into the schema data and pull out details #}
-      {% if "full_name" not in page.meta %}
-        {# trace #}
-        {% set details=page.meta %}
-      {% else %}
-        {% set temp_key=page.meta.name.split(".")[-1] %}
-        {% set temp=config["data"]["plot-schema"]["layout"]["layoutAttributes"][temp_key] %}
-        {% if "_isSubplotObj" in temp %}
-          {# subplot #}
-          {% set details=temp %}
-        {% else %}
-          {# everything that isn't trace or subplot #}
-          {% set details=temp["items"].values() | first %}
-        {% endif %}
-      {% endif %}
-
       {% block content %}{% endblock %}
     </main>
   </body>

From fd14bf63713ac67b4d7aff351247029f0b9ab25b Mon Sep 17 00:00:00 2001
From: Greg Wilson <greg.wilson@plot.ly>
Date: Mon, 9 Dec 2024 13:26:43 -0500
Subject: [PATCH 3/4] feat: add some CSS styling for homegrown test pages

---
 Makefile           |   1 +
 assets/mccole.css  | 262 +++++++++++++++++++++++++++++++++++++++++++++
 mkdocs-vanilla.yml |   2 +
 theme/main.jinja   |   4 +-
 4 files changed, 268 insertions(+), 1 deletion(-)
 create mode 100644 assets/mccole.css

diff --git a/Makefile b/Makefile
index ffcf2fe2cdd..2b1c5a22ab7 100644
--- a/Makefile
+++ b/Makefile
@@ -68,6 +68,7 @@ stubs: ${SCHEMA}
 	--pages ${REF_PAGES} \
 	--schema ${SCHEMA} \
 	--verbose
+	cp assets/*.css ${REF_PAGES}
 
 ## validate: check the generated HTML
 validate:
diff --git a/assets/mccole.css b/assets/mccole.css
new file mode 100644
index 00000000000..3945558f810
--- /dev/null
+++ b/assets/mccole.css
@@ -0,0 +1,262 @@
+/* Style variables */
+:root {
+    --border-thin: solid 1px;
+    --border-medium: solid 3px;
+    --color-border: gray;
+    --color-background: white;
+    --color-background-code: whitesmoke;
+    --color-link: #000060;
+    --color-text: black;
+
+    --expand: 120%;
+
+    --height-half-line: 0.75ex;
+
+    --width-li-adjust: 1rem;
+    --width-ol-adjust: 0.5rem;
+    --width-ul-adjust: 0.2rem;
+    --width-padding: 5px;
+    --width-page: 72rem;
+    --width-page-margin: 1rem;
+
+    --stamp-blue-dark: #1B2A83;
+    --stamp-blue-light: #BABDD8;
+    --stamp-brown-dark: #5F483C;
+    --stamp-brown-light: #CEC7C3;
+    --stamp-green-dark: #7F9971;
+    --stamp-green-light: #A7E0A3;
+    --stamp-orange-dark: #AD7353;
+    --stamp-orange-light: #E5D4CB;
+    --stamp-purple-dark: #7D6E87;
+    --stamp-purple-light: #D6D2DA;
+    --stamp-red-dark: #8B000F;
+    --stamp-red-light: #DAB3B7;
+}
+
+/* Generic coloring */
+.shaded {
+    background-color: var(--color-background-code);
+}
+
+/* Generic text alignment */
+.left { text-align: left; }
+.center { text-align: center; }
+.right { text-align: right; }
+
+/* Flex grid */
+.row {
+    display: flex;
+    flex-flow: row wrap;
+    width: 100%
+}
+.row > * {
+    flex: 1; /* allow children to grow when space available */
+}
+
+.col-1 { flex-basis: 8.33%; }
+.col-2 { flex-basis: 16.66%; }
+.col-3 { flex-basis: 25%; }
+.col-4 { flex-basis: 33.33%; }
+.col-5 { flex-basis: 41.66%; }
+.col-6 { flex-basis: 50%; }
+.col-7 { flex-basis: 58.33%; }
+.col-8 { flex-basis: 66.66%; }
+.col-9 { flex-basis: 75%; }
+.col-10 { flex-basis: 83.33%; }
+.col-11 { flex-basis: 91.66%; }
+.col-12 { flex-basis: 100%; }
+
+/* Hyperlinks */
+a {
+    color: var(--color-link);
+}
+
+/* Block quotes */
+blockquote {
+    border: var(--border-medium) var(--color-border);
+    padding-left: var(--width-page-margin);
+    padding-right: var(--width-page-margin);
+}
+
+/* Page body */
+body {
+    background-color: var(--color-background);
+    color: var(--color-text);
+    font-family: sans-serif;
+    font-size: var(--expand);
+    line-height: var(--expand);
+    margin: var(--width-page-margin);
+    max-width: var(--width-page);
+}
+
+/* Code snippets */
+code {
+    background-color: var(--color-background-code);
+}
+
+/* Definitions in definition lists */
+dd {
+    margin-bottom: var(--height-half-line);
+}
+dd:last-of-type {
+    margin-bottom: 0px;
+}
+
+/* Figures */
+figure {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    text-align: center;
+}
+
+figure img {
+    max-width: 100%;
+    height: auto;
+}
+
+figcaption {
+    margin-top: var(--height-half-line);
+}
+
+/* Footer */
+footer {
+    border-top: var(--border-thin) var(--color-border);
+    margin-top: var(--height-half-line);
+    padding-top: var(--height-half-line);
+    text-align: center;
+}
+
+/* Level-1 heading */
+h1 {
+    text-align: center;
+}
+
+/* Other headings */
+h2, h3, h4, h5, h6 {
+    font-style: italic;
+}
+
+/* Navigation */
+nav {
+    background-color: var(--color-background-code);
+}
+
+/* Ordered lists */
+ol {
+    margin-left: var(--width-ol-adjust);
+    padding-left: var(--width-ol-adjust);
+}
+ol li {
+    margin-left: var(--width-li-adjust);
+}
+ol.appendices {
+    list-style-type: upper-alpha;
+}
+ol.chapters {
+}
+
+/* Subtitle */
+p.subtitle {
+    font-weight: bold;
+    font-style: italic;
+    text-align: center;
+}
+
+/* Code blocks */
+pre {
+    border: var(--border-thin) var(--color-border);
+    padding: var(--width-padding);
+    background-color: var(--color-background-code);
+}
+
+/* Generic output */
+pre[class*='language'] {
+    border-left: var(--border-medium);
+    border-top: var(--border-thin);
+    border-bottom: var(--border-thin);
+    border-right: 0px;
+    padding-left: var(--width-padding);
+}
+
+/* Data files */
+pre.language-csv,
+pre.language-json,
+pre.language-md,
+pre.language-toml,
+pre.language-yml {
+    border-color: var(--stamp-orange-light);
+}
+
+/* JavaScript */
+pre.language-js {
+    border-color: var(--stamp-blue-light);
+}
+
+/* Output */
+pre.language-out {
+    border-color: var(--stamp-brown-light);
+    font-style: italic;
+}
+
+/* Python */
+pre.language-py {
+    border-color: var(--stamp-blue-light);
+}
+
+/* Shell */
+pre.language-sh {
+    border-color: var(--stamp-green-light);
+}
+
+/* SQL */
+pre.language-sql {
+    border-color: var(--stamp-red-light);
+}
+
+/* Transcripts */
+pre.language-text {
+    border-color: var(--stamp-purple-light);
+}
+
+
+/* Tables */
+table {
+    border-collapse: collapse;
+    caption-side: bottom;
+    margin-left: auto;
+    margin-right: auto;
+}
+th, td {
+    padding: var(--width-padding);
+    vertical-align: top;
+    border: var(--border-thin);
+    min-width: 8rem;
+}
+
+/* Unordered lists */
+ul {
+    list-style-type: disc;
+    margin-left: var(--width-ul-adjust);
+    padding-left: var(--width-ul-adjust);
+}
+ul li {
+    margin-left: var(--width-li-adjust);
+}
+
+.error-message {
+    color: red;
+    font-weight: bold;
+}
+
+/* Dark mode disabled for now */
+/*
+@media (prefers-color-scheme: dark) {
+    :root {
+        --color-background: #202020;
+	--color-background-code: #000060;
+        --color-text: white;
+	--color-link: lightgray;
+    }
+}
+*/
diff --git a/mkdocs-vanilla.yml b/mkdocs-vanilla.yml
index 61c9c4686e7..473c07ed7cc 100644
--- a/mkdocs-vanilla.yml
+++ b/mkdocs-vanilla.yml
@@ -8,6 +8,8 @@ site_dir: docs
 theme:
   name: null
   custom_dir: theme
+extra_css:
+  - mccole.css
 
 plugins:
   - exclude:
diff --git a/theme/main.jinja b/theme/main.jinja
index 604025ee15f..a677ab3d046 100644
--- a/theme/main.jinja
+++ b/theme/main.jinja
@@ -2,7 +2,9 @@
 <html>
   <head>
     {% block head_title %}<title>{{page["title"]}}</title>{% endblock %}
-    {% block head_includes %}{% endblock %}
+    {% block head_includes %}
+    <link href="{{base_url}}/mccole.css" rel="stylesheet" type="text/css"/>
+    {% endblock %}
   </head>
   <body>
     <main>

From b4c4f14673a6066394ed9b69557053cf8134368c Mon Sep 17 00:00:00 2001
From: Greg Wilson <greg.wilson@plot.ly>
Date: Mon, 27 Jan 2025 10:47:03 -0500
Subject: [PATCH 4/4] fix: reformat docs in Makefile

---
 Makefile | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/Makefile b/Makefile
index 2b1c5a22ab7..942e053dd3d 100644
--- a/Makefile
+++ b/Makefile
@@ -74,7 +74,7 @@ stubs: ${SCHEMA}
 validate:
 	@html5validator --root docs
 
-## regenerate JavaScript schema
+## schema: regenerate JavaScript schema
 schema:
 	npm run schema dist