From bfe3c2965058c9d9d51196117f2775f368f55fe2 Mon Sep 17 00:00:00 2001 From: Omar Ibrahim Date: Mon, 13 Jul 2026 14:17:18 -0500 Subject: [PATCH 01/16] Infrastructure(docs): wire up static site generation mode --- apps/docs/nuxt.config.static.ts | 30 ++++++++++++++++++++++++++++++ apps/docs/nuxt.config.ts | 16 +++++++++++++++- apps/docs/package.json | 2 +- 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 apps/docs/nuxt.config.static.ts diff --git a/apps/docs/nuxt.config.static.ts b/apps/docs/nuxt.config.static.ts new file mode 100644 index 00000000..21f6e08b --- /dev/null +++ b/apps/docs/nuxt.config.static.ts @@ -0,0 +1,30 @@ +import type { DefineNuxtConfig } from 'nuxt/config'; + +const staticOverrides: Parameters[0] = { + app: { + baseURL: "./", + cdnURL: "./" + }, + + features: { + 'inlineStyles': true, + 'noScripts': true, + }, + + experimental: { + 'payloadExtraction': false, + }, + + runtimeConfig: { + public: { + isOffline: true, + } + }, + + sitemap: { enabled: false }, + gtag: { enabled: false }, + ogImage: { enabled: false }, + linkChecker: { enabled: false }, +}; + +export default staticOverrides; \ No newline at end of file diff --git a/apps/docs/nuxt.config.ts b/apps/docs/nuxt.config.ts index 0bb6dd77..ca95a6e7 100644 --- a/apps/docs/nuxt.config.ts +++ b/apps/docs/nuxt.config.ts @@ -5,6 +5,7 @@ import { getDocumentedExtensionBuilders } from '@repo/netlogo-docs/extension-doc import { deepMerge } from '@repo/utils/std/objects'; import pdfOverrides from './nuxt.config.pdf'; +import staticOverrides from './nuxt.config.static'; import { websiteConfigSchema } from './runtime.config'; import * as MarkdownConfig from '@repo/nuxt-core/markdown.config'; @@ -17,10 +18,23 @@ const basePath = process.env['BASE_PATH'] ?? '/'; const isUsingPdfConfig = process.env['DOCS_ENV_PDF'] === '1'; const optionalPdfLayer = isUsingPdfConfig ? pdfOverrides : {}; +const isUsingStaticConfig = process.env['DOCS_ENV_STATIC'] === '1'; +const optionalStaticLayer = isUsingStaticConfig ? staticOverrides : {}; + +const optionalLayers = deepMerge( + optionalPdfLayer, + optionalStaticLayer, + { mergeArrays: false }, +) + if (isUsingPdfConfig) { console.info('[repo] Running with PDF generation configuration overrides'); } +if (isUsingStaticConfig) { + console.info('[repo] Running with static hosting configuration overrides'); +} + const extensions = (await getDocumentedExtensionBuilders(autogenConfig)).map((ext) => ({ title: ext.fullName, href: `/${ext.name.toLowerCase()}`, @@ -97,7 +111,7 @@ export default defineNuxtConfig( baseURL: basePath, }, }, - optionalPdfLayer, + optionalLayers, { mergeArrays: false }, ), ); diff --git a/apps/docs/package.json b/apps/docs/package.json index 01f5c888..660f65c3 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -15,7 +15,7 @@ "docs:clean-up": "bash ./scripts/clean-up.sh", "docs:build-and-generate-manual": "bash ./scripts/generate-manual/build-html.sh && yarn run docs:generate-manual", "docs:generate-manual": "bash ./scripts/generate-manual/run.sh", - "docs:make-static-site": "bash ./scripts/make-static/run.sh .build/latest .build-static", + "docs:make-static-site": "DOCS_ENV_STATIC=1 yarn run nuxt:generate", "docs:all": "npm run docs:clean-up && npm run docs:build && npm run docs:generate-manual && npm run docs:deploy", "docs:dev": "yarn run nuxt:dev:no-autogen", "nuxt:build": "nuxt build", From 14ccd13ec3c4db629e24a9bd7bc51d2b978f29b0 Mon Sep 17 00:00:00 2001 From: Omar Ibrahim Date: Mon, 13 Jul 2026 14:18:14 -0500 Subject: [PATCH 02/16] chore(docs): remove old (broken) static site generator --- apps/docs/scripts/make-static/globals.d.ts | 9 - apps/docs/scripts/make-static/index.cjs | 376 -------------------- apps/docs/scripts/make-static/jsconfig.json | 8 - apps/docs/scripts/make-static/run.sh | 64 ---- 4 files changed, 457 deletions(-) delete mode 100644 apps/docs/scripts/make-static/globals.d.ts delete mode 100644 apps/docs/scripts/make-static/index.cjs delete mode 100644 apps/docs/scripts/make-static/jsconfig.json delete mode 100644 apps/docs/scripts/make-static/run.sh diff --git a/apps/docs/scripts/make-static/globals.d.ts b/apps/docs/scripts/make-static/globals.d.ts deleted file mode 100644 index 47205039..00000000 --- a/apps/docs/scripts/make-static/globals.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -declare global { - interface Window { - __relativeFilePrefix: string; - __allowScriptElements: boolean; - readfile: (path: string) => string; - } -} - -export {}; diff --git a/apps/docs/scripts/make-static/index.cjs b/apps/docs/scripts/make-static/index.cjs deleted file mode 100644 index 5291100f..00000000 --- a/apps/docs/scripts/make-static/index.cjs +++ /dev/null @@ -1,376 +0,0 @@ -/** - * @file - * This script converts HTML URL(s) relative to the root to URL(s) relative to - * the current page when the root is known. This is applied to [href] and [src] - * attributes to make a static version (no server needed) of the NetLogo documentation. - * - * @license GPL-2.0-or-later - * @author Omar Ibrahim, Center for Connected Learning, Northwestern University - * @since 2025 - * - * Current Issues: - * - Scripts cannot be loaded which means interactive functionality is lost. - * - Fetch requests in scripts cannot be made, so dynamic loading of content is lost. - */ - -const path = require('path'); -const urllib = require('url'); -const glob = require('glob'); - -const NO_PAGE_CONSOLE = true; // Keep this true to reduce noise in output logs - -/** - * - * @typedef {import('puppeteer').Browser} Browser - * @typedef {import('puppeteer').Page} Page - * @typedef {import('fs-extra')} FsExtra - * @typedef {import('./globals')} - */ - -async function main() { - const { args, driver, dependencies, files } = await setup(); - const { buildDir } = args; - const { fs } = dependencies; - - let fileCount = files.length; - let filesDone = 0; - - /** - * @param {string} fileRelPath - */ - const handler = async (fileRelPath) => { - const page = await newPage(driver.browser, buildDir, fs); - try { - const fileExtension = path.extname(fileRelPath).toLowerCase(); - switch (fileExtension) { - case '.html': - case '.htm': - await handleHtmlFile(page, fileRelPath, buildDir, fs); - break; - default: - console.warn(`Unsupported file type for static processing: ${fileRelPath}`); - } - } catch (err) { - console.error(`Error processing file ${fileRelPath}:`, err); - } finally { - filesDone += 1; - console.info(`💬 Progress: ${filesDone} / ${fileCount} files processed.`); - await page.close(); - } - }; - - const batchPromise = async (items, batchSize, fn) => { - let promises = []; - for (let i = 0; i < items.length; i += batchSize) { - const batch = items.slice(i, i + batchSize); - batch.forEach((item) => { - promises.push(fn(item)); - }); - await Promise.all(promises); - promises = []; - } - }; - - const BATCH_SIZE = 20; - await batchPromise(files, BATCH_SIZE, handler); - - await cleanup(driver); -} - -async function setup() { - const missingDependencies = []; - for (const dep of ['puppeteer', 'fs-extra']) { - try { - require.resolve(dep); - } catch { - missingDependencies.push(dep); - } - } - - if (missingDependencies.length > 0) { - console.error(`Missing dependencies: ${missingDependencies.join(', ')}.`); - console.error(`Please install them using: npm install ${missingDependencies.join(' ')}`); - process.exit(1); - } - - const puppeteer = require('puppeteer'); - const fs = require('fs-extra'); - - const args = process.argv.slice(2); - if (args.length < 1) { - console.error('Usage: node index.cjs '); - process.exit(1); - } - - const buildDir = path.resolve(process.cwd(), args[0]); - if (!fs.existsSync(buildDir)) { - console.error(`Build directory does not exist: ${buildDir}`); - process.exit(1); - } - - const patterns = ['**/*.html', '**/*.htm']; - const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] }); - const files = await glob.sync(`{${patterns.join(',')}}`, { cwd: buildDir, nodir: true }); - - return { - args: { buildDir }, - driver: { browser }, - dependencies: { fs, puppeteer }, - files, - }; -} - -/** - * @param {Browser} browser - * @param {string} buildDir - * @param {FsExtra} fs - * - * @returns {Promise} - */ -async function newPage(browser, buildDir, fs) { - const page = await browser.newPage(); - await page.setJavaScriptEnabled(false); - await page.exposeFunction('readfile', (filePath) => { - const absPath = path.isAbsolute(filePath) ? filePath : path.join(buildDir, filePath); - return fs.readFileSync(absPath, 'utf-8'); - }); - page.on('console', (msg) => { - if (NO_PAGE_CONSOLE) return; - const type = msg.type(); - if (type === 'error') return; - console[type](`{@page} ${msg.text()}`); - }); - return page; -} - -async function cleanup(driver) { - await driver.browser.close(); -} - -/** - * @param {Page} page - * @param {string} filePath -- [relative] path to the HTML file - * @param {string} buildDir -- [absolute] path to the build directory - * @param {FsExtra} fs - */ -async function handleHtmlFile(page, filePath, buildDir, fs) { - const fullPath = path.join(buildDir, filePath); - const fileUrl = urllib.pathToFileURL(fullPath).href; - const relativeFilePrefix = path.relative(path.dirname(fullPath), buildDir) || '.'; - - await page.evaluateOnNewDocument((prefix) => { - window.__relativeFilePrefix = prefix; - window.__allowScriptElements = process.env.ALLOW_SCRIPT_ELEMENTS === 'true'; - }, relativeFilePrefix); - await page.goto(fileUrl, { waitUntil: 'networkidle0' }); - - await page.evaluate(async () => { - /** - * @param {string} property - * @returns {(element: Element) => void} - */ - const makeRelative = (property) => (element) => { - let value = element.getAttribute(property); - if (value.startsWith('/')) { - console.info(`⏳ Adjusting ${property} from absolute to relative: ${value}`); - console.info(` 🔸 Old URL: ${element.getAttribute(property)}`); - const [uri, rest] = value.split('#'); - const uriParts = uri.split('.'); - if (uriParts.length === 1) { - if (uriParts[0] === '/') { - uriParts[0] = 'index'; - } - uriParts.push('html'); - } - value = uriParts.join('.') + (rest ? `#${rest}` : ''); - value = value.replace(/^\//, ''); - element.setAttribute(property, value); - console.info(` 🔹 New URL: ${element.getAttribute(property)}`); - } - }; - - /** - * @param {string} href -- via el.getAttribute('href') - */ - const getFilePathFromHref = (href) => { - const url = new URL(href, window.location.origin); - let filePath = window.__relativeFilePrefix + url.pathname; - if (url.hash) { - filePath += url.hash; - } - return filePath; - }; - - /** - * @param {string} filePath - * @returns {string} mime type - */ - const getFileMimeType = (filePath) => { - if (filePath.includes('.json')) return 'application/json'; - if (filePath.includes('.jsonld')) return 'application/ld+json'; - if (filePath.includes('.js')) return 'application/javascript'; - if (filePath.includes('.css')) return 'text/css'; - if (filePath.includes('.svg')) return 'image/svg+xml'; - if (filePath.includes('.png')) return 'image/png'; - if (filePath.includes('.jpg') || filePath.includes('.jpeg')) return 'image/jpeg'; - if (filePath.includes('.gif')) return 'image/gif'; - return 'application/octet-stream'; - }; - - /** - * - * @param {Element} element - * @returns - */ - const respectNoScript = (element) => { - if (!window.__allowScriptElements) { - element.remove(); - return true; - } - return false; - }; - - /** - * - * @param {Element} element - * @param {string} data - */ - const transformLinkToDataHtml = (element, data) => { - if (element.tagName.toLowerCase() === 'script' && !respectNoScript(element)) { - element.setAttribute('src', data); - console.info(` ✅ Transformed script[src] to data URL`); - } else if (element.tagName.toLowerCase() === 'link') { - const rel = element.getAttribute('rel'); - switch (element.getAttribute('as')) { - case 'script': { - if (!respectNoScript(element)) { - const scriptElement = document.createElement('script'); - scriptElement.setAttribute('src', data); - if (rel === 'modulepreload' || rel === 'prefetch') { - scriptElement.setAttribute('type', 'module'); - } - element.parentNode.replaceChild(scriptElement, element); - console.info(` ✅ Transformed link[rel="${rel}"] to script with data URL`); - } - break; - } - default: - break; - } - } - }; - - /** - * @param {Element} linkElement - * @returns - */ - const inlineStylesheet = async (linkElement) => { - const href = linkElement.getAttribute('href'); - if (!href) return; - const filePath = getFilePathFromHref(href); - console.info(`🔍 Found stylesheet link: ${href} => ${filePath}`); - try { - const cssContent = await window.readfile(filePath); - const styleElement = document.createElement('style'); - styleElement.textContent = cssContent; - linkElement.parentNode.replaceChild(styleElement, linkElement); - console.info(` ✅ Inlined stylesheet: ${href}`); - } catch (err) { - console.error(` ❌ Failed to inline stylesheet: ${href}`, err); - } - }; - - /** - * - * @param {string} filePath - * @param {string} mimeType - * @returns {Promise} data URL - */ - const fileToDataUrl = async (filePath, mimeType) => { - const content = await window.readfile(filePath); - - const encoder = new TextEncoder(); - const bytes = encoder.encode(content); - - let binary = ''; - bytes.forEach((b) => (binary += String.fromCharCode(b))); - const base64Content = btoa(binary); - - return `data:${mimeType};base64,${base64Content}`; - }; - - /** - * @param {Element} element - * @returns - */ - const inlineData = async (element) => { - const href = element.getAttribute('href') || element.getAttribute('src'); - if (!href || href.startsWith('data:')) return; - - const filePath = getFilePathFromHref(href); - const mimeType = getFileMimeType(filePath); - - console.info(`🔍 Found data link: ${href} => ${filePath}`); - - try { - const dataUrl = await fileToDataUrl(filePath, mimeType); - transformLinkToDataHtml(element, dataUrl); - console.info(` ✅ Inlined data URL: ${href}`); - } catch (err) { - console.error(` ❌ Failed to inline data URL: ${href}`, err); - } - }; - - const removeSearchButton = () => { - const searchButton = document.querySelector('button span.truncate'); - if (searchButton && searchButton.textContent.toLowerCase().includes('search')) { - searchButton.parentElement.remove(); - console.info(`✅ Removed search button`); - } - }; - - const removeVersionSelector = () => { - const versionSelector = document.querySelector('span.iconify.align-middle'); - if (versionSelector) { - versionSelector.parentElement.remove(); - console.info(`✅ Removed version selector`); - } - }; - - await Promise.all(Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map(inlineStylesheet)); - - const scriptSelectors = ['script[src]', 'link[rel="modulepreload"]', 'link[rel="prefetch"]']; - for (const selector of scriptSelectors) { - document.querySelectorAll(selector).forEach(respectNoScript); - } - - const dataLinkSelectors = [ - ['preload', 'prefetch', 'dns-prefetch', 'preconnect'].map((as) => `link[rel="${as}"]`), - window.__allowScriptElements ? ['link[rel="modulepreload"]', 'script[src]'] : [], - ].flat(); - for (const selector of dataLinkSelectors) { - for (const linkEl of document.querySelectorAll(selector)) { - await inlineData(linkEl); - } - } - - document.querySelectorAll('a[href]').forEach(makeRelative('href')); - document.querySelectorAll('img[src]').forEach(makeRelative('src')); - - removeSearchButton(); - removeVersionSelector(); - }); - - const modifiedContent = await page.content(); - await fs.writeFile(fullPath, modifiedContent, 'utf-8'); - - console.info(`✅ Processed HTML file: ${filePath}`); -} - -main().catch((err) => { - console.error('Fatal error:', err); - console.info( - 'If this script timed out before it finished handling the first file, try running it again. This commonly happens when puppeteer is unable to launch the browser.', - ); - process.exit(1); -}); diff --git a/apps/docs/scripts/make-static/jsconfig.json b/apps/docs/scripts/make-static/jsconfig.json deleted file mode 100644 index 7e4ae8ba..00000000 --- a/apps/docs/scripts/make-static/jsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "compilerOptions": { - "checkJs": true, // Enables type checking for JavaScript files - "target": "es2020", - "module": "commonjs" - }, - "exclude": ["node_modules"] -} diff --git a/apps/docs/scripts/make-static/run.sh b/apps/docs/scripts/make-static/run.sh deleted file mode 100644 index 5e4b8e6d..00000000 --- a/apps/docs/scripts/make-static/run.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/bin/bash - -source .env -source "$(dirname "${BASH_SOURCE[0]}")/../../../../scripts/.helpers" - -ALLOW_SCRIPT_ELEMENTS=${ALLOW_SCRIPT_ELEMENTS:-"false"} -if [ $ALLOW_SCRIPT_ELEMENTS = "true" ]; then - log_warn " Warning: Script elements will be allowed in the static site. This will not work as intended." -else - log_info "Info: Script elements will be removed from the static site." -fi - -if [ "$#" -ne 2 ]; then - log "Usage: $0 " - exit 1 -fi - -extensions=$(find $REPO_ROOT/external/extensions -name documentation.yaml | awk -F'/' '{print $(NF-1)}' | paste -sd' ' -) - -buildDir="$1" -targetDir="$2" - -if [ ! -d "$buildDir" ]; then - log_error "Build directory does not exist: $buildDir" - exit 1 -fi - -if [ -d "$targetDir" ]; then - log_speak "Target directory already exists, removing: $targetDir" - rm -rf "$targetDir" -fi - -cp -r "$buildDir" "$targetDir" - -for ext in $extensions; do - extDir="$targetDir/$ext" - if [ -d "$extDir" ]; then - log "🗑️ Removing extension directory from static site: $extDir" - rm -rf "$extDir" - fi -done -if [ ! $ALLOW_SCRIPT_ELEMENTS = "true" ]; then - log "🗑️ Removing script elements from static site." - find "$targetDir" -name "*.js" -type f -delete -fi -unwantedDirs=("__og-image__", "__sitemap__", "dict") -for dir in "${unwantedDirs[@]}"; do - if [ -d "$targetDir/$dir" ]; then - log "🗑️ Removing unwanted directory from static site: $dir" - rm -rf "$targetDir/$dir" - fi -done - -log "🗑️ Removing payloads from static site" -find "$targetDir" -type f -name "_payload.json" -delete - -log "🗑️ Removing empty directories from static site" -find "$targetDir" -type d -empty -delete - -log_success "Copied static site from $buildDir to $targetDir" - -node scripts/make-static/index.cjs "$targetDir" - -exit 0 From dc3f7e85a91f8f0ca7c0b6f3b91fda6c1438c05f Mon Sep 17 00:00:00 2001 From: Omar Ibrahim Date: Mon, 13 Jul 2026 14:31:17 -0500 Subject: [PATCH 03/16] Feat(docs): Bake CSS signal for offline mode to HTML tag --- apps/docs/app/app.vue | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/docs/app/app.vue b/apps/docs/app/app.vue index 47161f3f..e8b95f41 100644 --- a/apps/docs/app/app.vue +++ b/apps/docs/app/app.vue @@ -6,3 +6,13 @@ + + \ No newline at end of file From f554db00110b99ab7605ebc91e423fbf66ab5d5e Mon Sep 17 00:00:00 2001 From: Omar Ibrahim Date: Mon, 13 Jul 2026 14:31:49 -0500 Subject: [PATCH 04/16] Feat(docs): Rewrite absolute URLs to relative URLs --- apps/docs/nuxt.config.static.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/apps/docs/nuxt.config.static.ts b/apps/docs/nuxt.config.static.ts index 21f6e08b..51e52256 100644 --- a/apps/docs/nuxt.config.static.ts +++ b/apps/docs/nuxt.config.static.ts @@ -25,6 +25,36 @@ const staticOverrides: Parameters[0] = { gtag: { enabled: false }, ogImage: { enabled: false }, linkChecker: { enabled: false }, + + nitro: { + hooks: { + 'prerender:generate'(route) { + if (!route.fileName?.endsWith('.html') || !route.contents) return + + const depth = route.route.split('/').filter(Boolean).length - 1 + const prefix = depth > 0 ? '../'.repeat(depth) : './' + // remove the trailing slash since we already have + // a slash at the beginning of each path we want + // to modify. + // -- Omar I. Jul 13 2026 + const p = prefix.slice(0, -1) + + route.contents = route.contents.replace( + /\b(src|href)="(\/[^"]*)"/g, + (m, attr, val) => { + if (val.startsWith('//')) return m + if (attr === 'href') { + const [path, ...rest] = val.split(/([?#])/) + const qh = rest.join('') + const target = path === '/' ? `${prefix}index.html${qh}` : `${p}${path}.html${qh}` + return `${attr}="${target}"` + } + return `${attr}="${p}${val}"` + } + ) + } + } + } }; export default staticOverrides; \ No newline at end of file From 8622bfec9fbe4a394130ef12a80a116385a9cd30 Mon Sep 17 00:00:00 2001 From: Omar Ibrahim Date: Mon, 13 Jul 2026 14:32:20 -0500 Subject: [PATCH 05/16] Feat(docs): Make existing components compatible with offline static mode --- apps/docs/app/components/ClientNavbar.vue | 4 +++- .../components/PrimitiveCatalog/PrimitiveCatalog.vue | 12 ++++++++++-- .../vue-ui/src/components/navbar/Navbar.module.scss | 1 + 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/docs/app/components/ClientNavbar.vue b/apps/docs/app/components/ClientNavbar.vue index 4efd1e70..f164fe75 100644 --- a/apps/docs/app/components/ClientNavbar.vue +++ b/apps/docs/app/components/ClientNavbar.vue @@ -23,9 +23,10 @@ - + -
@@ -20,7 +24,7 @@
-
+