@shivaedev/work-board 0.4.0 → 0.5.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 +45 -0
- package/README.md +27 -16
- package/assets/native.js +36 -0
- package/dist/board.d.ts +1 -1
- package/dist/board.d.ts.map +1 -1
- package/dist/board.js +38 -8
- package/dist/board.js.map +1 -1
- package/dist/browser/client.d.ts +88 -0
- package/dist/browser/client.d.ts.map +1 -0
- package/dist/browser/client.js +53 -0
- package/dist/browser/client.js.map +1 -0
- package/dist/browser/session.d.ts +89 -0
- package/dist/browser/session.d.ts.map +1 -0
- package/dist/browser/session.js +20 -0
- package/dist/browser/session.js.map +1 -0
- package/dist/cli.js +1 -1
- package/dist/files/changes.d.ts +4 -1
- package/dist/files/changes.d.ts.map +1 -1
- package/dist/files/changes.js +7 -3
- package/dist/files/changes.js.map +1 -1
- package/dist/files/list.d.ts +1 -1
- package/dist/files/list.d.ts.map +1 -1
- package/dist/files/list.js +5 -3
- package/dist/files/list.js.map +1 -1
- package/dist/history/schema.js +1 -1
- package/dist/history/schema.js.map +1 -1
- package/dist/history/script.d.ts.map +1 -1
- package/dist/history/script.js +28 -13
- package/dist/history/script.js.map +1 -1
- package/dist/http/assets.d.ts +2 -0
- package/dist/http/assets.d.ts.map +1 -1
- package/dist/http/assets.js +10 -0
- package/dist/http/assets.js.map +1 -1
- package/dist/http/history.d.ts.map +1 -1
- package/dist/http/history.js +1 -13
- package/dist/http/history.js.map +1 -1
- package/dist/http/sameOrigin.d.ts +3 -0
- package/dist/http/sameOrigin.d.ts.map +1 -0
- package/dist/http/sameOrigin.js +14 -0
- package/dist/http/sameOrigin.js.map +1 -0
- package/dist/page/ageScript.d.ts +2 -0
- package/dist/page/ageScript.d.ts.map +1 -0
- package/dist/page/ageScript.js +23 -0
- package/dist/page/ageScript.js.map +1 -0
- package/dist/page/client.d.ts +1 -1
- package/dist/page/client.d.ts.map +1 -1
- package/dist/page/client.js +73 -74
- package/dist/page/client.js.map +1 -1
- package/dist/page/navigationScript.d.ts.map +1 -1
- package/dist/page/navigationScript.js +7 -5
- package/dist/page/navigationScript.js.map +1 -1
- package/dist/page/searchScript.d.ts.map +1 -1
- package/dist/page/searchScript.js +6 -4
- package/dist/page/searchScript.js.map +1 -1
- package/dist/page/shell.js +1 -1
- package/dist/page/shell.js.map +1 -1
- package/dist/page/stateScript.js +1 -1
- package/dist/page/style.d.ts.map +1 -1
- package/dist/page/style.js +1 -0
- package/dist/page/style.js.map +1 -1
- package/dist/page/swap.d.ts +1 -1
- package/dist/page/swap.d.ts.map +1 -1
- package/dist/page/swap.js +16 -5
- package/dist/page/swap.js.map +1 -1
- package/dist/path/isMarkdown.d.ts +2 -0
- package/dist/path/isMarkdown.d.ts.map +1 -0
- package/dist/path/isMarkdown.js +7 -0
- package/dist/path/isMarkdown.js.map +1 -0
- package/dist/rpc/affected.d.ts +4 -0
- package/dist/rpc/affected.d.ts.map +1 -0
- package/dist/rpc/affected.js +75 -0
- package/dist/rpc/affected.js.map +1 -0
- package/dist/rpc/contract.d.ts +134 -0
- package/dist/rpc/contract.d.ts.map +1 -0
- package/dist/rpc/contract.js +59 -0
- package/dist/rpc/contract.js.map +1 -0
- package/dist/rpc/hints.d.ts +17 -0
- package/dist/rpc/hints.d.ts.map +1 -0
- package/dist/rpc/hints.js +28 -0
- package/dist/rpc/hints.js.map +1 -0
- package/dist/rpc/keys.d.ts +7 -0
- package/dist/rpc/keys.d.ts.map +1 -0
- package/dist/rpc/keys.js +9 -0
- package/dist/rpc/keys.js.map +1 -0
- package/dist/rpc/pageKeys.d.ts +3 -0
- package/dist/rpc/pageKeys.d.ts.map +1 -0
- package/dist/rpc/pageKeys.js +14 -0
- package/dist/rpc/pageKeys.js.map +1 -0
- package/dist/rpc/server.d.ts +12 -0
- package/dist/rpc/server.d.ts.map +1 -0
- package/dist/rpc/server.js +32 -0
- package/dist/rpc/server.js.map +1 -0
- package/package.json +9 -4
- package/src/board.ts +44 -9
- package/src/browser/client.ts +59 -0
- package/src/browser/session.ts +26 -0
- package/src/cli.ts +1 -1
- package/src/files/changes.ts +14 -4
- package/src/files/list.ts +5 -4
- package/src/history/schema.ts +1 -1
- package/src/history/script.ts +28 -13
- package/src/http/assets.ts +11 -0
- package/src/http/history.ts +1 -12
- package/src/http/sameOrigin.ts +14 -0
- package/src/page/ageScript.ts +22 -0
- package/src/page/client.ts +73 -74
- package/src/page/navigationScript.ts +7 -5
- package/src/page/searchScript.ts +6 -4
- package/src/page/shell.ts +1 -1
- package/src/page/stateScript.ts +1 -1
- package/src/page/style.ts +1 -0
- package/src/page/swap.ts +16 -5
- package/src/path/isMarkdown.ts +6 -0
- package/src/rpc/affected.ts +77 -0
- package/src/rpc/contract.ts +62 -0
- package/src/rpc/hints.ts +34 -0
- package/src/rpc/keys.ts +9 -0
- package/src/rpc/pageKeys.ts +14 -0
- package/src/rpc/server.ts +55 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Clock, Effect, Layer, Ref } from "effect";
|
|
2
|
+
import { HttpIncomingMessage, HttpServerResponse } from "effect/unstable/http";
|
|
3
|
+
import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
|
|
4
|
+
import { MAX_BYTES } from "#history/limits.ts";
|
|
5
|
+
import { historyResponse } from "#history/response.ts";
|
|
6
|
+
import { HEADERS } from "#http/respond.ts";
|
|
7
|
+
import { navHtml } from "#page/nav.ts";
|
|
8
|
+
import { searchMatch } from "#search/match.ts";
|
|
9
|
+
import { ReadFailed, workRpcs } from "./contract.js";
|
|
10
|
+
import { hints } from "./hints.js";
|
|
11
|
+
export const server = Effect.fn("WorkBoard.server")(function* ({ changes, home, index, page }) {
|
|
12
|
+
const handlers = workRpcs.toLayer({
|
|
13
|
+
"work-board.history": ({ action, baseline }) => Effect.gen(function* () {
|
|
14
|
+
const now = yield* Clock.currentTimeMillis;
|
|
15
|
+
const snapshot = action === "check" ? undefined : yield* index;
|
|
16
|
+
return historyResponse(baseline, now, snapshot);
|
|
17
|
+
}).pipe(Effect.catch(() => Effect.fail(new ReadFailed({ operation: "history", status: 500 })))),
|
|
18
|
+
"work-board.navigation": () => Effect.map(changes.files, (files) => navHtml(files, "", home)).pipe(Effect.catch(() => Effect.fail(new ReadFailed({ operation: "navigation", status: 500 })))),
|
|
19
|
+
"work-board.page": ({ url }) => page(url).pipe(Effect.flatMap((response) => {
|
|
20
|
+
if (response.status >= 500 || response.body._tag !== "Uint8Array") {
|
|
21
|
+
return Effect.fail(new ReadFailed({ operation: "page", status: response.status }));
|
|
22
|
+
}
|
|
23
|
+
return Effect.succeed({ html: new TextDecoder().decode(response.body.body), status: response.status });
|
|
24
|
+
}), Effect.catch((error) => Effect.fail(error instanceof ReadFailed ? error : new ReadFailed({ operation: "page", status: 500 })))),
|
|
25
|
+
"work-board.search": ({ query }) => Effect.map(index, (snapshot) => ({ ...searchMatch(snapshot.entries, query), unavailable: snapshot.unavailable })).pipe(Effect.catch(() => Effect.fail(new ReadFailed({ operation: "search", status: 500 })))),
|
|
26
|
+
"work-board.subscribe": () => hints(changes, index),
|
|
27
|
+
"work-board.watcher": () => Effect.map(Ref.get(changes.watching), (watching) => ({ watching })),
|
|
28
|
+
});
|
|
29
|
+
const handler = yield* RpcServer.toHttpEffect(workRpcs).pipe(Effect.provide(Layer.merge(handlers, RpcSerialization.layerNdjson)));
|
|
30
|
+
return handler.pipe(Effect.provideService(HttpIncomingMessage.MaxBodySize, BigInt(4 * MAX_BYTES + 4096)), Effect.map((response) => HttpServerResponse.setHeaders(response, HEADERS)));
|
|
31
|
+
});
|
|
32
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../../src/rpc/server.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shivaedev/work-board",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Serve a folder of markdown files as a live local page that updates in place when a file changes",
|
|
5
5
|
"homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/work-board#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"files": [
|
|
38
38
|
"dist",
|
|
39
39
|
"src",
|
|
40
|
+
"assets",
|
|
40
41
|
"!src/**/*.test.ts",
|
|
41
42
|
"!src/**/*.spec.ts",
|
|
42
43
|
"!src/**/test-support",
|
|
@@ -44,6 +45,7 @@
|
|
|
44
45
|
"README.md"
|
|
45
46
|
],
|
|
46
47
|
"dependencies": {
|
|
48
|
+
"@shivaedev/effect-contract": "0.4.0",
|
|
47
49
|
"mermaid": "11.16.1",
|
|
48
50
|
"satteri": "0.9.5",
|
|
49
51
|
"shiki": "4.4.2",
|
|
@@ -52,12 +54,13 @@
|
|
|
52
54
|
"devDependencies": {
|
|
53
55
|
"@effect/platform-node": "4.0.0-rc.112",
|
|
54
56
|
"@effect/platform-node-shared": "4.0.0-rc.112",
|
|
55
|
-
"@shivaedev/quality": "0.
|
|
57
|
+
"@shivaedev/quality": "0.16.0",
|
|
56
58
|
"@types/hast": "3.0.5",
|
|
57
59
|
"@types/mdast": "4.0.4",
|
|
58
60
|
"@types/node": "24.10.1",
|
|
59
61
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
60
62
|
"effect": "4.0.0-rc.112",
|
|
63
|
+
"esbuild": "0.28.1",
|
|
61
64
|
"happy-dom": "20.11.2",
|
|
62
65
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
63
66
|
"vitest": "4.1.11"
|
|
@@ -75,9 +78,11 @@
|
|
|
75
78
|
"provenance": true
|
|
76
79
|
},
|
|
77
80
|
"scripts": {
|
|
78
|
-
"build": "node --eval \"import('node:fs').then(({ rmSync }) => rmSync('dist', { force: true, recursive: true }))\" && tsc6 --project tsconfig.build.json",
|
|
81
|
+
"build": "node --eval \"import('node:fs').then(({ rmSync }) => rmSync('dist', { force: true, recursive: true }))\" && tsc6 --project tsconfig.build.json && pnpm build:browser",
|
|
82
|
+
"build:browser": "esbuild src/browser/session.ts --bundle --platform=browser --format=esm --conditions=source --minify --outfile=assets/native.js",
|
|
79
83
|
"ready": "pnpm typecheck && pnpm test && pnpm build",
|
|
80
|
-
"test": "vitest run",
|
|
84
|
+
"test": "pnpm test:prepare && vitest run",
|
|
85
|
+
"test:prepare": "pnpm build:browser",
|
|
81
86
|
"typecheck": "tsc --noEmit"
|
|
82
87
|
}
|
|
83
88
|
}
|
package/src/board.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { Effect,
|
|
2
|
-
import { HttpRouter,
|
|
1
|
+
import { Context, Effect, FileSystem, Layer, Path, type PlatformError, type Scope } from "effect";
|
|
2
|
+
import { HttpRouter, HttpServerRequest, type HttpServerResponse } from "effect/unstable/http";
|
|
3
3
|
import { watchChanges } from "#files/changes.ts";
|
|
4
4
|
import { type HomeMissing, homeIn } from "#files/home.ts";
|
|
5
|
-
import { ASSETS, MERMAID_ROUTE, type MermaidMissing, mermaidFile, mermaidRoot } from "#http/assets.ts";
|
|
5
|
+
import { ASSETS, MERMAID_ROUTE, type MermaidMissing, mermaidFile, mermaidRoot, NATIVE_ROUTE, nativeAsset } from "#http/assets.ts";
|
|
6
6
|
import { events } from "#http/events.ts";
|
|
7
7
|
import { changesPage, history } from "#http/history.ts";
|
|
8
8
|
import { identity } from "#http/identity.ts";
|
|
@@ -10,10 +10,12 @@ import { loopbackOnly } from "#http/loopback.ts";
|
|
|
10
10
|
import { overview } from "#http/overview.ts";
|
|
11
11
|
import { page } from "#http/page.ts";
|
|
12
12
|
import { respond } from "#http/respond.ts";
|
|
13
|
+
import { sameOrigin } from "#http/sameOrigin.ts";
|
|
13
14
|
import { search } from "#http/search.ts";
|
|
14
15
|
import { work } from "#http/work.ts";
|
|
15
16
|
import type { RenderFailed } from "#render/failed.ts";
|
|
16
17
|
import { Highlighter } from "#render/highlighter.ts";
|
|
18
|
+
import { server } from "#rpc/server.ts";
|
|
17
19
|
import { searchSnapshot } from "#search/snapshot.ts";
|
|
18
20
|
|
|
19
21
|
export interface BoardOptions {
|
|
@@ -22,7 +24,7 @@ export interface BoardOptions {
|
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
type Services = Highlighter | FileSystem.FileSystem | Path.Path;
|
|
25
|
-
type Handler<E> = (request: HttpServerRequest.HttpServerRequest) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, Services>;
|
|
27
|
+
type Handler<E> = (request: HttpServerRequest.HttpServerRequest) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, Services | Scope.Scope>;
|
|
26
28
|
|
|
27
29
|
const routes = (options: BoardOptions) =>
|
|
28
30
|
HttpRouter.use((router) =>
|
|
@@ -30,7 +32,7 @@ const routes = (options: BoardOptions) =>
|
|
|
30
32
|
const home = yield* homeIn(options.root, options.home);
|
|
31
33
|
const mermaid = yield* mermaidRoot();
|
|
32
34
|
const changes = yield* watchChanges(options.root);
|
|
33
|
-
const context = yield* Effect.context<Services>();
|
|
35
|
+
const context = (yield* Effect.context<Services>()).pipe(Context.pick(Highlighter, FileSystem.FileSystem, Path.Path));
|
|
34
36
|
const serve = <E>(route: HttpRouter.PathInput, handler: Handler<E>, method: "GET" | "POST" = "GET") =>
|
|
35
37
|
router.add(
|
|
36
38
|
method,
|
|
@@ -40,16 +42,49 @@ const routes = (options: BoardOptions) =>
|
|
|
40
42
|
yield* serve("/events", events(changes));
|
|
41
43
|
const index = yield* searchSnapshot(options.root, home, changes);
|
|
42
44
|
yield* serve("/_board/search", search(index));
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
const workPage = work(index, changes, home);
|
|
46
|
+
const overviewPage = overview(index, changes, home);
|
|
47
|
+
const historyPage = changesPage(changes, home);
|
|
48
|
+
yield* serve("/_board/work", workPage);
|
|
49
|
+
yield* serve("/_board/overview", overviewPage);
|
|
50
|
+
yield* serve("/_board/changes", historyPage);
|
|
46
51
|
yield* serve("/_board/history", history(index), "POST");
|
|
47
52
|
for (const [route, body, contentType] of ASSETS) {
|
|
48
53
|
yield* serve(route, () => Effect.succeed(respond(body, contentType)));
|
|
49
54
|
}
|
|
50
55
|
yield* serve(MERMAID_ROUTE, mermaidFile(mermaid));
|
|
56
|
+
yield* serve(NATIVE_ROUTE, nativeAsset);
|
|
51
57
|
const pages = page({ home, root: options.root }, changes, index);
|
|
52
|
-
|
|
58
|
+
const items = identity(index, pages, changes, home);
|
|
59
|
+
yield* serve("/_board/item/*", items);
|
|
60
|
+
function readPage(url: string) {
|
|
61
|
+
const request = HttpServerRequest.fromWeb(new Request(`http://127.0.0.1${url}`));
|
|
62
|
+
const pathname = new URL(url, "http://127.0.0.1").pathname;
|
|
63
|
+
let selected: ReturnType<typeof pages>;
|
|
64
|
+
switch (pathname) {
|
|
65
|
+
case "/_board/work":
|
|
66
|
+
selected = workPage(request);
|
|
67
|
+
break;
|
|
68
|
+
case "/_board/overview":
|
|
69
|
+
selected = overviewPage();
|
|
70
|
+
break;
|
|
71
|
+
case "/_board/changes":
|
|
72
|
+
selected = historyPage();
|
|
73
|
+
break;
|
|
74
|
+
default:
|
|
75
|
+
selected = pathname.startsWith("/_board/item/") ? items(request) : pages(request);
|
|
76
|
+
}
|
|
77
|
+
return Effect.provideContext(selected, context);
|
|
78
|
+
}
|
|
79
|
+
const rpc = yield* server({ changes, home, index: Effect.provideContext(index, context), page: readPage });
|
|
80
|
+
yield* serve(
|
|
81
|
+
"/_board/rpc",
|
|
82
|
+
(request) =>
|
|
83
|
+
sameOrigin(request)
|
|
84
|
+
? rpc.pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, request))
|
|
85
|
+
: Effect.succeed(respond("Local same-origin requests only", "text/plain", 403)),
|
|
86
|
+
"POST",
|
|
87
|
+
);
|
|
53
88
|
yield* serve("/", pages);
|
|
54
89
|
yield* serve("/*", pages);
|
|
55
90
|
}),
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Effect, Layer, Stream } from "effect";
|
|
2
|
+
import { FetchHttpClient } from "effect/unstable/http";
|
|
3
|
+
import type * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
|
|
4
|
+
import type * as Atom from "effect/unstable/reactivity/Atom";
|
|
5
|
+
import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry";
|
|
6
|
+
import * as AtomRpc from "effect/unstable/reactivity/AtomRpc";
|
|
7
|
+
import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
|
|
8
|
+
import { bind } from "@shivaedev/effect-contract/bind.ts";
|
|
9
|
+
import { live } from "@shivaedev/effect-contract/live.ts";
|
|
10
|
+
import { type ResumeOptions, resumeSignal } from "@shivaedev/effect-contract/resume.ts";
|
|
11
|
+
import { workContract, workRpcs } from "#rpc/contract.ts";
|
|
12
|
+
import { workspace } from "#rpc/keys.ts";
|
|
13
|
+
|
|
14
|
+
export interface Reader {
|
|
15
|
+
readonly reader: unique symbol;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function browserClient(url: string, options: ResumeOptions = {}) {
|
|
19
|
+
class Client extends AtomRpc.Service<Reader>()("WorkBoard/Reader", {
|
|
20
|
+
group: workRpcs,
|
|
21
|
+
protocol: RpcClient.layerProtocolHttp({ url: `${url}/_board/rpc` }).pipe(Layer.provide([FetchHttpClient.layer, RpcSerialization.layerNdjson])),
|
|
22
|
+
}) {}
|
|
23
|
+
const api = bind(workContract, Client);
|
|
24
|
+
const registry = AtomRegistry.make({ "defaultIdleTTL": 0, timeoutResolution: 1 });
|
|
25
|
+
const updates = live(Client.runtime, {
|
|
26
|
+
resume: resumeSignal(options),
|
|
27
|
+
resyncKeys: [workspace.list],
|
|
28
|
+
stream: Stream.unwrap(Client.use((client) => Effect.succeed(client("work-board.subscribe", undefined)))),
|
|
29
|
+
});
|
|
30
|
+
registry.mount(updates.connection);
|
|
31
|
+
async function read<A, E>(atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>, signal?: AbortSignal) {
|
|
32
|
+
try {
|
|
33
|
+
return await Effect.runPromise(AtomRegistry.getResult(registry, atom, { suspendOnWaiting: true }), { signal });
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (signal?.aborted) {
|
|
36
|
+
const canceled = new Error("Reading canceled", { cause: error });
|
|
37
|
+
canceled.name = "AbortError";
|
|
38
|
+
throw canceled;
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function run<A, E>(effect: Effect.Effect<A, E, Reader>, signal?: AbortSignal) {
|
|
44
|
+
try {
|
|
45
|
+
return await Effect.runPromise(
|
|
46
|
+
Effect.flatMap(AtomRegistry.getResult(registry, Client.runtime), (context) => Effect.provideContext(effect, context)),
|
|
47
|
+
{ signal },
|
|
48
|
+
);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (signal?.aborted) {
|
|
51
|
+
const canceled = new Error("Reading canceled", { cause: error });
|
|
52
|
+
canceled.name = "AbortError";
|
|
53
|
+
throw canceled;
|
|
54
|
+
}
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { api, read, registry, run, updates };
|
|
59
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ResumeWindow } from "@shivaedev/effect-contract/resume.ts";
|
|
2
|
+
import { browserClient } from "./client.ts";
|
|
3
|
+
|
|
4
|
+
type BrowserEnvironment = ResumeWindow & {
|
|
5
|
+
readonly location: { readonly origin: string };
|
|
6
|
+
readonly addEventListener: (type: "pagehide", listener: () => void) => void;
|
|
7
|
+
readonly removeEventListener: (type: "pagehide", listener: () => void) => void;
|
|
8
|
+
};
|
|
9
|
+
const sessions = new WeakMap<BrowserEnvironment, ReturnType<typeof browserClient>>();
|
|
10
|
+
export function session(environment: BrowserEnvironment) {
|
|
11
|
+
let client = sessions.get(environment);
|
|
12
|
+
if (client === undefined) {
|
|
13
|
+
client = browserClient(environment.location.origin, { window: environment });
|
|
14
|
+
sessions.set(environment, client);
|
|
15
|
+
const owned = client;
|
|
16
|
+
function dispose() {
|
|
17
|
+
owned.registry.dispose();
|
|
18
|
+
if (sessions.get(environment) === owned) {
|
|
19
|
+
sessions.delete(environment);
|
|
20
|
+
}
|
|
21
|
+
environment.removeEventListener("pagehide", dispose);
|
|
22
|
+
}
|
|
23
|
+
environment.addEventListener("pagehide", dispose);
|
|
24
|
+
}
|
|
25
|
+
return client;
|
|
26
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { Effect, Layer, Option, Path } from "effect";
|
|
|
4
4
|
import { Argument, Command, Flag } from "effect/unstable/cli";
|
|
5
5
|
import { HOST, serveBoard } from "./serve.ts";
|
|
6
6
|
|
|
7
|
-
const VERSION = "0.
|
|
7
|
+
const VERSION = "0.5.0";
|
|
8
8
|
|
|
9
9
|
const serve = Effect.fn("WorkBoard.serve")(function* (input: { readonly dir: string; readonly port: number; readonly home: Option.Option<string> }) {
|
|
10
10
|
const path = yield* Path.Path;
|
package/src/files/changes.ts
CHANGED
|
@@ -2,13 +2,16 @@ import { Deferred, Effect, FileSystem, Option, Path, type PlatformError, PubSub,
|
|
|
2
2
|
import { listMarkdown, type MarkdownFile } from "./list.ts";
|
|
3
3
|
import { watchDirectories } from "./watchDirectories.ts";
|
|
4
4
|
|
|
5
|
-
export type Change = { readonly _tag: "Changed"; readonly paths: readonly string[] } | { readonly _tag: "Watching"; readonly watching: boolean }
|
|
5
|
+
export type Change = ({ readonly _tag: "Changed"; readonly paths: readonly string[] } | { readonly _tag: "Watching"; readonly watching: boolean }) & {
|
|
6
|
+
readonly sequence?: number;
|
|
7
|
+
};
|
|
6
8
|
|
|
7
9
|
export interface Changes {
|
|
8
10
|
readonly events: PubSub.PubSub<Change>;
|
|
9
11
|
readonly files: Effect.Effect<readonly MarkdownFile[], PlatformError.PlatformError>;
|
|
10
12
|
readonly realRoot: string;
|
|
11
13
|
readonly revision: Effect.Effect<number>;
|
|
14
|
+
readonly sequence?: Effect.Effect<number>;
|
|
12
15
|
readonly watching: Ref.Ref<boolean>;
|
|
13
16
|
}
|
|
14
17
|
|
|
@@ -27,17 +30,24 @@ export const watchChanges = Effect.fn("WorkBoard.watchChanges")(function* (root:
|
|
|
27
30
|
const events = yield* Effect.acquireRelease(PubSub.sliding<Change>(BACKLOG), PubSub.shutdown);
|
|
28
31
|
const watching = yield* Ref.make(false);
|
|
29
32
|
const generation = yield* Ref.make(0);
|
|
33
|
+
const sequence = yield* Ref.make(0);
|
|
34
|
+
function publish(change: Change) {
|
|
35
|
+
return Effect.flatMap(
|
|
36
|
+
Ref.updateAndGet(sequence, (value) => value + 1),
|
|
37
|
+
(value) => PubSub.publish(events, { ...change, sequence: value }),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
30
40
|
const cached = yield* Ref.make(Option.none<Cached>());
|
|
31
41
|
const settled = yield* Deferred.make<void>();
|
|
32
42
|
const stale = Ref.update(generation, (count) => count + 1);
|
|
33
43
|
const setWatching = (now: boolean) =>
|
|
34
44
|
Effect.andThen(
|
|
35
45
|
Effect.flatMap(Ref.getAndSet(watching, now), (was) =>
|
|
36
|
-
was === now ? Effect.void : Effect.andThen(stale,
|
|
46
|
+
was === now ? Effect.void : Effect.andThen(stale, publish({ _tag: "Watching", watching: now })),
|
|
37
47
|
),
|
|
38
48
|
Deferred.succeed(settled, undefined),
|
|
39
49
|
);
|
|
40
|
-
const attempt = watchDirectories(root, realRoot, stale, setWatching, (paths) =>
|
|
50
|
+
const attempt = watchDirectories(root, realRoot, stale, setWatching, (paths) => publish({ _tag: "Changed", paths }));
|
|
41
51
|
yield* attempt.pipe(
|
|
42
52
|
Effect.sandbox,
|
|
43
53
|
Effect.tapError((cause) => Effect.andThen(setWatching(false), Effect.logWarning("Watching the folder failed; retrying", cause))),
|
|
@@ -55,6 +65,6 @@ export const watchChanges = Effect.fn("WorkBoard.watchChanges")(function* (root:
|
|
|
55
65
|
yield* Ref.set(cached, Option.some({ files: listed, generation: now }));
|
|
56
66
|
return listed;
|
|
57
67
|
});
|
|
58
|
-
const changes: Changes = { events, files, realRoot, revision: Ref.get(generation), watching };
|
|
68
|
+
const changes: Changes = { events, files, realRoot, revision: Ref.get(generation), sequence: Ref.get(sequence), watching };
|
|
59
69
|
return changes;
|
|
60
70
|
});
|
package/src/files/list.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { Effect, FileSystem, Option, Path, type PlatformError } from "effect";
|
|
2
|
+
import { isMarkdown as markdown } from "#path/isMarkdown.ts";
|
|
2
3
|
|
|
3
4
|
export interface MarkdownFile {
|
|
4
5
|
readonly modified: number;
|
|
5
6
|
readonly path: string;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
export function isMarkdown(path: string): boolean {
|
|
10
|
+
return markdown(path);
|
|
11
|
+
}
|
|
11
12
|
|
|
12
13
|
export const within = (realRoot: string, separator: string, real: string): boolean => real === realRoot || real.startsWith(`${realRoot}${separator}`);
|
|
13
14
|
|
|
@@ -52,7 +53,7 @@ export const scanMarkdown = Effect.fn("WorkBoard.scanMarkdown")(function* (root:
|
|
|
52
53
|
const branch = new Set([...ancestors, real]);
|
|
53
54
|
const names = yield* fs.readDirectory(real);
|
|
54
55
|
const found = yield* Effect.forEach(
|
|
55
|
-
names.filter((name) => !
|
|
56
|
+
names.filter((name) => !name.startsWith(".") && name !== "node_modules"),
|
|
56
57
|
(name) => entry(relative === "" ? name : `${relative}/${name}`, boundary, branch, real),
|
|
57
58
|
);
|
|
58
59
|
return found.flat();
|
package/src/history/schema.ts
CHANGED
package/src/history/script.ts
CHANGED
|
@@ -2,12 +2,14 @@ export function historyScript(): string {
|
|
|
2
2
|
return `
|
|
3
3
|
import { swap, remember } from "/_board/swap.js";
|
|
4
4
|
import { captureReading, restoreReading } from "/_board/reading-state.js";
|
|
5
|
+
import { session } from "/_board/native.js";
|
|
5
6
|
const key = "work-board:changes:" + document.documentElement.dataset.workspace;
|
|
6
7
|
let memory;
|
|
7
8
|
let storageNotice = "";
|
|
8
9
|
let unavailableHtml = "";
|
|
9
10
|
let generation = 0;
|
|
10
11
|
let controller;
|
|
12
|
+
let observing = null;
|
|
11
13
|
const baseline = () => {
|
|
12
14
|
if (memory !== undefined) return memory;
|
|
13
15
|
try { return localStorage.getItem(key); }
|
|
@@ -52,22 +54,23 @@ const query = async (action) => {
|
|
|
52
54
|
controller?.abort();
|
|
53
55
|
controller = new AbortController();
|
|
54
56
|
const raw = baseline();
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const result = await response.json();
|
|
57
|
+
const native = session(window);
|
|
58
|
+
const payload = { action, baseline: raw };
|
|
59
|
+
const atom = native.api.history.query(payload);
|
|
60
|
+
if (action !== "observe") native.registry.refresh(atom);
|
|
61
|
+
const result = action === "observe" ? await native.run(native.api.history.run(payload), controller.signal) : await native.read(atom, controller.signal);
|
|
61
62
|
if (mine !== generation) return null;
|
|
62
63
|
if (result.discardBaseline && baseline() === raw) { forget(); unavailableHtml = result.html; }
|
|
63
64
|
return result;
|
|
64
65
|
};
|
|
65
|
-
const refresh = async () => {
|
|
66
|
-
|
|
66
|
+
const refresh = async (manual = false) => {
|
|
67
|
+
if (observing !== null) return;
|
|
68
|
+
const paused = session(window).registry.get(session(window).updates.paused) && manual !== true;
|
|
69
|
+
const visible = document.getElementById("doc")?.dataset.view === "changes" && !paused;
|
|
67
70
|
try {
|
|
68
71
|
const result = await query(visible ? "compare" : "check");
|
|
69
72
|
if (!result) return;
|
|
70
|
-
if (visible && document.getElementById("doc")?.dataset.view === "changes") {
|
|
73
|
+
if ((visible || result.discardBaseline) && document.getElementById("doc")?.dataset.view === "changes") {
|
|
71
74
|
render(baseline() === null && unavailableHtml ? unavailableHtml : result.html);
|
|
72
75
|
controls(result.snapshot !== null);
|
|
73
76
|
}
|
|
@@ -81,13 +84,17 @@ const refresh = async () => {
|
|
|
81
84
|
};
|
|
82
85
|
document.addEventListener("click", async (event) => {
|
|
83
86
|
if (event.target.closest?.("#history-clear")) {
|
|
87
|
+
observing = null;
|
|
84
88
|
++generation;
|
|
85
89
|
controller?.abort();
|
|
86
90
|
forget();
|
|
87
91
|
unavailableHtml = "";
|
|
88
|
-
await refresh();
|
|
92
|
+
await refresh(true);
|
|
89
93
|
}
|
|
90
94
|
if (event.target.closest?.("#history-mark")) {
|
|
95
|
+
if (observing !== null) return;
|
|
96
|
+
const mine = generation + 1;
|
|
97
|
+
observing = mine;
|
|
91
98
|
controls(false, true);
|
|
92
99
|
try {
|
|
93
100
|
const result = await query("observe");
|
|
@@ -95,16 +102,24 @@ document.addEventListener("click", async (event) => {
|
|
|
95
102
|
if (result.snapshot === null) { render(result.html); controls(false); return; }
|
|
96
103
|
keep(result.snapshot);
|
|
97
104
|
unavailableHtml = "";
|
|
98
|
-
await refresh();
|
|
99
105
|
} catch (error) {
|
|
100
106
|
if (error.name !== "AbortError") { render("<p>Could not remember the current observation. Your previous baseline is preserved.</p>"); controls(false); }
|
|
101
|
-
|
|
107
|
+
return;
|
|
108
|
+
} finally { if (observing === mine) observing = null; }
|
|
109
|
+
await refresh(true);
|
|
102
110
|
}
|
|
103
111
|
});
|
|
112
|
+
window.addEventListener("pagehide", () => { ++generation; observing = null; controller?.abort(); });
|
|
104
113
|
document.addEventListener("board-page", refresh);
|
|
114
|
+
document.addEventListener("board-updates-resumed", refresh);
|
|
105
115
|
document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });
|
|
106
116
|
window.addEventListener("storage", (event) => {
|
|
107
|
-
if (event.key === key || event.key === null) {
|
|
117
|
+
if (event.key === key || event.key === null) {
|
|
118
|
+
observing = null;
|
|
119
|
+
++generation;
|
|
120
|
+
controller?.abort();
|
|
121
|
+
memory = undefined; storageNotice = ""; unavailableHtml = ""; refresh(true);
|
|
122
|
+
}
|
|
108
123
|
});
|
|
109
124
|
setInterval(() => { if (baseline() !== null) refresh(); }, 60000);
|
|
110
125
|
refresh();
|
package/src/http/assets.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
import { Data, Effect, FileSystem, Path } from "effect";
|
|
3
3
|
import type { HttpRouter, HttpServerRequest } from "effect/unstable/http";
|
|
4
4
|
import { historyScript } from "#history/script.ts";
|
|
5
|
+
import { ageScript } from "#page/ageScript.ts";
|
|
5
6
|
import { client, preferences } from "#page/client.ts";
|
|
6
7
|
import { diagrams } from "#page/diagrams.ts";
|
|
7
8
|
import { libraryScript } from "#page/libraryScript.ts";
|
|
@@ -19,6 +20,7 @@ export const ASSETS: ReadonlyArray<readonly [HttpRouter.PathInput, string, strin
|
|
|
19
20
|
["/_board/style.css", style, "text/css"],
|
|
20
21
|
["/_board/saved-views.js", savedScript(), "text/javascript"],
|
|
21
22
|
["/_board/client.js", client, "text/javascript"],
|
|
23
|
+
["/_board/ages.js", ageScript(), "text/javascript"],
|
|
22
24
|
["/_board/history.js", historyScript(), "text/javascript"],
|
|
23
25
|
["/_board/preferences.js", preferences, "text/javascript"],
|
|
24
26
|
["/_board/search.js", searchScript(), "text/javascript"],
|
|
@@ -53,3 +55,12 @@ export const mermaidFile = (mermaidRoot: string) =>
|
|
|
53
55
|
});
|
|
54
56
|
|
|
55
57
|
export const MERMAID_ROUTE: HttpRouter.PathInput = `${MERMAID}*`;
|
|
58
|
+
|
|
59
|
+
export const NATIVE_ROUTE: HttpRouter.PathInput = "/_board/native.js";
|
|
60
|
+
export const nativeAsset = Effect.fn("WorkBoard.nativeAsset")(function* () {
|
|
61
|
+
const fs = yield* FileSystem.FileSystem;
|
|
62
|
+
const path = yield* Path.Path;
|
|
63
|
+
const module = yield* path.fromFileUrl(new URL(import.meta.url)).pipe(Effect.orDie);
|
|
64
|
+
const asset = path.resolve(path.dirname(module), "../../assets/native.js");
|
|
65
|
+
return respond(yield* fs.readFile(asset), "text/javascript");
|
|
66
|
+
});
|
package/src/http/history.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { navHtml } from "#page/nav.ts";
|
|
|
9
9
|
import { shell } from "#page/shell.ts";
|
|
10
10
|
import type { searchSnapshot } from "#search/snapshot.ts";
|
|
11
11
|
import { respond } from "./respond.ts";
|
|
12
|
+
import { sameOrigin } from "./sameOrigin.ts";
|
|
12
13
|
|
|
13
14
|
export function changesPage(changes: Changes, home: string | undefined) {
|
|
14
15
|
return Effect.fn("WorkBoard.changesPage")(function* () {
|
|
@@ -16,18 +17,6 @@ export function changesPage(changes: Changes, home: string | undefined) {
|
|
|
16
17
|
return respond(shell("Changes", nav, changesHtml(), changes.realRoot, true, undefined, "changes"), "text/html");
|
|
17
18
|
});
|
|
18
19
|
}
|
|
19
|
-
function sameOrigin(request: HttpServerRequest.HttpServerRequest): boolean {
|
|
20
|
-
const origin = request.headers.origin;
|
|
21
|
-
if (origin === undefined) {
|
|
22
|
-
return true;
|
|
23
|
-
}
|
|
24
|
-
try {
|
|
25
|
-
const url = new URL(origin);
|
|
26
|
-
return url.protocol === "http:" && url.host === request.headers.host;
|
|
27
|
-
} catch {
|
|
28
|
-
return false;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
20
|
export function history(index: Effect.Success<ReturnType<typeof searchSnapshot>>) {
|
|
32
21
|
return Effect.fn("WorkBoard.history")(function* (request: HttpServerRequest.HttpServerRequest) {
|
|
33
22
|
if (!sameOrigin(request)) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { HttpServerRequest } from "effect/unstable/http";
|
|
2
|
+
|
|
3
|
+
export function sameOrigin(request: HttpServerRequest.HttpServerRequest): boolean {
|
|
4
|
+
const origin = request.headers.origin;
|
|
5
|
+
if (origin === undefined) {
|
|
6
|
+
return true;
|
|
7
|
+
}
|
|
8
|
+
try {
|
|
9
|
+
const url = new URL(origin);
|
|
10
|
+
return url.protocol === "http:" && url.host === request.headers.host;
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function ageScript() {
|
|
2
|
+
return `
|
|
3
|
+
const since = (modified) => {
|
|
4
|
+
const seconds = Math.max(0, Math.round((Date.now() - modified) / 1000));
|
|
5
|
+
if (seconds < 60) return seconds + "s";
|
|
6
|
+
if (seconds < 3600) return Math.round(seconds / 60) + "m";
|
|
7
|
+
if (seconds < 172800) return Math.round(seconds / 3600) + "h";
|
|
8
|
+
return Math.round(seconds / 86400) + "d";
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const tick = () => {
|
|
12
|
+
for (const age of document.querySelectorAll("[data-modified]")) {
|
|
13
|
+
const ago = since(Number(age.dataset.modified));
|
|
14
|
+
age.textContent = age.classList.contains("short") ? ago : "updated " + ago + " ago";
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
document.addEventListener("board-page", tick);
|
|
19
|
+
tick();
|
|
20
|
+
setInterval(tick, 5000);
|
|
21
|
+
`;
|
|
22
|
+
}
|