Merge commit 'a024bc7d76fcc5e49e8210f9b0896db9ef21861a'

This commit is contained in:
Bjørn Erik Pedersen
2025-02-13 10:40:34 +01:00
817 changed files with 5301 additions and 14766 deletions

View File

@@ -0,0 +1,123 @@
var debug = 0 ? console.log.bind(console, '[explorer]') : function () {};
// This is cureently not used, but kept in case I change my mind.
export const explorer = (Alpine) => ({
uiState: {
containerScrollTop: -1,
lastActiveRef: '',
},
treeState: {
// The href of the current page.
currentNode: '',
// The state of each node in the tree.
nodes: {},
// We currently only list the sections, not regular pages, in the side bar.
// This strikes me as the right balance. The pages gets listed on the section pages.
// This array is sorted by length, so we can find the longest prefix of the current page
// without having to iterate over all the keys.
nodeRefsByLength: [],
},
async init() {
let keys = Reflect.ownKeys(this.$refs);
for (let key of keys) {
let n = {
open: false,
active: false,
};
this.treeState.nodes[key] = n;
this.treeState.nodeRefsByLength.push(key);
}
this.treeState.nodeRefsByLength.sort((a, b) => b.length - a.length);
this.setCurrentActive();
},
longestPrefix(ref) {
let longestPrefix = '';
for (let key of this.treeState.nodeRefsByLength) {
if (ref.startsWith(key)) {
longestPrefix = key;
break;
}
}
return longestPrefix;
},
setCurrentActive() {
let ref = this.longestPrefix(window.location.pathname);
let activeChanged = this.uiState.lastActiveRef !== ref;
debug('setCurrentActive', this.uiState.lastActiveRef, window.location.pathname, '=>', ref, activeChanged);
this.uiState.lastActiveRef = ref;
if (this.uiState.containerScrollTop === -1 && activeChanged) {
// Navigation outside of the explorer menu.
let el = document.querySelector(`[x-ref="${ref}"]`);
if (el) {
this.$nextTick(() => {
debug('scrolling to', ref);
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
}
this.treeState.currentNode = ref;
for (let key in this.treeState.nodes) {
let n = this.treeState.nodes[key];
n.active = false;
n.open = ref == key || ref.startsWith(key);
if (n.open) {
debug('open', key);
}
}
let n = this.treeState.nodes[this.longestPrefix(ref)];
if (n) {
n.active = true;
}
},
getScrollingContainer() {
return document.getElementById('leftsidebar');
},
onLoad() {
debug('onLoad', this.uiState.containerScrollTop);
if (this.uiState.containerScrollTop >= 0) {
debug('onLoad: scrolling to', this.uiState.containerScrollTop);
this.getScrollingContainer().scrollTo(0, this.uiState.containerScrollTop);
}
this.uiState.containerScrollTop = -1;
},
onBeforeRender() {
debug('onBeforeRender', this.uiState.containerScrollTop);
this.setCurrentActive();
},
toggleNode(ref) {
this.uiState.containerScrollTop = this.getScrollingContainer().scrollTop;
this.uiState.lastActiveRef = '';
debug('toggleNode', ref, this.uiState.containerScrollTop);
let node = this.treeState.nodes[ref];
if (!node) {
debug('node not found', ref);
return;
}
let wasOpen = node.open;
},
isCurrent(ref) {
let n = this.treeState.nodes[ref];
return n && n.active;
},
isOpen(ref) {
let node = this.treeState.nodes[ref];
if (!node) return false;
if (node.open) {
debug('isOpen', ref);
}
return node.open;
},
});

View File

@@ -0,0 +1,3 @@
export * from './navbar';
export * from './search';
export * from './toc';

View File

@@ -0,0 +1,10 @@
export const navbar = (Alpine) => ({
init: function () {
Alpine.bind(this.$root, this.root);
},
root: {
['@scroll.window.debounce.10ms'](event) {
this.$store.nav.scroll.atTop = window.scrollY < 40 ? true : false;
},
},
});

View File

@@ -0,0 +1,109 @@
const designMode = false;
const groupByLvl0 = (array) => {
if (!array) return [];
return array.reduce((result, currentValue) => {
(result[currentValue.hierarchy.lvl0] = result[currentValue.hierarchy.lvl0] || []).push(currentValue);
return result;
}, {});
};
const applyHelperFuncs = (array) => {
if (!array) return [];
return array.map((item) => {
item.getHeadingHTML = function () {
let lvl2 = this._highlightResult.hierarchy.lvl2;
let lvl3 = this._highlightResult.hierarchy.lvl3;
if (!lvl3) {
if (lvl2) {
return lvl2.value;
}
return '';
}
if (!lvl2) {
return lvl3.value;
}
return `${lvl2.value} <span class="text-gray-500">&nbsp;>&nbsp;</span> ${lvl3.value}`;
};
return item;
});
};
export const search = (Alpine, cfg) => ({
query: designMode ? 'shortcodes' : '',
open: designMode,
result: {},
init() {
Alpine.bind(this.$root, this.root);
this.checkOpen();
return this.$nextTick(() => {
this.$watch('query', () => {
this.search();
});
});
},
toggleOpen: function () {
this.open = !this.open;
this.checkOpen();
},
checkOpen: function () {
if (!this.open) {
return;
}
this.search();
this.$nextTick(() => {
this.$refs.input.focus();
});
},
search: function () {
if (!this.query) {
this.result = {};
return;
}
var queries = {
requests: [
{
indexName: cfg.index,
params: `query=${encodeURIComponent(this.query)}`,
attributesToHighlight: ['hierarchy', 'content'],
attributesToRetrieve: ['hierarchy', 'url', 'content'],
},
],
};
const host = `https://${cfg.app_id}-dsn.algolia.net`;
const url = `${host}/1/indexes/*/queries`;
fetch(url, {
method: 'POST',
headers: {
'X-Algolia-Application-Id': cfg.app_id,
'X-Algolia-API-Key': cfg.api_key,
},
body: JSON.stringify(queries),
})
.then((response) => response.json())
.then((data) => {
this.result = groupByLvl0(applyHelperFuncs(data.results[0].hits));
});
},
root: {
['@click']() {
if (!this.open) {
this.toggleOpen();
}
},
['@search-toggle.window']() {
this.toggleOpen();
},
['@keydown.meta.k.window.prevent']() {
this.toggleOpen();
},
},
});

View File

@@ -0,0 +1,71 @@
var debug = 0 ? console.log.bind(console, '[toc]') : function () {};
export const toc = (Alpine) => ({
contentScrollSpy: null,
activeHeading: '',
justClicked: false,
setActive(id) {
debug('setActive', id);
this.activeHeading = id;
// Prevent the intersection observer from changing the active heading right away.
this.justClicked = true;
setTimeout(() => {
this.justClicked = false;
}, 200);
},
init() {
this.$watch('$store.nav.scroll.atTop', (value) => {
if (!value) return;
this.activeHeading = '';
this.$root.scrollTop = 0;
});
return this.$nextTick(() => {
let contentEl = document.getElementById('content');
if (contentEl) {
const handleIntersect = (entries) => {
if (this.justClicked) {
return;
}
for (let entry of entries) {
if (entry.isIntersecting) {
let id = entry.target.id;
this.activeHeading = id;
let liEl = this.$refs[id];
if (liEl) {
// If liEl is not in the viewport, scroll it into view.
let bounding = liEl.getBoundingClientRect();
if (bounding.top < 0 || bounding.bottom > window.innerHeight) {
this.$root.scrollTop = liEl.offsetTop - 100;
}
}
debug('intersecting', id);
break;
}
}
};
let opts = {
rootMargin: '0px 0px -75%',
threshold: 0.75,
};
this.contentScrollSpy = new IntersectionObserver(handleIntersect, opts);
// Observe all headings.
let headings = contentEl.querySelectorAll('h2, h3, h4, h5, h6');
for (let heading of headings) {
this.contentScrollSpy.observe(heading);
}
}
});
},
destroy() {
if (this.contentScrollSpy) {
debug('disconnecting');
this.contentScrollSpy.disconnect();
}
},
});

View File

@@ -0,0 +1,36 @@
'use strict';
export function registerMagics(Alpine) {
Alpine.magic('copy', (currentEl) => {
return function (el) {
if (!el) {
el = currentEl;
}
// Select the element to copy.
let range = document.createRange();
range.selectNode(el);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
// Remove the selection after some time.
setTimeout(() => {
window.getSelection().removeAllRanges();
}, 500);
// Trim whitespace.
let text = el.textContent.trim();
navigator.clipboard.writeText(text);
};
});
Alpine.magic('isScrollX', (currentEl) => {
return function (el) {
if (!el) {
el = currentEl;
}
return el.clientWidth < el.scrollWidth;
};
});
}

View File

@@ -0,0 +1 @@
export * from './helpers';

View File

@@ -0,0 +1 @@
export * from './nav.js';

View File

@@ -0,0 +1,94 @@
var debug = 1 ? console.log.bind(console, '[navStore]') : function () {};
var ColorScheme = {
System: 1,
Light: 2,
Dark: 3,
};
const localStorageUserSettingsKey = 'hugoDocsUserSettings';
export const navStore = (Alpine) => ({
init() {
// There is no $watch available in Alpine stores,
// but this has the same effect.
this.userSettings.onColorSchemeChanged = Alpine.effect(() => {
if (this.userSettings.settings.colorScheme) {
this.userSettings.isDark = isDark(this.userSettings.settings.colorScheme);
toggleDarkMode(this.userSettings.isDark);
}
});
// Also react to changes in system settings.
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
this.userSettings.setColorScheme(ColorScheme.System);
});
},
destroy() {},
scroll: {
atTop: true,
},
userSettings: {
// settings gets persisted between page navigations.
settings: Alpine.$persist({
// light, dark or system mode.
// If not set, we use the OS setting.
colorScheme: ColorScheme.System,
// Used to show the most relevant tab in config listings etc.
configFileType: 'toml',
}).as(localStorageUserSettingsKey),
isDark: false,
setColorScheme(colorScheme) {
this.settings.colorScheme = colorScheme;
this.isDark = isDark(colorScheme);
},
toggleColorScheme() {
let next = this.settings.colorScheme + 1;
if (next > ColorScheme.Dark) {
next = ColorScheme.System;
}
this.setColorScheme(next);
},
colorScheme() {
return this.settings.colorScheme ? this.settings.colorScheme : ColorScheme.System;
},
},
});
function isMediaDark() {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function isDark(colorScheme) {
if (!colorScheme || colorScheme == ColorScheme.System) {
return isMediaDark();
}
return colorScheme == ColorScheme.Dark;
}
export function initColorScheme() {
// The AlpineJS store has not have been initialized yet, so access the
// localStorage directly.
let settingsJSON = localStorage[localStorageUserSettingsKey];
if (settingsJSON) {
let settings = JSON.parse(settingsJSON);
toggleDarkMode(isDark(settings.colorScheme));
return;
}
toggleDarkMode(isDark(null));
}
const toggleDarkMode = function (dark) {
if (dark) {
document.body.classList.add('dark');
} else {
document.body.classList.remove('dark');
}
};

View File

@@ -0,0 +1,6 @@
import { initColorScheme } from './alpinejs/stores/index';
(function () {
// This allows us to initialize the color scheme before AlpineJS etc. is loaded.
initColorScheme();
})();

View File

@@ -0,0 +1,16 @@
import { scrollToActive } from 'js/helpers/index';
(function () {
// Now we know that the browser has JS enabled.
document.documentElement.classList.remove('no-js');
// Add os-macos class to body if user is using macOS.
if (navigator.userAgent.indexOf('Mac') > -1) {
document.documentElement.classList.add('os-macos');
}
// Wait for the DOM to be ready.
document.addEventListener('DOMContentLoaded', function () {
scrollToActive('DOMContentLoaded');
});
})();

View File

@@ -0,0 +1,67 @@
export function bridgeTurboAndAlpine(Alpine) {
document.addEventListener('turbo:before-render', (event) => {
event.detail.newBody.querySelectorAll('[data-alpine-generated]').forEach((el) => {
if (el.hasAttribute('data-alpine-generated')) {
el.removeAttribute('data-alpine-generated');
el.remove();
}
});
});
document.addEventListener('turbo:render', () => {
if (document.documentElement.hasAttribute('data-turbo-preview')) {
return;
}
document.querySelectorAll('[data-alpine-ignored]').forEach((el) => {
el.removeAttribute('x-ignore');
el.removeAttribute('data-alpine-ignored');
});
document.body.querySelectorAll('[x-data]').forEach((el) => {
if (el.hasAttribute('data-turbo-permanent')) {
return;
}
Alpine.initTree(el);
});
Alpine.startObservingMutations();
});
// Cleanup Alpine state on navigation.
document.addEventListener('turbo:before-cache', () => {
// This will be restarted in turbo:render.
Alpine.stopObservingMutations();
document.body.querySelectorAll('[data-turbo-permanent]').forEach((el) => {
if (!el.hasAttribute('x-ignore')) {
el.setAttribute('x-ignore', true);
el.setAttribute('data-alpine-ignored', true);
}
});
document.body.querySelectorAll('[x-for],[x-if],[x-teleport]').forEach((el) => {
if (el.hasAttribute('x-for') && el._x_lookup) {
Object.values(el._x_lookup).forEach((el) => el.setAttribute('data-alpine-generated', true));
}
if (el.hasAttribute('x-if') && el._x_currentIfEl) {
el._x_currentIfEl.setAttribute('data-alpine-generated', true);
}
if (el.hasAttribute('x-teleport') && el._x_teleport) {
el._x_teleport.setAttribute('data-alpine-generated', true);
}
});
document.body.querySelectorAll('[x-data]').forEach((el) => {
if (!el.hasAttribute('data-turbo-permanent')) {
Alpine.destroyTree(el);
// Turbo leaks DOM elements via their data-turbo-permanent handling.
// That needs to be fixed upstream, but until then.
let clone = el.cloneNode(true);
el.replaceWith(clone);
}
});
});
}

View File

@@ -0,0 +1,17 @@
export const scrollToActive = (when) => {
let els = document.querySelectorAll('.scroll-active');
if (!els.length) {
return;
}
els.forEach((el) => {
// Find scrolling container.
let container = el.closest('[data-turbo-preserve-scroll-container]');
if (container) {
// Avoid scrolling if el is already in view.
if (el.offsetTop >= container.scrollTop && el.offsetTop <= container.scrollTop + container.clientHeight) {
return;
}
container.scrollTop = el.offsetTop - container.offsetTop;
}
});
};

View File

@@ -0,0 +1,2 @@
export * from './bridgeTurboAndAlpine';
export * from './helpers';

89
docs/assets/js/main.js Normal file
View File

@@ -0,0 +1,89 @@
import Alpine from 'alpinejs';
import { registerMagics } from './alpinejs/magics/index';
import { navbar, search, toc } from './alpinejs/data/index';
import { navStore, initColorScheme } from './alpinejs/stores/index';
import { bridgeTurboAndAlpine } from './helpers/index';
import persist from '@alpinejs/persist';
import focus from '@alpinejs/focus';
var debug = 0 ? console.log.bind(console, '[index]') : function () {};
// Turbolinks init.
(function () {
document.addEventListener('turbo:render', function (e) {
// This is also called right after the body start. This is added to prevent flicker on navigation.
initColorScheme();
});
})();
// Set up and start Alpine.
(function () {
// Register AlpineJS plugins.
{
Alpine.plugin(focus);
Alpine.plugin(persist);
}
// Register AlpineJS magics and directives.
{
// Handles copy to clipboard etc.
registerMagics(Alpine);
}
// Register AlpineJS controllers.
{
// Register AlpineJS data controllers.
let searchConfig = {
index: 'hugodocs',
app_id: 'D1BPLZHGYQ',
api_key: '6df94e1e5d55d258c56f60d974d10314',
};
Alpine.data('navbar', () => navbar(Alpine));
Alpine.data('search', () => search(Alpine, searchConfig));
Alpine.data('toc', () => toc(Alpine));
}
// Register AlpineJS stores.
{
Alpine.store('nav', navStore(Alpine));
}
// Start AlpineJS.
Alpine.start();
// Start the Turbo-Alpine bridge.
bridgeTurboAndAlpine(Alpine);
{
let containerScrollTops = {};
// To preserve scroll position in scrolling elements on navigation add data-turbo-preserve-scroll-container="somename" to the scrolling container.
addEventListener('turbo:click', () => {
document.querySelectorAll('[data-turbo-preserve-scroll-container]').forEach((el2) => {
containerScrollTops[el2.dataset.turboPreserveScrollContainer] = el2.scrollTop;
});
});
addEventListener('turbo:render', () => {
document.querySelectorAll('[data-turbo-preserve-scroll-container]').forEach((ele) => {
const containerScrollTop = containerScrollTops[ele.dataset.turboPreserveScrollContainer];
if (containerScrollTop) {
ele.scrollTop = containerScrollTop;
} else {
let els = ele.querySelectorAll('.scroll-active');
if (els.length) {
els.forEach((el) => {
// Avoid scrolling if el is already in view.
if (el.offsetTop >= ele.scrollTop && el.offsetTop <= ele.scrollTop + ele.clientHeight) {
return;
}
ele.scrollTop = el.offsetTop - ele.offsetTop;
});
}
}
});
containerScrollTops = {};
});
}
})();

1
docs/assets/js/turbo.js Normal file
View File

@@ -0,0 +1 @@
import * as Turbo from '@hotwired/turbo';