@goodbones/browser 0.1.0-beta.1
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/LICENSE +21 -0
- package/build/dts/index.d.ts +12 -0
- package/build/dts/index.d.ts.map +1 -0
- package/build/dts/main.d.ts +3 -0
- package/build/dts/main.d.ts.map +1 -0
- package/build/dts/model/atlas.d.ts +157 -0
- package/build/dts/model/atlas.d.ts.map +1 -0
- package/build/dts/model/campaigns.d.ts +141 -0
- package/build/dts/model/campaigns.d.ts.map +1 -0
- package/build/dts/server/collect.d.ts +16 -0
- package/build/dts/server/collect.d.ts.map +1 -0
- package/build/dts/server/compose.d.ts +13 -0
- package/build/dts/server/compose.d.ts.map +1 -0
- package/build/dts/server/export.d.ts +3 -0
- package/build/dts/server/export.d.ts.map +1 -0
- package/build/dts/server/handler.d.ts +6 -0
- package/build/dts/server/handler.d.ts.map +1 -0
- package/build/dts/server/serve.d.ts +15 -0
- package/build/dts/server/serve.d.ts.map +1 -0
- package/build/dts/server/site.d.ts +2 -0
- package/build/dts/server/site.d.ts.map +1 -0
- package/build/dts/server/source.d.ts +19 -0
- package/build/dts/server/source.d.ts.map +1 -0
- package/build/dts/server/vite-plugin.d.ts +8 -0
- package/build/dts/server/vite-plugin.d.ts.map +1 -0
- package/build/dts/server/watch.d.ts +10 -0
- package/build/dts/server/watch.d.ts.map +1 -0
- package/build/esm/index.js +14 -0
- package/build/esm/index.js.map +1 -0
- package/build/esm/main.js +134 -0
- package/build/esm/main.js.map +1 -0
- package/build/esm/model/atlas.js +431 -0
- package/build/esm/model/atlas.js.map +1 -0
- package/build/esm/model/campaigns.js +181 -0
- package/build/esm/model/campaigns.js.map +1 -0
- package/build/esm/server/collect.js +83 -0
- package/build/esm/server/collect.js.map +1 -0
- package/build/esm/server/compose.js +50 -0
- package/build/esm/server/compose.js.map +1 -0
- package/build/esm/server/export.js +19 -0
- package/build/esm/server/export.js.map +1 -0
- package/build/esm/server/handler.js +67 -0
- package/build/esm/server/handler.js.map +1 -0
- package/build/esm/server/serve.js +80 -0
- package/build/esm/server/serve.js.map +1 -0
- package/build/esm/server/site.js +6 -0
- package/build/esm/server/site.js.map +1 -0
- package/build/esm/server/source.js +67 -0
- package/build/esm/server/source.js.map +1 -0
- package/build/esm/server/vite-plugin.js +21 -0
- package/build/esm/server/vite-plugin.js.map +1 -0
- package/build/esm/server/watch.js +56 -0
- package/build/esm/server/watch.js.map +1 -0
- package/build/site/_astro/Browser.yxldcnQ3.js +3 -0
- package/build/site/_astro/client.Buyw3Q1S.js +9 -0
- package/build/site/_astro/index.DQrWs1cb.css +1 -0
- package/build/site/_astro/react.DJY1zw8Z.js +1 -0
- package/build/site/favicon.svg +1 -0
- package/build/site/index.html +8 -0
- package/package.json +78 -0
- package/src/app/components/Browser.tsx +153 -0
- package/src/app/components/architecture/ArchitectureBrowser.tsx +240 -0
- package/src/app/components/architecture/Detail.tsx +655 -0
- package/src/app/components/architecture/ManifestPane.tsx +178 -0
- package/src/app/components/architecture/Tree.tsx +205 -0
- package/src/app/components/architecture/rows.ts +181 -0
- package/src/app/components/architecture/selection.ts +6 -0
- package/src/app/components/campaigns/CampaignBrowser.tsx +146 -0
- package/src/app/components/campaigns/Detail.tsx +653 -0
- package/src/app/components/campaigns/Ladder.tsx +326 -0
- package/src/app/components/campaigns/pick.ts +7 -0
- package/src/app/components/lib/data.ts +132 -0
- package/src/app/pages/index.astro +34 -0
- package/src/app/public/favicon.svg +1 -0
- package/src/app/styles/theme.css +993 -0
- package/src/index.ts +37 -0
- package/src/main.ts +155 -0
- package/src/model/atlas.ts +751 -0
- package/src/model/campaigns.ts +376 -0
- package/src/server/collect.ts +132 -0
- package/src/server/compose.ts +79 -0
- package/src/server/export.ts +24 -0
- package/src/server/handler.ts +77 -0
- package/src/server/serve.ts +100 -0
- package/src/server/site.ts +6 -0
- package/src/server/source.ts +89 -0
- package/src/server/vite-plugin.ts +32 -0
- package/src/server/watch.ts +65 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createReadStream, existsSync, statSync } from "node:fs";
|
|
2
|
+
import { createServer, type Server } from "node:http";
|
|
3
|
+
import type { AddressInfo } from "node:net";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { type Handler } from "./handler.js";
|
|
7
|
+
|
|
8
|
+
// The bin's server: the prebuilt page from `build/site`, and the handler's
|
|
9
|
+
// routes in front of it. No framework — the page is static files and the
|
|
10
|
+
// data is four routes.
|
|
11
|
+
|
|
12
|
+
const TYPES: Readonly<Record<string, string>> = {
|
|
13
|
+
".html": "text/html; charset=utf-8",
|
|
14
|
+
".js": "text/javascript; charset=utf-8",
|
|
15
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
16
|
+
".css": "text/css; charset=utf-8",
|
|
17
|
+
".json": "application/json; charset=utf-8",
|
|
18
|
+
".svg": "image/svg+xml",
|
|
19
|
+
".png": "image/png",
|
|
20
|
+
".ico": "image/x-icon",
|
|
21
|
+
".woff": "font/woff",
|
|
22
|
+
".woff2": "font/woff2",
|
|
23
|
+
".txt": "text/plain; charset=utf-8",
|
|
24
|
+
".map": "application/json; charset=utf-8",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ServeOptions = {
|
|
28
|
+
readonly siteDir: string;
|
|
29
|
+
readonly handler: Handler;
|
|
30
|
+
readonly port: number;
|
|
31
|
+
readonly host: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type Served = {
|
|
35
|
+
readonly server: Server;
|
|
36
|
+
readonly url: string;
|
|
37
|
+
readonly close: () => Promise<void>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// A path inside the site directory, or nothing for one that escapes it.
|
|
41
|
+
const fileFor = (siteDir: string, pathname: string): string | null => {
|
|
42
|
+
const decoded = decodeURIComponent(pathname);
|
|
43
|
+
const wanted = decoded.endsWith("/") ? `${decoded}index.html` : decoded;
|
|
44
|
+
const resolved = path.resolve(siteDir, `.${wanted}`);
|
|
45
|
+
if (!resolved.startsWith(siteDir)) return null;
|
|
46
|
+
if (existsSync(resolved) && statSync(resolved).isFile()) return resolved;
|
|
47
|
+
// `/architecture` for `architecture.html`, as the build's `format: file`
|
|
48
|
+
// emits pages.
|
|
49
|
+
const asPage = `${resolved}.html`;
|
|
50
|
+
if (existsSync(asPage) && statSync(asPage).isFile()) return asPage;
|
|
51
|
+
return null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const serve = (options: ServeOptions): Promise<Served> =>
|
|
55
|
+
new Promise((resolve, reject) => {
|
|
56
|
+
const siteDir = path.resolve(options.siteDir);
|
|
57
|
+
const server = createServer((request, response) => {
|
|
58
|
+
options
|
|
59
|
+
.handler(request, response)
|
|
60
|
+
.then((handled) => {
|
|
61
|
+
if (handled) return;
|
|
62
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
63
|
+
const file = fileFor(siteDir, url.pathname);
|
|
64
|
+
if (file === null) {
|
|
65
|
+
response.statusCode = 404;
|
|
66
|
+
response.setHeader("content-type", "text/plain; charset=utf-8");
|
|
67
|
+
response.end("not found");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
response.statusCode = 200;
|
|
71
|
+
response.setHeader(
|
|
72
|
+
"content-type",
|
|
73
|
+
TYPES[path.extname(file)] ?? "application/octet-stream",
|
|
74
|
+
);
|
|
75
|
+
response.setHeader("cache-control", "no-cache");
|
|
76
|
+
createReadStream(file).pipe(response);
|
|
77
|
+
})
|
|
78
|
+
.catch((cause: unknown) => {
|
|
79
|
+
response.statusCode = 500;
|
|
80
|
+
response.setHeader("content-type", "text/plain; charset=utf-8");
|
|
81
|
+
response.end(String(cause));
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
server.once("error", reject);
|
|
85
|
+
server.listen(options.port, options.host, () => {
|
|
86
|
+
const address = server.address() as AddressInfo;
|
|
87
|
+
const host = options.host === "0.0.0.0" || options.host === "::" ? "localhost" : options.host;
|
|
88
|
+
resolve({
|
|
89
|
+
server,
|
|
90
|
+
url: `http://${host}:${String(address.port)}/`,
|
|
91
|
+
close: () =>
|
|
92
|
+
new Promise<void>((done) => {
|
|
93
|
+
server.closeAllConnections();
|
|
94
|
+
server.close(() => {
|
|
95
|
+
done();
|
|
96
|
+
});
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
// Where `astro build` put the page: `build/site`, beside this module's
|
|
5
|
+
// `build/esm`. The bin serves it; a static export copies it.
|
|
6
|
+
export const SITE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../site");
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { collect, type Collected, type CollectOptions } from "./collect.js";
|
|
2
|
+
import { watchRepository } from "./watch.js";
|
|
3
|
+
|
|
4
|
+
// What the handler serves: the two models, computed once and kept until the
|
|
5
|
+
// repository changes, and a feed of those changes for the page to refetch on.
|
|
6
|
+
|
|
7
|
+
export type Change = { readonly files: ReadonlyArray<string>; readonly at: string };
|
|
8
|
+
|
|
9
|
+
export type Source = {
|
|
10
|
+
readonly current: () => Promise<Collected>;
|
|
11
|
+
readonly subscribe: (listener: (change: Change) => void) => () => void;
|
|
12
|
+
readonly invalidate: (files: ReadonlyArray<string>) => void;
|
|
13
|
+
readonly live: boolean;
|
|
14
|
+
readonly close: () => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type SourceOptions = CollectOptions & {
|
|
18
|
+
readonly watch?: boolean | undefined;
|
|
19
|
+
readonly debounceMs?: number | undefined;
|
|
20
|
+
// For tests: what to compute instead of walking the repository.
|
|
21
|
+
readonly compute?: (() => Promise<Collected>) | undefined;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const makeSource = (options: SourceOptions): Source => {
|
|
25
|
+
const listeners = new Set<(change: Change) => void>();
|
|
26
|
+
let cached: Promise<Collected> | null = null;
|
|
27
|
+
const compute = options.compute ?? (() => collect(options));
|
|
28
|
+
|
|
29
|
+
const current = (): Promise<Collected> => {
|
|
30
|
+
if (cached === null) {
|
|
31
|
+
const started = compute();
|
|
32
|
+
cached = started;
|
|
33
|
+
// A failed computation is not kept: the next request tries again,
|
|
34
|
+
// after the author has fixed the manifest.
|
|
35
|
+
started.catch(() => {
|
|
36
|
+
if (cached === started) cached = null;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return cached;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const invalidate = (files: ReadonlyArray<string>): void => {
|
|
43
|
+
cached = null;
|
|
44
|
+
const change: Change = { files, at: new Date().toISOString() };
|
|
45
|
+
for (const listener of listeners) listener(change);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
let stopWatching: (() => void) | null = null;
|
|
49
|
+
if (options.watch === true) {
|
|
50
|
+
// The extensions and the ledger directory come from the policy, once it
|
|
51
|
+
// has loaded; until then every change under the roots counts.
|
|
52
|
+
stopWatching = watchRepository({
|
|
53
|
+
repoRoot: options.repoRoot,
|
|
54
|
+
extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"],
|
|
55
|
+
ledgerDir: ".architecture-campaigns",
|
|
56
|
+
debounceMs: options.debounceMs,
|
|
57
|
+
onChange: invalidate,
|
|
58
|
+
});
|
|
59
|
+
void current()
|
|
60
|
+
.then((collected) => {
|
|
61
|
+
stopWatching?.();
|
|
62
|
+
stopWatching = watchRepository({
|
|
63
|
+
repoRoot: options.repoRoot,
|
|
64
|
+
extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"],
|
|
65
|
+
ledgerDir: collected.campaigns.ledgerDir,
|
|
66
|
+
debounceMs: options.debounceMs,
|
|
67
|
+
onChange: invalidate,
|
|
68
|
+
});
|
|
69
|
+
})
|
|
70
|
+
.catch(() => {
|
|
71
|
+
// Keep the first watcher: the manifest that failed to load is one of
|
|
72
|
+
// the files it watches, and its fix is the next change.
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
current,
|
|
78
|
+
subscribe: (listener) => {
|
|
79
|
+
listeners.add(listener);
|
|
80
|
+
return () => listeners.delete(listener);
|
|
81
|
+
},
|
|
82
|
+
invalidate,
|
|
83
|
+
live: options.watch === true,
|
|
84
|
+
close: () => {
|
|
85
|
+
stopWatching?.();
|
|
86
|
+
listeners.clear();
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Plugin } from "vite";
|
|
2
|
+
|
|
3
|
+
import { makeHandler } from "./handler.js";
|
|
4
|
+
import { makeSource } from "./source.js";
|
|
5
|
+
|
|
6
|
+
// For working on the browsers themselves: `astro dev` in this package, with
|
|
7
|
+
// the routes mounted on Vite's own server over whichever repository
|
|
8
|
+
// `GOODBONES_REPO` names (this one, by default). The page then has Vite's
|
|
9
|
+
// hot reload for its own source and the feed for the repository's.
|
|
10
|
+
export type BrowserPluginOptions = {
|
|
11
|
+
readonly repoRoot: string;
|
|
12
|
+
readonly roots: ReadonlyArray<string>;
|
|
13
|
+
readonly configFilename?: string | undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const goodbonesBrowser = (options: BrowserPluginOptions): Plugin => ({
|
|
17
|
+
name: "goodbones-browser",
|
|
18
|
+
configureServer(server) {
|
|
19
|
+
const source = makeSource({ ...options, watch: true });
|
|
20
|
+
const handle = makeHandler(source);
|
|
21
|
+
server.middlewares.use((request, response, next) => {
|
|
22
|
+
handle(request, response)
|
|
23
|
+
.then((handled) => {
|
|
24
|
+
if (!handled) next();
|
|
25
|
+
})
|
|
26
|
+
.catch(next);
|
|
27
|
+
});
|
|
28
|
+
server.httpServer?.once("close", () => {
|
|
29
|
+
source.close();
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type FSWatcher, watch } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
// The repository, watched: a change to a source file, a manifest or a ledger
|
|
5
|
+
// is what redraws the browsers. Node's recursive watcher, so no dependency;
|
|
6
|
+
// events are debounced, since an editor's save is several of them.
|
|
7
|
+
|
|
8
|
+
const IGNORED = ["node_modules", ".git", "build", "dist", ".nx", ".astro", "coverage"];
|
|
9
|
+
|
|
10
|
+
export type WatchOptions = {
|
|
11
|
+
readonly repoRoot: string;
|
|
12
|
+
// File extensions that count as source, with their dots.
|
|
13
|
+
readonly extensions: ReadonlyArray<string>;
|
|
14
|
+
// Repo-relative directory of the ledgers.
|
|
15
|
+
readonly ledgerDir: string;
|
|
16
|
+
readonly debounceMs?: number | undefined;
|
|
17
|
+
readonly onChange: (files: ReadonlyArray<string>) => void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const MANIFEST = /(^|\/)architecture(\.config)?\.(ya?ml|json|mjs)$/;
|
|
21
|
+
|
|
22
|
+
export const isRelevant = (
|
|
23
|
+
file: string,
|
|
24
|
+
extensions: ReadonlyArray<string>,
|
|
25
|
+
ledgerDir: string,
|
|
26
|
+
): boolean => {
|
|
27
|
+
const segments = file.split("/");
|
|
28
|
+
if (segments.some((segment) => IGNORED.includes(segment))) return false;
|
|
29
|
+
if (MANIFEST.test(file)) return true;
|
|
30
|
+
if (ledgerDir !== "" && (file === ledgerDir || file.startsWith(`${ledgerDir}/`))) return true;
|
|
31
|
+
return extensions.includes(path.extname(file));
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const watchRepository = (options: WatchOptions): (() => void) => {
|
|
35
|
+
const delay = options.debounceMs ?? 150;
|
|
36
|
+
let pending = new Set<string>();
|
|
37
|
+
let timer: NodeJS.Timeout | null = null;
|
|
38
|
+
const flush = (): void => {
|
|
39
|
+
timer = null;
|
|
40
|
+
const files = [...pending].sort();
|
|
41
|
+
pending = new Set();
|
|
42
|
+
if (files.length > 0) options.onChange(files);
|
|
43
|
+
};
|
|
44
|
+
let watcher: FSWatcher | null = null;
|
|
45
|
+
try {
|
|
46
|
+
watcher = watch(options.repoRoot, { recursive: true }, (_event, filename) => {
|
|
47
|
+
if (filename === null) return;
|
|
48
|
+
const file = String(filename).split(path.sep).join("/");
|
|
49
|
+
if (!isRelevant(file, options.extensions, options.ledgerDir)) return;
|
|
50
|
+
pending.add(file);
|
|
51
|
+
if (timer !== null) clearTimeout(timer);
|
|
52
|
+
timer = setTimeout(flush, delay);
|
|
53
|
+
});
|
|
54
|
+
watcher.on("error", () => {
|
|
55
|
+
// A watcher that dies leaves the page static; the next request still
|
|
56
|
+
// recomputes, so nothing is wrong, only not live.
|
|
57
|
+
});
|
|
58
|
+
} catch {
|
|
59
|
+
watcher = null;
|
|
60
|
+
}
|
|
61
|
+
return () => {
|
|
62
|
+
if (timer !== null) clearTimeout(timer);
|
|
63
|
+
watcher?.close();
|
|
64
|
+
};
|
|
65
|
+
};
|