@shivaedev/work-board 0.0.0 → 0.2.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/CHANGELOG.md +51 -0
- package/LICENSE +21 -0
- package/README.md +157 -2
- package/dist/board.d.ts +8 -0
- package/dist/board.d.ts.map +1 -0
- package/dist/board.js +27 -0
- package/dist/board.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +17 -0
- package/dist/cli.js.map +1 -0
- package/dist/files/changes.d.ts +17 -0
- package/dist/files/changes.d.ts.map +1 -0
- package/dist/files/changes.js +38 -0
- package/dist/files/changes.js.map +1 -0
- package/dist/files/home.d.ts +11 -0
- package/dist/files/home.d.ts.map +1 -0
- package/dist/files/home.js +33 -0
- package/dist/files/home.js.map +1 -0
- package/dist/files/list.d.ts +9 -0
- package/dist/files/list.d.ts.map +1 -0
- package/dist/files/list.js +38 -0
- package/dist/files/list.js.map +1 -0
- package/dist/http/assets.d.ts +15 -0
- package/dist/http/assets.d.ts.map +1 -0
- package/dist/http/assets.js +32 -0
- package/dist/http/assets.js.map +1 -0
- package/dist/http/events.d.ts +5 -0
- package/dist/http/events.d.ts.map +1 -0
- package/dist/http/events.js +17 -0
- package/dist/http/events.js.map +1 -0
- package/dist/http/loopback.d.ts +4 -0
- package/dist/http/loopback.d.ts.map +1 -0
- package/dist/http/loopback.js +22 -0
- package/dist/http/loopback.js.map +1 -0
- package/dist/http/page.d.ts +9 -0
- package/dist/http/page.d.ts.map +1 -0
- package/dist/http/page.js +43 -0
- package/dist/http/page.js.map +1 -0
- package/dist/http/respond.d.ts +8 -0
- package/dist/http/respond.d.ts.map +1 -0
- package/dist/http/respond.js +15 -0
- package/dist/http/respond.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/page/board.d.ts +4 -0
- package/dist/page/board.d.ts.map +1 -0
- package/dist/page/board.js +32 -0
- package/dist/page/board.js.map +1 -0
- package/dist/page/client.d.ts +2 -0
- package/dist/page/client.d.ts.map +1 -0
- package/dist/page/client.js +91 -0
- package/dist/page/client.js.map +1 -0
- package/dist/page/diagrams.d.ts +2 -0
- package/dist/page/diagrams.d.ts.map +1 -0
- package/dist/page/diagrams.js +73 -0
- package/dist/page/diagrams.js.map +1 -0
- package/dist/page/document.d.ts +4 -0
- package/dist/page/document.d.ts.map +1 -0
- package/dist/page/document.js +9 -0
- package/dist/page/document.js.map +1 -0
- package/dist/page/escape.d.ts +2 -0
- package/dist/page/escape.d.ts.map +1 -0
- package/dist/page/escape.js +3 -0
- package/dist/page/escape.js.map +1 -0
- package/dist/page/nav.d.ts +3 -0
- package/dist/page/nav.d.ts.map +1 -0
- package/dist/page/nav.js +22 -0
- package/dist/page/nav.js.map +1 -0
- package/dist/page/shell.d.ts +2 -0
- package/dist/page/shell.d.ts.map +1 -0
- package/dist/page/shell.js +11 -0
- package/dist/page/shell.js.map +1 -0
- package/dist/page/style.d.ts +2 -0
- package/dist/page/style.d.ts.map +1 -0
- package/dist/page/style.js +75 -0
- package/dist/page/style.js.map +1 -0
- package/dist/page/swap.d.ts +2 -0
- package/dist/page/swap.d.ts.map +1 -0
- package/dist/page/swap.js +123 -0
- package/dist/page/swap.js.map +1 -0
- package/dist/page/updated.d.ts +2 -0
- package/dist/page/updated.d.ts.map +1 -0
- package/dist/page/updated.js +2 -0
- package/dist/page/updated.js.map +1 -0
- package/dist/render/board.d.ts +20 -0
- package/dist/render/board.d.ts.map +1 -0
- package/dist/render/board.js +54 -0
- package/dist/render/board.js.map +1 -0
- package/dist/render/code.d.ts +10 -0
- package/dist/render/code.d.ts.map +1 -0
- package/dist/render/code.js +47 -0
- package/dist/render/code.js.map +1 -0
- package/dist/render/failed.d.ts +9 -0
- package/dist/render/failed.d.ts.map +1 -0
- package/dist/render/failed.js +4 -0
- package/dist/render/failed.js.map +1 -0
- package/dist/render/footnotes.d.ts +19 -0
- package/dist/render/footnotes.d.ts.map +1 -0
- package/dist/render/footnotes.js +23 -0
- package/dist/render/footnotes.js.map +1 -0
- package/dist/render/highlighter.d.ts +13 -0
- package/dist/render/highlighter.d.ts.map +1 -0
- package/dist/render/highlighter.js +11 -0
- package/dist/render/highlighter.js.map +1 -0
- package/dist/render/markdown.d.ts +5 -0
- package/dist/render/markdown.d.ts.map +1 -0
- package/dist/render/markdown.js +15 -0
- package/dist/render/markdown.js.map +1 -0
- package/dist/serve.d.ts +9 -0
- package/dist/serve.d.ts.map +1 -0
- package/dist/serve.js +9 -0
- package/dist/serve.js.map +1 -0
- package/package.json +62 -3
- package/src/board.ts +44 -0
- package/src/cli.ts +24 -0
- package/src/files/changes.ts +71 -0
- package/src/files/home.ts +37 -0
- package/src/files/list.ts +55 -0
- package/src/http/assets.ts +38 -0
- package/src/http/events.ts +29 -0
- package/src/http/loopback.ts +29 -0
- package/src/http/page.ts +54 -0
- package/src/http/respond.ts +18 -0
- package/src/index.ts +1 -0
- package/src/page/board.ts +37 -0
- package/src/page/client.ts +90 -0
- package/src/page/diagrams.ts +72 -0
- package/src/page/document.ts +10 -0
- package/src/page/escape.ts +3 -0
- package/src/page/nav.ts +28 -0
- package/src/page/shell.ts +12 -0
- package/src/page/style.ts +79 -0
- package/src/page/swap.ts +122 -0
- package/src/page/updated.ts +1 -0
- package/src/render/board.ts +82 -0
- package/src/render/code.ts +54 -0
- package/src/render/failed.ts +3 -0
- package/src/render/footnotes.ts +32 -0
- package/src/render/highlighter.ts +17 -0
- package/src/render/markdown.ts +16 -0
- package/src/serve.ts +16 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Data, Deferred, Effect, FileSystem, Option, Path, type PlatformError, PubSub, Ref, Schedule, Stream } from "effect";
|
|
2
|
+
import { isMarkdown, listMarkdown, type MarkdownFile } from "./list.ts";
|
|
3
|
+
|
|
4
|
+
export type Change = { readonly _tag: "Changed"; readonly paths: ReadonlyArray<string> } | { readonly _tag: "Watching"; readonly watching: boolean };
|
|
5
|
+
|
|
6
|
+
export interface Changes {
|
|
7
|
+
readonly realRoot: string;
|
|
8
|
+
readonly events: PubSub.PubSub<Change>;
|
|
9
|
+
readonly watching: Ref.Ref<boolean>;
|
|
10
|
+
readonly files: Effect.Effect<ReadonlyArray<MarkdownFile>, PlatformError.PlatformError>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Cached {
|
|
14
|
+
readonly generation: number;
|
|
15
|
+
readonly files: ReadonlyArray<MarkdownFile>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class WatchEnded extends Data.TaggedError("WatchEnded") {}
|
|
19
|
+
|
|
20
|
+
const SETTLE = "100 millis";
|
|
21
|
+
const BATCH = 256;
|
|
22
|
+
const BACKLOG = 16;
|
|
23
|
+
const RETRY = Schedule.min([Schedule.exponential("100 millis"), Schedule.spaced("5 seconds")]);
|
|
24
|
+
|
|
25
|
+
export const watchChanges = Effect.fn("WorkBoard.watchChanges")(function* (root: string) {
|
|
26
|
+
const fs = yield* FileSystem.FileSystem;
|
|
27
|
+
const path = yield* Path.Path;
|
|
28
|
+
const realRoot = yield* fs.realPath(root);
|
|
29
|
+
const events = yield* Effect.acquireRelease(PubSub.sliding<Change>(BACKLOG), PubSub.shutdown);
|
|
30
|
+
const watching = yield* Ref.make(false);
|
|
31
|
+
const generation = yield* Ref.make(0);
|
|
32
|
+
const cached = yield* Ref.make(Option.none<Cached>());
|
|
33
|
+
const settled = yield* Deferred.make<void>();
|
|
34
|
+
const stale = Ref.update(generation, (count) => count + 1);
|
|
35
|
+
const relative = (changed: string) => (path.isAbsolute(changed) ? path.relative(root, changed) : changed).split(path.sep).join("/");
|
|
36
|
+
const setWatching = (now: boolean) =>
|
|
37
|
+
Effect.andThen(
|
|
38
|
+
Effect.flatMap(Ref.getAndSet(watching, now), (was) =>
|
|
39
|
+
was === now ? Effect.void : Effect.andThen(stale, PubSub.publish(events, { _tag: "Watching", watching: now })),
|
|
40
|
+
),
|
|
41
|
+
Deferred.succeed(settled, undefined),
|
|
42
|
+
);
|
|
43
|
+
const watchOnce = fs.watch(root, { recursive: true }).pipe(
|
|
44
|
+
Stream.tap(() => stale),
|
|
45
|
+
Stream.map((event) => relative(event.path)),
|
|
46
|
+
Stream.filter(isMarkdown),
|
|
47
|
+
Stream.groupedWithin(BATCH, SETTLE),
|
|
48
|
+
Stream.runForEach((paths) => PubSub.publish(events, { _tag: "Changed", paths: [...new Set(paths)] })),
|
|
49
|
+
Effect.andThen(Effect.fail(new WatchEnded())),
|
|
50
|
+
);
|
|
51
|
+
const attempt = Effect.scoped(Effect.andThen(Effect.forkScoped(Effect.delay(setWatching(true), SETTLE)), watchOnce));
|
|
52
|
+
yield* attempt.pipe(
|
|
53
|
+
Effect.sandbox,
|
|
54
|
+
Effect.tapError((cause) => Effect.andThen(setWatching(false), Effect.logWarning("Watching the folder failed; retrying", cause))),
|
|
55
|
+
Effect.retry(RETRY),
|
|
56
|
+
Effect.forkScoped,
|
|
57
|
+
);
|
|
58
|
+
yield* Deferred.await(settled);
|
|
59
|
+
const files = Effect.gen(function* () {
|
|
60
|
+
const now = yield* Ref.get(generation);
|
|
61
|
+
const hit = Option.filter(yield* Ref.get(cached), (entry) => entry.generation === now);
|
|
62
|
+
if ((yield* Ref.get(watching)) && Option.isSome(hit)) {
|
|
63
|
+
return hit.value.files;
|
|
64
|
+
}
|
|
65
|
+
const listed = yield* listMarkdown(root, realRoot).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path));
|
|
66
|
+
yield* Ref.set(cached, Option.some({ generation: now, files: listed }));
|
|
67
|
+
return listed;
|
|
68
|
+
});
|
|
69
|
+
const changes: Changes = { realRoot, events, watching, files };
|
|
70
|
+
return changes;
|
|
71
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Data, Effect, FileSystem, Option, Path } from "effect";
|
|
2
|
+
import { listMarkdown } from "./list.ts";
|
|
3
|
+
|
|
4
|
+
export class HomeMissing extends Data.TaggedError("HomeMissing")<{ readonly message: string }> {}
|
|
5
|
+
|
|
6
|
+
export const homeIn = Effect.fn("WorkBoard.homeIn")(function* (root: string, home: string | undefined) {
|
|
7
|
+
if (home === undefined) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
const fs = yield* FileSystem.FileSystem;
|
|
11
|
+
const path = yield* Path.Path;
|
|
12
|
+
const missing = new HomeMissing({ message: `The home file ${home} is not a markdown file in ${root}` });
|
|
13
|
+
const realRoot = yield* fs.realPath(root);
|
|
14
|
+
const real = yield* Effect.option(fs.realPath(path.resolve(root, home)));
|
|
15
|
+
if (Option.isNone(real)) {
|
|
16
|
+
return yield* missing;
|
|
17
|
+
}
|
|
18
|
+
const target = yield* Effect.option(fs.stat(real.value));
|
|
19
|
+
if (Option.isNone(target)) {
|
|
20
|
+
return yield* missing;
|
|
21
|
+
}
|
|
22
|
+
const files = yield* listMarkdown(root, realRoot);
|
|
23
|
+
const relative = path.relative(realRoot, real.value).split(path.sep).join("/");
|
|
24
|
+
if (files.some((file) => file.path === relative)) {
|
|
25
|
+
return relative;
|
|
26
|
+
}
|
|
27
|
+
const inode = Option.getOrUndefined(target.value.ino);
|
|
28
|
+
const same = (info: FileSystem.File.Info) => inode !== undefined && info.dev === target.value.dev && Option.getOrUndefined(info.ino) === inode;
|
|
29
|
+
const matches = yield* Effect.filter(files, (file) =>
|
|
30
|
+
Effect.map(Effect.option(fs.stat(path.join(realRoot, file.path))), (info) => Option.isSome(info) && same(info.value)),
|
|
31
|
+
);
|
|
32
|
+
const direct = yield* Effect.filter(matches, (file) =>
|
|
33
|
+
Effect.map(Effect.option(fs.realPath(path.join(realRoot, file.path))), (real) => Option.contains(real, path.join(realRoot, file.path))),
|
|
34
|
+
);
|
|
35
|
+
const chosen = matches.find((file) => file.path.toLowerCase() === relative.toLowerCase()) ?? direct[0] ?? matches[0];
|
|
36
|
+
return chosen === undefined ? yield* missing : chosen.path;
|
|
37
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Effect, FileSystem, Option, Path, type PlatformError } from "effect";
|
|
2
|
+
|
|
3
|
+
export interface MarkdownFile {
|
|
4
|
+
readonly path: string;
|
|
5
|
+
readonly modified: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const skipped = (segment: string): boolean => segment.startsWith(".") || segment === "node_modules";
|
|
9
|
+
|
|
10
|
+
export const isMarkdown = (path: string): boolean => path.endsWith(".md") && !path.split("/").some(skipped);
|
|
11
|
+
|
|
12
|
+
export const within = (realRoot: string, separator: string, real: string): boolean => real === realRoot || real.startsWith(`${realRoot}${separator}`);
|
|
13
|
+
|
|
14
|
+
const folderOf = (path: string): string => path.slice(0, Math.max(0, path.lastIndexOf("/")));
|
|
15
|
+
|
|
16
|
+
const byFolder = (left: string, right: string): number => folderOf(left).localeCompare(folderOf(right)) || left.localeCompare(right);
|
|
17
|
+
|
|
18
|
+
type Listing = Effect.Effect<ReadonlyArray<MarkdownFile>, PlatformError.PlatformError>;
|
|
19
|
+
|
|
20
|
+
export const listMarkdown = Effect.fn("WorkBoard.listMarkdown")(function* (root: string, realRoot: string) {
|
|
21
|
+
const fs = yield* FileSystem.FileSystem;
|
|
22
|
+
const path = yield* Path.Path;
|
|
23
|
+
const visited = new Set<string>();
|
|
24
|
+
const entry = (relative: string): Listing =>
|
|
25
|
+
Effect.gen(function* () {
|
|
26
|
+
const real = yield* Effect.option(fs.realPath(path.join(root, relative)));
|
|
27
|
+
if (Option.isNone(real) || !within(realRoot, path.sep, real.value)) {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
const info = yield* Effect.option(fs.stat(real.value));
|
|
31
|
+
if (Option.isNone(info)) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
if (info.value.type === "Directory") {
|
|
35
|
+
return yield* Effect.orElseSucceed(folder(relative, real.value), () => []);
|
|
36
|
+
}
|
|
37
|
+
const modified = Option.match(info.value.mtime, { onNone: () => 0, onSome: (date) => date.getTime() });
|
|
38
|
+
return info.value.type === "File" && relative.endsWith(".md") ? [{ path: relative, modified }] : [];
|
|
39
|
+
});
|
|
40
|
+
const folder = (relative: string, real: string): Listing =>
|
|
41
|
+
Effect.gen(function* () {
|
|
42
|
+
if (visited.has(real)) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
visited.add(real);
|
|
46
|
+
const names = yield* fs.readDirectory(real);
|
|
47
|
+
const found = yield* Effect.forEach(
|
|
48
|
+
names.filter((name) => !skipped(name)),
|
|
49
|
+
(name) => entry(relative === "" ? name : `${relative}/${name}`),
|
|
50
|
+
);
|
|
51
|
+
return found.flat();
|
|
52
|
+
});
|
|
53
|
+
const files = yield* folder("", realRoot);
|
|
54
|
+
return files.toSorted((left, right) => byFolder(left.path, right.path));
|
|
55
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { Data, Effect, FileSystem, Path } from "effect";
|
|
3
|
+
import type { HttpRouter, HttpServerRequest } from "effect/unstable/http";
|
|
4
|
+
import { client } from "../page/client.ts";
|
|
5
|
+
import { diagrams } from "../page/diagrams.ts";
|
|
6
|
+
import { style } from "../page/style.ts";
|
|
7
|
+
import { swap } from "../page/swap.ts";
|
|
8
|
+
import { respond } from "./respond.ts";
|
|
9
|
+
|
|
10
|
+
export const ASSETS: ReadonlyArray<readonly [HttpRouter.PathInput, string, string]> = [
|
|
11
|
+
["/_board/style.css", style, "text/css"],
|
|
12
|
+
["/_board/client.js", client, "text/javascript"],
|
|
13
|
+
["/_board/diagrams.js", diagrams, "text/javascript"],
|
|
14
|
+
["/_board/swap.js", swap, "text/javascript"],
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const MERMAID = "/_board/mermaid/";
|
|
18
|
+
|
|
19
|
+
export class MermaidMissing extends Data.TaggedError("MermaidMissing")<{ readonly cause: unknown }> {}
|
|
20
|
+
|
|
21
|
+
export const mermaidRoot = Effect.fn("WorkBoard.mermaidRoot")(function* () {
|
|
22
|
+
const path = yield* Path.Path;
|
|
23
|
+
const entry = yield* Effect.try({ try: () => createRequire(import.meta.url).resolve("mermaid"), catch: (cause) => new MermaidMissing({ cause }) });
|
|
24
|
+
return path.dirname(entry);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const mermaidFile = (mermaidRoot: string) =>
|
|
28
|
+
Effect.fn("WorkBoard.mermaidFile")(function* (request: HttpServerRequest.HttpServerRequest) {
|
|
29
|
+
const fs = yield* FileSystem.FileSystem;
|
|
30
|
+
const path = yield* Path.Path;
|
|
31
|
+
const file = path.resolve(mermaidRoot, new URL(request.url, "http://127.0.0.1").pathname.slice(MERMAID.length));
|
|
32
|
+
if (!file.startsWith(`${mermaidRoot}${path.sep}`) || !file.endsWith(".mjs") || !(yield* fs.exists(file))) {
|
|
33
|
+
return respond("Not found", "text/plain", 404);
|
|
34
|
+
}
|
|
35
|
+
return respond(yield* fs.readFile(file), "text/javascript");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const MERMAID_ROUTE: HttpRouter.PathInput = `${MERMAID}*`;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Effect, PubSub, Ref, Stream } from "effect";
|
|
2
|
+
import { Sse } from "effect/unstable/encoding";
|
|
3
|
+
import { HttpServerResponse } from "effect/unstable/http";
|
|
4
|
+
import type { Change, Changes } from "../files/changes.ts";
|
|
5
|
+
import { HEADERS } from "./respond.ts";
|
|
6
|
+
|
|
7
|
+
const event = (name: string, data = ""): Sse.Event => ({ _tag: "Event", event: name, id: undefined, data });
|
|
8
|
+
|
|
9
|
+
const watching = (up: boolean): Sse.Event => event(up ? "ready" : "down");
|
|
10
|
+
|
|
11
|
+
const eventOf = (change: Change): Sse.Event =>
|
|
12
|
+
change._tag === "Changed" ? event("change", JSON.stringify({ paths: change.paths })) : watching(change.watching);
|
|
13
|
+
|
|
14
|
+
const subscribed = (changes: Changes) =>
|
|
15
|
+
Stream.unwrap(
|
|
16
|
+
Effect.gen(function* () {
|
|
17
|
+
const subscription = yield* PubSub.subscribe(changes.events);
|
|
18
|
+
const now = yield* Ref.get(changes.watching);
|
|
19
|
+
return Stream.concat(Stream.succeed(watching(now)), Stream.map(Stream.fromSubscription(subscription), eventOf));
|
|
20
|
+
}),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
export const events = (changes: Changes) => () =>
|
|
24
|
+
Effect.succeed(
|
|
25
|
+
HttpServerResponse.stream(Stream.encodeText(Stream.map(subscribed(changes), (message) => Sse.encoder.write(message))), {
|
|
26
|
+
contentType: "text/event-stream",
|
|
27
|
+
headers: HEADERS,
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Effect, Option } from "effect";
|
|
2
|
+
import type { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
|
|
3
|
+
import { respond } from "./respond.ts";
|
|
4
|
+
|
|
5
|
+
const LOOPBACK_NAMES = new Set(["127.0.0.1", "localhost", "[::1]"]);
|
|
6
|
+
|
|
7
|
+
const hostnameOf = (host: string): string | undefined => {
|
|
8
|
+
try {
|
|
9
|
+
return new URL(`http://${host}/`).hostname;
|
|
10
|
+
} catch {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const loopbackAddress = (address: string): boolean => {
|
|
16
|
+
const unmapped = address.replace(/^::ffff:/i, "");
|
|
17
|
+
return unmapped === "::1" || /^127(?:\.\d{1,3}){3}$/.test(unmapped);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const fromLoopback = (request: HttpServerRequest.HttpServerRequest): boolean => {
|
|
21
|
+
const host = request.headers.host;
|
|
22
|
+
const addressed = host !== undefined && LOOPBACK_NAMES.has(hostnameOf(host) ?? "");
|
|
23
|
+
return addressed && Option.match(request.remoteAddress, { onNone: () => true, onSome: loopbackAddress });
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const loopbackOnly =
|
|
27
|
+
<E, R>(handler: (request: HttpServerRequest.HttpServerRequest) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) =>
|
|
28
|
+
(request: HttpServerRequest.HttpServerRequest) =>
|
|
29
|
+
fromLoopback(request) ? handler(request) : Effect.succeed(respond("Only loopback hosts are served", "text/plain", 403));
|
package/src/http/page.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Effect, FileSystem, Option, Path } from "effect";
|
|
2
|
+
import type { HttpServerRequest } from "effect/unstable/http";
|
|
3
|
+
import type { Changes } from "../files/changes.ts";
|
|
4
|
+
import { type MarkdownFile, within } from "../files/list.ts";
|
|
5
|
+
import { boardHtml } from "../page/board.ts";
|
|
6
|
+
import { documentHtml } from "../page/document.ts";
|
|
7
|
+
import { escapeHtml } from "../page/escape.ts";
|
|
8
|
+
import { navHtml } from "../page/nav.ts";
|
|
9
|
+
import { shell } from "../page/shell.ts";
|
|
10
|
+
import { boardOf } from "../render/board.ts";
|
|
11
|
+
import { respond } from "./respond.ts";
|
|
12
|
+
|
|
13
|
+
export interface PageOptions {
|
|
14
|
+
readonly root: string;
|
|
15
|
+
readonly home: string | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const requestedPath = (url: string): string => {
|
|
19
|
+
const pathname = new URL(url, "http://127.0.0.1").pathname.replace(/^\/+/, "");
|
|
20
|
+
try {
|
|
21
|
+
return decodeURIComponent(pathname);
|
|
22
|
+
} catch {
|
|
23
|
+
return pathname;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const message = (text: string): string => `<p class="empty">${escapeHtml(text)}</p>`;
|
|
28
|
+
|
|
29
|
+
export const page = (options: PageOptions, changes: Changes) => {
|
|
30
|
+
const body = Effect.fn("WorkBoard.pageBody")(function* (file: MarkdownFile) {
|
|
31
|
+
const fs = yield* FileSystem.FileSystem;
|
|
32
|
+
const path = yield* Path.Path;
|
|
33
|
+
const real = yield* Effect.option(fs.realPath(path.join(options.root, file.path)));
|
|
34
|
+
if (Option.isNone(real) || !within(changes.realRoot, path.sep, real.value)) {
|
|
35
|
+
return Option.none();
|
|
36
|
+
}
|
|
37
|
+
const source = yield* fs.readFileString(real.value);
|
|
38
|
+
return Option.some(file.path === options.home ? yield* boardHtml(boardOf(source), file.path, file.modified) : yield* documentHtml(source, file));
|
|
39
|
+
});
|
|
40
|
+
return Effect.fn("WorkBoard.page")(function* (request: HttpServerRequest.HttpServerRequest) {
|
|
41
|
+
const files = yield* changes.files;
|
|
42
|
+
const requested = requestedPath(request.url) || options.home || files[0]?.path || "";
|
|
43
|
+
const file = files.find((candidate) => candidate.path === requested);
|
|
44
|
+
const nav = navHtml(files, requested, options.home);
|
|
45
|
+
const missing = () => respond(shell(requested, nav, message(`No markdown file at ${requested || options.root}.`)), "text/html", 404);
|
|
46
|
+
if (file === undefined) {
|
|
47
|
+
return missing();
|
|
48
|
+
}
|
|
49
|
+
return yield* body(file).pipe(
|
|
50
|
+
Effect.map(Option.match({ onNone: missing, onSome: (main) => respond(shell(file.path, nav, main), "text/html") })),
|
|
51
|
+
Effect.catch(() => Effect.succeed(respond(shell(file.path, nav, message(`${file.path} could not be read.`)), "text/html", 500))),
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { HttpServerResponse } from "effect/unstable/http";
|
|
2
|
+
|
|
3
|
+
const POLICY = [
|
|
4
|
+
"default-src 'self'",
|
|
5
|
+
"script-src 'self'",
|
|
6
|
+
"style-src 'self' 'unsafe-inline'",
|
|
7
|
+
"img-src 'self' data: https:",
|
|
8
|
+
"object-src 'none'",
|
|
9
|
+
"base-uri 'none'",
|
|
10
|
+
"frame-ancestors 'none'",
|
|
11
|
+
].join("; ");
|
|
12
|
+
|
|
13
|
+
export const HEADERS = { "cache-control": "no-store", "content-security-policy": POLICY, "x-content-type-options": "nosniff" };
|
|
14
|
+
|
|
15
|
+
export const respond = (body: string | Uint8Array, contentType: string, status = 200): HttpServerResponse.HttpServerResponse =>
|
|
16
|
+
typeof body === "string"
|
|
17
|
+
? HttpServerResponse.text(body, { contentType: `${contentType}; charset=utf-8`, headers: HEADERS, status })
|
|
18
|
+
: HttpServerResponse.uint8Array(body, { contentType, headers: HEADERS, status });
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { type BoardOptions, boardLayer } from "./board.ts";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { type Board, countsOf, type Section } from "../render/board.ts";
|
|
3
|
+
import { renderMarkdown } from "../render/markdown.ts";
|
|
4
|
+
import { escapeHtml } from "./escape.ts";
|
|
5
|
+
import { updated } from "./updated.ts";
|
|
6
|
+
|
|
7
|
+
const counts = (board: Board): string =>
|
|
8
|
+
countsOf(board)
|
|
9
|
+
.map(({ count, title }) => `<span><b>${count}</b> ${escapeHtml(title.toLowerCase())}</span>`)
|
|
10
|
+
.join(" · ");
|
|
11
|
+
|
|
12
|
+
type Render = (fragment: string) => ReturnType<typeof renderMarkdown>;
|
|
13
|
+
|
|
14
|
+
const section = Effect.fn("WorkBoard.section")(function* ({ heading, items, notes }: Section, render: Render) {
|
|
15
|
+
const rendered = yield* Effect.forEach(items, (item) => Effect.map(render(item), (html) => `<article class="item">${html}</article>`));
|
|
16
|
+
const note = notes === "" ? "" : `<div class="notes">${yield* render(notes)}</div>`;
|
|
17
|
+
return `<section>${yield* render(heading)}${note}${rendered.join("")}</section>`;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const boardHtml = Effect.fn("WorkBoard.boardHtml")(function* (board: Board, fallbackTitle: string, modified: number) {
|
|
21
|
+
let fragments = 0;
|
|
22
|
+
const render: Render = (fragment) => {
|
|
23
|
+
fragments += 1;
|
|
24
|
+
return renderMarkdown(board.definitions === "" ? fragment : `${fragment}\n\n${board.definitions}`, `part-${fragments}-`);
|
|
25
|
+
};
|
|
26
|
+
const title = board.title === "" ? `<h1>${escapeHtml(fallbackTitle)}</h1>` : yield* render(board.title);
|
|
27
|
+
const intro = board.intro === "" ? "" : `<div class="intro">${yield* render(board.intro)}</div>`;
|
|
28
|
+
const sections = yield* Effect.forEach(board.sections, (each) => section(each, render));
|
|
29
|
+
const footer = board.footer === "" ? "" : `<footer>${yield* render(board.footer)}</footer>`;
|
|
30
|
+
return [
|
|
31
|
+
`<header class="board-head">${title}${intro}`,
|
|
32
|
+
`<p class="counts">${counts(board)}</p>`,
|
|
33
|
+
`<p class="meta">${updated(modified)}</p></header>`,
|
|
34
|
+
`<div class="board">${sections.join("")}</div>`,
|
|
35
|
+
footer,
|
|
36
|
+
].join("");
|
|
37
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export const client = `
|
|
2
|
+
import { renderDiagrams } from "/_board/diagrams.js";
|
|
3
|
+
import { remember, swap } from "/_board/swap.js";
|
|
4
|
+
|
|
5
|
+
const since = (modified) => {
|
|
6
|
+
const seconds = Math.max(0, Math.round((Date.now() - modified) / 1000));
|
|
7
|
+
if (seconds < 60) return seconds + "s";
|
|
8
|
+
if (seconds < 3600) return Math.round(seconds / 60) + "m";
|
|
9
|
+
if (seconds < 172800) return Math.round(seconds / 3600) + "h";
|
|
10
|
+
return Math.round(seconds / 86400) + "d";
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const tick = () => {
|
|
14
|
+
for (const age of document.querySelectorAll("[data-modified]")) {
|
|
15
|
+
const ago = since(Number(age.dataset.modified));
|
|
16
|
+
age.textContent = age.classList.contains("short") ? ago : "updated " + ago + " ago";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
remember(document.getElementById("files"));
|
|
21
|
+
remember(document.getElementById("doc"));
|
|
22
|
+
|
|
23
|
+
const live = document.getElementById("live");
|
|
24
|
+
let connected = false;
|
|
25
|
+
let dropped = true;
|
|
26
|
+
let failure = "";
|
|
27
|
+
|
|
28
|
+
const show = () => {
|
|
29
|
+
const state = failure !== "" ? "stale" : connected ? "live" : "down";
|
|
30
|
+
live.dataset.state = state;
|
|
31
|
+
live.textContent = failure !== "" ? failure : connected ? "live" : "reconnecting";
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const load = async () => {
|
|
35
|
+
try {
|
|
36
|
+
const response = await fetch(location.pathname, { cache: "no-store" });
|
|
37
|
+
const next = new DOMParser().parseFromString(await response.text(), "text/html");
|
|
38
|
+
const files = next.getElementById("files");
|
|
39
|
+
const doc = next.getElementById("doc");
|
|
40
|
+
if (files === null || doc === null) {
|
|
41
|
+
failure = "refresh failed (" + response.status + ")";
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
failure = "";
|
|
45
|
+
swap(document.getElementById("files"), files);
|
|
46
|
+
swap(document.getElementById("doc"), doc);
|
|
47
|
+
document.title = next.title;
|
|
48
|
+
tick();
|
|
49
|
+
renderDiagrams(document);
|
|
50
|
+
} catch {
|
|
51
|
+
failure = "refresh failed";
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
let loading = false;
|
|
56
|
+
let dirty = false;
|
|
57
|
+
const refresh = async () => {
|
|
58
|
+
if (loading) {
|
|
59
|
+
dirty = true;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
loading = true;
|
|
63
|
+
do {
|
|
64
|
+
dirty = false;
|
|
65
|
+
await load();
|
|
66
|
+
} while (dirty);
|
|
67
|
+
loading = false;
|
|
68
|
+
show();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const events = new EventSource("/events");
|
|
72
|
+
events.addEventListener("ready", () => {
|
|
73
|
+
connected = true;
|
|
74
|
+
show();
|
|
75
|
+
if (dropped) refresh();
|
|
76
|
+
dropped = false;
|
|
77
|
+
});
|
|
78
|
+
const down = () => {
|
|
79
|
+
connected = false;
|
|
80
|
+
dropped = true;
|
|
81
|
+
show();
|
|
82
|
+
};
|
|
83
|
+
events.addEventListener("change", refresh);
|
|
84
|
+
events.addEventListener("down", down);
|
|
85
|
+
events.addEventListener("error", down);
|
|
86
|
+
|
|
87
|
+
tick();
|
|
88
|
+
setInterval(tick, 5000);
|
|
89
|
+
renderDiagrams(document);
|
|
90
|
+
`;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export const diagrams = `
|
|
2
|
+
const rendered = new Map();
|
|
3
|
+
const dark = matchMedia("(prefers-color-scheme: dark)");
|
|
4
|
+
let mermaid;
|
|
5
|
+
let counter = 0;
|
|
6
|
+
|
|
7
|
+
const token = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
8
|
+
|
|
9
|
+
const load = async () => {
|
|
10
|
+
mermaid ??= (await import("/_board/mermaid/mermaid.esm.min.mjs")).default;
|
|
11
|
+
mermaid.initialize({
|
|
12
|
+
startOnLoad: false,
|
|
13
|
+
securityLevel: "strict",
|
|
14
|
+
theme: "base",
|
|
15
|
+
themeVariables: {
|
|
16
|
+
darkMode: dark.matches,
|
|
17
|
+
background: token("--bg"),
|
|
18
|
+
primaryColor: token("--surface"),
|
|
19
|
+
primaryTextColor: token("--text"),
|
|
20
|
+
primaryBorderColor: token("--border"),
|
|
21
|
+
lineColor: token("--muted"),
|
|
22
|
+
textColor: token("--text"),
|
|
23
|
+
fontFamily: token("--sans"),
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
return mermaid;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const schemeNow = () => (dark.matches ? "dark" : "light");
|
|
30
|
+
|
|
31
|
+
const draw = async (figure) => {
|
|
32
|
+
const source = figure.querySelector(".diagram-source").textContent;
|
|
33
|
+
const scheme = schemeNow();
|
|
34
|
+
const key = scheme + ":" + source;
|
|
35
|
+
if (!rendered.has(key)) {
|
|
36
|
+
rendered.set(key, load().then((engine) => engine.render("diagram-" + ++counter, source)).then((result) => result.svg));
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const svg = await rendered.get(key);
|
|
40
|
+
if (scheme !== schemeNow()) {
|
|
41
|
+
rendered.delete(key);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
figure.querySelector(".diagram-svg")?.remove();
|
|
45
|
+
figure.insertAdjacentHTML("afterbegin", '<div class="diagram-svg">' + svg + "</div>");
|
|
46
|
+
figure.dataset.state = "drawn";
|
|
47
|
+
} catch (error) {
|
|
48
|
+
rendered.delete(key);
|
|
49
|
+
if (scheme !== schemeNow()) return;
|
|
50
|
+
figure.querySelector(".diagram-svg")?.remove();
|
|
51
|
+
figure.dataset.state = "failed";
|
|
52
|
+
figure.dataset.error = String(error?.message ?? error);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const forget = (root) => {
|
|
57
|
+
const shown = new Set([...root.querySelectorAll("figure.diagram .diagram-source")].map((source) => source.textContent));
|
|
58
|
+
for (const key of rendered.keys()) {
|
|
59
|
+
if (!shown.has(key.slice(key.indexOf(":") + 1))) rendered.delete(key);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const renderDiagrams = (root, selector = "figure.diagram:not([data-state=drawn])") => {
|
|
64
|
+
forget(root);
|
|
65
|
+
return Promise.all([...root.querySelectorAll(selector)].map(draw));
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
dark.addEventListener("change", () => {
|
|
69
|
+
mermaid = undefined;
|
|
70
|
+
renderDiagrams(document, "figure.diagram");
|
|
71
|
+
});
|
|
72
|
+
`;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { MarkdownFile } from "../files/list.ts";
|
|
3
|
+
import { renderMarkdown } from "../render/markdown.ts";
|
|
4
|
+
import { escapeHtml } from "./escape.ts";
|
|
5
|
+
import { updated } from "./updated.ts";
|
|
6
|
+
|
|
7
|
+
export const documentHtml = Effect.fn("WorkBoard.documentHtml")(function* (source: string, file: MarkdownFile) {
|
|
8
|
+
const html = yield* renderMarkdown(source);
|
|
9
|
+
return `<p class="meta">${escapeHtml(file.path)} · ${updated(file.modified)}</p><article class="doc">${html}</article>`;
|
|
10
|
+
});
|
package/src/page/nav.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { MarkdownFile } from "../files/list.ts";
|
|
2
|
+
import { escapeHtml } from "./escape.ts";
|
|
3
|
+
|
|
4
|
+
const nameOf = (path: string): string => path.slice(path.lastIndexOf("/") + 1).replace(/\.md$/, "");
|
|
5
|
+
|
|
6
|
+
const folderOf = (path: string): string => path.slice(0, Math.max(0, path.lastIndexOf("/")));
|
|
7
|
+
|
|
8
|
+
const hrefOf = (path: string): string => `/${path.split("/").map(encodeURIComponent).join("/")}`;
|
|
9
|
+
|
|
10
|
+
const link = (file: MarkdownFile, current: string): string => {
|
|
11
|
+
const here = file.path === current ? ' aria-current="page"' : "";
|
|
12
|
+
const age = `<span class="age short" data-modified="${file.modified}"></span>`;
|
|
13
|
+
return `<a href="${hrefOf(file.path)}"${here}>${escapeHtml(nameOf(file.path))}</a>${age}`;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const folder = (name: string, files: ReadonlyArray<MarkdownFile>, current: string): string => {
|
|
17
|
+
const links = files.map((file) => `<li>${link(file, current)}</li>`).join("");
|
|
18
|
+
const open = files.some((file) => file.path === current) ? " open" : "";
|
|
19
|
+
return `<details data-key="folder:${escapeHtml(name)}"${open}><summary>${escapeHtml(name)}/ (${files.length})</summary><ul>${links}</ul></details>`;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const navHtml = (files: ReadonlyArray<MarkdownFile>, current: string, home: string | undefined): string => {
|
|
23
|
+
const ordered = [...files.filter((file) => file.path === home), ...files.filter((file) => file.path !== home)];
|
|
24
|
+
const folders = Map.groupBy(ordered, (file) => (file.path === home ? "" : folderOf(file.path)));
|
|
25
|
+
return [...folders]
|
|
26
|
+
.map(([name, grouped]) => (name === "" ? grouped.map((file) => `<span>${link(file, current)}</span>`).join("") : folder(name, grouped, current)))
|
|
27
|
+
.join("");
|
|
28
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { escapeHtml } from "./escape.ts";
|
|
2
|
+
|
|
3
|
+
export const shell = (title: string, nav: string, main: string): string =>
|
|
4
|
+
[
|
|
5
|
+
'<!doctype html><html lang="en"><head><meta charset="utf-8">',
|
|
6
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1"><meta name="color-scheme" content="light dark">',
|
|
7
|
+
`<title>${escapeHtml(title)}</title>`,
|
|
8
|
+
'<link rel="stylesheet" href="/_board/style.css"><script type="module" src="/_board/client.js"></script>',
|
|
9
|
+
'</head><body><div class="page"><header class="bar">',
|
|
10
|
+
`<nav id="files" class="files">${nav}</nav><span id="live" class="live">connecting</span></header>`,
|
|
11
|
+
`<main id="doc">${main}</main></div></body></html>`,
|
|
12
|
+
].join("");
|