diff --git a/www/README.md b/www/README.md index 47fb0562e..7ef6986b0 100644 --- a/www/README.md +++ b/www/README.md @@ -15,19 +15,20 @@ Run `deno run -A main.tsx --help` for the current list. Each parameter is addressable two ways — as a `--kebab-case` flag or as a `SCREAMING_SNAKE_CASE` environment variable — and the command line wins when both are supplied. -| Flag | Environment | Default | Purpose | -| ---------------------- | -------------------- | ----------------- | ----------------------------------------------------- | -| `--port` | `PORT` | `8000` | Port the HTTP server listens on. `0` picks a free one | -| `--github-token` | `GITHUB_TOKEN` | _unauthenticated_ | GitHub access token for the API | -| `--jsr-api` | `JSR_API` | _none_ | JSR API token; the score card is skipped without it | -| `--deno-deployment-id` | `DENO_DEPLOYMENT_ID` | fresh id per boot | Deployment identity behind the `ETag` | -| `--effectionx-dir` | `EFFECTIONX_DIR` | _none_ | Local effectionx checkout to read instead of cloning | -| `--clones-dir` | `CLONES_DIR` | `build/clones` | Git clones of the documented repositories | -| `--worktrees-dir` | `WORKTREES_DIR` | `build/worktrees` | A git worktree per documented series | -| `--pagefind-dir` | `PAGEFIND_DIR` | `pagefind` | Generated Pagefind search bundle | -| `--tailwind-input` | `TAILWIND_INPUT` | `main.css` | Tailwind entry stylesheet | -| `--tailwind-outdir` | `TAILWIND_OUTDIR` | `tailwind` | Where the compiled stylesheet is written | -| `--verbose` | `VERBOSE` | `false` | Emit debug and warning logs, not just info and error | +| Flag | Environment | Default | Purpose | +| ---------------------- | -------------------- | --------------------------------- | ----------------------------------------------------- | +| `--port` | `PORT` | `8000` | Port the HTTP server listens on. `0` picks a free one | +| `--github-token` | `GITHUB_TOKEN` | _unauthenticated_ | GitHub access token for the API | +| `--jsr-api` | `JSR_API` | _none_ | JSR API token; the score card is skipped without it | +| `--deno-deployment-id` | `DENO_DEPLOYMENT_ID` | fresh id per boot | Deployment identity behind the `ETag` | +| `--effectionx-dir` | `EFFECTIONX_DIR` | _none_ | Local effectionx checkout to read instead of cloning | +| `--base` | `BASE` | `https://frontside.com/effection` | Origin every page names as the original | +| `--clones-dir` | `CLONES_DIR` | `build/clones` | Git clones of the documented repositories | +| `--worktrees-dir` | `WORKTREES_DIR` | `build/worktrees` | A git worktree per documented series | +| `--pagefind-dir` | `PAGEFIND_DIR` | `pagefind` | Generated Pagefind search bundle | +| `--tailwind-input` | `TAILWIND_INPUT` | `main.css` | Tailwind entry stylesheet | +| `--tailwind-outdir` | `TAILWIND_OUTDIR` | `tailwind` | Where the compiled stylesheet is written | +| `--verbose` | `VERBOSE` | `false` | Emit debug and warning logs, not just info and error | The defaults reproduce what used to be hardcoded, so `deno task dev` and the deployment workflow need no flags. diff --git a/www/cli.ts b/www/cli.ts index 15e0f14e9..51834da54 100644 --- a/www/cli.ts +++ b/www/cli.ts @@ -72,6 +72,13 @@ export const www = command( ), schema(fallback("")), ), + option( + name("base"), + description( + "Origin every page names as the original, whatever origin served it.", + ), + schema(fallback("https://frontside.com/effection")), + ), option( name("clonesDir"), description("Directory holding git clones of the documented repositories."), diff --git a/www/context/context-api.ts b/www/context/context-api.ts deleted file mode 100644 index 4b49ae08f..000000000 --- a/www/context/context-api.ts +++ /dev/null @@ -1,73 +0,0 @@ -// deno-lint-ignore-file no-explicit-any ban-types -import { createContext, type Operation } from "effection"; - -export type Around = { - [K in keyof Operations]: A[K] extends - (...args: infer TArgs) => infer TReturn ? Middleware - : Middleware<[], A[K]>; -}; - -export interface Middleware { - (args: TArgs, next: (...args: TArgs) => TReturn): TReturn; -} - -export interface Api { - operations: Operations; - around: (around: Partial>) => Operation; -} - -export type Operations = { - [K in keyof T]: T[K] extends ((...args: infer TArgs) => infer TReturn) - ? (...args: TArgs) => TReturn - : T[K] extends Operation ? Operation - : never; -}; - -export function createApi(name: string, handler: A): Api { - let fields = Object.keys(handler) as (keyof A)[]; - - let middleware: Around = fields.reduce((sum, field) => { - return Object.assign(sum, { - [field]: (args: any, next: any) => next(...args), - }); - }, {} as Around); - - let context = createContext>(`$api:${name}`, middleware); - - let operations = fields.reduce((api, field) => { - let handle = handler[field]; - if (typeof handle === "function") { - return Object.assign(api, { - [field]: function* (...args: any[]) { - let around = yield* context.expect(); - let middleware = around[field] as Function; - return yield* middleware(args, handle); - }, - }); - } else { - return Object.assign(api, { - [field]: { - *[Symbol.iterator]() { - let around = yield* context.expect(); - let middleware = around[field] as Function; - return yield* middleware([], () => handle); - }, - }, - }); - } - }, {} as Operations); - - function* around(around: Partial>): Operation { - let current = yield* context.expect(); - yield* context.set(fields.reduce((sum, field) => { - let prior = current[field] as Middleware; - let middleware = around[field] as Middleware; - return Object.assign(sum, { - [field]: (args: any, next: any) => - middleware(args, (...args) => prior(args, next)), - }); - }, Object.assign({}, current))); - } - - return { operations, around }; -} diff --git a/www/context/fetch.ts b/www/context/fetch.ts index 479c20336..c2e8f0c58 100644 --- a/www/context/fetch.ts +++ b/www/context/fetch.ts @@ -1,5 +1,5 @@ import { Operation, until } from "effection"; -import { createApi } from "./context-api.ts"; +import { createApi } from "@effectionx/context-api"; import { rewrite } from "./url-rewrite.ts"; import { log } from "./logging.ts"; diff --git a/www/context/logging.ts b/www/context/logging.ts index 5dcf9beed..6bd1d82c4 100644 --- a/www/context/logging.ts +++ b/www/context/logging.ts @@ -1,5 +1,5 @@ import type { Operation } from "effection"; -import { createApi } from "./context-api.ts"; +import { createApi } from "@effectionx/context-api"; export interface Logger { info: (message: string, ...args: unknown[]) => Operation; diff --git a/www/context/process.ts b/www/context/process.ts index 50cfb5064..c5cbbd01e 100644 --- a/www/context/process.ts +++ b/www/context/process.ts @@ -2,7 +2,7 @@ import type { Operation, Stream } from "effection"; import { each, spawn, until, withResolvers } from "effection"; import md5 from "md5"; import { regex } from "arktype"; -import { createApi } from "./context-api.ts"; +import { createApi } from "@effectionx/context-api"; import { exec, ExecOptions, ProcessResult } from "@effectionx/process"; import { log } from "./logging.ts"; import { cwd, useCwd } from "./shell.ts"; diff --git a/www/context/url-rewrite.ts b/www/context/url-rewrite.ts index dbaa782ef..704f95d82 100644 --- a/www/context/url-rewrite.ts +++ b/www/context/url-rewrite.ts @@ -1,5 +1,5 @@ import { Operation } from "effection"; -import { createApi } from "./context-api.ts"; +import { createApi } from "@effectionx/context-api"; interface UrlRewrite { rewrite( diff --git a/www/context/url.ts b/www/context/url.ts new file mode 100644 index 000000000..a86ca3b4e --- /dev/null +++ b/www/context/url.ts @@ -0,0 +1,62 @@ +import { type Operation } from "effection"; +import { posixNormalize } from "_posixNormalize"; +import { type Api, createApi } from "@effectionx/context-api"; +import { CurrentRequest } from "./request.ts"; + +export interface UrlApi { + /** + * Where the site says it lives: the origin every page names as the + * original, whatever origin actually served it. `main` supplies it from + * configuration; `urlApi.around` can rebase it for a narrower scope. + */ + base: string; + + /** + * Fully qualify a path against the origin that served the current request, + * so that a document advertises the site a reader is actually on. + * + * Middleware installed with `urlApi.around` sees each path, so an override + * can rebase some paths and leave others alone. + */ + url(path: string): Operation; + + /** + * The canonical url for the current path, under `base`. + * + * A preview and the dev server serve the same page from their own origin, + * but every copy names the same original. + */ + canonical(): Operation; +} + +// annotated, because `canonical` reads `base` back through the api +export const urlApi: Api = createApi("url", { + base: "https://frontside.com/effection", + + *url(path) { + let request = yield* CurrentRequest.expect(); + let absolute = new URL(path, new URL(request.url).origin); + + absolute.pathname = posixNormalize(absolute.pathname); + + return absolute.toString(); + }, + + *canonical() { + let request = yield* CurrentRequest.expect(); + // through the api, so that an override of `base` reaches here too + let base = yield* urlApi.operations.base; + + let requested = new URL(request.url); + let original = new URL(base); + + // normalized, because a base without a path contributes its own "/" + original.pathname = posixNormalize( + `${original.pathname}${requested.pathname}`, + ); + + return String(original); + }, +}); + +export const { base, canonical, url } = urlApi.operations; diff --git a/www/deno.json b/www/deno.json index b2810587e..bae4ccd87 100644 --- a/www/deno.json +++ b/www/deno.json @@ -49,6 +49,7 @@ "@std/http": "jsr:@std/http@^1.0.22", "@types/hast": "npm:@types/hast@3.0.4", "effection": "npm:effection@^3.6.1", + "@effectionx/context-api": "npm:@effectionx/context-api@^0.6.0", "@effectionx/deno-deploy": "jsr:@effectionx/deno-deploy@^0.3.1", "@effectionx/process": "jsr:@effectionx/process@^0.6.2", "hast": "npm:hast@^1.0.0", diff --git a/www/main.tsx b/www/main.tsx index b1f846a0f..89f5683f4 100644 --- a/www/main.tsx +++ b/www/main.tsx @@ -39,6 +39,7 @@ import { initClones } from "./lib/clones.ts"; import { initOctokitContext } from "./lib/octokit.ts"; import { currentRequestPlugin } from "./plugins/current-request.ts"; import { verboseLogging } from "./context/logging.ts"; +import { urlApi } from "./context/url.ts"; // Learn more at https://docs.deno.com/runtime/manual/examples/module_metadata#concepts if (import.meta.main) { @@ -61,6 +62,11 @@ if (import.meta.main) { function* serve(options: Options) { yield* verboseLogging(options.verbose); + // the origin pages name as the original, wherever this copy is served + yield* urlApi.around({ + base: () => options.base, + }); + let { current, series } = yield* useConfig(); // Get stable series (no prereleases) for guides diff --git a/www/plugins/current-request.ts b/www/plugins/current-request.ts index 6347074f7..d9d984efa 100644 --- a/www/plugins/current-request.ts +++ b/www/plugins/current-request.ts @@ -1,6 +1,4 @@ import type { RevolutionPlugin } from "revolution"; -import { posixNormalize } from "_posixNormalize"; -import { type Operation } from "effection"; import { CurrentRequest } from "../context/request.ts"; export function currentRequestPlugin(): RevolutionPlugin { @@ -11,37 +9,3 @@ export function currentRequestPlugin(): RevolutionPlugin { }, }; } - -/** - * Convert a non fully qualified url into a fully qualified url, complete - * with protocol. - */ -export function* useAbsoluteUrl(path: string = "/"): Operation { - let absolute = yield* useAbsoluteUrlFactory(); - - return absolute(path); -} - -export function* useAbsoluteUrlFactory(): Operation<(path: string) => string> { - let request = yield* CurrentRequest.expect(); - - let origin = new URL(request.url).origin; - - return (path) => { - let url = new URL(path, origin); - url.pathname = posixNormalize(url.pathname); - return url.toString(); - }; -} - -/** - * Get the canonical url for the current path. - */ -export function* useCanonicalUrl(options: { base: string }): Operation { - let request = yield* CurrentRequest.expect(); - - let req = new URL(request.url); - let url = new URL(options.base); - url.pathname = `${url.pathname}${req.pathname}`; - return String(url); -} diff --git a/www/plugins/sitemap.ts b/www/plugins/sitemap.ts index fc1a0c459..189a5cfda 100644 --- a/www/plugins/sitemap.ts +++ b/www/plugins/sitemap.ts @@ -1,17 +1,17 @@ import type { Middleware, RevolutionPlugin } from "revolution"; import { route as revolutionRoute, useRevolutionOptions } from "revolution"; -import type { Operation } from "effection"; +import { all, type Operation } from "effection"; import { stringify } from "@libs/xml"; import { compile } from "path-to-regexp"; -import { useAbsoluteUrlFactory } from "./current-request.ts"; +import { url } from "../context/url.ts"; export function sitemapPlugin(): RevolutionPlugin { return { *http(request, next) { let options = yield* useRevolutionOptions(); - let url = new URL(request.url); + let requested = new URL(request.url); - if (url.pathname === "/sitemap.xml") { + if (requested.pathname === "/sitemap.xml") { let app = options.app ?? []; let paths: RoutePath[] = []; for (let middleware of app) { @@ -21,21 +21,21 @@ export function sitemapPlugin(): RevolutionPlugin { } } - let absolute = yield* useAbsoluteUrlFactory(); + let entries = yield* all(paths.map(function* (path) { + let { pathname, ...entry } = path; + + return { + loc: yield* url(pathname), + ...entry, + }; + })); let xml = stringify({ "@version": "1.0", "@encoding": "UTF-8", urlset: { "@xmlns": "http://www.sitemaps.org/schemas/sitemap/0.9", - url: paths.map((path) => { - let { pathname, ...entry } = path; - - return { - loc: absolute(pathname), - ...entry, - }; - }), + url: entries, }, }); diff --git a/www/routes/app.html.tsx b/www/routes/app.html.tsx index f32f5dbba..ddf8fb921 100644 --- a/www/routes/app.html.tsx +++ b/www/routes/app.html.tsx @@ -3,7 +3,7 @@ import type { JSXChild } from "revolution"; import { Footer } from "../components/footer.tsx"; import { Header, type HeaderProps } from "../components/header.tsx"; -import { useAbsoluteUrl, useCanonicalUrl } from "../plugins/current-request.ts"; +import { canonical, url } from "../context/url.ts"; import { JSXElement } from "revolution/jsx-runtime"; export type Options = { @@ -25,11 +25,9 @@ export function* useAppHtml({ head, image = "/assets/images/meta-effection.png", }: Options): Operation<({ children, search }: AppHtmlProps) => JSX.Element> { - let ogImageURL = yield* useAbsoluteUrl(image); + let ogImageURL = yield* url(image); - let canonicalURL = yield* useCanonicalUrl({ - base: "https://frontside.com/effection", - }); + let canonicalURL = yield* canonical(); let header = yield* Header({ hasLeftSidebar });