diff --git a/.gitignore b/.gitignore index 1c9bec49..e3abf309 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,4 @@ /node_modules/ -/static/images/vendor/ -/static/fonts/vendor/ -/static/js/ -/static/css/ /public/ +/.hugo/ /.project diff --git a/Jenkinsfile b/Jenkinsfile index f79d39ab..8556c2a2 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -5,6 +5,7 @@ hugo ( productionDomain: 'openhwfoundation.org', build: [ containerImage: 'eclipsefdn/hugo-node:h0.144.2-n22.14.0', + script: 'build.sh', ], deployment: [ nginxServerConf: 'config/nginx/default.conf' diff --git a/build.sh b/build.sh new file mode 100644 index 00000000..89f3a696 --- /dev/null +++ b/build.sh @@ -0,0 +1,4 @@ +set -euo pipefail + +yarn install --frozen-lockfile; +yarn build; diff --git a/config.toml b/config.toml index 8b05d855..433fcf15 100644 --- a/config.toml +++ b/config.toml @@ -7,6 +7,9 @@ metaDataFormat = "yaml" disableKinds = ["taxonomyTerm", "taxonomy"] themesDir = "node_modules/" +# Vite handles static files via publicDir — prevent Hugo from copying them. +staticDir = [] + [Params] website_type = "Working Group" google_tag_manager = "GTM-5WLCZXC" @@ -17,14 +20,14 @@ themesDir = "node_modules/" keywords = ["open-source", "risc-v", "hardware", "software", "hw", "sw", "eclipse ide"] logo = "/images/openhw-landscape.png" logo_width = "200" - styles = "css/styles.css" + styles = "less/styles.less" + js = "js/main.ts" favicon = "images/favicon.ico" # share_img = "" gcse = "" # subtheme = "" header_wrapper_class = "header-default-bg-img" hide_call_for_action = true - js = "js/solstice.js" featured_content_publish_target = "openhwgroup" call_for_action_text = "Download" call_for_action_url = "#" diff --git a/js/main.js b/js/main.js deleted file mode 100644 index 472ba9b0..00000000 --- a/js/main.js +++ /dev/null @@ -1,14 +0,0 @@ -/*! - * Copyright (c) 2021 Eclipse Foundation, Inc. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * Contributors: - * Christopher Guindon - * - * SPDX-License-Identifier: EPL-2.0 - */ - -import 'eclipsefdn-solstice-assets/js/astro'; diff --git a/js/main.ts b/js/main.ts new file mode 100644 index 00000000..9ee0ac02 --- /dev/null +++ b/js/main.ts @@ -0,0 +1,45 @@ +/*! + * Vite entry point — wraps js/main.js with jQuery global setup. + * jQuery must be on window.$ / window.jQuery BEFORE bootstrap and + * solstice-assets initialise, so we use top-level await + dynamic import + * to guarantee execution order (static imports are hoisted). + * + */ + +// Patch addEventListener BEFORE loading any library code so that +// DOMContentLoaded listeners registered by eclipsefdn-solstice-assets +// fire immediately when the DOM is already ready (which it is, because +// + {{ end }} + + {{- with .Site.Params.hugo_js }} + + {{ end }} + + + {{ range $key, $value := (.Page.Scratch.Get "mustache-templates") }} + + {{ end }} + + + + + {{ if eq (.Page.Scratch.Get "is_using_twitter") "true" }} + + {{ end }} + diff --git a/package.json b/package.json index 108b1fe3..b7ba3e76 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,6 @@ { "name": "eclipsefdn-hugo-openhwgroup", "description": "Source code for openhwfoundation.org", - "main": "webpack.min.js", "author": "Christopher Guindon", "license": "EPL-2.0", "version": "0.0.1", @@ -10,19 +9,23 @@ }, "repository": { "type": "git", - "url": "git://github.com/openhwgroup/openhwgroup.org.git" + "url": "git://github.com/openhwgroup/openhwfoundation.org.git" }, "scripts": { - "dev": "NODE_ENV=development webpack --progress --config=node_modules/laravel-mix/setup/webpack.config.js", - "watch": "NODE_ENV=development webpack --watch --progress --config=node_modules/laravel-mix/setup/webpack.config.js", - "hot": "NODE_ENV=development webpack-dev-server --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js", - "production": "NODE_ENV=production webpack --progress --config=node_modules/laravel-mix/setup/webpack.config.js", - "postinstall": "NODE_ENV=production npm run production" + "dev": "concurrently \"hugo server --port 1313\" \"vite --open\"", + "build": "hugo --minify -d .hugo && vite build", + "preview": "vite preview" }, "readmeFilename": "README.md", "browserslist": "last 5 version, > 0.2%, not dead, IE 11", "dependencies": { "eclipsefdn-hugo-solstice-theme": "0.2.6" }, - "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e", + "devDependencies": { + "concurrently": "^9.2.1", + "less": "^4.6.4", + "smol-toml": "^1.6.1", + "vite": "^8.0.2" + } } diff --git a/static/images/bg-jumbotron-default.jpg b/static/images/bg-jumbotron-default.jpg new file mode 100644 index 00000000..e356698f Binary files /dev/null and b/static/images/bg-jumbotron-default.jpg differ diff --git a/static/images/bg-traces-alt.jpg b/static/images/bg-traces-alt.jpg new file mode 100644 index 00000000..92788498 Binary files /dev/null and b/static/images/bg-traces-alt.jpg differ diff --git a/static/images/bg-traces.jpg b/static/images/bg-traces.jpg new file mode 100644 index 00000000..8938f8e0 Binary files /dev/null and b/static/images/bg-traces.jpg differ diff --git a/static/mix-manifest.json b/static/mix-manifest.json deleted file mode 100644 index ea94b686..00000000 --- a/static/mix-manifest.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "/js/solstice.js": "/js/solstice.js", - "/css/styles.css": "/css/styles.css" -} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 00000000..ffb698a1 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,379 @@ +import { defineConfig, type Connect, type Plugin, type ViteDevServer } from "vite"; +import type { ServerResponse } from "node:http"; +import fs from "node:fs"; +import path from "node:path"; +import { parse as parseToml } from "smol-toml"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Recursively walk a directory, skipping symlinks. */ +function walkDir(dir: string, callback: (filePath: string) => void): void { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isSymbolicLink()) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walkDir(full, callback); + else callback(full); + } +} + +/** + * Relay Hugo's livereload events through Vite's HMR WebSocket. + * Auto-reconnects when Hugo restarts. + */ +function relayHugoLivereload( + hugoUrl: string, + viteWs: ViteDevServer["ws"], +): void { + const wsUrl = hugoUrl.replace(/^http/, "ws") + "/livereload"; + + function connect(): void { + const ws = new WebSocket(wsUrl); + ws.addEventListener("open", () => { + ws.send( + JSON.stringify({ + command: "hello", + protocols: ["http://livereload.com/protocols/official-7"], + }), + ); + }); + ws.addEventListener("message", (event) => { + try { + if (JSON.parse(event.data as string).command === "reload") { + viteWs.send({ type: "full-reload" }); + } + } catch {} + }); + ws.addEventListener("close", () => setTimeout(connect, 1000)); + ws.addEventListener("error", () => {}); + } + + connect(); +} + +/** + * Symlink directories from the project root into Hugo's output + * so Vite can resolve the imports referenced in Hugo's HTML. + */ +function symlinkSources( + sources: string[], + viteRoot: string, + absHugoDir: string, +): void { + for (const name of sources) { + const link = path.join(absHugoDir, name); + if (!fs.existsSync(link)) { + fs.symlinkSync(path.join(viteRoot, name), link); + } + } +} + +/** + * Discover all HTML files Hugo generated. + * Returns a Rollup `input` object mapping entry names to absolute paths. + * walkDir skips symlinks, so symlinked source dirs won't be scanned. + */ +function discoverHtmlEntries( + absHugoDir: string, +): Record { + const htmlFiles: string[] = []; + walkDir(absHugoDir, (f) => { + if (f.endsWith(".html")) htmlFiles.push(path.relative(absHugoDir, f)); + }); + + return Object.fromEntries( + htmlFiles.map((f) => [ + f.replace(/\.html$/, "").replaceAll("/", "_") || "index", + path.join(absHugoDir, f), + ]), + ); +} + +/** + * Re-resolve publicDir to an absolute path. + * Needed because the plugin changes root to Hugo's output dir, + * which would break Vite's relative resolution of publicDir. + */ +function resolvePublicDir( + userPublicDir: string | false | undefined, + viteRoot: string, +): string | false { + if (userPublicDir === false) return false; + return path.resolve(viteRoot, userPublicDir || "public"); +} + +/** + * Copy Hugo's generated files (HTML, sitemap, RSS, images…) into outDir. + * Vite will overwrite the HTML with processed versions. + * Symlinked source dirs are skipped — they're not part of the output. + */ +function syncHugoOutput(absHugoDir: string, absOutDir: string): void { + fs.rmSync(absOutDir, { recursive: true, force: true }); + fs.mkdirSync(absOutDir, { recursive: true }); + for (const entry of fs.readdirSync(absHugoDir, { withFileTypes: true })) { + if (entry.isSymbolicLink()) continue; + fs.cpSync( + path.join(absHugoDir, entry.name), + path.join(absOutDir, entry.name), + { recursive: true }, + ); + } +} + +/** + * Absolutize canonical/alternate hrefs so Vite doesn't try + * to resolve them as local file paths (which would fail). + */ +function absolutizeLinkHrefs(html: string, baseUrl: string): string { + return html.replaceAll(/]*>/gi, (tag) => { + if (!/rel=(?:"|)(?:canonical|alternate)(?:"|)/i.test(tag)) return tag; + return tag.replace( + /(href=(?:"|))(\/[^">\s]*)/, + (_, pre: string, url: string) => pre + baseUrl + url, + ); + }); +} + +/** + * Proxy a request to Hugo's dev server. + * HTML responses have Hugo's livereload script stripped and are piped + * through Vite's transform pipeline for HMR injection. + * Non-HTML responses are forwarded as-is. + */ +async function proxyToHugo( + req: Connect.IncomingMessage, + res: ServerResponse, + next: Connect.NextFunction, + { hugoUrl, server }: { hugoUrl: string; server: ViteDevServer }, +): Promise { + const isHTML = req.headers.accept?.includes("text/html"); + + try { + const r = await fetch(`${hugoUrl}${req.url}`); + if (!r.ok) return next(); + + if (isHTML) { + let html = await r.text(); + // Strip Hugo's livereload script — Vite handles reloading now + html = html.replaceAll( + /]*><\/script>/gi, + "", + ); + html = await server.transformIndexHtml(req.url ?? "/", html); + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(html); + return; + } + + const ct = r.headers.get("content-type"); + if (ct) res.setHeader("Content-Type", ct); + res.writeHead(200); + res.end(Buffer.from(await r.arrayBuffer())); + } catch { + if (isHTML) { + res.writeHead(502, { "Content-Type": "text/html" }); + res.end("

502 – Hugo not reachable

"); + return; + } + next(); + } +} + +// --------------------------------------------------------------------------- +// Plugins +// --------------------------------------------------------------------------- + +interface MustachePluginOptions { + importPath?: string; +} + +/** + * Vite plugin that transforms .mustache files into ES modules. + * + * .partial.mustache → raw string export + * .mustache → callable function via Hogan.js + */ +function mustachePlugin({ importPath = "hogan.js" }: MustachePluginOptions = {}): Plugin { + return { + name: "vite-plugin-mustache", + transform(src, id) { + if (!id.endsWith(".mustache")) return null; + + if (id.endsWith(".partial.mustache")) { + return { code: `export default ${JSON.stringify(src)};`, map: null }; + } + + return { + code: [ + `import Hogan from ${JSON.stringify(importPath)};`, + `var t = Hogan.compile(${JSON.stringify(src)});`, + `export default function() { return t.render.apply(t, arguments); };`, + ].join("\n"), + map: null, + }; + }, + }; +} + +interface HugoPluginOptions { + hugoOutDir?: string; + hugoUrl?: string; + sources?: string[]; + baseUrl?: string; +} + +/** + * Hugo + Vite integration plugin. + * + * Dev: Proxies requests to Hugo's dev server and relays its livereload + * events through Vite's HMR so Hugo content changes trigger a + * browser refresh. + * + * Build: Expects Hugo to have already built into `hugoOutDir` (e.g. .hugo/). + * Vite reads the HTML from there, bundles JS/CSS imports, and writes + * the final result to its own `build.outDir` (e.g. public/). + */ +function hugo({ + hugoOutDir = ".hugo", + hugoUrl = "http://localhost:1313", + sources = ["node_modules"], + baseUrl = "", +}: HugoPluginOptions = {}): Plugin[] { + let viteRoot: string; + let absHugoDir: string; + let absOutDir: string; + + // Requests matching these prefixes stay in Vite (not proxied to Hugo) + const viteOwned = ["/@", ...sources.map((d) => `/${d}/`)]; + + return [ + // ── Dev: proxy to Hugo ──────────────────────────────────────────── + { + name: "vite-plugin-hugo-proxy", + apply: "serve", + + configureServer(server) { + relayHugoLivereload(hugoUrl, server.ws); + + server.middlewares.use(async (req, res, next) => { + if (viteOwned.some((p) => req.url!.startsWith(p))) return next(); + return proxyToHugo(req, res, next, { hugoUrl, server }); + }); + }, + }, + + // ── Build: process Hugo's pre-built output ──────────────────────── + { + name: "vite-plugin-hugo-build", + apply: "build", + + config(userConfig) { + viteRoot = userConfig.root || process.cwd(); + absHugoDir = path.resolve(viteRoot, hugoOutDir); + absOutDir = path.resolve(viteRoot, userConfig.build?.outDir || "dist"); + + if (!fs.existsSync(absHugoDir)) { + throw new Error( + `Hugo output "${absHugoDir}" not found. ` + + `Run hugo before vite build.`, + ); + } + + symlinkSources(sources, viteRoot, absHugoDir); + + return { + root: absHugoDir, + publicDir: resolvePublicDir(userConfig.publicDir, viteRoot), + build: { + outDir: absOutDir, + // We handle cleanup ourselves in buildStart — Vite must NOT + // empty outDir or it would delete our pre-copied Hugo files. + emptyOutDir: false, + rollupOptions: { input: discoverHtmlEntries(absHugoDir) }, + }, + }; + }, + + buildStart() { + syncHugoOutput(absHugoDir, absOutDir); + }, + + transformIndexHtml: { + order: "pre", + handler(html) { + if (!baseUrl) return html; + return absolutizeLinkHrefs(html, baseUrl); + }, + }, + }, + ]; +} + +// --------------------------------------------------------------------------- +// Project config +// --------------------------------------------------------------------------- + +const rel = (...segments: string[]) => + path.resolve(import.meta.dirname, ...segments); + +const hugoConfig = parseToml(fs.readFileSync(rel("config.toml"), "utf-8")); +const baseUrl = + (hugoConfig.baseurl as string | undefined)?.replace(/\/$/, "") || ""; + +export default defineConfig({ + plugins: [ + mustachePlugin(), + hugo({ + hugoOutDir: rel(".hugo"), + baseUrl, + // Dirs containing source files that Hugo's HTML references via + //