Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 106 additions & 45 deletions docs/js/repo-source.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/*
* Update the header source link after each instant-navigation swap.
* Keep the header/drawer source link pointing at the right repository, and
* show that repo's GitHub stats, after each instant-navigation swap.
*
* Material's instant-nav inject() only replaces a fixed whitelist of
* [data-md-component] elements (announce, container, header-topic, outdated,
Expand All @@ -8,37 +9,55 @@
* Material's own fact-fetching is also a session-wide singleton keyed by a
* single sessionStorage entry, not scoped per repo, so it can't be reused
* for a monorepo where each tab points at a different GitHub repository.
* The update below is intentionally limited to top-level route changes:
* moving between pages within one project cannot change its repository.
*
* The route->repo mapping is read from a data-repo-map attribute on the
* source link element, populated by the server via
* overrides/partials/source.html and hooks/merge_subproject_configs.py.
* Adding a new sub-project to the monorepo automatically makes its repo
* available here -- no duplicate config to hand-edit.
*
* That partial marks the link with data-repo-source instead of Material's own
* data-md-component="source", so Material never mounts its fact-fetching over
* the same element. Both writers appending to one element is what rendered the
* stats twice on a cold load: Material's mount runs once at bundle-eval time
* and appends without clearing, and this script appended a second list. The
* duplicate disappeared on the first navigation only because the repaint below
* rewrites the element's text, dropping every child it had.
*
* The partial is rendered TWICE per page -- once in the header, once in the
* navigation drawer (the copy actually shown below 960px) -- so everything here
* works over both copies. The drawer copy lives inside
* [data-md-component=container], which instant navigation replaces wholesale,
* so its element is a brand new node after every swap while the header's
* survives. Element references are therefore re-queried on each repaint rather
* than captured once, the same way nav-persistence.js and glightbox-instant.js
* re-query on each document$ emission. Caching them would leave the drawer
* facts painted onto a detached node and the live one empty.
*
* Uses window.document$ -- Material's own public observable, exposed for
* exactly this kind of post-navigation patch -- to re-sync the link and
* fetch stars/forks from the public GitHub API.
* exactly this kind of post-navigation patch.
*/

