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 });