diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..5f0889ce --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "npm" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 933c0c01..6a57b7da 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,5 +1,5 @@ -# Sample workflow for building and deploying a Jekyll site to GitHub Pages -name: Deploy Jekyll site to Pages +# Build the VitePress site and deploy it to GitHub Pages +name: Deploy site to Pages on: # Runs on pushes targeting the default branch @@ -29,29 +29,25 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 + with: + fetch-depth: 0 # needed for the last modified date of the pages - name: Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 + cache: npm - name: Install Node deps run: npm ci - - name: Compile typescript - run: npm run build - - name: Setup Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.0' # Not needed with a .ruby-version file - bundler-cache: true # runs 'bundle install' and caches installed gems automatically - cache-version: 0 # Increment this number if you need to re-download cached gems - name: Setup Pages id: pages uses: actions/configure-pages@v5 - - run: bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}" # defaults output to '/_site' - env: - JEKYLL_ENV: production + - name: Build with VitePress + run: npm run build - name: Upload artifact - uses: actions/upload-pages-artifact@v3 # This will automatically upload an artifact from the '/_site' directory + uses: actions/upload-pages-artifact@v3 + with: + path: .vitepress/dist - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 diff --git a/.github/workflows/preview-pr.yaml b/.github/workflows/preview-pr.yaml index f171f712..0f10ea10 100644 --- a/.github/workflows/preview-pr.yaml +++ b/.github/workflows/preview-pr.yaml @@ -3,10 +3,10 @@ name: preview-pr on: pull_request_target: types: [opened, reopened, synchronize] - + permissions: - pull-requests: write - + pull-requests: write + jobs: authorize: environment: @@ -16,32 +16,33 @@ jobs: runs-on: ubuntu-latest steps: - run: "true" - + build: needs: authorize runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} - - name: Setup Ruby - uses: ruby/setup-ruby@v1 + fetch-depth: 0 + persist-credentials: false + - name: Setup node + uses: actions/setup-node@v4 with: - ruby-version: '3.0' - bundler-cache: true - - run: bundle exec jekyll build --baseurl "" + node-version: 22 + cache: npm + - run: npm ci + - run: npm run build - name: Publish to Cloudflare Pages id: preview-pages - uses: cloudflare/pages-action@v1 + uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT }} - projectName: hack-gpon-preview - directory: _site - branch: preview + command: pages deploy .vitepress/dist --project-name=hack-gpon-preview --branch=preview - uses: thollander/actions-comment-pull-request@v2 with: message: | - Preview of the website obtained from the PR: ${{ steps.preview-pages.outputs.url }} + Preview of the website obtained from the PR: ${{ steps.preview-pages.outputs.deployment-url }} diff --git a/.gitignore b/.gitignore index 02a683b3..dbef75fe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,7 @@ -### Jekyll ### -_site/ -.sass-cache/ -.jekyll-cache/ -.jekyll-metadata -Gemfile.lock -assets/js/zzzz-search-data.json +### VitePress ### +.vitepress/dist/ +.vitepress/cache/ +.vitepress/.temp/ + +### Node ### node_modules/ -assets/js/generated diff --git a/.vitepress/config.mts b/.vitepress/config.mts new file mode 100644 index 00000000..0808040e --- /dev/null +++ b/.vitepress/config.mts @@ -0,0 +1,128 @@ +import fs from 'node:fs' +import path from 'node:path' +import { createMarkdownRenderer, defineConfig, type MarkdownRenderer } from 'vitepress' +import { configureMarkdown, extractHardwareSpecs } from './markdown' +import { isExternal, pages, resolveLink, rewrites, sidebar, srcExclude } from './pages' + +const hostname = 'https://hack-gpon.org' +const repository = 'https://github.com/hack-gpon/hack-gpon.github.io' + +let specsRenderer: Promise | undefined + +const telegramIcon = + 'Telegram' + +export default defineConfig({ + lang: 'en-US', + title: 'Hack GPON', + description: 'Worldwide wiki on how to access, change and edit ONTs', + + srcExclude, + rewrites, + cleanUrls: true, + lastUpdated: true, + sitemap: { + hostname, + // the redirect pages are not real pages (as with jekyll-redirect-from) + transformItems: (items) => { + const redirects = new Set(pages.filter((p) => p.frontmatter.redirect_to).map((p) => p.url.slice(1))) + return items.filter((item) => !redirects.has(decodeURI(item.url))) + } + }, + + head: [ + ['link', { rel: 'icon', href: '/favicon.ico', sizes: '48x48' }], + ['link', { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32x32.png' }], + ['link', { rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-16x16.png' }], + ['link', { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' }], + ['link', { rel: 'manifest', href: '/site.webmanifest' }], + ['link', { rel: 'mask-icon', href: '/safari-pinned-tab.svg', color: '#27262b' }], + ['meta', { name: 'msapplication-TileColor', content: '#27262b' }], + ['meta', { name: 'theme-color', content: '#27262b' }] + ], + + transformHead({ pageData }) { + const head: [string, Record][] = [] + const page = pages.find((p) => p.file === pageData.filePath) + const redirect = pageData.frontmatter.redirect_to + if (redirect) { + const target = (page && resolveLink(page.url)) ?? redirect + head.push(['meta', { 'http-equiv': 'refresh', content: `0; url=${target}` }]) + head.push(['link', { rel: 'canonical', href: isExternal(target) ? target : hostname + target }]) + } else if (page) { + head.push(['link', { rel: 'canonical', href: hostname + page.url }]) + } + return head + }, + + markdown: { + config: configureMarkdown, + // in dev the cache of the rendered pages is not cleared for the rewritten pages when a + // partial changes, so the page would not be updated + cache: process.argv[2] !== 'dev' + }, + + // the "Hardware Specifications" table is also rendered in the aside, above the outline + async transformPageData(pageData, { siteConfig }) { + const file = path.join(siteConfig.srcDir, pageData.filePath) + if (!fs.existsSync(file)) return + const table = extractHardwareSpecs(fs.readFileSync(file, 'utf8')) + if (!table) return + specsRenderer ??= createMarkdownRenderer(siteConfig.srcDir, siteConfig.markdown, siteConfig.site.base, siteConfig.logger) + pageData.hardwareSpecs = (await specsRenderer).render(table, { path: file, relativePath: pageData.relativePath }) + }, + + vite: { + build: { + // the big chunks (search index, mermaid, pages with serial dumps) are loaded on demand + chunkSizeWarningLimit: 1000 + } + }, + + themeConfig: { + logo: { src: '/favicon-32x32.png', alt: '' }, + + nav: [ + { text: 'Quick Start', link: '/quick-start/' }, + { text: 'FAQ', link: '/faq/' } + ], + + sidebar: sidebar(), + + socialLinks: [ + { icon: 'github', link: repository, ariaLabel: 'GitHub' }, + { icon: { svg: telegramIcon }, link: 'https://t.me/HackGPON', ariaLabel: 'Telegram' } + ], + + editLink: { + pattern: `${repository}/edit/main/:path`, + text: 'Edit this page on GitHub' + }, + + lastUpdated: { + text: 'Last Modified', + formatOptions: { dateStyle: 'medium' } + }, + + search: { + provider: 'local', + options: { + _render(src, env, md) { + const html = md.render(src, env) + if (env.frontmatter?.search === false || env.frontmatter?.redirect_to) return '' + // footnote references in the headings would be taken as section anchors + return html.replace(/.*?<\/sup>/g, '') + } + } + }, + + outline: { + level: [1, 3] + }, + + docFooter: { + prev: false, + next: false + } + } +}) diff --git a/.vitepress/markdown.ts b/.vitepress/markdown.ts new file mode 100644 index 00000000..7081e5b3 --- /dev/null +++ b/.vitepress/markdown.ts @@ -0,0 +1,115 @@ +import fs from 'node:fs' +import path from 'node:path' +import { Liquid } from 'liquidjs' +import type MarkdownIt from 'markdown-it' +import footnote from 'markdown-it-footnote' +import { resolveLink } from './pages' + +const liquid = new Liquid() + +/** + * Parametrized partials. + * + * + * + * The partial is a Liquid template, the parameters are available as `include.`. + * Every parameter is on its own line: `name: value`, the value can be a JSON string. + */ +export function expandPartials(src: string, file: string, includes?: string[]): string { + return src.replace(//g, (_, target: string, rawParams = '') => { + const params: Record = {} + for (const line of rawParams.split(/\r?\n/)) { + const kv = /^\s*([A-Za-z_][\w-]*):\s*(.*?)\s*$/.exec(line) + if (!kv) continue + try { + params[kv[1]] = JSON.parse(kv[2]) + } catch { + params[kv[1]] = kv[2] + } + } + const partialFile = path.resolve(path.dirname(file), target) + // registered as dependency of the page, like the VitePress includes: the dev server reloads it + includes?.push(partialFile.replace(/\\/g, '/')) + const template = fs.readFileSync(partialFile, 'utf8') + return liquid.parseAndRenderSync(template, { include: params }) + }) +} + +/** + * Internal links point to the canonical URL of the page, with the trailing slash (as Jekyll did), + * and links to the redirect pages point directly to their destination. + */ +function canonicalLinks(md: MarkdownIt) { + md.core.ruler.push('canonical_links', (state) => { + for (const block of state.tokens) { + for (const token of block.children ?? []) { + if (token.type !== 'link_open') continue + const href = token.attrGet('href') + if (!href || !href.startsWith('/') || href.startsWith('//')) continue + const url = resolveLink(href) + if (url) token.attrSet('href', url) + } + } + }) +} + +const hardwareSpecsHeading = /^#{1,6}[ \t]+Hardware Specifications[ \t]*$/m + +/** Markdown of the table under the "Hardware Specifications" heading, shown in the aside. */ +export function extractHardwareSpecs(src: string): string | undefined { + const heading = hardwareSpecsHeading.exec(src) + if (!heading) return + const lines = src.slice(heading.index + heading[0].length).split(/\r?\n/) + let i = 0 + while (i < lines.length && lines[i].trim() === '') i++ + const table: string[] = [] + while (i < lines.length && lines[i].trim().startsWith('|')) table.push(lines[i++]) + return table.length > 2 ? table.join('\n') : undefined +} + +/** Marks the "Hardware Specifications" table, hidden in the page when it is shown in the aside. */ +function hardwareSpecs(md: MarkdownIt) { + md.core.ruler.push('hardware_specs', (state) => { + const tokens = state.tokens + const heading = tokens.findIndex( + (t, i) => t.type === 'heading_open' && tokens[i + 1]?.content.trim() === 'Hardware Specifications' + ) + if (heading === -1) return + // the table must follow the heading directly + const table = tokens[heading + 3] + if (table?.type === 'table_open') table.attrJoin('class', 'hardware-specs') + }) + // the VitePress renderer of the tables drops the attributes + const tableOpen = md.renderer.rules.table_open! + md.renderer.rules.table_open = (tokens, idx, options, env, self) => { + const html = tableOpen(tokens, idx, options, env, self) + const cls = tokens[idx].attrGet('class') + return cls ? html.replace(' component. */ +function mermaid(md: MarkdownIt) { + const fence = md.renderer.rules.fence! + md.renderer.rules.fence = (tokens, idx, options, env, self) => { + const token = tokens[idx] + if (token.info.trim() === 'mermaid') { + return `` + } + return fence(tokens, idx, options, env, self) + } +} + +export function configureMarkdown(md: MarkdownIt) { + const parse = md.parse.bind(md) + md.parse = (src, env) => { + const file = env?.realPath ?? env?.path + return parse(file ? expandPartials(src, file, env.includes) : src, env) + } + md.use(footnote) + md.use(canonicalLinks) + md.use(hardwareSpecs) + md.use(mermaid) +} diff --git a/.vitepress/pages.ts b/.vitepress/pages.ts new file mode 100644 index 00000000..d4cd0ae8 --- /dev/null +++ b/.vitepress/pages.ts @@ -0,0 +1,176 @@ +/* + * Index of the site pages, built from the markdown front matter. + * + * It reproduces the Jekyll / Just the Docs structure of the site: + * - the URL of every page (Jekyll collections permalinks), used by the VitePress rewrites + * - the navigation tree (`parent`, `has_children`, `nav_order`, `nav_exclude`), used by the sidebar + */ +import fs from 'node:fs' +import path from 'node:path' +import type { DefaultTheme } from 'vitepress' + +export const root = path.resolve(__dirname, '..') + +/** Collections, in sidebar order, with the URL prefix of their pages. */ +export const collections = [ + { dir: 'ont', name: 'ONT GPON', prefix: '/' }, + { dir: 'ont-xgs', name: 'ONT XGS-PON', prefix: '/xgs/' }, + { dir: 'ont-epon', name: 'ONT EPON', prefix: '/epon/' }, + { dir: 'router', name: 'Router PON', prefix: '/router/' }, + { dir: 'tools', name: 'Tools', prefix: '/' }, + { dir: 'sfp', name: 'SFP Resources & standard', prefix: '/' }, + { dir: 'gpon', name: 'GPON Resources & standard', prefix: '/' }, + { dir: 'sfp-cage', name: 'SFP cage', prefix: '/' } +] + +/** Markdown files that are not pages. */ +export const srcExclude = ['README.md', 'CONTRIBUTING.md', '**/_partials/**', 'ont/ont-template.md'] + +export interface Page { + /** source path relative to the project root, e.g. `ont/ont-zte.md` */ + file: string + /** page URL with trailing slash, e.g. `/ont-zte/` */ + url: string + collection?: string + frontmatter: Record +} + +/** + * Minimal parser for the flat `key: value` front matter used by the pages (it is enough for the + * navigation keys). Lists and multi-line values are not supported and are reported. + */ +function parseFrontmatter(src: string, file: string): Record { + const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(src) + const data: Record = {} + if (!match) return data + for (const line of match[1].split(/\r?\n/)) { + if (line.trim() === '' || line.trimStart().startsWith('#')) continue + const kv = /^([A-Za-z_][\w-]*):\s*(.*?)\s*$/.exec(line) + if (!kv) { + console.warn(`[pages] ${file}: unsupported front matter line, only "key: value" is read: ${line}`) + continue + } + let value: any = kv[2] + // trailing comment of an unquoted value + if (!/^["']/.test(value)) value = value.replace(/\s+#.*$/, '') + if (/^(["']).*\1$/.test(value)) value = value.slice(1, -1) + else if (value === 'true' || value === 'false') value = value === 'true' + else if (/^-?\d+(\.\d+)?$/.test(value)) value = Number(value) + data[kv[1]] = value + } + return data +} + +function readPage(file: string, collection?: (typeof collections)[number]): Page { + const frontmatter = parseFrontmatter(fs.readFileSync(path.join(root, file), 'utf8'), file) + const name = path.basename(file, '.md') + const url = name === 'index' && !collection ? '/' : `${collection?.prefix ?? '/'}${name}/` + return { file, url, collection: collection?.dir, frontmatter } +} + +function loadPages(): Page[] { + const pages: Page[] = [] + for (const f of fs.readdirSync(root)) { + if (f.endsWith('.md') && !srcExclude.includes(f)) pages.push(readPage(f)) + } + for (const c of collections) { + for (const f of fs.readdirSync(path.join(root, c.dir))) { + const file = `${c.dir}/${f}` + if (f.endsWith('.md') && !srcExclude.includes(file)) pages.push(readPage(file, c)) + } + } + const seen = new Map() + for (const p of pages) { + const key = p.url.toLowerCase() + if (seen.has(key)) throw new Error(`URL ${p.url} is used by both ${seen.get(key)} and ${p.file}`) + seen.set(key, p.file) + } + return pages +} + +export const pages = loadPages() + +const urls = new Set(pages.map((p) => p.url.toLowerCase())) + +/** Returns the canonical URL (with trailing slash) of an internal link, if it points to a page. */ +export function resolvePageUrl(pathname: string): string | undefined { + if (pathname === '/' || pathname.endsWith('/')) return urls.has(pathname.toLowerCase()) ? pathname : undefined + const withSlash = decodeURI(pathname).replace(/\.(md|html)$/, '') + '/' + return urls.has(withSlash.toLowerCase()) ? encodeURI(withSlash) : undefined +} + +const pagesByUrl = new Map(pages.map((p) => [p.url.toLowerCase(), p])) + +export function isExternal(href: string) { + return /^([a-z][a-z0-9+.-]*:)?\/\//i.test(href) +} + +/** + * Final destination of a link: canonical URL of the page, following the `redirect_to` of the + * redirect pages. Returns undefined if the link does not point to a page of the site. + */ +export function resolveLink(href: string): string | undefined { + const [, pathname, hrefRest] = /^([^?#]*)(.*)$/.exec(href)! + let rest = hrefRest + let url = resolvePageUrl(pathname) + for (let i = 0; url && i < 10; i++) { + const redirect: string | undefined = pagesByUrl.get(url.toLowerCase())?.frontmatter.redirect_to + if (!redirect) return url + rest + if (isExternal(redirect)) return redirect + const [, target, targetRest] = /^([^?#]*)(.*)$/.exec(redirect)! + // the anchor of the original link wins over the one of the redirect + rest = rest || targetRest + url = resolvePageUrl(target) + if (!url) return redirect + } + return url && url + rest +} + +const rewriteMap = new Map(pages.filter((p) => p.url !== '/').map((p) => [p.file, `${p.url.slice(1)}index.md`])) + +/** VitePress rewrites: `ont/ont-zte.md` -> `ont-zte/index.md` */ +export function rewrites(file: string) { + return rewriteMap.get(file) ?? file +} + +/** Just the Docs ordering: pages with `nav_order` first, then the others sorted by title. */ +function byNavOrder(a: Page, b: Page) { + const oa = a.frontmatter.nav_order + const ob = b.frontmatter.nav_order + if (oa !== undefined && ob !== undefined) return oa - ob + if (oa !== undefined) return -1 + if (ob !== undefined) return 1 + const ta = String(a.frontmatter.title ?? '') + const tb = String(b.frontmatter.title ?? '') + return ta < tb ? -1 : ta > tb ? 1 : 0 +} + +function link(page: Page) { + return resolveLink(page.url) ?? page.frontmatter.redirect_to ?? page.url +} + +function sidebarItems(list: Page[], parent?: string): DefaultTheme.SidebarItem[] { + return list + .filter((p) => (parent === undefined ? !p.frontmatter.parent : p.frontmatter.parent === parent)) + .sort(byNavOrder) + .map((p) => { + const items = p.frontmatter.has_children ? sidebarItems(list, p.frontmatter.title) : [] + return { + text: String(p.frontmatter.title), + link: link(p), + ...(items.length ? { items, collapsed: true } : {}) + } + }) +} + +export function sidebar(): DefaultTheme.SidebarItem[] { + const visible = pages.filter((p) => !p.frontmatter.nav_exclude && p.frontmatter.title) + return [ + ...sidebarItems(visible.filter((p) => !p.collection)), + ...collections.map((c) => ({ + text: c.name, + collapsed: true, + items: sidebarItems(visible.filter((p) => p.collection === c.dir)) + })) + ] +} diff --git a/.vitepress/theme/Layout.vue b/.vitepress/theme/Layout.vue new file mode 100644 index 00000000..a37797f7 --- /dev/null +++ b/.vitepress/theme/Layout.vue @@ -0,0 +1,24 @@ + + + diff --git a/.vitepress/theme/components/AsciiHex.vue b/.vitepress/theme/components/AsciiHex.vue new file mode 100644 index 00000000..9a412839 --- /dev/null +++ b/.vitepress/theme/components/AsciiHex.vue @@ -0,0 +1,95 @@ + + + diff --git a/.vitepress/theme/components/AsideSpecs.vue b/.vitepress/theme/components/AsideSpecs.vue new file mode 100644 index 00000000..5bb9f355 --- /dev/null +++ b/.vitepress/theme/components/AsideSpecs.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/.vitepress/theme/components/CigPassword.vue b/.vitepress/theme/components/CigPassword.vue new file mode 100644 index 00000000..dbb9854c --- /dev/null +++ b/.vitepress/theme/components/CigPassword.vue @@ -0,0 +1,52 @@ + + + diff --git a/.vitepress/theme/components/CiteAs.vue b/.vitepress/theme/components/CiteAs.vue new file mode 100644 index 00000000..71595734 --- /dev/null +++ b/.vitepress/theme/components/CiteAs.vue @@ -0,0 +1,12 @@ + + + diff --git a/.vitepress/theme/components/HisensePloam.vue b/.vitepress/theme/components/HisensePloam.vue new file mode 100644 index 00000000..3437f36e --- /dev/null +++ b/.vitepress/theme/components/HisensePloam.vue @@ -0,0 +1,28 @@ + + + diff --git a/.vitepress/theme/components/ImageFigure.vue b/.vitepress/theme/components/ImageFigure.vue new file mode 100644 index 00000000..56391286 --- /dev/null +++ b/.vitepress/theme/components/ImageFigure.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/assets/js/vue/vue-lantiq-eeprom.vue b/.vitepress/theme/components/LantiqEeprom.vue similarity index 98% rename from assets/js/vue/vue-lantiq-eeprom.vue rename to .vitepress/theme/components/LantiqEeprom.vue index 098cca69..96b989c0 100644 --- a/assets/js/vue/vue-lantiq-eeprom.vue +++ b/.vitepress/theme/components/LantiqEeprom.vue @@ -1,3 +1,4 @@ +