(() => {
const SRC = document.querySelector("[data-md-component=source]")
if (!SRC) return

const textEl = SRC.querySelector(".md-source__repository")
const ORG_URL = "https://github.com/workfloworchestrator"
const ORG_NAME = "workfloworchestrator"

/* Built from the server-sent data-repo-map attribute. */
let repoMap = {}
try {
repoMap = JSON.parse(SRC.getAttribute("data-repo-map") || "{}")
} catch (_) {
/* Keep the root fallback if malformed data ever reaches the page. */
/* Re-queried on every repaint, never cached -- see the header comment. */
function sources() {
return [...document.querySelectorAll("[data-repo-source]")]
}

let lastPrefix = null
/* Both copies carry the same server-rendered map, and it's identical on
every page of a given build, so reading it once at startup is safe. */
let repoMap = {}
const first = document.querySelector("[data-repo-source]")
if (first) {
try {
repoMap = JSON.parse(first.getAttribute("data-repo-map") || "{}")
} catch (_) {
/* Keep the root fallback if malformed data ever reaches the page. */
}
}

/* Same markup Material's own renderSourceFacts produces, so its CSS applies. */
function renderFacts(facts) {
Expand All @@ -48,16 +67,25 @@
return `<ul class="md-source__facts">${items}</ul>`
}

function clearFacts() {
const facts = SRC.querySelector(".md-source__facts")
if (facts) facts.remove()
if (textEl) textEl.classList.remove("md-source__repository--active")
}

function showFacts(facts) {
if (!textEl || !facts || !Object.keys(facts).length) return
textEl.insertAdjacentHTML("beforeend", renderFacts(facts))
textEl.classList.add("md-source__repository--active")
/* Point every copy at one repo and, when its facts are already known, render
them in the same pass. Setting textContent drops whatever the element held,
so the facts list is always rebuilt rather than appended to -- that is what
keeps a second list from ever accumulating. */
function paint(url, name, facts) {
for (const el of sources()) {
el.href = url

const textEl = el.querySelector(".md-source__repository")
if (!textEl) continue

textEl.textContent = name
if (facts && Object.keys(facts).length) {
textEl.insertAdjacentHTML("beforeend", renderFacts(facts))
textEl.classList.add("md-source__repository--active")
} else {
textEl.classList.remove("md-source__repository--active")
}
}
}

async function fetchJSON(url) {
Expand Down Expand Up @@ -113,20 +141,24 @@
return at > 0 ? tagName.slice(at + 1) : tagName
}

function update() {
const prefix = location.pathname.split("/", 2)[1] || ""
if (prefix === lastPrefix) return
lastPrefix = prefix
/* Resolved facts per route prefix. The drawer element is rebuilt on every
navigation, so repainting is frequent while the answer almost never
changes; memoising what was resolved lets a repaint render synchronously,
with no flash of a bare link and no extra API call. Failures memoise their
empty result too -- without that, a repo whose request failed (or a 404 on
releases/latest) would be retried on every single navigation and drain the
hourly budget. */
const factsByPrefix = new Map()

clearFacts()
const repo = repoMap[prefix]
function factsFor(prefix, repo) {
if (factsByPrefix.has(prefix)) return factsByPrefix.get(prefix)

let pending
if (repo) {
SRC.href = repo.url
if (textEl) textEl.textContent = repo.name

const api = apiURL(repo.url)
if (api) {
if (!api) {
pending = Promise.resolve({})
} else {
/* Stats and the latest release are independent facts: a repo without
any releases (404 on releases/latest) must still show stars/forks,
so each request degrades on its own rather than sharing one catch. */
Expand All @@ -137,26 +169,55 @@
.then(info => ({ version: formatVersion(info.tag_name) }))
.catch(() => ({}))

Promise.all([stats, release])
.then(([statsFacts, releaseFacts]) => showFacts({ ...statsFacts, ...releaseFacts }))
pending = Promise.all([stats, release]).then(([s, r]) => ({ ...s, ...r }))
}
} else {
SRC.href = ORG_URL
if (textEl) textEl.textContent = ORG_NAME
pending = cachedFetchJSON(`https://api.github.com/users/${ORG_NAME}`)
.then(info => ({ repositories: info.public_repos }))
.catch(() => ({}))
}

/* Swap the promise for its resolved value so later repaints are synchronous. */
const entry = pending.then(facts => {
factsByPrefix.set(prefix, facts)
return facts
})
factsByPrefix.set(prefix, entry)
return entry
}

cachedFetchJSON(`https://api.github.com/users/${ORG_NAME}`)
.then(info => showFacts({ repositories: info.public_repos }))
.catch(() => {})
/* Guards against a slow response for a route the reader has already left:
only the newest prefix is allowed to paint. */
let currentPrefix = null

function update() {
const prefix = location.pathname.split("/", 2)[1] || ""
currentPrefix = prefix

const repo = repoMap[prefix]
const url = repo ? repo.url : ORG_URL
const name = repo ? repo.name : ORG_NAME

/* Paint the link immediately; facts follow, synchronously when memoised. */
const facts = factsFor(prefix, repo)
if (typeof facts.then !== "function") {
paint(url, name, facts)
return
}

paint(url, name, null)
facts.then(resolved => {
if (currentPrefix === prefix) paint(url, name, resolved)
})
}

update()
if (window.document$) {
/* document$ replays the current document; skip that event because update()
already ran above, then react only to later instant-navigation swaps. */
let first = true
let replayed = false
window.document$.subscribe(() => {
if (first) { first = false; return }
if (!replayed) { replayed = true; return }
update()
})
}
Expand Down
12 changes: 10 additions & 2 deletions overrides/partials/source.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{#-
CUSTOMIZED: copied from mkdocs-material 9.7.6
CUSTOMIZED: copied from mkdocs-material 9.7.7
(material/templates/partials/source.html) to prefer page.meta.repo_url
and page.meta.repo_name. The merge_subproject_configs hook's
on_page_context sets those for pages inside !include'd sub-projects so
Expand All @@ -11,10 +11,18 @@
a single repo -- both wrong for a monorepo with a different repo per tab.
docs/js/repo-source.js re-derives the correct repo from the current URL
and re-fetches its stats after every navigation; see that file for why.

CUSTOMIZED: the link is marked with data-repo-source instead of upstream's
data-md-component="source". Material mounts its own fact-fetching over every
element carrying that component name, which would append a second stats list
next to the one repo-source.js renders -- the cause of the stats showing
twice on a cold page load. Dropping the component name leaves repo-source.js
as the only writer and saves a redundant GitHub API call; nothing else keys
off the value, as the theme's styling targets the .md-source* classes.
-#}
{% set repo_url = page.meta.repo_url if page and page.meta.repo_url else config.repo_url %}
{% set repo_name = page.meta.repo_name if page and page.meta.repo_name else config.repo_name %}
<a href="{{ repo_url }}" title="{{ lang.t('source') }}" class="md-source" data-md-component="source" data-repo-map='{{ config.extra.repo_by_prefix | tojson }}'>
<a href="{{ repo_url }}" title="{{ lang.t('source') }}" class="md-source" data-repo-source data-repo-map='{{ config.extra.repo_by_prefix | tojson }}'>
<div class="md-source__icon md-icon">
{% set icon = config.theme.icon.repo or "fontawesome/brands/git-alt" %}
{% include ".icons/" ~ icon ~ ".svg" %}
Expand Down
Loading