@txco/astro 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # @txco/astro
2
+
3
+ An [Astro](https://astro.build) integration for
4
+ [Thanks, Computer](https://www.thanks.computer) (txco). After `astro build`,
5
+ it writes a **Web ABI build** beside your `dist/`:
6
+
7
+ - a copy of the built site as `public/`;
8
+ - the 404 page and a catch-all, as `ops/`;
9
+ - a `txco-web.json` manifest.
10
+
11
+ `txco apply` installs the build into a stack.
12
+
13
+ ```sh
14
+ npm install -D @txco/astro
15
+ ```
16
+
17
+ Astro 5, 6 or 7, with static output.
18
+
19
+ ## Usage
20
+
21
+ ```js
22
+ // astro.config.mjs
23
+ import { defineConfig } from "astro/config";
24
+ import txco from "@txco/astro";
25
+
26
+ export default defineConfig({
27
+ integrations: [txco()],
28
+ });
29
+ ```
30
+
31
+ Bind the build to a stack in your workspace's `txco.yaml`. The path is
32
+ relative to the workspace root, and must lie outside `OPS/`:
33
+
34
+ ```yaml
35
+ stacks:
36
+ web:
37
+ abi: txco-web
38
+ ```
39
+
40
+ Then build, check and deploy:
41
+
42
+ ```sh
43
+ astro build # writes dist/ as usual, and txco-web/
44
+ txco web check txco-web # installs it on a scratch chassis and probes it
45
+ txco apply # or: txco push web
46
+ ```
47
+
48
+ Gitignore `txco-web/`. It's all build output, and the 404 op embeds your
49
+ 404 page, so it changes on every build.
50
+
51
+ ## How a request is answered
52
+
53
+ Every page of an Astro site is a file, so:
54
+
55
+ - **a page** is served as a file: `/about` from `about/index.html`, or from
56
+ `about.html` with `build.format: "file"`;
57
+ - **a path with no page** gets your `404.html` (from `src/pages/404.astro`)
58
+ with status 404, or a plain 404 page if there's none;
59
+ - **everything else**, such as a missing asset or a POST, gets a plain 404.
60
+
61
+ Astro's hashed output under `_astro/` is cached for a year. With
62
+ `base: "/docs"` the site lands under `public/docs/`, and is served at
63
+ `/docs/`.
64
+
65
+ ## Server output
66
+
67
+ With `output: "server"`, Astro builds a server, and no chassis runs one yet.
68
+ The integration deploys the prerendered pages and warns.
69
+
70
+ ## Options
71
+
72
+ | Option | Default | Meaning |
73
+ | ----------- | -------------- | ------- |
74
+ | `out` | `"txco-web"` | The Web ABI directory to write, relative to the project root. It must be a directory of its own, outside `OPS/` and `dist/`. |
75
+ | `immutable` | `["_astro/"]` | The `public/` prefixes cached for a year. |
76
+ | `scope` | `900000` | The 404 op's scope. The catch-all goes 900 above it. |
77
+
78
+ ## License
79
+
80
+ MIT
@@ -0,0 +1,33 @@
1
+ export declare const NAME = "@txco/astro";
2
+ export declare const VERSION = "0.1.0";
3
+ export interface TxcoOptions {
4
+ /** The Web ABI directory to write, relative to the project root. Default "txco-web". */
5
+ out?: string;
6
+ /** The public/ prefixes cached for a year. Default: Astro's build.assets (`_astro/`). */
7
+ immutable?: string[];
8
+ /** The navigation op's scope; the catch-all goes 900 above it. Default 900000. */
9
+ scope?: number;
10
+ }
11
+ export interface BuildInput {
12
+ /** The finished static output: outDir, or build.client for a server build. */
13
+ dir: string;
14
+ /** The Web ABI directory, absolute. */
15
+ out: string;
16
+ /** Astro's base (where the site is served; not part of the file paths). */
17
+ base: string;
18
+ /** Astro's build.assets. */
19
+ assets: string;
20
+ /** "static" or "server". */
21
+ output: string;
22
+ /** How many pages Astro wrote. */
23
+ pages: number;
24
+ options: TxcoOptions;
25
+ log: {
26
+ info(msg: string): void;
27
+ warn(msg: string): void;
28
+ };
29
+ }
30
+ export declare function writeBuild(i: BuildInput): Promise<{
31
+ ops: string[];
32
+ manifest: Record<string, unknown>;
33
+ }>;
package/dist/build.js ADDED
@@ -0,0 +1,58 @@
1
+ // Writes the Web ABI build from Astro's finished static output. Called from
2
+ // the integration's astro:build:done; takes plain inputs, so it's tested
3
+ // without a build.
4
+ import { readFile, rm } from "node:fs/promises";
5
+ import { join, relative } from "node:path";
6
+ import { copyPublic, listFiles, looksHashed, publicPrefix, renderOps, writeManifest, writeOps } from "@txco/web-abi/producer";
7
+ export const NAME = "@txco/astro";
8
+ export const VERSION = "0.1.0";
9
+ /** An op answer is capped at 4 MiB (--op-payload-max), base64 included. */
10
+ const PAGE_WARN_BYTES = 1 << 20;
11
+ export async function writeBuild(i) {
12
+ const warn = i.log.warn;
13
+ const { prefix, external } = publicPrefix(i.base);
14
+ if (external)
15
+ warn(`base is ${i.base}: the pages are deployed at the root`);
16
+ // Astro writes the site without its base; the files go under public/<base>/.
17
+ await rm(i.out, { recursive: true, force: true });
18
+ const { skipped } = await copyPublic(i.dir, i.out, prefix);
19
+ if (skipped.length > 0)
20
+ warn(`the build has ${skipped.length} dot path(s) (${skipped.slice(0, 3).join(", ")}); they never deploy`);
21
+ if (i.output === "server") {
22
+ warn("output: 'server' needs a server, and no chassis runs one yet: only the prerendered pages deploy.");
23
+ }
24
+ // An Astro site is multi-page: a path with no page gets 404.html.
25
+ const html = await readFile(join(i.dir, "404.html"), "utf8").catch(() => null);
26
+ const page = html === null ? null : { name: "404.html", html };
27
+ if (page && Buffer.byteLength(page.html) > PAGE_WARN_BYTES) {
28
+ warn(`404.html is ${Buffer.byteLength(page.html)} bytes; the op that serves it is capped at 4 MiB, base64 included`);
29
+ }
30
+ const ops = renderOps({
31
+ mode: "404",
32
+ scope: i.options.scope,
33
+ producer: NAME,
34
+ page,
35
+ notes: page ? [] : ["Add src/pages/404.astro to serve your own."],
36
+ });
37
+ await writeOps(i.out, ops);
38
+ const files = await listFiles(join(i.out, "public"));
39
+ const assets = i.assets.replace(/^\/+|\/+$/g, "");
40
+ const immutable = i.options.immutable ?? (assets && files.some((f) => f.startsWith(`${prefix}${assets}/`)) ? [`${prefix}${assets}/`] : []);
41
+ for (const p of immutable) {
42
+ const unhashed = files.filter((f) => f.startsWith(p) && !looksHashed(f.split("/").pop()));
43
+ if (unhashed.length > 0) {
44
+ warn(`${unhashed.length} file(s) under the immutable prefix ${p} carry no content hash (${unhashed.slice(0, 3).join(", ")}), so a change to them wouldn't reach a browser for a year`);
45
+ }
46
+ }
47
+ const manifest = {
48
+ abi: 1,
49
+ ...(immutable.length > 0 ? { immutable } : {}),
50
+ "x-producer": { name: NAME, version: VERSION },
51
+ "x-astro": { output: i.output, pages: i.pages },
52
+ };
53
+ await writeManifest(i.out, manifest);
54
+ const shown = relative(process.cwd(), i.out) || ".";
55
+ i.log.info(`Web ABI build at ${shown}: ${files.length} public file(s), ${Object.keys(ops).length} op(s). ` +
56
+ `Next: \`txco web check ${shown}\`, bind it in txco.yaml (stacks: <name>: abi: ${shown}), then \`txco apply\`.`);
57
+ return { ops: Object.keys(ops).sort(), manifest };
58
+ }
@@ -0,0 +1,35 @@
1
+ import { type TxcoOptions } from "./build.js";
2
+ export type { TxcoOptions } from "./build.js";
3
+ interface Logger {
4
+ info(msg: string): void;
5
+ warn(msg: string): void;
6
+ }
7
+ interface ConfigLike {
8
+ root: URL;
9
+ srcDir: URL;
10
+ publicDir: URL;
11
+ outDir: URL;
12
+ base: string;
13
+ output?: string;
14
+ build: {
15
+ assets: string;
16
+ };
17
+ }
18
+ export interface AstroIntegration {
19
+ name: string;
20
+ hooks: {
21
+ "astro:config:done"?: (opts: {
22
+ config: ConfigLike;
23
+ buildOutput?: "static" | "server";
24
+ logger: Logger;
25
+ }) => Promise<void>;
26
+ "astro:build:done"?: (opts: {
27
+ dir: URL;
28
+ pages: {
29
+ pathname: string;
30
+ }[];
31
+ logger: Logger;
32
+ }) => Promise<void>;
33
+ };
34
+ }
35
+ export default function txco(options?: TxcoOptions): AstroIntegration;
package/dist/index.js ADDED
@@ -0,0 +1,58 @@
1
+ // @txco/astro: an Astro integration that writes a Web ABI build for Thanks,
2
+ // Computer (txco) after `astro build`:
3
+ //
4
+ // txco-web/
5
+ // txco-web.json the manifest
6
+ // public/ a copy of the built site
7
+ // ops/ the 404 page and the catch-all
8
+ //
9
+ // // astro.config.mjs
10
+ // import txco from "@txco/astro";
11
+ // export default defineConfig({ integrations: [txco()] });
12
+ //
13
+ // `txco apply` installs it into the stack txco.yaml binds it to.
14
+ import { readdir } from "node:fs/promises";
15
+ import { resolve } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { checkOutDir } from "@txco/web-abi/producer";
18
+ import { NAME, writeBuild } from "./build.js";
19
+ export default function txco(options = {}) {
20
+ let config;
21
+ let output = "static";
22
+ let out = "";
23
+ return {
24
+ name: NAME,
25
+ hooks: {
26
+ // Fail before the build: the integration wipes and rewrites `out`.
27
+ async "astro:config:done"({ config: c, buildOutput }) {
28
+ config = c;
29
+ output = buildOutput ?? (c.output === "server" ? "server" : "static");
30
+ const root = fileURLToPath(c.root);
31
+ const outDir = fileURLToPath(c.outDir);
32
+ out = resolve(root, options.out ?? "txco-web");
33
+ const problems = checkOutDir({
34
+ dir: out,
35
+ protect: [root, fileURLToPath(c.srcDir), fileURLToPath(c.publicDir), outDir],
36
+ notInside: [outDir],
37
+ entries: await readdir(out).catch(() => null),
38
+ });
39
+ if (problems.length > 0)
40
+ throw new Error(`[${NAME}] ${problems.join("\n")}`);
41
+ },
42
+ async "astro:build:done"({ dir, pages, logger }) {
43
+ if (!config)
44
+ throw new Error(`[${NAME}] astro:build:done ran before astro:config:done`);
45
+ await writeBuild({
46
+ dir: fileURLToPath(dir),
47
+ out,
48
+ base: config.base,
49
+ assets: config.build.assets,
50
+ output,
51
+ pages: pages.length,
52
+ options,
53
+ log: logger,
54
+ });
55
+ },
56
+ },
57
+ };
58
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@txco/astro",
3
+ "version": "0.1.0",
4
+ "description": "Astro integration for Thanks, Computer (txco): after `astro build`, writes a Web ABI build — your static site as public/, a 404 page and catch-all as ops/, and txco-web.json — for `txco apply` to install.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "keywords": [
9
+ "astro",
10
+ "astro-integration",
11
+ "withastro",
12
+ "static",
13
+ "txco",
14
+ "thanks-computer",
15
+ "web-abi"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/LoremLabs/thanks-computer.git",
20
+ "directory": "sdk/astro"
21
+ },
22
+ "homepage": "https://github.com/LoremLabs/thanks-computer/tree/master/sdk/astro#readme",
23
+ "bugs": "https://github.com/LoremLabs/thanks-computer/issues",
24
+ "files": [
25
+ "dist",
26
+ "src",
27
+ "README.md"
28
+ ],
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "default": "./dist/index.js"
33
+ }
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "engines": {
39
+ "node": ">=20"
40
+ },
41
+ "scripts": {
42
+ "clean": "rm -rf dist",
43
+ "build": "tsc -p tsconfig.json",
44
+ "prepublishOnly": "npm run clean && npm run build",
45
+ "test": "npm run build && node --test test/*.test.mjs"
46
+ },
47
+ "dependencies": {
48
+ "@txco/web-abi": "^0.2.0"
49
+ },
50
+ "peerDependencies": {
51
+ "astro": "^5.0.0 || ^6.0.0 || ^7.0.0"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^20.0.0",
55
+ "typescript": "^5.4.0"
56
+ }
57
+ }
package/src/build.ts ADDED
@@ -0,0 +1,94 @@
1
+ // Writes the Web ABI build from Astro's finished static output. Called from
2
+ // the integration's astro:build:done; takes plain inputs, so it's tested
3
+ // without a build.
4
+ import { readFile, rm } from "node:fs/promises";
5
+ import { join, relative } from "node:path";
6
+
7
+ import { copyPublic, listFiles, looksHashed, publicPrefix, renderOps, writeManifest, writeOps } from "@txco/web-abi/producer";
8
+
9
+ export const NAME = "@txco/astro";
10
+ export const VERSION = "0.1.0";
11
+ /** An op answer is capped at 4 MiB (--op-payload-max), base64 included. */
12
+ const PAGE_WARN_BYTES = 1 << 20;
13
+
14
+ export interface TxcoOptions {
15
+ /** The Web ABI directory to write, relative to the project root. Default "txco-web". */
16
+ out?: string;
17
+ /** The public/ prefixes cached for a year. Default: Astro's build.assets (`_astro/`). */
18
+ immutable?: string[];
19
+ /** The navigation op's scope; the catch-all goes 900 above it. Default 900000. */
20
+ scope?: number;
21
+ }
22
+
23
+ export interface BuildInput {
24
+ /** The finished static output: outDir, or build.client for a server build. */
25
+ dir: string;
26
+ /** The Web ABI directory, absolute. */
27
+ out: string;
28
+ /** Astro's base (where the site is served; not part of the file paths). */
29
+ base: string;
30
+ /** Astro's build.assets. */
31
+ assets: string;
32
+ /** "static" or "server". */
33
+ output: string;
34
+ /** How many pages Astro wrote. */
35
+ pages: number;
36
+ options: TxcoOptions;
37
+ log: { info(msg: string): void; warn(msg: string): void };
38
+ }
39
+
40
+ export async function writeBuild(i: BuildInput): Promise<{ ops: string[]; manifest: Record<string, unknown> }> {
41
+ const warn = i.log.warn;
42
+ const { prefix, external } = publicPrefix(i.base);
43
+ if (external) warn(`base is ${i.base}: the pages are deployed at the root`);
44
+
45
+ // Astro writes the site without its base; the files go under public/<base>/.
46
+ await rm(i.out, { recursive: true, force: true });
47
+ const { skipped } = await copyPublic(i.dir, i.out, prefix);
48
+ if (skipped.length > 0) warn(`the build has ${skipped.length} dot path(s) (${skipped.slice(0, 3).join(", ")}); they never deploy`);
49
+
50
+ if (i.output === "server") {
51
+ warn("output: 'server' needs a server, and no chassis runs one yet: only the prerendered pages deploy.");
52
+ }
53
+
54
+ // An Astro site is multi-page: a path with no page gets 404.html.
55
+ const html = await readFile(join(i.dir, "404.html"), "utf8").catch(() => null);
56
+ const page = html === null ? null : { name: "404.html", html };
57
+ if (page && Buffer.byteLength(page.html) > PAGE_WARN_BYTES) {
58
+ warn(`404.html is ${Buffer.byteLength(page.html)} bytes; the op that serves it is capped at 4 MiB, base64 included`);
59
+ }
60
+ const ops = renderOps({
61
+ mode: "404",
62
+ scope: i.options.scope,
63
+ producer: NAME,
64
+ page,
65
+ notes: page ? [] : ["Add src/pages/404.astro to serve your own."],
66
+ });
67
+ await writeOps(i.out, ops);
68
+
69
+ const files = await listFiles(join(i.out, "public"));
70
+ const assets = i.assets.replace(/^\/+|\/+$/g, "");
71
+ const immutable =
72
+ i.options.immutable ?? (assets && files.some((f) => f.startsWith(`${prefix}${assets}/`)) ? [`${prefix}${assets}/`] : []);
73
+ for (const p of immutable) {
74
+ const unhashed = files.filter((f) => f.startsWith(p) && !looksHashed(f.split("/").pop()!));
75
+ if (unhashed.length > 0) {
76
+ warn(`${unhashed.length} file(s) under the immutable prefix ${p} carry no content hash (${unhashed.slice(0, 3).join(", ")}), so a change to them wouldn't reach a browser for a year`);
77
+ }
78
+ }
79
+
80
+ const manifest: Record<string, unknown> = {
81
+ abi: 1,
82
+ ...(immutable.length > 0 ? { immutable } : {}),
83
+ "x-producer": { name: NAME, version: VERSION },
84
+ "x-astro": { output: i.output, pages: i.pages },
85
+ };
86
+ await writeManifest(i.out, manifest);
87
+
88
+ const shown = relative(process.cwd(), i.out) || ".";
89
+ i.log.info(
90
+ `Web ABI build at ${shown}: ${files.length} public file(s), ${Object.keys(ops).length} op(s). ` +
91
+ `Next: \`txco web check ${shown}\`, bind it in txco.yaml (stacks: <name>: abi: ${shown}), then \`txco apply\`.`,
92
+ );
93
+ return { ops: Object.keys(ops).sort(), manifest };
94
+ }
package/src/index.ts ADDED
@@ -0,0 +1,86 @@
1
+ // @txco/astro: an Astro integration that writes a Web ABI build for Thanks,
2
+ // Computer (txco) after `astro build`:
3
+ //
4
+ // txco-web/
5
+ // txco-web.json the manifest
6
+ // public/ a copy of the built site
7
+ // ops/ the 404 page and the catch-all
8
+ //
9
+ // // astro.config.mjs
10
+ // import txco from "@txco/astro";
11
+ // export default defineConfig({ integrations: [txco()] });
12
+ //
13
+ // `txco apply` installs it into the stack txco.yaml binds it to.
14
+ import { readdir } from "node:fs/promises";
15
+ import { resolve } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ import { checkOutDir } from "@txco/web-abi/producer";
19
+
20
+ import { NAME, writeBuild, type TxcoOptions } from "./build.js";
21
+
22
+ export type { TxcoOptions } from "./build.js";
23
+
24
+ // The parts of Astro's integration API this reads, declared here so the
25
+ // package needs no Astro at build time (they match Astro 5–7).
26
+ interface Logger {
27
+ info(msg: string): void;
28
+ warn(msg: string): void;
29
+ }
30
+ interface ConfigLike {
31
+ root: URL;
32
+ srcDir: URL;
33
+ publicDir: URL;
34
+ outDir: URL;
35
+ base: string;
36
+ output?: string;
37
+ build: { assets: string };
38
+ }
39
+ export interface AstroIntegration {
40
+ name: string;
41
+ hooks: {
42
+ "astro:config:done"?: (opts: { config: ConfigLike; buildOutput?: "static" | "server"; logger: Logger }) => Promise<void>;
43
+ "astro:build:done"?: (opts: { dir: URL; pages: { pathname: string }[]; logger: Logger }) => Promise<void>;
44
+ };
45
+ }
46
+
47
+ export default function txco(options: TxcoOptions = {}): AstroIntegration {
48
+ let config: ConfigLike | undefined;
49
+ let output = "static";
50
+ let out = "";
51
+
52
+ return {
53
+ name: NAME,
54
+ hooks: {
55
+ // Fail before the build: the integration wipes and rewrites `out`.
56
+ async "astro:config:done"({ config: c, buildOutput }) {
57
+ config = c;
58
+ output = buildOutput ?? (c.output === "server" ? "server" : "static");
59
+ const root = fileURLToPath(c.root);
60
+ const outDir = fileURLToPath(c.outDir);
61
+ out = resolve(root, options.out ?? "txco-web");
62
+ const problems = checkOutDir({
63
+ dir: out,
64
+ protect: [root, fileURLToPath(c.srcDir), fileURLToPath(c.publicDir), outDir],
65
+ notInside: [outDir],
66
+ entries: await readdir(out).catch(() => null),
67
+ });
68
+ if (problems.length > 0) throw new Error(`[${NAME}] ${problems.join("\n")}`);
69
+ },
70
+
71
+ async "astro:build:done"({ dir, pages, logger }) {
72
+ if (!config) throw new Error(`[${NAME}] astro:build:done ran before astro:config:done`);
73
+ await writeBuild({
74
+ dir: fileURLToPath(dir),
75
+ out,
76
+ base: config.base,
77
+ assets: config.build.assets,
78
+ output,
79
+ pages: pages.length,
80
+ options,
81
+ log: logger,
82
+ });
83
+ },
84
+ },
85
+ };
86
+ }