From 2d0e9174c8562d8e37c58f1fa4ff6b94dec8298f Mon Sep 17 00:00:00 2001 From: hhfrancois Date: Sun, 18 Oct 2026 18:16:24 +0700 Subject: [PATCH 1/3] Every page of the site is rendered at build time at an address of its own, without the hash, with its title, description and canonical link, listed in a sitemap; links from the hash era land on the page they name --- docs/.gitignore | 2 + docs/angular.json | 14 +++--- docs/package-lock.json | 68 ++++++++++++++++++++++------- docs/package.json | 6 ++- docs/scripts/gen-sitemap.mjs | 35 +++++++++++++++ docs/scripts/gen-sitemap.spec.mjs | 34 +++++++++++++++ docs/src/app/app.component.ts | 8 +++- docs/src/app/app.config.server.ts | 8 ++++ docs/src/app/app.config.ts | 13 ++++-- docs/src/app/app.routes.server.ts | 5 +++ docs/src/app/app.routes.ts | 29 ++++++++++-- docs/src/app/file/file.component.ts | 8 ++++ docs/src/app/page-meta.ts | 56 ++++++++++++++++++++++++ docs/src/index.html | 4 ++ docs/src/main.server.ts | 10 +++++ docs/src/main.ts | 14 +++--- docs/src/root/robots.txt | 4 ++ docs/tsconfig.app.json | 2 +- 18 files changed, 283 insertions(+), 37 deletions(-) create mode 100644 docs/scripts/gen-sitemap.mjs create mode 100644 docs/scripts/gen-sitemap.spec.mjs create mode 100644 docs/src/app/app.config.server.ts create mode 100644 docs/src/app/app.routes.server.ts create mode 100644 docs/src/app/page-meta.ts create mode 100644 docs/src/main.server.ts create mode 100644 docs/src/root/robots.txt diff --git a/docs/.gitignore b/docs/.gitignore index 6ada58b5..51ab9057 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -7,6 +7,8 @@ /src/assets/plug-stack.yml /src/assets/plug-k8s.yaml /src/assets/plug-service.yml +# generated at build by scripts/gen-sitemap.mjs (the pages of app.routes.ts) +/src/root/sitemap.xml *.log .DS_Store /.frames diff --git a/docs/angular.json b/docs/angular.json index 3d550b99..74671333 100644 --- a/docs/angular.json +++ b/docs/angular.json @@ -18,7 +18,7 @@ "polyfills": [], "tsConfig": "tsconfig.app.json", "inlineStyleLanguage": "scss", - "assets": ["src/assets"], + "assets": ["src/assets", { "glob": "*", "input": "src/root", "output": "/" }], "styles": ["src/styles.scss"], "scripts": [], "allowedCommonJsDependencies": [ @@ -28,9 +28,8 @@ "prismjs/components/prism-typescript", "prismjs/components/prism-yaml" ], - "security": { - "autoCsp": true - } + "server": "src/main.server.ts", + "outputMode": "static" }, "configurations": { "production": { @@ -41,7 +40,12 @@ "maximumError": "1.5mb" } ], - "outputHashing": "all" + "outputHashing": "all", + "optimization": { + "scripts": true, + "styles": { "minify": true, "inlineCritical": false }, + "fonts": true + } }, "development": { "optimization": false, diff --git a/docs/package-lock.json b/docs/package-lock.json index 63698a98..3e2471ec 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -15,7 +15,9 @@ "@angular/forms": "^22.2.3", "@angular/material": "^22.2.3", "@angular/platform-browser": "^22.2.3", + "@angular/platform-server": "^22.2.3", "@angular/router": "^22.2.3", + "@angular/ssr": "^22.2.3", "@softwarity/projects": "^1.0.30", "prismjs": "^1.30.0", "rxjs": "~7.8.0", @@ -1081,6 +1083,26 @@ } } }, + "node_modules/@angular/platform-server": { + "version": "22.2.3", + "resolved": "https://registry.npmjs.org/@angular/platform-server/-/platform-server-22.2.3.tgz", + "integrity": "sha512-ROuJklv7iQVI1zIulo4MBDnk3Z0ZzihBWuN4nxFY8MTWxVMk97p92j1Wuyt4Kmx2kb5PB+kpcR1Jl6e13idftA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0", + "xhr2": "^0.2.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/common": "22.2.3", + "@angular/compiler": "22.2.3", + "@angular/core": "22.2.3", + "@angular/platform-browser": "22.2.3", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@angular/router": { "version": "22.2.3", "resolved": "https://registry.npmjs.org/@angular/router/-/router-22.2.3.tgz", @@ -1099,6 +1121,27 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, + "node_modules/@angular/ssr": { + "version": "22.2.3", + "resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-22.2.3.tgz", + "integrity": "sha512-u/jeHCvSBMf9H72/rxrzON0UQLXux73pgft2Az4lgyhEgnFq+ttTUVoDSABcUoqZJKfh4pSTiCuFZJCpYB+xJQ==", + "license": "MIT", + "dependencies": { + "beasties": "0.5.4", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": "^22.0.0", + "@angular/core": "^22.0.0", + "@angular/platform-server": "^22.0.0", + "@angular/router": "^22.0.0" + }, + "peerDependenciesMeta": { + "@angular/platform-server": { + "optional": true + } + } + }, "node_modules/@asamuzakjp/css-color": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.3.tgz", @@ -4738,7 +4781,6 @@ "version": "0.5.4", "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.5.4.tgz", "integrity": "sha512-8/lOOjT+PElYCnaUbmhgd3Uad5R1VTQ4Wg0k4rK8bX04m5WEqHNmnlLy3Q/lJ7dk25XA+72gayzc/skTfKKpcA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "css-select": "^7.0.0", @@ -4769,7 +4811,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz", "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==", - "dev": true, "license": "ISC", "engines": { "node": ">=20.19.0" @@ -5058,7 +5099,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz", "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "boolbase": "^2.0.0", @@ -5093,7 +5133,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz", "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=20.19.0" @@ -5178,7 +5217,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", - "dev": true, "license": "MIT", "dependencies": { "domelementtype": "^3.0.0", @@ -5197,7 +5235,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", - "dev": true, "funding": [ { "type": "github", @@ -5213,7 +5250,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^3.0.0" @@ -5230,7 +5266,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^3.0.0", @@ -5871,7 +5906,6 @@ "version": "12.0.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz", "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==", - "dev": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", { @@ -6652,7 +6686,6 @@ "version": "3.3.19", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", - "dev": true, "funding": [ { "type": "github", @@ -6728,7 +6761,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz", "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "boolbase": "^2.0.0" @@ -6960,7 +6992,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -6993,7 +7024,6 @@ "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", - "dev": true, "funding": [ { "type": "opencollective", @@ -7022,14 +7052,12 @@ "version": "0.2.3", "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", - "dev": true, "license": "MIT" }, "node_modules/postcss-safe-parser": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.1.0.tgz", "integrity": "sha512-1WzZxRLaAFwEh6Do+zyGpjWV3nGJNxxhuh7Ubu/q1ICImMgZJnLwhoaEpRbG4pJppp2Y1ncL9ffA2f+LhrefQg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -7714,7 +7742,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -8368,6 +8395,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/xhr2": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/xhr2/-/xhr2-0.2.1.tgz", + "integrity": "sha512-sID0rrVCqkVNUn8t6xuv9+6FViXjUVXq8H5rWOH2rz9fDNQEd4g0EA2XlcEdJXRz5BMEn4O1pJFdT+z4YHhoWw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/docs/package.json b/docs/package.json index 52af0552..7d7a714f 100644 --- a/docs/package.json +++ b/docs/package.json @@ -4,8 +4,8 @@ "private": true, "scripts": { "ng": "ng", - "start": "node scripts/gen-version.mjs && ng serve", - "build": "node scripts/gen-version.mjs && ng build", + "start": "node scripts/gen-version.mjs && node scripts/gen-sitemap.mjs && ng serve", + "build": "node scripts/gen-version.mjs && node scripts/gen-sitemap.mjs && ng build", "watch": "ng build --watch --configuration development", "test": "ng test && vitest run --config vitest.scripts.config.mjs", "lint": "ng lint" @@ -18,7 +18,9 @@ "@angular/forms": "^22.2.3", "@angular/material": "^22.2.3", "@angular/platform-browser": "^22.2.3", + "@angular/platform-server": "^22.2.3", "@angular/router": "^22.2.3", + "@angular/ssr": "^22.2.3", "@softwarity/projects": "^1.0.30", "prismjs": "^1.30.0", "rxjs": "~7.8.0", diff --git a/docs/scripts/gen-sitemap.mjs b/docs/scripts/gen-sitemap.mjs new file mode 100644 index 00000000..4af6b799 --- /dev/null +++ b/docs/scripts/gen-sitemap.mjs @@ -0,0 +1,35 @@ +// Writes src/root/sitemap.xml from the router's own list of pages, so a page +// added to app.routes.ts is in the sitemap without anyone remembering it. Run +// by the `build`/`start` npm scripts, before Angular builds; the file is served +// at the site's root beside robots.txt (angular.json, assets). +// +// A page is a route with a component. Redirects (an old address kept working) +// and the catch-all are not pages, and listing them would hand a crawler +// addresses that only bounce. Each address ends with a slash: the pages are +// pre-rendered into /index.html, and GitHub Pages answers /page with a +// redirect to /page/. +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// The same origin the pages name as canonical (src/app/page-meta.ts). +export const ORIGIN = 'https://plug.softwarity.dev'; + +export function pagesOf(routesSource) { + const found = [...routesSource.matchAll(/\bpath: '([^']*)'/g)]; + return found + .filter((m, i) => routesSource.slice(m.index, found[i + 1]?.index).includes('loadComponent')) + .map((m) => m[1]); +} + +export function sitemap(origin, pages) { + const urls = pages.map((p) => ` ${origin}/${p ? p + '/' : ''}`).join('\n'); + return `\n\n${urls}\n\n`; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const docs = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + const pages = pagesOf(readFileSync(resolve(docs, 'src/app/app.routes.ts'), 'utf8')); + writeFileSync(resolve(docs, 'src/root/sitemap.xml'), sitemap(ORIGIN, pages)); + console.log(`sitemap.xml: ${pages.length} pages`); +} diff --git a/docs/scripts/gen-sitemap.spec.mjs b/docs/scripts/gen-sitemap.spec.mjs new file mode 100644 index 00000000..6be30f4c --- /dev/null +++ b/docs/scripts/gen-sitemap.spec.mjs @@ -0,0 +1,34 @@ +// The sitemap is read from the real routes file: what is proven is that the +// pages a reader can open are the ones a crawler is given, and nothing else. +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { ORIGIN, pagesOf, sitemap } from './gen-sitemap.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const routes = readFileSync(resolve(here, '../src/app/app.routes.ts'), 'utf8'); +const meta = readFileSync(resolve(here, '../src/app/page-meta.ts'), 'utf8'); + +describe('gen-sitemap', () => { + it('lists every page with a component, and no redirect or catch-all', () => { + const pages = pagesOf(routes); + expect(pages).toContain(''); + expect(pages).toContain('kubernetes'); + expect(pages).toContain('security'); + expect(pages).not.toContain('agent'); + expect(pages).not.toContain('**'); + expect(pages.length).toBe((routes.match(/loadComponent/g) ?? []).length); + }); + + it('gives each page its address with a trailing slash, the home page bare', () => { + const xml = sitemap(ORIGIN, ['', 'cli']); + expect(xml).toContain('https://plug.softwarity.dev/'); + expect(xml).toContain('https://plug.softwarity.dev/cli/'); + expect(xml.startsWith(' { + expect(meta).toContain(`siteOrigin = '${ORIGIN}'`); + }); +}); diff --git a/docs/src/app/app.component.ts b/docs/src/app/app.component.ts index b1ed10cf..2f39b727 100644 --- a/docs/src/app/app.component.ts +++ b/docs/src/app/app.component.ts @@ -1,7 +1,9 @@ -import { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, inject, viewChild } from '@angular/core'; +import { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, inject, PLATFORM_ID, viewChild } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; import { NavigationEnd, Router, RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router'; import { filter } from 'rxjs/operators'; import { MatIconModule, MatIconRegistry } from '@angular/material/icon'; +import { PageMeta } from './page-meta'; interface DocLink { path: string; @@ -30,6 +32,10 @@ export class AppComponent { const router = inject(Router); // Use Material Symbols (loaded in index.html) as the default glyph set for every . iconRegistry.setDefaultFontSetClass('material-symbols-outlined'); + inject(PageMeta).start(); + // Focus and scrolling belong to a reader's browser, not to the build that + // renders the pages ahead of time. + if (!isPlatformBrowser(inject(PLATFORM_ID))) return; // Move focus into the page after every navigation, and scroll it to the top. // diff --git a/docs/src/app/app.config.server.ts b/docs/src/app/app.config.server.ts new file mode 100644 index 00000000..8aaf70ce --- /dev/null +++ b/docs/src/app/app.config.server.ts @@ -0,0 +1,8 @@ +import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; +import { provideServerRendering, withRoutes } from '@angular/ssr'; +import { appConfig } from './app.config'; +import { serverRoutes } from './app.routes.server'; + +export const config: ApplicationConfig = mergeApplicationConfig(appConfig, { + providers: [provideServerRendering(withRoutes(serverRoutes))], +}); diff --git a/docs/src/app/app.config.ts b/docs/src/app/app.config.ts index f0ba279a..e8e19d26 100644 --- a/docs/src/app/app.config.ts +++ b/docs/src/app/app.config.ts @@ -1,10 +1,17 @@ -import { ApplicationConfig } from '@angular/core'; -import { provideRouter, withHashLocation } from '@angular/router'; +import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core'; +import { provideRouter, withInMemoryScrolling } from '@angular/router'; import { routes } from './app.routes'; // The release tag is not fetched at runtime: scripts/gen-version.mjs pins it // into the manifests copied to src/assets at build time, and serves // those. Anything that needs the tag reads it from there. +// +// Routed on the path (/kubernetes), so each page has an address of its own that +// is rendered at build time; addresses from the hash era (/#/kubernetes) are +// moved onto it before the app starts (main.ts). export const appConfig: ApplicationConfig = { - providers: [provideRouter(routes, withHashLocation())], + providers: [ + provideZonelessChangeDetection(), + provideRouter(routes, withInMemoryScrolling({ anchorScrolling: 'enabled' })), + ], }; diff --git a/docs/src/app/app.routes.server.ts b/docs/src/app/app.routes.server.ts new file mode 100644 index 00000000..cb233594 --- /dev/null +++ b/docs/src/app/app.routes.server.ts @@ -0,0 +1,5 @@ +import { RenderMode, ServerRoute } from '@angular/ssr'; + +// Every page is static: rendered once at build time into its own index.html, +// which GitHub Pages serves as it is. No server runs anywhere. +export const serverRoutes: ServerRoute[] = [{ path: '**', renderMode: RenderMode.Prerender }]; diff --git a/docs/src/app/app.routes.ts b/docs/src/app/app.routes.ts index f90bda7a..7bde20a3 100644 --- a/docs/src/app/app.routes.ts +++ b/docs/src/app/app.routes.ts @@ -2,80 +2,96 @@ import { Routes } from '@angular/router'; // A title per route: on a single-page site the browser tab, the history entry // and what a screen reader announces on navigation all come from -// document.title. Angular's default TitleStrategy applies these. +// document.title. Angular's default TitleStrategy applies these. The +// description is the page's meta description, written into its pre-rendered +// HTML with its canonical address (page-meta.ts). export const routes: Routes = [ { path: '', - title: 'plug - About', + title: 'plug - A local process as a member of your cluster', + data: { description: "plug runs a local process as a member of your cluster: it resolves the cluster's service names, reaches its services, and is reachable under a name of its own, with no change to the application. Docker, Compose, Swarm, Kubernetes, OpenShift." }, pathMatch: 'full', loadComponent: () => import('./pages/about.component').then((m) => m.AboutComponent), }, { path: 'getting-started', title: 'plug - Getting started', + data: { description: 'Deploy the plug agent on a cluster, install the CLI from it in one command, and run your first local process as a member of the cluster.' }, loadComponent: () => import('./pages/getting-started.component').then((m) => m.GettingStartedComponent), }, { path: 'use-cases', title: 'plug - Use cases', + data: { description: 'Scenarios where plug fits: debugging against a real cluster, a front end on the laptop, QA trying a branch before its pull request, two developers or AI agents on one feature.' }, loadComponent: () => import('./pages/use-cases.component').then((m) => m.UseCasesComponent), }, { path: 'cli', title: 'plug - CLI reference', + data: { description: 'Every plug command and option: serving a name with -s, pure client with -c, profiles, environment and volume projection, doctor, update.' }, loadComponent: () => import('./pages/cli.component').then((m) => m.CliComponent), }, { path: 'build-tools', title: 'plug - From your build tool', + data: { description: 'Running plug from npm, Maven, Gradle, Composer, Deno, PDM, Hatch, Make or just, and from the IDE with a debugger attached.' }, loadComponent: () => import('./pages/build-tools.component').then((m) => m.BuildToolsComponent), }, { path: 'comparison', title: 'plug - How it compares', + data: { description: "How plug compares with mirrord and Telepresence, row by row, from each tool's public documentation." }, loadComponent: () => import('./pages/comparison.component').then((m) => m.ComparisonComponent), }, { path: 'how-it-works', title: 'plug - How it works', + data: { description: 'How plug works: one SSH connection carries both directions, a deployed service is parked for the session, its volumes are mounted live, and the session cleans up after itself.' }, loadComponent: () => import('./pages/how-it-works.component').then((m) => m.HowItWorksComponent), }, { path: 'under-the-hood', title: 'plug - Under the hood', + data: { description: "plug's data path step by step: a userspace TUN on your machine, SSH to an agent in the cluster, the privilege granted once, several clusters at once." }, loadComponent: () => import('./pages/under-the-hood.component').then((m) => m.UnderTheHoodComponent), }, { path: 'profiles', title: 'plug - Profiles & versions', + data: { description: "plug profiles: one per cluster, automatic selection, split-horizon DNS, and the launcher that runs the version each cluster's agent serves." }, loadComponent: () => import('./pages/profiles.component').then((m) => m.ProfilesComponent), }, { path: 'swarm', - title: 'plug - Swarm', + title: 'plug - Docker, Compose and Swarm', + data: { description: 'Deploy the plug agent on Docker, Compose or Swarm: in your stack or standalone, the Docker socket it needs, image tags, and how it publishes a name in the cluster.' }, loadComponent: () => import('./pages/agent-swarm.component').then((m) => m.AgentSwarmComponent), }, { path: 'kubernetes', - title: 'plug - Kubernetes', + title: 'plug - Kubernetes and OpenShift', + data: { description: 'Deploy the plug agent on Kubernetes, OpenShift or OKD: the manifest, the namespace-scoped Role rule by rule, and how to reach the agent.' }, loadComponent: () => import('./pages/agent-kubernetes.component').then((m) => m.AgentKubernetesComponent), }, { path: 'mcp', title: 'plug - MCP for AI coding agents', + data: { description: "plug mcp: an MCP server that gives an AI coding agent the clusters a machine knows, doctor checks, a workload's environment and DNS lookups." }, loadComponent: () => import('./pages/mcp.component').then((m) => m.McpComponent), }, { path: 'troubleshooting', title: 'plug - Troubleshooting', + data: { description: 'Fixes for the problems plug users meet: an agent that will not start, a name nothing reaches, a service that did not come back, unknown hosts.' }, loadComponent: () => import('./pages/troubleshooting.component').then((m) => m.TroubleshootingComponent), }, { path: 'continuous-deployment', title: 'plug - CD & GitOps', + data: { description: 'plug alongside Argo CD, Flux, Rancher Fleet and operators: why a GitOps controller reverts a takeover, and the setting that lets both coexist.' }, loadComponent: () => import('./pages/continuous-deployment.component').then((m) => m.ContinuousDeploymentComponent), }, @@ -83,26 +99,31 @@ export const routes: Routes = [ { path: 'security', title: 'plug - Security model', + data: { description: "plug's security model: who may open a session (open, by approval, hosted), what a session can reach once in, the Docker socket, signed binaries." }, loadComponent: () => import('./pages/security.component').then((m) => m.SecurityComponent), }, { path: 'meerkat', title: 'plug - Meerkat', + data: { description: 'Meerkat, the app gateway that embeds the plug agent: sessions tied to named developers, visible to everyone using the cluster.' }, loadComponent: () => import('./pages/meerkat.component').then((m) => m.MeerkatComponent), }, { path: 'integration', title: 'plug - Integration', + data: { description: 'How a gateway embeds the plug agent as a Go package: the Host it implements, Start, the hosted client, cluster rights, following and ending sessions.' }, loadComponent: () => import('./pages/integration.component').then((m) => m.IntegrationComponent), }, { path: 'roadmap', title: 'plug - Roadmap', + data: { description: 'What is planned for plug, and what is left out on purpose.' }, loadComponent: () => import('./pages/roadmap.component').then((m) => m.RoadmapComponent), }, { path: 'coverage', title: 'plug - Coverage matrix', + data: { description: "plug's coverage matrix: what is proven in CI on each cluster backend and each client OS, and what is not proven yet." }, loadComponent: () => import('./pages/coverage.component').then((m) => m.CoverageComponent), }, { path: '**', redirectTo: '' }, diff --git a/docs/src/app/file/file.component.ts b/docs/src/app/file/file.component.ts index d3f647a4..bf591d2b 100644 --- a/docs/src/app/file/file.component.ts +++ b/docs/src/app/file/file.component.ts @@ -3,12 +3,15 @@ import { Component, ElementRef, OnInit, + PLATFORM_ID, computed, effect, + inject, input, signal, viewChild, } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; import { MatIconModule } from '@angular/material/icon'; import { Prism } from '../prism'; @@ -340,7 +343,12 @@ export class FileComponent implements OnInit { pre.style.overflowY = 'auto'; } + private readonly browser = isPlatformBrowser(inject(PLATFORM_ID)); + ngOnInit(): void { + // Fetched by the reader's browser: the build that renders the pages ahead + // of time has no server to fetch it from. + if (!this.browser) return; fetch(new URL(this.src(), document.baseURI)) .then((r) => (r.ok ? r.text() : Promise.reject(new Error(`HTTP ${r.status}`)))) .then((t) => { diff --git a/docs/src/app/page-meta.ts b/docs/src/app/page-meta.ts new file mode 100644 index 00000000..d85f6f8a --- /dev/null +++ b/docs/src/app/page-meta.ts @@ -0,0 +1,56 @@ +import { DOCUMENT, inject, Injectable } from '@angular/core'; +import { Meta } from '@angular/platform-browser'; +import { ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router'; +import { filter } from 'rxjs/operators'; + +// The address the site is served from. Canonical links and link previews name +// it, whatever host the page was opened on (a mirror, a preview, localhost). +export const siteOrigin = 'https://plug.softwarity.dev'; + +/** + * Writes each page's own description and canonical address into the document, + * on every navigation. It runs when the pages are rendered at build time too, + * so the HTML a crawler or a link preview reads carries them, not only the + * page a browser has finished drawing. + */ +@Injectable({ providedIn: 'root' }) +export class PageMeta { + private readonly meta = inject(Meta); + private readonly doc = inject(DOCUMENT); + private readonly router = inject(Router); + + start(): void { + this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe(() => { + let route: ActivatedRouteSnapshot = this.router.routerState.snapshot.root; + while (route.firstChild) route = route.firstChild; + const description = route.data['description'] as string | undefined; + const path = this.router.url.split(/[?#]/)[0]; + // With its trailing slash: each page is /index.html, and GitHub + // Pages answers /page by redirecting to /page/. + const url = siteOrigin + (path === '/' ? '/' : path + '/'); + if (description) { + this.meta.updateTag({ name: 'description', content: description }); + this.meta.updateTag({ property: 'og:description', content: description }); + this.meta.updateTag({ name: 'twitter:description', content: description }); + } + const title = this.doc.title; + if (title) { + this.meta.updateTag({ property: 'og:title', content: title }); + this.meta.updateTag({ name: 'twitter:title', content: title }); + } + this.meta.updateTag({ property: 'og:url', content: url }); + this.meta.updateTag({ name: 'twitter:url', content: url }); + this.canonical(url); + }); + } + + private canonical(url: string): void { + let link = this.doc.head.querySelector('link[rel="canonical"]'); + if (!link) { + link = this.doc.createElement('link'); + link.setAttribute('rel', 'canonical'); + this.doc.head.appendChild(link); + } + link.setAttribute('href', url); + } +} diff --git a/docs/src/index.html b/docs/src/index.html index c62aba61..943bd985 100644 --- a/docs/src/index.html +++ b/docs/src/index.html @@ -5,6 +5,10 @@ plug - your local process, inside the cluster · Documentation + + - + - +