@shivaedev/work-board 0.2.1 → 0.3.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 +66 -0
- package/README.md +141 -3
- package/dist/board.d.ts.map +1 -1
- package/dist/board.js +9 -1
- package/dist/board.js.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/files/changes.d.ts +1 -0
- package/dist/files/changes.d.ts.map +1 -1
- package/dist/files/changes.js +1 -1
- package/dist/files/changes.js.map +1 -1
- package/dist/files/url.d.ts +2 -0
- package/dist/files/url.d.ts.map +1 -0
- package/dist/files/url.js +4 -0
- package/dist/files/url.js.map +1 -0
- package/dist/http/assets.d.ts.map +1 -1
- package/dist/http/assets.js +16 -1
- package/dist/http/assets.js.map +1 -1
- package/dist/http/identity.d.ts +7 -0
- package/dist/http/identity.d.ts.map +1 -0
- package/dist/http/identity.js +38 -0
- package/dist/http/identity.js.map +1 -0
- package/dist/http/page.d.ts +2 -1
- package/dist/http/page.d.ts.map +1 -1
- package/dist/http/page.js +29 -7
- package/dist/http/page.js.map +1 -1
- package/dist/http/search.d.ts +5 -0
- package/dist/http/search.d.ts.map +1 -0
- package/dist/http/search.js +11 -0
- package/dist/http/search.js.map +1 -0
- package/dist/http/work.d.ts +6 -0
- package/dist/http/work.d.ts.map +1 -0
- package/dist/http/work.js +22 -0
- package/dist/http/work.js.map +1 -0
- package/dist/metadata/backlinks.d.ts +8 -0
- package/dist/metadata/backlinks.d.ts.map +1 -0
- package/dist/metadata/backlinks.js +29 -0
- package/dist/metadata/backlinks.js.map +1 -0
- package/dist/metadata/fields.d.ts +29 -0
- package/dist/metadata/fields.d.ts.map +1 -0
- package/dist/metadata/fields.js +25 -0
- package/dist/metadata/fields.js.map +1 -0
- package/dist/metadata/links.d.ts +4 -0
- package/dist/metadata/links.d.ts.map +1 -0
- package/dist/metadata/links.js +18 -0
- package/dist/metadata/links.js.map +1 -0
- package/dist/metadata/locations.d.ts +3 -0
- package/dist/metadata/locations.d.ts.map +1 -0
- package/dist/metadata/locations.js +35 -0
- package/dist/metadata/locations.js.map +1 -0
- package/dist/metadata/model.d.ts +15 -0
- package/dist/metadata/model.d.ts.map +1 -0
- package/dist/metadata/model.js +13 -0
- package/dist/metadata/model.js.map +1 -0
- package/dist/metadata/parse.d.ts +16 -0
- package/dist/metadata/parse.d.ts.map +1 -0
- package/dist/metadata/parse.js +48 -0
- package/dist/metadata/parse.js.map +1 -0
- package/dist/metadata/problems.d.ts +4 -0
- package/dist/metadata/problems.d.ts.map +1 -0
- package/dist/metadata/problems.js +57 -0
- package/dist/metadata/problems.js.map +1 -0
- package/dist/metadata/relationshipLabel.d.ts +2 -0
- package/dist/metadata/relationshipLabel.d.ts.map +1 -0
- package/dist/metadata/relationshipLabel.js +13 -0
- package/dist/metadata/relationshipLabel.js.map +1 -0
- package/dist/metadata/schema.d.ts +34 -0
- package/dist/metadata/schema.d.ts.map +1 -0
- package/dist/metadata/schema.js +27 -0
- package/dist/metadata/schema.js.map +1 -0
- package/dist/metadata/sourceLinks.d.ts +5 -0
- package/dist/metadata/sourceLinks.d.ts.map +1 -0
- package/dist/metadata/sourceLinks.js +44 -0
- package/dist/metadata/sourceLinks.js.map +1 -0
- package/dist/page/backlinksHtml.d.ts +3 -0
- package/dist/page/backlinksHtml.d.ts.map +1 -0
- package/dist/page/backlinksHtml.js +16 -0
- package/dist/page/backlinksHtml.js.map +1 -0
- package/dist/page/board.d.ts +1 -1
- package/dist/page/board.d.ts.map +1 -1
- package/dist/page/board.js +14 -4
- package/dist/page/board.js.map +1 -1
- package/dist/page/client.d.ts +2 -1
- package/dist/page/client.d.ts.map +1 -1
- package/dist/page/client.js +65 -7
- package/dist/page/client.js.map +1 -1
- package/dist/page/criteriaEvidence.d.ts +4 -0
- package/dist/page/criteriaEvidence.d.ts.map +1 -0
- package/dist/page/criteriaEvidence.js +27 -0
- package/dist/page/criteriaEvidence.js.map +1 -0
- package/dist/page/diagrams.d.ts +1 -1
- package/dist/page/diagrams.d.ts.map +1 -1
- package/dist/page/diagrams.js +12 -4
- package/dist/page/diagrams.js.map +1 -1
- package/dist/page/document.d.ts +1 -1
- package/dist/page/document.d.ts.map +1 -1
- package/dist/page/document.js +6 -3
- package/dist/page/document.js.map +1 -1
- package/dist/page/libraryScript.d.ts +2 -0
- package/dist/page/libraryScript.d.ts.map +1 -0
- package/dist/page/libraryScript.js +73 -0
- package/dist/page/libraryScript.js.map +1 -0
- package/dist/page/metadata.d.ts +5 -0
- package/dist/page/metadata.d.ts.map +1 -0
- package/dist/page/metadata.js +34 -0
- package/dist/page/metadata.js.map +1 -0
- package/dist/page/metadataDetails.d.ts +4 -0
- package/dist/page/metadataDetails.d.ts.map +1 -0
- package/dist/page/metadataDetails.js +21 -0
- package/dist/page/metadataDetails.js.map +1 -0
- package/dist/page/metadataLinks.d.ts +6 -0
- package/dist/page/metadataLinks.d.ts.map +1 -0
- package/dist/page/metadataLinks.js +31 -0
- package/dist/page/metadataLinks.js.map +1 -0
- package/dist/page/nav.d.ts.map +1 -1
- package/dist/page/nav.js +3 -3
- package/dist/page/nav.js.map +1 -1
- package/dist/page/navigationScript.d.ts +2 -0
- package/dist/page/navigationScript.d.ts.map +1 -0
- package/dist/page/navigationScript.js +104 -0
- package/dist/page/navigationScript.js.map +1 -0
- package/dist/page/readingStateScript.d.ts +2 -0
- package/dist/page/readingStateScript.d.ts.map +1 -0
- package/dist/page/readingStateScript.js +62 -0
- package/dist/page/readingStateScript.js.map +1 -0
- package/dist/page/readingStyles.d.ts +2 -0
- package/dist/page/readingStyles.d.ts.map +1 -0
- package/dist/page/readingStyles.js +29 -0
- package/dist/page/readingStyles.js.map +1 -0
- package/dist/page/readingTools.d.ts +3 -0
- package/dist/page/readingTools.d.ts.map +1 -0
- package/dist/page/readingTools.js +10 -0
- package/dist/page/readingTools.js.map +1 -0
- package/dist/page/recordedEvidence.d.ts +4 -0
- package/dist/page/recordedEvidence.d.ts.map +1 -0
- package/dist/page/recordedEvidence.js +10 -0
- package/dist/page/recordedEvidence.js.map +1 -0
- package/dist/page/searchDialog.d.ts +3 -0
- package/dist/page/searchDialog.d.ts.map +1 -0
- package/dist/page/searchDialog.js +16 -0
- package/dist/page/searchDialog.js.map +1 -0
- package/dist/page/searchResultsScript.d.ts +2 -0
- package/dist/page/searchResultsScript.d.ts.map +1 -0
- package/dist/page/searchResultsScript.js +64 -0
- package/dist/page/searchResultsScript.js.map +1 -0
- package/dist/page/searchScript.d.ts +2 -0
- package/dist/page/searchScript.d.ts.map +1 -0
- package/dist/page/searchScript.js +73 -0
- package/dist/page/searchScript.js.map +1 -0
- package/dist/page/shell.d.ts +1 -1
- package/dist/page/shell.d.ts.map +1 -1
- package/dist/page/shell.js +23 -5
- package/dist/page/shell.js.map +1 -1
- package/dist/page/stateScript.d.ts +2 -0
- package/dist/page/stateScript.d.ts.map +1 -0
- package/dist/page/stateScript.js +49 -0
- package/dist/page/stateScript.js.map +1 -0
- package/dist/page/style.d.ts.map +1 -1
- package/dist/page/style.js +14 -34
- 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 +4 -1
- package/dist/page/swap.js.map +1 -1
- package/dist/page/workspaceStyles.d.ts +3 -0
- package/dist/page/workspaceStyles.d.ts.map +1 -0
- package/dist/page/workspaceStyles.js +79 -0
- package/dist/page/workspaceStyles.js.map +1 -0
- package/dist/render/documentHeadings.d.ts +667 -0
- package/dist/render/documentHeadings.d.ts.map +1 -0
- package/dist/render/documentHeadings.js +45 -0
- package/dist/render/documentHeadings.js.map +1 -0
- package/dist/render/documentLinks.d.ts +659 -0
- package/dist/render/documentLinks.d.ts.map +1 -0
- package/dist/render/documentLinks.js +19 -0
- package/dist/render/documentLinks.js.map +1 -0
- package/dist/render/markdown.d.ts +7 -1
- package/dist/render/markdown.d.ts.map +1 -1
- package/dist/render/markdown.js +13 -2
- package/dist/render/markdown.js.map +1 -1
- package/dist/search/collector.d.ts +13 -0
- package/dist/search/collector.d.ts.map +1 -0
- package/dist/search/collector.js +40 -0
- package/dist/search/collector.js.map +1 -0
- package/dist/search/entries.d.ts +10 -0
- package/dist/search/entries.d.ts.map +1 -0
- package/dist/search/entries.js +37 -0
- package/dist/search/entries.js.map +1 -0
- package/dist/search/linkCollector.d.ts +8 -0
- package/dist/search/linkCollector.d.ts.map +1 -0
- package/dist/search/linkCollector.js +16 -0
- package/dist/search/linkCollector.js.map +1 -0
- package/dist/search/match.d.ts +14 -0
- package/dist/search/match.d.ts.map +1 -0
- package/dist/search/match.js +26 -0
- package/dist/search/match.js.map +1 -0
- package/dist/search/metadataEntries.d.ts +4 -0
- package/dist/search/metadataEntries.d.ts.map +1 -0
- package/dist/search/metadataEntries.js +40 -0
- package/dist/search/metadataEntries.js.map +1 -0
- package/dist/search/snapshot.d.ts +13 -0
- package/dist/search/snapshot.d.ts.map +1 -0
- package/dist/search/snapshot.js +50 -0
- package/dist/search/snapshot.js.map +1 -0
- package/dist/views/items.d.ts +11 -0
- package/dist/views/items.d.ts.map +1 -0
- package/dist/views/items.js +48 -0
- package/dist/views/items.js.map +1 -0
- package/dist/views/projection.d.ts +15 -0
- package/dist/views/projection.d.ts.map +1 -0
- package/dist/views/projection.js +47 -0
- package/dist/views/projection.js.map +1 -0
- package/dist/views/savedScript.d.ts +2 -0
- package/dist/views/savedScript.d.ts.map +1 -0
- package/dist/views/savedScript.js +89 -0
- package/dist/views/savedScript.js.map +1 -0
- package/dist/views/state.d.ts +15 -0
- package/dist/views/state.d.ts.map +1 -0
- package/dist/views/state.js +45 -0
- package/dist/views/state.js.map +1 -0
- package/dist/views/tools.d.ts +3 -0
- package/dist/views/tools.d.ts.map +1 -0
- package/dist/views/tools.js +11 -0
- package/dist/views/tools.js.map +1 -0
- package/dist/views/workCards.d.ts +4 -0
- package/dist/views/workCards.d.ts.map +1 -0
- package/dist/views/workCards.js +23 -0
- package/dist/views/workCards.js.map +1 -0
- package/dist/views/workControls.d.ts +4 -0
- package/dist/views/workControls.d.ts.map +1 -0
- package/dist/views/workControls.js +32 -0
- package/dist/views/workControls.js.map +1 -0
- package/dist/views/workDetail.d.ts +13 -0
- package/dist/views/workDetail.d.ts.map +1 -0
- package/dist/views/workDetail.js +26 -0
- package/dist/views/workDetail.js.map +1 -0
- package/dist/views/workHtml.d.ts +15 -0
- package/dist/views/workHtml.d.ts.map +1 -0
- package/dist/views/workHtml.js +35 -0
- package/dist/views/workHtml.js.map +1 -0
- package/dist/views/workStyle.d.ts +2 -0
- package/dist/views/workStyle.d.ts.map +1 -0
- package/dist/views/workStyle.js +18 -0
- package/dist/views/workStyle.js.map +1 -0
- package/dist/views/workTable.d.ts +4 -0
- package/dist/views/workTable.d.ts.map +1 -0
- package/dist/views/workTable.js +19 -0
- package/dist/views/workTable.js.map +1 -0
- package/package.json +11 -8
- package/src/board.ts +9 -1
- package/src/cli.ts +1 -1
- package/src/files/changes.ts +2 -1
- package/src/files/url.ts +3 -0
- package/src/http/assets.ts +16 -1
- package/src/http/identity.ts +62 -0
- package/src/http/page.ts +46 -9
- package/src/http/search.ts +13 -0
- package/src/http/work.ts +41 -0
- package/src/metadata/backlinks.ts +39 -0
- package/src/metadata/fields.ts +26 -0
- package/src/metadata/links.ts +19 -0
- package/src/metadata/locations.ts +34 -0
- package/src/metadata/model.ts +27 -0
- package/src/metadata/parse.ts +62 -0
- package/src/metadata/problems.ts +62 -0
- package/src/metadata/relationshipLabel.ts +13 -0
- package/src/metadata/schema.ts +36 -0
- package/src/metadata/sourceLinks.ts +46 -0
- package/src/page/backlinksHtml.ts +18 -0
- package/src/page/board.ts +14 -4
- package/src/page/client.ts +65 -7
- package/src/page/criteriaEvidence.ts +36 -0
- package/src/page/diagrams.ts +12 -4
- package/src/page/document.ts +6 -3
- package/src/page/libraryScript.ts +72 -0
- package/src/page/metadata.ts +40 -0
- package/src/page/metadataDetails.ts +24 -0
- package/src/page/metadataLinks.ts +35 -0
- package/src/page/nav.ts +5 -4
- package/src/page/navigationScript.ts +103 -0
- package/src/page/readingStateScript.ts +61 -0
- package/src/page/readingStyles.ts +28 -0
- package/src/page/readingTools.ts +12 -0
- package/src/page/recordedEvidence.ts +12 -0
- package/src/page/searchDialog.ts +16 -0
- package/src/page/searchResultsScript.ts +63 -0
- package/src/page/searchScript.ts +72 -0
- package/src/page/shell.ts +24 -5
- package/src/page/stateScript.ts +48 -0
- package/src/page/style.ts +14 -35
- package/src/page/swap.ts +4 -1
- package/src/page/workspaceStyles.ts +79 -0
- package/src/render/documentHeadings.ts +52 -0
- package/src/render/documentLinks.ts +19 -0
- package/src/render/markdown.ts +21 -4
- package/src/search/collector.ts +44 -0
- package/src/search/entries.ts +51 -0
- package/src/search/linkCollector.ts +16 -0
- package/src/search/match.ts +28 -0
- package/src/search/metadataEntries.ts +42 -0
- package/src/search/snapshot.ts +60 -0
- package/src/views/items.ts +55 -0
- package/src/views/projection.ts +59 -0
- package/src/views/savedScript.ts +88 -0
- package/src/views/state.ts +47 -0
- package/src/views/tools.ts +11 -0
- package/src/views/workCards.ts +26 -0
- package/src/views/workControls.ts +38 -0
- package/src/views/workDetail.ts +28 -0
- package/src/views/workHtml.ts +39 -0
- package/src/views/workStyle.ts +17 -0
- package/src/views/workTable.ts +21 -0
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -2
- package/dist/index.js.map +0 -1
- package/src/index.ts +0 -1
package/src/http/assets.ts
CHANGED
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
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
|
-
import { client } from "#page/client.ts";
|
|
4
|
+
import { client, preferences } from "#page/client.ts";
|
|
5
5
|
import { diagrams } from "#page/diagrams.ts";
|
|
6
|
+
import { libraryScript } from "#page/libraryScript.ts";
|
|
7
|
+
import { navigationScript } from "#page/navigationScript.ts";
|
|
8
|
+
import { readingStateScript } from "#page/readingStateScript.ts";
|
|
9
|
+
import { searchResultsScript } from "#page/searchResultsScript.ts";
|
|
10
|
+
import { searchScript } from "#page/searchScript.ts";
|
|
11
|
+
import { pageStateScript } from "#page/stateScript.ts";
|
|
6
12
|
import { style } from "#page/style.ts";
|
|
7
13
|
import { swap } from "#page/swap.ts";
|
|
14
|
+
import { savedScript } from "#views/savedScript.ts";
|
|
8
15
|
import { respond } from "./respond.ts";
|
|
9
16
|
|
|
10
17
|
export const ASSETS: ReadonlyArray<readonly [HttpRouter.PathInput, string, string]> = [
|
|
11
18
|
["/_board/style.css", style, "text/css"],
|
|
19
|
+
["/_board/saved-views.js", savedScript(), "text/javascript"],
|
|
12
20
|
["/_board/client.js", client, "text/javascript"],
|
|
21
|
+
["/_board/preferences.js", preferences, "text/javascript"],
|
|
22
|
+
["/_board/search.js", searchScript(), "text/javascript"],
|
|
23
|
+
["/_board/search-results.js", searchResultsScript(), "text/javascript"],
|
|
24
|
+
["/_board/library.js", libraryScript(), "text/javascript"],
|
|
25
|
+
["/_board/navigation.js", navigationScript(), "text/javascript"],
|
|
26
|
+
["/_board/page-state.js", pageStateScript(), "text/javascript"],
|
|
27
|
+
["/_board/reading-state.js", readingStateScript(), "text/javascript"],
|
|
13
28
|
["/_board/diagrams.js", diagrams, "text/javascript"],
|
|
14
29
|
["/_board/swap.js", swap, "text/javascript"],
|
|
15
30
|
];
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { HttpServerRequest } from "effect/unstable/http";
|
|
3
|
+
import type { Changes } from "#files/changes.ts";
|
|
4
|
+
import { fileUrl } from "#files/url.ts";
|
|
5
|
+
import { escapeHtml } from "#page/escape.ts";
|
|
6
|
+
import { navHtml } from "#page/nav.ts";
|
|
7
|
+
import { shell } from "#page/shell.ts";
|
|
8
|
+
import type { searchSnapshot } from "#search/snapshot.ts";
|
|
9
|
+
import type { page } from "./page.ts";
|
|
10
|
+
import { respond } from "./respond.ts";
|
|
11
|
+
|
|
12
|
+
function requestedIdentity(pathname: string): string {
|
|
13
|
+
try {
|
|
14
|
+
return decodeURIComponent(pathname.slice("/_board/item/".length).replace(/\/$/u, ""));
|
|
15
|
+
} catch {
|
|
16
|
+
return "";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function identity(
|
|
21
|
+
index: Effect.Success<ReturnType<typeof searchSnapshot>>,
|
|
22
|
+
pages: ReturnType<typeof page>,
|
|
23
|
+
changes: Changes,
|
|
24
|
+
home: string | undefined,
|
|
25
|
+
) {
|
|
26
|
+
return Effect.fn("WorkBoard.identity")(function* (request: HttpServerRequest.HttpServerRequest) {
|
|
27
|
+
const pathname = new URL(request.url, "http://127.0.0.1").pathname;
|
|
28
|
+
const id = requestedIdentity(pathname);
|
|
29
|
+
if (!pathname.endsWith("/") && /\.md$/iu.exec(id) !== null) {
|
|
30
|
+
return yield* pages(request);
|
|
31
|
+
}
|
|
32
|
+
const snapshot = yield* index;
|
|
33
|
+
const matches = snapshot.model.ids.get(id) ?? [];
|
|
34
|
+
if (snapshot.unavailable.length > 0) {
|
|
35
|
+
return respond(
|
|
36
|
+
shell(
|
|
37
|
+
`Item ${id}`,
|
|
38
|
+
"",
|
|
39
|
+
`<p class="empty">The workspace index is incomplete. Could not read ${escapeHtml(snapshot.unavailable.join(", "))}; item identity could not be validated. Retry when the files are readable.</p>`,
|
|
40
|
+
changes.realRoot,
|
|
41
|
+
false,
|
|
42
|
+
),
|
|
43
|
+
"text/html",
|
|
44
|
+
503,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if (matches.length === 1 && matches[0]) {
|
|
48
|
+
return yield* pages(request, matches[0].file, id);
|
|
49
|
+
}
|
|
50
|
+
const text =
|
|
51
|
+
matches.length > 0
|
|
52
|
+
? `The ID ${id} is duplicated. No document was selected.`
|
|
53
|
+
: `No item with ID ${id} is available. It may be missing, renamed, or invalid.`;
|
|
54
|
+
const choices = matches.map((document) => `<li><a href="${escapeHtml(fileUrl(document.file))}">${escapeHtml(document.file)}</a></li>`).join("");
|
|
55
|
+
const nav = navHtml(yield* changes.files, "", home);
|
|
56
|
+
return respond(
|
|
57
|
+
shell(`Item ${id}`, nav, `<p class="empty">${escapeHtml(text)}</p>${choices ? `<ul>${choices}</ul>` : ""}`, changes.realRoot, false),
|
|
58
|
+
"text/html",
|
|
59
|
+
matches.length > 0 ? 409 : 404,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
}
|
package/src/http/page.ts
CHANGED
|
@@ -2,12 +2,16 @@ import { Effect, FileSystem, Option, Path } from "effect";
|
|
|
2
2
|
import type { HttpServerRequest } from "effect/unstable/http";
|
|
3
3
|
import type { Changes } from "#files/changes.ts";
|
|
4
4
|
import { type MarkdownFile, within } from "#files/list.ts";
|
|
5
|
+
import { metadataModel } from "#metadata/model.ts";
|
|
6
|
+
import { metadataParse } from "#metadata/parse.ts";
|
|
5
7
|
import { boardHtml } from "#page/board.ts";
|
|
6
8
|
import { documentHtml } from "#page/document.ts";
|
|
7
9
|
import { escapeHtml } from "#page/escape.ts";
|
|
10
|
+
import { metadataHtml } from "#page/metadata.ts";
|
|
8
11
|
import { navHtml } from "#page/nav.ts";
|
|
9
12
|
import { shell } from "#page/shell.ts";
|
|
10
13
|
import { boardOf } from "#render/board.ts";
|
|
14
|
+
import type { searchSnapshot } from "#search/snapshot.ts";
|
|
11
15
|
import { respond } from "./respond.ts";
|
|
12
16
|
|
|
13
17
|
export interface PageOptions {
|
|
@@ -26,8 +30,8 @@ const requestedPath = (url: string): string => {
|
|
|
26
30
|
|
|
27
31
|
const message = (text: string): string => `<p class="empty">${escapeHtml(text)}</p>`;
|
|
28
32
|
|
|
29
|
-
export const page = (options: PageOptions, changes: Changes) => {
|
|
30
|
-
const body = Effect.fn("WorkBoard.pageBody")(function* (file: MarkdownFile) {
|
|
33
|
+
export const page = (options: PageOptions, changes: Changes, index: Effect.Success<ReturnType<typeof searchSnapshot>>) => {
|
|
34
|
+
const body = Effect.fn("WorkBoard.pageBody")(function* (file: MarkdownFile, expectedIdentity?: string) {
|
|
31
35
|
const fs = yield* FileSystem.FileSystem;
|
|
32
36
|
const path = yield* Path.Path;
|
|
33
37
|
const real = yield* Effect.option(fs.realPath(path.join(options.root, file.path)));
|
|
@@ -35,20 +39,53 @@ export const page = (options: PageOptions, changes: Changes) => {
|
|
|
35
39
|
return Option.none();
|
|
36
40
|
}
|
|
37
41
|
const source = yield* fs.readFileString(real.value);
|
|
38
|
-
|
|
42
|
+
const parsed = metadataParse(source);
|
|
43
|
+
let identity: string | undefined;
|
|
44
|
+
const snapshot = yield* index;
|
|
45
|
+
const model = metadataModel(
|
|
46
|
+
[...snapshot.documents.filter((document) => document.file !== file.path), { file: file.path, parsed }],
|
|
47
|
+
snapshot.unavailable,
|
|
48
|
+
options.home ?? (yield* changes.files)[0]?.path,
|
|
49
|
+
);
|
|
50
|
+
const metadata = metadataHtml(parsed, file.path, model);
|
|
51
|
+
if (snapshot.unavailable.length === 0 && parsed.fields.id && model.ids.get(parsed.fields.id)?.length === 1) {
|
|
52
|
+
identity = parsed.fields.id;
|
|
53
|
+
}
|
|
54
|
+
if (expectedIdentity && identity !== expectedIdentity) {
|
|
55
|
+
return Option.none();
|
|
56
|
+
}
|
|
57
|
+
const html =
|
|
58
|
+
file.path === options.home
|
|
59
|
+
? yield* boardHtml(boardOf(parsed.body), file.path, file.modified, metadata)
|
|
60
|
+
: yield* documentHtml(parsed.body, file, metadata);
|
|
61
|
+
return Option.some({ html, identity });
|
|
39
62
|
});
|
|
40
|
-
return Effect.fn("WorkBoard.page")(function* (request: HttpServerRequest.HttpServerRequest) {
|
|
63
|
+
return Effect.fn("WorkBoard.page")(function* (request: HttpServerRequest.HttpServerRequest, resolved?: string, expectedIdentity?: string) {
|
|
41
64
|
const files = yield* changes.files;
|
|
42
|
-
const requested = requestedPath(request.url) || options.home || files[0]?.path || "";
|
|
65
|
+
const requested = resolved ?? (requestedPath(request.url) || options.home || files[0]?.path || "");
|
|
43
66
|
const file = files.find((candidate) => candidate.path === requested);
|
|
44
67
|
const nav = navHtml(files, requested, options.home);
|
|
45
|
-
|
|
68
|
+
function layout(content: string, identity?: string) {
|
|
69
|
+
return shell(requested, nav, content, changes.realRoot, requested === options.home, identity);
|
|
70
|
+
}
|
|
71
|
+
const missing = () =>
|
|
72
|
+
respond(
|
|
73
|
+
layout(
|
|
74
|
+
message(
|
|
75
|
+
expectedIdentity
|
|
76
|
+
? `No item with ID ${expectedIdentity} at ${requested}; its source identity changed or is invalid.`
|
|
77
|
+
: `No markdown file at ${requested || options.root}.`,
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
"text/html",
|
|
81
|
+
404,
|
|
82
|
+
);
|
|
46
83
|
if (file === undefined) {
|
|
47
84
|
return missing();
|
|
48
85
|
}
|
|
49
|
-
return yield* body(file).pipe(
|
|
50
|
-
Effect.map(Option.match({ onNone: missing, onSome: (main) => respond(
|
|
51
|
-
Effect.catch(() => Effect.succeed(respond(
|
|
86
|
+
return yield* body(file, expectedIdentity).pipe(
|
|
87
|
+
Effect.map(Option.match({ onNone: missing, onSome: (main) => respond(layout(main.html, main.identity), "text/html") })),
|
|
88
|
+
Effect.catch(() => Effect.succeed(respond(layout(message(`${file.path} could not be read.`)), "text/html", 500))),
|
|
52
89
|
);
|
|
53
90
|
});
|
|
54
91
|
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { HttpServerRequest } from "effect/unstable/http";
|
|
3
|
+
import { searchMatch } from "#search/match.ts";
|
|
4
|
+
import type { searchSnapshot } from "#search/snapshot.ts";
|
|
5
|
+
import { respond } from "./respond.ts";
|
|
6
|
+
|
|
7
|
+
export function search(index: Effect.Success<ReturnType<typeof searchSnapshot>>) {
|
|
8
|
+
return Effect.fn("WorkBoard.search")(function* (request: HttpServerRequest.HttpServerRequest) {
|
|
9
|
+
const query = new URL(request.url, "http://127.0.0.1").searchParams.get("q") ?? "";
|
|
10
|
+
const snapshot = yield* index;
|
|
11
|
+
return respond(JSON.stringify({ ...searchMatch(snapshot.entries, query), unavailable: snapshot.unavailable }), "application/json");
|
|
12
|
+
});
|
|
13
|
+
}
|
package/src/http/work.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { HttpServerRequest } from "effect/unstable/http";
|
|
3
|
+
import type { Changes } from "#files/changes.ts";
|
|
4
|
+
import { escapeHtml } from "#page/escape.ts";
|
|
5
|
+
import { navHtml } from "#page/nav.ts";
|
|
6
|
+
import { shell } from "#page/shell.ts";
|
|
7
|
+
import type { searchSnapshot } from "#search/snapshot.ts";
|
|
8
|
+
import { stateOf } from "#views/state.ts";
|
|
9
|
+
import { workHtml } from "#views/workHtml.ts";
|
|
10
|
+
import { respond } from "./respond.ts";
|
|
11
|
+
|
|
12
|
+
export function work(index: Effect.Success<ReturnType<typeof searchSnapshot>>, changes: Changes, home: string | undefined) {
|
|
13
|
+
return Effect.fn("WorkBoard.work")(function* (request: HttpServerRequest.HttpServerRequest) {
|
|
14
|
+
const snapshot = yield* index;
|
|
15
|
+
const nav = navHtml(yield* changes.files, "", home);
|
|
16
|
+
if (snapshot.unavailable.length > 0) {
|
|
17
|
+
return respond(
|
|
18
|
+
shell(
|
|
19
|
+
"Work",
|
|
20
|
+
nav,
|
|
21
|
+
`<h1>Workspace incomplete</h1><p class="empty">Could not read ${escapeHtml(snapshot.unavailable.join(", "))}. Work identity and view counts cannot be validated. Read available source files or retry when the workspace is readable.</p>`,
|
|
22
|
+
changes.realRoot,
|
|
23
|
+
true,
|
|
24
|
+
undefined,
|
|
25
|
+
true,
|
|
26
|
+
),
|
|
27
|
+
"text/html",
|
|
28
|
+
503,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
const result = yield* workHtml(snapshot, stateOf(request.url)).pipe(
|
|
32
|
+
Effect.catch(() =>
|
|
33
|
+
Effect.succeed({
|
|
34
|
+
html: '<h1>Work unavailable</h1><p class="empty">The selected source could not be rendered. Read its Markdown file or retry.</p>',
|
|
35
|
+
status: 500,
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
return respond(shell("Work", nav, result.html, changes.realRoot, true, undefined, true), "text/html", result.status);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { MetadataDocument, MetadataModel } from "./model.ts";
|
|
2
|
+
import { referenceFile, sourceFile } from "./sourceLinks.ts";
|
|
3
|
+
|
|
4
|
+
export interface Backlink {
|
|
5
|
+
readonly file: string;
|
|
6
|
+
readonly kind: string;
|
|
7
|
+
readonly line?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function references(document: MetadataDocument, model: MetadataModel) {
|
|
11
|
+
const { fields, lines } = document.parsed;
|
|
12
|
+
return [
|
|
13
|
+
...(fields.relationships?.map((link, index) => ({
|
|
14
|
+
kind: link.kind,
|
|
15
|
+
line: lines[`relationships.${index}.target`],
|
|
16
|
+
target: referenceFile(link.target, model),
|
|
17
|
+
})) ?? []),
|
|
18
|
+
...(fields.items?.map((item, index) => ({ kind: "board member", line: lines[`items.${index}`], target: referenceFile(item, model) })) ?? []),
|
|
19
|
+
...(fields.evidence?.flatMap((evidence, index) => [
|
|
20
|
+
{ kind: "evidence source", line: lines[`evidence.${index}.source`], target: sourceFile(evidence.source, document.file, model) },
|
|
21
|
+
{
|
|
22
|
+
kind: "criterion evidence",
|
|
23
|
+
line: lines[`evidence.${index}.criterion`],
|
|
24
|
+
target: evidence.criterion ? referenceFile(evidence.criterion, model) : undefined,
|
|
25
|
+
},
|
|
26
|
+
]) ?? []),
|
|
27
|
+
...(document.links?.map((link) => ({ kind: "Markdown link", line: undefined, target: sourceFile(link, document.file, model) })) ?? []),
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function backlinksOf(file: string, model: MetadataModel): readonly Backlink[] {
|
|
32
|
+
return model.documents.flatMap((document) =>
|
|
33
|
+
document.file === file
|
|
34
|
+
? []
|
|
35
|
+
: references(document, model)
|
|
36
|
+
.filter((reference) => reference.target === file)
|
|
37
|
+
.map((reference) => ({ file: document.file, kind: reference.kind, ...(reference.line === undefined ? {} : { line: reference.line }) })),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Option, Schema } from "effect";
|
|
2
|
+
import type { Diagnostic } from "./parse.ts";
|
|
3
|
+
import { Metadata } from "./schema.ts";
|
|
4
|
+
|
|
5
|
+
const schemas: ReadonlyMap<string, Schema.ConstraintDecoder<unknown, never>> = new Map(Object.entries(Metadata.to.fields));
|
|
6
|
+
const sourceKeys = new Map([["next_action", "nextAction"]]);
|
|
7
|
+
|
|
8
|
+
export function metadataFields(data: object, lines: Readonly<Record<string, number>>) {
|
|
9
|
+
const fields: Record<string, unknown> = {};
|
|
10
|
+
const diagnostics: Diagnostic[] = [];
|
|
11
|
+
for (const [field, value] of Object.entries(data)) {
|
|
12
|
+
const key = sourceKeys.get(field) ?? field;
|
|
13
|
+
const schema = field === "nextAction" ? undefined : schemas.get(key);
|
|
14
|
+
if (!schema) {
|
|
15
|
+
diagnostics.push({ field, line: lines[field] ?? 2, message: `Unknown field ${field}; kept in the original frontmatter.` });
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const decoded = Schema.decodeUnknownOption(schema, { onExcessProperty: "error" })(value);
|
|
19
|
+
if (Option.isNone(decoded)) {
|
|
20
|
+
diagnostics.push({ field, line: lines[field] ?? 2, message: `Invalid ${field}; kept in the original frontmatter and not interpreted.` });
|
|
21
|
+
} else {
|
|
22
|
+
fields[key] = decoded.value;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { diagnostics, fields: Schema.decodeUnknownSync(Schema.toType(Metadata))(fields) };
|
|
26
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { MetadataModel } from "./model.ts";
|
|
2
|
+
|
|
3
|
+
export function identityUrl(id: string): string {
|
|
4
|
+
return `/_board/item/${encodeURIComponent(id)}/`;
|
|
5
|
+
}
|
|
6
|
+
export function referenceTarget(reference: string, model: MetadataModel): string | undefined {
|
|
7
|
+
if (model.unavailable.length > 0) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
const [id = "", criterion] = reference.split("#");
|
|
11
|
+
const targets = model.ids.get(id);
|
|
12
|
+
if (targets?.length !== 1) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
if (criterion && targets[0]?.parsed.fields.criteria?.filter((item) => item.id === criterion).length !== 1) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
return identityUrl(id) + (criterion ? `#criterion-${encodeURIComponent(criterion)}` : "");
|
|
19
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { isMap, isNode, isScalar, isSeq, type LineCounter, type Node, type YAMLMap } from "yaml";
|
|
2
|
+
|
|
3
|
+
function recordMap(node: YAMLMap, field: string, counter: LineCounter, lines: Record<string, number>) {
|
|
4
|
+
for (const pair of node.items) {
|
|
5
|
+
if (!isScalar(pair.key) || typeof pair.key.value !== "string") {
|
|
6
|
+
continue;
|
|
7
|
+
}
|
|
8
|
+
const key = field ? `${field}.${pair.key.value}` : pair.key.value;
|
|
9
|
+
visit(pair.value, key, counter, lines);
|
|
10
|
+
if (pair.key.range) {
|
|
11
|
+
lines[key] = counter.linePos(pair.key.range[0]).line + 1;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function visit(value: unknown, field: string, counter: LineCounter, lines: Record<string, number>) {
|
|
16
|
+
if (!isNode(value)) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (value.range && field) {
|
|
20
|
+
lines[field] = counter.linePos(value.range[0]).line + 1;
|
|
21
|
+
}
|
|
22
|
+
if (isMap(value)) {
|
|
23
|
+
recordMap(value, field, counter, lines);
|
|
24
|
+
} else if (isSeq(value)) {
|
|
25
|
+
value.items.forEach((item, index) => {
|
|
26
|
+
visit(item, `${field}.${index}`, counter, lines);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function metadataLocations(node: Node | null, counter: LineCounter): Readonly<Record<string, number>> {
|
|
31
|
+
const lines: Record<string, number> = {};
|
|
32
|
+
visit(node, "", counter, lines);
|
|
33
|
+
return lines;
|
|
34
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Diagnostic, ParsedMetadata } from "./parse.ts";
|
|
2
|
+
import { metadataProblems } from "./problems.ts";
|
|
3
|
+
|
|
4
|
+
export interface MetadataDocument {
|
|
5
|
+
readonly file: string;
|
|
6
|
+
readonly links?: readonly string[];
|
|
7
|
+
readonly parsed: ParsedMetadata;
|
|
8
|
+
}
|
|
9
|
+
export interface MetadataModel {
|
|
10
|
+
readonly diagnostics: ReadonlyMap<string, readonly Diagnostic[]>;
|
|
11
|
+
readonly documents: readonly MetadataDocument[];
|
|
12
|
+
readonly ids: ReadonlyMap<string, readonly MetadataDocument[]>;
|
|
13
|
+
readonly rootFile: string | undefined;
|
|
14
|
+
readonly unavailable: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function metadataModel(documents: readonly MetadataDocument[], unavailable: readonly string[] = [], rootFile?: string): MetadataModel {
|
|
18
|
+
const ids = new Map<string, MetadataDocument[]>();
|
|
19
|
+
for (const document of documents) {
|
|
20
|
+
const id = document.parsed.fields.id;
|
|
21
|
+
if (id) {
|
|
22
|
+
ids.set(id, [...(ids.get(id) ?? []), document]);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const diagnostics = new Map(documents.map((document) => [document.file, metadataProblems(document, ids, unavailable)]));
|
|
26
|
+
return { diagnostics, documents, ids, rootFile, unavailable };
|
|
27
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { LineCounter, parseDocument } from "yaml";
|
|
2
|
+
import { metadataFields } from "./fields.ts";
|
|
3
|
+
import { metadataLocations } from "./locations.ts";
|
|
4
|
+
import type { Metadata } from "./schema.ts";
|
|
5
|
+
|
|
6
|
+
export interface Diagnostic {
|
|
7
|
+
readonly field?: string;
|
|
8
|
+
readonly line: number;
|
|
9
|
+
readonly message: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ParsedMetadata {
|
|
12
|
+
readonly body: string;
|
|
13
|
+
readonly bodyLine: number;
|
|
14
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
15
|
+
readonly fields: Metadata;
|
|
16
|
+
readonly lines: Readonly<Record<string, number>>;
|
|
17
|
+
readonly raw?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function metadataParse(source: string): ParsedMetadata {
|
|
21
|
+
const plain: ParsedMetadata = { body: source, bodyLine: 1, diagnostics: [], fields: {}, lines: {} };
|
|
22
|
+
const opening = /^\uFEFF?---[ \t]*\r?\n/u.exec(source);
|
|
23
|
+
if (!opening) {
|
|
24
|
+
return plain;
|
|
25
|
+
}
|
|
26
|
+
const remainder = source.slice(opening[0].length);
|
|
27
|
+
const closing = /^---[ \t]*\r?$/mu.exec(remainder);
|
|
28
|
+
if (!closing) {
|
|
29
|
+
return { ...plain, diagnostics: [{ line: 1, message: "Unterminated frontmatter; the original Markdown is shown." }] };
|
|
30
|
+
}
|
|
31
|
+
let end = opening[0].length + closing.index + closing[0].length;
|
|
32
|
+
if (source[end] === "\n") {
|
|
33
|
+
end += 1;
|
|
34
|
+
}
|
|
35
|
+
const raw = source.slice(0, end);
|
|
36
|
+
const body = source.slice(end);
|
|
37
|
+
const bodyLine = raw.split("\n").length;
|
|
38
|
+
const counter = new LineCounter();
|
|
39
|
+
const doc = parseDocument(remainder.slice(0, closing.index), { lineCounter: counter });
|
|
40
|
+
const lines = metadataLocations(doc.contents, counter);
|
|
41
|
+
const diagnostics: Diagnostic[] = [...doc.errors, ...doc.warnings].map((error) => ({
|
|
42
|
+
line: (error.linePos?.[0].line ?? 1) + 1,
|
|
43
|
+
message: error.message,
|
|
44
|
+
}));
|
|
45
|
+
const result = { body, bodyLine, diagnostics, fields: {}, lines, raw };
|
|
46
|
+
if (doc.errors.length > 0 || doc.warnings.length > 0) {
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
let data: unknown;
|
|
50
|
+
try {
|
|
51
|
+
data = doc.toJS({ maxAliasCount: 50 });
|
|
52
|
+
} catch {
|
|
53
|
+
return { ...result, diagnostics: [...diagnostics, { line: 2, message: "Frontmatter aliases could not be expanded safely." }] };
|
|
54
|
+
}
|
|
55
|
+
if (data === null) {
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
if (typeof data !== "object" || Array.isArray(data)) {
|
|
59
|
+
return { ...plain, diagnostics: [{ line: 2, message: "Frontmatter must be a mapping; the original Markdown is shown." }] };
|
|
60
|
+
}
|
|
61
|
+
return { ...result, ...metadataFields(data, lines) };
|
|
62
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { MetadataDocument } from "./model.ts";
|
|
2
|
+
import type { Diagnostic } from "./parse.ts";
|
|
3
|
+
|
|
4
|
+
function referenceProblem(target: string, ids: ReadonlyMap<string, readonly MetadataDocument[]>): string | undefined {
|
|
5
|
+
const [id = "", criterion] = target.split("#");
|
|
6
|
+
const matches = ids.get(id);
|
|
7
|
+
if (!matches?.length) {
|
|
8
|
+
return `Unresolved reference ${target}.`;
|
|
9
|
+
}
|
|
10
|
+
if (matches.length > 1) {
|
|
11
|
+
return `Ambiguous reference ${target}; the ID is duplicated.`;
|
|
12
|
+
}
|
|
13
|
+
if (criterion && matches[0]?.parsed.fields.criteria?.filter((item) => item.id === criterion).length !== 1) {
|
|
14
|
+
return `Unresolved or duplicated criterion ${target}.`;
|
|
15
|
+
}
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
function criterionProblems(document: MetadataDocument): readonly Diagnostic[] {
|
|
19
|
+
const criteria = document.parsed.fields.criteria ?? [];
|
|
20
|
+
return criteria.flatMap((criterion, index) =>
|
|
21
|
+
criteria.filter((item) => item.id === criterion.id).length > 1
|
|
22
|
+
? [{ field: "criteria", line: document.parsed.lines[`criteria.${index}`] ?? 2, message: `Duplicate criterion ID ${criterion.id}.` }]
|
|
23
|
+
: [],
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
function referenceProblems(document: MetadataDocument, ids: ReadonlyMap<string, readonly MetadataDocument[]>): readonly Diagnostic[] {
|
|
27
|
+
const { fields, lines } = document.parsed;
|
|
28
|
+
const references = [
|
|
29
|
+
...(fields.relationships?.map((link, index) => ({ field: "relationships", key: `relationships.${index}.target`, target: link.target })) ?? []),
|
|
30
|
+
...(fields.items?.map((target, index) => ({ field: "items", key: `items.${index}`, target })) ?? []),
|
|
31
|
+
...(fields.evidence?.flatMap((item, index) =>
|
|
32
|
+
item.criterion ? [{ field: "evidence", key: `evidence.${index}.criterion`, target: item.criterion }] : [],
|
|
33
|
+
) ?? []),
|
|
34
|
+
];
|
|
35
|
+
return references.flatMap((reference) => {
|
|
36
|
+
const message = referenceProblem(reference.target, ids);
|
|
37
|
+
return message ? [{ field: reference.field, line: lines[reference.key] ?? lines[reference.field] ?? 2, message }] : [];
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
export function metadataProblems(document: MetadataDocument, ids: ReadonlyMap<string, readonly MetadataDocument[]>, unavailable: readonly string[]) {
|
|
41
|
+
const { parsed } = document;
|
|
42
|
+
const problems = [...parsed.diagnostics, ...criterionProblems(document)];
|
|
43
|
+
const matches = parsed.fields.id ? ids.get(parsed.fields.id) : undefined;
|
|
44
|
+
if (matches && matches.length > 1) {
|
|
45
|
+
problems.push({
|
|
46
|
+
field: "id",
|
|
47
|
+
line: parsed.lines.id ?? 2,
|
|
48
|
+
message: `Duplicate ID ${parsed.fields.id}: ${matches.map((item) => item.file).join(", ")}. No source is selected.`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (unavailable.length > 0) {
|
|
52
|
+
if (parsed.raw !== undefined) {
|
|
53
|
+
problems.push({
|
|
54
|
+
line: 1,
|
|
55
|
+
message: `Workspace index incomplete; could not read ${unavailable.join(", ")}. Identity and references cannot be validated completely.`,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
problems.push(...referenceProblems(document, ids));
|
|
60
|
+
}
|
|
61
|
+
return problems;
|
|
62
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const labels = new Map([
|
|
2
|
+
["board member", "Board membership"],
|
|
3
|
+
["criterion evidence", "Criterion evidence"],
|
|
4
|
+
["depends_on", "Depends on"],
|
|
5
|
+
["evidence source", "Evidence source"],
|
|
6
|
+
["implements", "Implements"],
|
|
7
|
+
["informs", "Informs"],
|
|
8
|
+
["relates_to", "Related to"],
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
export function relationshipLabel(kind: string): string {
|
|
12
|
+
return labels.get(kind) ?? kind;
|
|
13
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
export const Identity = Schema.String.check(Schema.isPattern(/^[a-zA-Z\d][a-zA-Z\d._-]*$/u));
|
|
4
|
+
export const Reference = Schema.String.check(Schema.isPattern(/^[a-zA-Z\d][a-zA-Z\d._-]*(?:#[a-zA-Z\d][a-zA-Z\d._-]*)?$/u));
|
|
5
|
+
const Text = Schema.String.check(Schema.isPattern(/\S/u));
|
|
6
|
+
const Criterion = Schema.Struct({ id: Identity, text: Text });
|
|
7
|
+
const Relationship = Schema.Struct({ kind: Schema.Literals(["implements", "informs", "depends_on", "relates_to"]), target: Reference });
|
|
8
|
+
const Evidence = Schema.Struct({
|
|
9
|
+
checkedRevision: Schema.optional(Schema.String.check(Schema.isPattern(/^(?:[a-f\d]{40}|[a-f\d]{64})$/iu))),
|
|
10
|
+
criterion: Schema.optional(Reference),
|
|
11
|
+
method: Schema.optional(Text),
|
|
12
|
+
observedAt: Schema.optional(
|
|
13
|
+
Text.check(
|
|
14
|
+
Schema.makeFilter(
|
|
15
|
+
(value) =>
|
|
16
|
+
(Number.isFinite(Date.parse(value)) && /^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/u.exec(value) !== null)
|
|
17
|
+
|| "Expected an ISO timestamp with timezone",
|
|
18
|
+
),
|
|
19
|
+
),
|
|
20
|
+
),
|
|
21
|
+
outcome: Schema.optional(Text),
|
|
22
|
+
source: Text,
|
|
23
|
+
}).pipe(Schema.encodeKeys({ checkedRevision: "checked_revision", observedAt: "observed_at" }));
|
|
24
|
+
|
|
25
|
+
export const Metadata = Schema.Struct({
|
|
26
|
+
criteria: Schema.optional(Schema.Array(Criterion)),
|
|
27
|
+
evidence: Schema.optional(Schema.Array(Evidence)),
|
|
28
|
+
id: Schema.optional(Identity),
|
|
29
|
+
items: Schema.optional(Schema.Array(Identity)),
|
|
30
|
+
kind: Schema.optional(Schema.Literals(["task", "investigation", "decision", "result", "project", "board"])),
|
|
31
|
+
nextAction: Schema.optional(Text),
|
|
32
|
+
owner: Schema.optional(Text),
|
|
33
|
+
relationships: Schema.optional(Schema.Array(Relationship)),
|
|
34
|
+
status: Schema.optional(Text),
|
|
35
|
+
}).pipe(Schema.encodeKeys({ nextAction: "next_action" }));
|
|
36
|
+
export type Metadata = typeof Metadata.Type;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { fileUrl } from "#files/url.ts";
|
|
2
|
+
import { referenceTarget } from "./links.ts";
|
|
3
|
+
import type { MetadataModel } from "./model.ts";
|
|
4
|
+
|
|
5
|
+
const BASE = "http://work-board.local";
|
|
6
|
+
const absolute = /^(?:[a-z][a-z\d+.-]*:|\/\/)/iu;
|
|
7
|
+
|
|
8
|
+
export function sourceHref(source: string, file: string): string | undefined {
|
|
9
|
+
try {
|
|
10
|
+
const url = new URL(source, `${BASE}${fileUrl(file)}`);
|
|
11
|
+
if (!["http:", "https:"].includes(url.protocol)) {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
return absolute.exec(source) === null ? url.pathname + url.search + url.hash : url.href;
|
|
15
|
+
} catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function referenceFile(reference: string, model: MetadataModel): string | undefined {
|
|
21
|
+
if (!referenceTarget(reference, model)) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
return model.ids.get(reference.split("#")[0] ?? "")?.[0]?.file;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function sourceFile(source: string, file: string, model: MetadataModel): string | undefined {
|
|
28
|
+
const href = sourceHref(source, file);
|
|
29
|
+
if (!href?.startsWith("/")) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
const url = new URL(href, BASE);
|
|
33
|
+
try {
|
|
34
|
+
const path = decodeURIComponent(url.pathname);
|
|
35
|
+
if (path === "/") {
|
|
36
|
+
return model.rootFile;
|
|
37
|
+
}
|
|
38
|
+
const identity = /^\/_board\/item\/(?<id>[^/]+)\/$/u.exec(path)?.groups?.id;
|
|
39
|
+
if (identity) {
|
|
40
|
+
return referenceFile(identity, model);
|
|
41
|
+
}
|
|
42
|
+
return /\.md$/iu.exec(path) === null ? undefined : path.slice(1);
|
|
43
|
+
} catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { backlinksOf } from "#metadata/backlinks.ts";
|
|
2
|
+
import type { MetadataModel } from "#metadata/model.ts";
|
|
3
|
+
import { relationshipLabel } from "#metadata/relationshipLabel.ts";
|
|
4
|
+
import { escapeHtml } from "./escape.ts";
|
|
5
|
+
import { documentReference } from "./metadataLinks.ts";
|
|
6
|
+
|
|
7
|
+
export function backlinksHtml(file: string, model: MetadataModel): string {
|
|
8
|
+
const links = backlinksOf(file, model);
|
|
9
|
+
if (links.length === 0) {
|
|
10
|
+
return "";
|
|
11
|
+
}
|
|
12
|
+
const entries = [...new Map(links.map((link) => [`${link.file}:${link.kind}:${link.line ?? ""}`, link])).values()];
|
|
13
|
+
const incomplete =
|
|
14
|
+
model.unavailable.length > 0
|
|
15
|
+
? "<p>Available source references only; unreadable files prevent complete backlink and identity validation.</p>"
|
|
16
|
+
: "";
|
|
17
|
+
return `<section class="work-context" aria-label="Source backlinks"><details data-key="work-backlinks"><summary>Referenced by (${entries.length})</summary>${incomplete}<ul>${entries.map((link) => `<li>${escapeHtml(relationshipLabel(link.kind))}: <a href="${escapeHtml(documentReference(link.file, model))}">${escapeHtml(link.file)}${link.line === undefined ? "" : `:${link.line}`}</a></li>`).join("")}</ul><p>These are explicit source links, not inferred blockers or workflow rules.</p></details></section>`;
|
|
18
|
+
}
|