marksites 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +153 -0
- package/dist/annotations/model.d.ts +29 -0
- package/dist/annotations/model.js +56 -0
- package/dist/annotations/storage.d.ts +4 -0
- package/dist/annotations/storage.js +31 -0
- package/dist/cli/directory.d.ts +4 -0
- package/dist/cli/directory.js +3 -0
- package/dist/cli/open-browser.d.ts +12 -0
- package/dist/cli/open-browser.js +37 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +125 -0
- package/dist/conversion/directory.d.ts +3 -0
- package/dist/conversion/directory.js +208 -0
- package/dist/conversion/gitignore.d.ts +8 -0
- package/dist/conversion/gitignore.js +43 -0
- package/dist/conversion/manifest.d.ts +8 -0
- package/dist/conversion/manifest.js +40 -0
- package/dist/conversion/navigation.d.ts +4 -0
- package/dist/conversion/navigation.js +70 -0
- package/dist/conversion/paths.d.ts +10 -0
- package/dist/conversion/paths.js +86 -0
- package/dist/conversion/rendering.d.ts +5 -0
- package/dist/conversion/rendering.js +39 -0
- package/dist/conversion/single-file.d.ts +1 -0
- package/dist/conversion/single-file.js +35 -0
- package/dist/conversion/types.d.ts +39 -0
- package/dist/conversion/types.js +1 -0
- package/dist/features/annotations/index.d.ts +7 -0
- package/dist/features/annotations/index.js +79 -0
- package/dist/features/annotations.d.ts +9 -0
- package/dist/features/annotations.js +78 -0
- package/dist/features/code-blocks/index.d.ts +5 -0
- package/dist/features/code-blocks/index.js +97 -0
- package/dist/features/code-blocks.d.ts +5 -0
- package/dist/features/code-blocks.js +89 -0
- package/dist/features/file-tree/index.d.ts +7 -0
- package/dist/features/file-tree/index.js +347 -0
- package/dist/features/file-tree.d.ts +7 -0
- package/dist/features/file-tree.js +325 -0
- package/dist/features/header/index.d.ts +4 -0
- package/dist/features/header/index.js +47 -0
- package/dist/features/header.d.ts +6 -0
- package/dist/features/header.js +47 -0
- package/dist/features/sidebar/index.d.ts +10 -0
- package/dist/features/sidebar/index.js +55 -0
- package/dist/features/sidebar.d.ts +13 -0
- package/dist/features/sidebar.js +55 -0
- package/dist/features/table-of-contents/index.d.ts +16 -0
- package/dist/features/table-of-contents/index.js +91 -0
- package/dist/features/table-of-contents.d.ts +16 -0
- package/dist/features/table-of-contents.js +91 -0
- package/dist/features/types.d.ts +6 -0
- package/dist/features/types.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/markdown-to-html.d.ts +5 -0
- package/dist/markdown-to-html.js +77 -0
- package/dist/server/annotation-repository.d.ts +35 -0
- package/dist/server/annotation-repository.js +184 -0
- package/dist/server/api.d.ts +4 -0
- package/dist/server/api.js +77 -0
- package/dist/server/constants.d.ts +2 -0
- package/dist/server/constants.js +2 -0
- package/dist/server/html-security.d.ts +4 -0
- package/dist/server/html-security.js +61 -0
- package/dist/server/response.d.ts +2 -0
- package/dist/server/response.js +9 -0
- package/dist/server/server.d.ts +3 -0
- package/dist/server/server.js +77 -0
- package/dist/server/static-files.d.ts +2 -0
- package/dist/server/static-files.js +72 -0
- package/dist/server/types.d.ts +16 -0
- package/dist/server/types.js +1 -0
- package/dist/template/document.d.ts +20 -0
- package/dist/template/document.js +33 -0
- package/dist/template/styles.d.ts +5 -0
- package/dist/template/styles.js +109 -0
- package/dist/types.d.ts +52 -0
- package/dist/types.js +1 -0
- package/dist/utils/files.d.ts +1 -0
- package/dist/utils/files.js +9 -0
- package/dist/utils/html.d.ts +2 -0
- package/dist/utils/html.js +17 -0
- package/dist/utils/icons.d.ts +8 -0
- package/dist/utils/icons.js +24 -0
- package/package.json +43 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Renderer } from "marked";
|
|
2
|
+
interface TableOfContentsConfig {
|
|
3
|
+
enabled: boolean;
|
|
4
|
+
title: string;
|
|
5
|
+
minDepth: number;
|
|
6
|
+
maxDepth: number;
|
|
7
|
+
}
|
|
8
|
+
export interface TableOfContentsFeature {
|
|
9
|
+
render(): {
|
|
10
|
+
markup: string;
|
|
11
|
+
script: string;
|
|
12
|
+
title: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export declare function createTableOfContentsFeature(renderer: Renderer, config: TableOfContentsConfig): TableOfContentsFeature;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import GithubSlugger from "github-slugger";
|
|
2
|
+
import { escapeHtml, plainTextFromHtml } from "../../utils/html.js";
|
|
3
|
+
function renderTableOfContents(items, title, minDepth) {
|
|
4
|
+
if (items.length === 0)
|
|
5
|
+
return "";
|
|
6
|
+
const links = items
|
|
7
|
+
.map(({ depth, id, text }) => ` <li style="--toc-level: ${depth - minDepth}"><a href="#${escapeHtml(id)}">${escapeHtml(text)}</a></li>`)
|
|
8
|
+
.join("\n");
|
|
9
|
+
const escapedTitle = escapeHtml(title);
|
|
10
|
+
return `<nav class="table-of-contents sidebar-panel" id="sidebar-panel-toc" role="tabpanel" aria-labelledby="sidebar-tab-toc" aria-label="${escapedTitle}">
|
|
11
|
+
<div class="toc-panel">
|
|
12
|
+
<ul>
|
|
13
|
+
${links}
|
|
14
|
+
</ul>
|
|
15
|
+
</div>
|
|
16
|
+
</nav>
|
|
17
|
+
`;
|
|
18
|
+
}
|
|
19
|
+
function renderTableOfContentsScript() {
|
|
20
|
+
return `<script>
|
|
21
|
+
(() => {
|
|
22
|
+
const navigation = document.querySelector('.table-of-contents');
|
|
23
|
+
if (!navigation) return;
|
|
24
|
+
|
|
25
|
+
const panel = navigation.querySelector('.toc-panel');
|
|
26
|
+
const links = [...panel.querySelectorAll('a[href^="#"]')];
|
|
27
|
+
const entries = links
|
|
28
|
+
.map((link) => ({ link, heading: document.getElementById(link.getAttribute('href').slice(1)) }))
|
|
29
|
+
.filter((entry) => entry.heading);
|
|
30
|
+
if (entries.length === 0) return;
|
|
31
|
+
|
|
32
|
+
let scheduled = false;
|
|
33
|
+
const update = () => {
|
|
34
|
+
scheduled = false;
|
|
35
|
+
const marker = Math.min(160, window.innerHeight * 0.25);
|
|
36
|
+
let active = entries[0];
|
|
37
|
+
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
if (entry.heading.getBoundingClientRect().top > marker) break;
|
|
40
|
+
active = entry;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (entry === active) entry.link.setAttribute('aria-current', 'location');
|
|
45
|
+
else entry.link.removeAttribute('aria-current');
|
|
46
|
+
}
|
|
47
|
+
const navigationRect = navigation.getBoundingClientRect();
|
|
48
|
+
const activeRect = active.link.getBoundingClientRect();
|
|
49
|
+
if (!navigation.hidden && (activeRect.top < navigationRect.top || activeRect.bottom > navigationRect.bottom)) {
|
|
50
|
+
navigation.scrollTop += activeRect.top - navigationRect.top - navigation.clientHeight / 2;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const schedule = () => {
|
|
54
|
+
if (scheduled) return;
|
|
55
|
+
scheduled = true;
|
|
56
|
+
requestAnimationFrame(update);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
addEventListener('scroll', schedule, { passive: true });
|
|
60
|
+
addEventListener('resize', schedule);
|
|
61
|
+
update();
|
|
62
|
+
})();
|
|
63
|
+
</script>`;
|
|
64
|
+
}
|
|
65
|
+
export function createTableOfContentsFeature(renderer, config) {
|
|
66
|
+
const items = [];
|
|
67
|
+
const slugger = new GithubSlugger();
|
|
68
|
+
renderer.heading = ({ tokens, depth }) => {
|
|
69
|
+
const renderedText = renderer.parser.parseInline(tokens);
|
|
70
|
+
const plainText = plainTextFromHtml(renderedText);
|
|
71
|
+
const id = slugger.slug(plainText);
|
|
72
|
+
if (config.enabled &&
|
|
73
|
+
depth >= config.minDepth &&
|
|
74
|
+
depth <= config.maxDepth) {
|
|
75
|
+
items.push({ depth, id, text: plainText });
|
|
76
|
+
}
|
|
77
|
+
return `<h${depth} id="${escapeHtml(id)}">${renderedText}</h${depth}>\n`;
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
render() {
|
|
81
|
+
const markup = config.enabled
|
|
82
|
+
? renderTableOfContents(items, config.title, config.minDepth)
|
|
83
|
+
: "";
|
|
84
|
+
return {
|
|
85
|
+
markup,
|
|
86
|
+
script: markup ? renderTableOfContentsScript() : "",
|
|
87
|
+
title: config.title,
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Renderer } from "marked";
|
|
2
|
+
interface TableOfContentsConfig {
|
|
3
|
+
enabled: boolean;
|
|
4
|
+
title: string;
|
|
5
|
+
minDepth: number;
|
|
6
|
+
maxDepth: number;
|
|
7
|
+
}
|
|
8
|
+
export interface TableOfContentsFeature {
|
|
9
|
+
render(): {
|
|
10
|
+
markup: string;
|
|
11
|
+
script: string;
|
|
12
|
+
title: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export declare function createTableOfContentsFeature(renderer: Renderer, config: TableOfContentsConfig): TableOfContentsFeature;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import GithubSlugger from "github-slugger";
|
|
2
|
+
import { escapeHtml, plainTextFromHtml } from "../utils/html.js";
|
|
3
|
+
function renderTableOfContents(items, title, minDepth) {
|
|
4
|
+
if (items.length === 0)
|
|
5
|
+
return "";
|
|
6
|
+
const links = items
|
|
7
|
+
.map(({ depth, id, text }) => ` <li style="--toc-level: ${depth - minDepth}"><a href="#${escapeHtml(id)}">${escapeHtml(text)}</a></li>`)
|
|
8
|
+
.join("\n");
|
|
9
|
+
const escapedTitle = escapeHtml(title);
|
|
10
|
+
return `<nav class="table-of-contents sidebar-panel" id="sidebar-panel-toc" role="tabpanel" aria-labelledby="sidebar-tab-toc" aria-label="${escapedTitle}">
|
|
11
|
+
<div class="toc-panel">
|
|
12
|
+
<ul>
|
|
13
|
+
${links}
|
|
14
|
+
</ul>
|
|
15
|
+
</div>
|
|
16
|
+
</nav>
|
|
17
|
+
`;
|
|
18
|
+
}
|
|
19
|
+
function renderTableOfContentsScript() {
|
|
20
|
+
return `<script>
|
|
21
|
+
(() => {
|
|
22
|
+
const navigation = document.querySelector('.table-of-contents');
|
|
23
|
+
if (!navigation) return;
|
|
24
|
+
|
|
25
|
+
const panel = navigation.querySelector('.toc-panel');
|
|
26
|
+
const links = [...panel.querySelectorAll('a[href^="#"]')];
|
|
27
|
+
const entries = links
|
|
28
|
+
.map((link) => ({ link, heading: document.getElementById(link.getAttribute('href').slice(1)) }))
|
|
29
|
+
.filter((entry) => entry.heading);
|
|
30
|
+
if (entries.length === 0) return;
|
|
31
|
+
|
|
32
|
+
let scheduled = false;
|
|
33
|
+
const update = () => {
|
|
34
|
+
scheduled = false;
|
|
35
|
+
const marker = Math.min(160, window.innerHeight * 0.25);
|
|
36
|
+
let active = entries[0];
|
|
37
|
+
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
if (entry.heading.getBoundingClientRect().top > marker) break;
|
|
40
|
+
active = entry;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (entry === active) entry.link.setAttribute('aria-current', 'location');
|
|
45
|
+
else entry.link.removeAttribute('aria-current');
|
|
46
|
+
}
|
|
47
|
+
const navigationRect = navigation.getBoundingClientRect();
|
|
48
|
+
const activeRect = active.link.getBoundingClientRect();
|
|
49
|
+
if (!navigation.hidden && (activeRect.top < navigationRect.top || activeRect.bottom > navigationRect.bottom)) {
|
|
50
|
+
navigation.scrollTop += activeRect.top - navigationRect.top - navigation.clientHeight / 2;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const schedule = () => {
|
|
54
|
+
if (scheduled) return;
|
|
55
|
+
scheduled = true;
|
|
56
|
+
requestAnimationFrame(update);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
addEventListener('scroll', schedule, { passive: true });
|
|
60
|
+
addEventListener('resize', schedule);
|
|
61
|
+
update();
|
|
62
|
+
})();
|
|
63
|
+
</script>`;
|
|
64
|
+
}
|
|
65
|
+
export function createTableOfContentsFeature(renderer, config) {
|
|
66
|
+
const items = [];
|
|
67
|
+
const slugger = new GithubSlugger();
|
|
68
|
+
renderer.heading = ({ tokens, depth }) => {
|
|
69
|
+
const renderedText = renderer.parser.parseInline(tokens);
|
|
70
|
+
const plainText = plainTextFromHtml(renderedText);
|
|
71
|
+
const id = slugger.slug(plainText);
|
|
72
|
+
if (config.enabled &&
|
|
73
|
+
depth >= config.minDepth &&
|
|
74
|
+
depth <= config.maxDepth) {
|
|
75
|
+
items.push({ depth, id, text: plainText });
|
|
76
|
+
}
|
|
77
|
+
return `<h${depth} id="${escapeHtml(id)}">${renderedText}</h${depth}>\n`;
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
render() {
|
|
81
|
+
const markup = config.enabled
|
|
82
|
+
? renderTableOfContents(items, config.title, config.minDepth)
|
|
83
|
+
: "";
|
|
84
|
+
return {
|
|
85
|
+
markup,
|
|
86
|
+
script: markup ? renderTableOfContentsScript() : "",
|
|
87
|
+
title: config.title,
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { markdownToHtml } from "./markdown-to-html.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AnnotationDocument } from "./annotations/model.js";
|
|
2
|
+
import type { RenderOptions } from "./types.js";
|
|
3
|
+
/** Convert Markdown into a standalone HTML document styled like GitHub. */
|
|
4
|
+
export declare function markdownToHtml(markdown: string, options?: RenderOptions): string;
|
|
5
|
+
export declare function renderMarkdown(markdown: string, options?: RenderOptions, annotations?: AnnotationDocument): string;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { marked, Renderer } from "marked";
|
|
2
|
+
import { createCodeBlocksFeature } from "./features/code-blocks/index.js";
|
|
3
|
+
import { createHeaderFeature } from "./features/header/index.js";
|
|
4
|
+
import { createAnnotationsFeature } from "./features/annotations/index.js";
|
|
5
|
+
import { renderBreadcrumbs, renderFileSidebar, renderFileTree, renderFileTreeScript, renderModifiedAt, renderModifiedAtScript, } from "./features/file-tree/index.js";
|
|
6
|
+
import { createTableOfContentsFeature } from "./features/table-of-contents/index.js";
|
|
7
|
+
import { createSidebarFeature } from "./features/sidebar/index.js";
|
|
8
|
+
import { renderDocument } from "./template/document.js";
|
|
9
|
+
import { escapeHtml } from "./utils/html.js";
|
|
10
|
+
/** Convert Markdown into a standalone HTML document styled like GitHub. */
|
|
11
|
+
export function markdownToHtml(markdown, options = {}) {
|
|
12
|
+
return renderMarkdown(markdown, options);
|
|
13
|
+
}
|
|
14
|
+
export function renderMarkdown(markdown, options = {}, annotations) {
|
|
15
|
+
const rawTitle = options.title ?? "Markdown文書";
|
|
16
|
+
const title = escapeHtml(rawTitle);
|
|
17
|
+
const language = escapeHtml(options.language ?? "ja");
|
|
18
|
+
const highlight = options.highlight ?? true;
|
|
19
|
+
const tocOptions = typeof options.tableOfContents === "object" ? options.tableOfContents : {};
|
|
20
|
+
const renderer = new Renderer();
|
|
21
|
+
const tableOfContents = createTableOfContentsFeature(renderer, {
|
|
22
|
+
enabled: options.tableOfContents !== false,
|
|
23
|
+
title: tocOptions.title ?? "目次",
|
|
24
|
+
minDepth: tocOptions.minDepth ?? 2,
|
|
25
|
+
maxDepth: tocOptions.maxDepth ?? 6,
|
|
26
|
+
});
|
|
27
|
+
const codeBlocks = createCodeBlocksFeature(renderer, highlight);
|
|
28
|
+
const header = createHeaderFeature();
|
|
29
|
+
const content = marked.parse(markdown, {
|
|
30
|
+
...options.markedOptions,
|
|
31
|
+
renderer,
|
|
32
|
+
async: false,
|
|
33
|
+
});
|
|
34
|
+
const toc = tableOfContents.render();
|
|
35
|
+
const fileTree = renderFileTree(options.fileTree);
|
|
36
|
+
const fileSidebar = renderFileSidebar(options.fileTree);
|
|
37
|
+
const fileTreeScript = renderFileTreeScript(fileTree !== "");
|
|
38
|
+
const modifiedAt = renderModifiedAt(options.modifiedAt);
|
|
39
|
+
const breadcrumbs = fileTree
|
|
40
|
+
? renderBreadcrumbs(options.fileTree?.breadcrumbs, options.modifiedAt)
|
|
41
|
+
: modifiedAt
|
|
42
|
+
? `<div class="document-metadata">${modifiedAt}</div>\n`
|
|
43
|
+
: "";
|
|
44
|
+
const annotationFeature = createAnnotationsFeature(annotations);
|
|
45
|
+
const sidebar = createSidebarFeature({
|
|
46
|
+
tableOfContents: toc.markup,
|
|
47
|
+
tableOfContentsTitle: toc.title,
|
|
48
|
+
annotations: annotationFeature.panel,
|
|
49
|
+
annotationCount: annotationFeature.count,
|
|
50
|
+
});
|
|
51
|
+
return renderDocument({
|
|
52
|
+
title,
|
|
53
|
+
language,
|
|
54
|
+
content,
|
|
55
|
+
highlight,
|
|
56
|
+
regions: {
|
|
57
|
+
header: header.markup,
|
|
58
|
+
breadcrumbs,
|
|
59
|
+
fileTree,
|
|
60
|
+
fileSidebar,
|
|
61
|
+
sidebar: sidebar.markup,
|
|
62
|
+
overlays: annotationFeature.markup,
|
|
63
|
+
},
|
|
64
|
+
assets: {
|
|
65
|
+
styles: [sidebar.styles, annotationFeature.styles, header.styles],
|
|
66
|
+
scripts: [
|
|
67
|
+
header.script,
|
|
68
|
+
fileTreeScript,
|
|
69
|
+
renderModifiedAtScript(modifiedAt !== ""),
|
|
70
|
+
sidebar.script,
|
|
71
|
+
toc.script,
|
|
72
|
+
`\n${codeBlocks.renderScript()}\n`,
|
|
73
|
+
`${annotationFeature.script}\n`,
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type AnnotationDocument, type AnnotationSelection } from "../annotations/model.js";
|
|
2
|
+
export declare class AnnotationRepository {
|
|
3
|
+
private outputRoot;
|
|
4
|
+
private documents;
|
|
5
|
+
private onChange;
|
|
6
|
+
private queues;
|
|
7
|
+
constructor(outputRoot: string, documents: Map<string, string>, onChange: (document: string, data: AnnotationDocument) => Promise<void>);
|
|
8
|
+
private path;
|
|
9
|
+
get(document: string): Promise<AnnotationDocument>;
|
|
10
|
+
exportProject(): Promise<{
|
|
11
|
+
schemaVersion: 1;
|
|
12
|
+
documents: AnnotationDocument[];
|
|
13
|
+
}>;
|
|
14
|
+
importProject(value: unknown, replace?: boolean): Promise<AnnotationDocument[]>;
|
|
15
|
+
private locked;
|
|
16
|
+
private checkRevision;
|
|
17
|
+
create(document: string, input: {
|
|
18
|
+
baseRevision: unknown;
|
|
19
|
+
selection: AnnotationSelection;
|
|
20
|
+
comment: {
|
|
21
|
+
body: string;
|
|
22
|
+
author?: string | null;
|
|
23
|
+
};
|
|
24
|
+
}): Promise<AnnotationDocument>;
|
|
25
|
+
update(document: string, id: string, input: {
|
|
26
|
+
baseRevision: unknown;
|
|
27
|
+
comment?: {
|
|
28
|
+
body: string;
|
|
29
|
+
author?: string | null;
|
|
30
|
+
};
|
|
31
|
+
status?: "open" | "archived";
|
|
32
|
+
}): Promise<AnnotationDocument>;
|
|
33
|
+
delete(document: string, id: string, baseRevision: unknown): Promise<AnnotationDocument>;
|
|
34
|
+
private validateLimits;
|
|
35
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { resolve, sep } from "node:path";
|
|
3
|
+
import { readAnnotations, writeAnnotations } from "../annotations/storage.js";
|
|
4
|
+
import { validateAnnotation, validateAnnotationDocument, } from "../annotations/model.js";
|
|
5
|
+
const MAX_BODY = 10_000, MAX_EXACT = 20_000, MAX_ANNOTATIONS = 5_000;
|
|
6
|
+
export class AnnotationRepository {
|
|
7
|
+
outputRoot;
|
|
8
|
+
documents;
|
|
9
|
+
onChange;
|
|
10
|
+
queues = new Map();
|
|
11
|
+
constructor(outputRoot, documents, onChange) {
|
|
12
|
+
this.outputRoot = outputRoot;
|
|
13
|
+
this.documents = documents;
|
|
14
|
+
this.onChange = onChange;
|
|
15
|
+
}
|
|
16
|
+
path(document) {
|
|
17
|
+
const relative = this.documents.get(document);
|
|
18
|
+
if (!relative)
|
|
19
|
+
throw Object.assign(new Error("Unknown document"), { statusCode: 404 });
|
|
20
|
+
const path = resolve(this.outputRoot, ...relative.split("/"));
|
|
21
|
+
if (path !== this.outputRoot && !path.startsWith(this.outputRoot + sep))
|
|
22
|
+
throw Object.assign(new Error("Invalid document path"), {
|
|
23
|
+
statusCode: 400,
|
|
24
|
+
});
|
|
25
|
+
return path;
|
|
26
|
+
}
|
|
27
|
+
get(document) {
|
|
28
|
+
return readAnnotations(this.path(document), document);
|
|
29
|
+
}
|
|
30
|
+
async exportProject() {
|
|
31
|
+
const documents = [];
|
|
32
|
+
for (const document of [...this.documents.keys()].sort())
|
|
33
|
+
documents.push(await this.get(document));
|
|
34
|
+
return { schemaVersion: 1, documents };
|
|
35
|
+
}
|
|
36
|
+
async importProject(value, replace = false) {
|
|
37
|
+
if (typeof value !== "object" ||
|
|
38
|
+
value === null ||
|
|
39
|
+
value.schemaVersion !== 1 ||
|
|
40
|
+
!Array.isArray(value.documents))
|
|
41
|
+
throw Object.assign(new Error("Invalid annotation export"), {
|
|
42
|
+
statusCode: 400,
|
|
43
|
+
});
|
|
44
|
+
const imported = [];
|
|
45
|
+
for (const candidate of value.documents) {
|
|
46
|
+
const document = candidate?.document;
|
|
47
|
+
if (typeof document !== "string")
|
|
48
|
+
throw Object.assign(new Error("Invalid imported document"), {
|
|
49
|
+
statusCode: 400,
|
|
50
|
+
});
|
|
51
|
+
let incoming;
|
|
52
|
+
try {
|
|
53
|
+
incoming = validateAnnotationDocument(candidate, document);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw Object.assign(new Error(`Invalid imported annotations for ${document}`), { statusCode: 400 });
|
|
57
|
+
}
|
|
58
|
+
await this.locked(document, async () => {
|
|
59
|
+
const current = await this.get(document);
|
|
60
|
+
const ids = new Set(current.annotations.map((item) => item.id));
|
|
61
|
+
const additions = incoming.annotations.filter((item) => !ids.has(item.id));
|
|
62
|
+
if (!replace && additions.length !== incoming.annotations.length)
|
|
63
|
+
throw Object.assign(new Error(`Duplicate annotation ID in ${document}`), { statusCode: 409 });
|
|
64
|
+
const next = {
|
|
65
|
+
schemaVersion: 1,
|
|
66
|
+
document,
|
|
67
|
+
revision: current.revision + 1,
|
|
68
|
+
annotations: replace
|
|
69
|
+
? incoming.annotations
|
|
70
|
+
: [...current.annotations, ...additions],
|
|
71
|
+
};
|
|
72
|
+
if (next.annotations.length > MAX_ANNOTATIONS)
|
|
73
|
+
throw Object.assign(new Error("Too many annotations"), {
|
|
74
|
+
statusCode: 413,
|
|
75
|
+
});
|
|
76
|
+
await writeAnnotations(this.path(document), next);
|
|
77
|
+
await this.onChange(document, next);
|
|
78
|
+
imported.push(next);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return imported;
|
|
82
|
+
}
|
|
83
|
+
async locked(document, operation) {
|
|
84
|
+
const prior = this.queues.get(document) ?? Promise.resolve();
|
|
85
|
+
let release;
|
|
86
|
+
const next = new Promise((r) => (release = r));
|
|
87
|
+
const queued = prior.then(() => next);
|
|
88
|
+
this.queues.set(document, queued);
|
|
89
|
+
await prior;
|
|
90
|
+
try {
|
|
91
|
+
return await operation();
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
release();
|
|
95
|
+
if (this.queues.get(document) === queued)
|
|
96
|
+
this.queues.delete(document);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
checkRevision(data, baseRevision) {
|
|
100
|
+
if (baseRevision !== data.revision)
|
|
101
|
+
throw Object.assign(new Error("Annotation revision conflict"), {
|
|
102
|
+
statusCode: 409,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
async create(document, input) {
|
|
106
|
+
return this.locked(document, async () => {
|
|
107
|
+
const data = await this.get(document);
|
|
108
|
+
this.checkRevision(data, input.baseRevision);
|
|
109
|
+
if (data.annotations.length >= MAX_ANNOTATIONS)
|
|
110
|
+
throw Object.assign(new Error("Too many annotations"), {
|
|
111
|
+
statusCode: 413,
|
|
112
|
+
});
|
|
113
|
+
const now = new Date().toISOString();
|
|
114
|
+
const annotation = {
|
|
115
|
+
id: randomUUID(),
|
|
116
|
+
selection: input.selection,
|
|
117
|
+
comment: {
|
|
118
|
+
body: input.comment?.body,
|
|
119
|
+
author: input.comment?.author ?? null,
|
|
120
|
+
},
|
|
121
|
+
status: "open",
|
|
122
|
+
createdAt: now,
|
|
123
|
+
updatedAt: now,
|
|
124
|
+
};
|
|
125
|
+
validateAnnotation(annotation);
|
|
126
|
+
this.validateLimits(annotation);
|
|
127
|
+
data.annotations.push(annotation);
|
|
128
|
+
data.revision++;
|
|
129
|
+
await writeAnnotations(this.path(document), data);
|
|
130
|
+
await this.onChange(document, data);
|
|
131
|
+
return data;
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
async update(document, id, input) {
|
|
135
|
+
return this.locked(document, async () => {
|
|
136
|
+
const data = await this.get(document);
|
|
137
|
+
this.checkRevision(data, input.baseRevision);
|
|
138
|
+
const item = data.annotations.find((a) => a.id === id);
|
|
139
|
+
if (!item)
|
|
140
|
+
throw Object.assign(new Error("Annotation not found"), {
|
|
141
|
+
statusCode: 404,
|
|
142
|
+
});
|
|
143
|
+
if (input.comment)
|
|
144
|
+
item.comment = {
|
|
145
|
+
body: input.comment.body,
|
|
146
|
+
author: input.comment.author ?? item.comment.author,
|
|
147
|
+
};
|
|
148
|
+
if (input.status)
|
|
149
|
+
item.status = input.status;
|
|
150
|
+
item.updatedAt = new Date().toISOString();
|
|
151
|
+
validateAnnotation(item);
|
|
152
|
+
this.validateLimits(item);
|
|
153
|
+
data.revision++;
|
|
154
|
+
await writeAnnotations(this.path(document), data);
|
|
155
|
+
await this.onChange(document, data);
|
|
156
|
+
return data;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
async delete(document, id, baseRevision) {
|
|
160
|
+
return this.locked(document, async () => {
|
|
161
|
+
const data = await this.get(document);
|
|
162
|
+
this.checkRevision(data, baseRevision);
|
|
163
|
+
const index = data.annotations.findIndex((a) => a.id === id);
|
|
164
|
+
if (index < 0)
|
|
165
|
+
throw Object.assign(new Error("Annotation not found"), {
|
|
166
|
+
statusCode: 404,
|
|
167
|
+
});
|
|
168
|
+
data.annotations.splice(index, 1);
|
|
169
|
+
data.revision++;
|
|
170
|
+
await writeAnnotations(this.path(document), data);
|
|
171
|
+
await this.onChange(document, data);
|
|
172
|
+
return data;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
validateLimits(a) {
|
|
176
|
+
if (a.comment.body.length > MAX_BODY ||
|
|
177
|
+
a.selection.exact.length > MAX_EXACT ||
|
|
178
|
+
a.selection.prefix.length > 500 ||
|
|
179
|
+
a.selection.suffix.length > 500)
|
|
180
|
+
throw Object.assign(new Error("Annotation is too large"), {
|
|
181
|
+
statusCode: 413,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { AnnotationRepository } from "./annotation-repository.js";
|
|
3
|
+
import type { MarksitesServerOptions } from "./types.js";
|
|
4
|
+
export declare function handleApi(request: IncomingMessage, response: ServerResponse, url: URL, origin: string, options: MarksitesServerOptions, repository: AnnotationRepository): Promise<void>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { MARKSITES_API_BASE_PATH } from "./constants.js";
|
|
2
|
+
import { sendJson } from "./response.js";
|
|
3
|
+
const MAX_REQUEST = 128 * 1024;
|
|
4
|
+
async function readBody(request) {
|
|
5
|
+
let size = 0;
|
|
6
|
+
const chunks = [];
|
|
7
|
+
for await (const chunk of request) {
|
|
8
|
+
size += chunk.length;
|
|
9
|
+
if (size > MAX_REQUEST)
|
|
10
|
+
throw Object.assign(new Error("Request body is too large"), {
|
|
11
|
+
statusCode: 413,
|
|
12
|
+
});
|
|
13
|
+
chunks.push(chunk);
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw Object.assign(new Error("Invalid JSON request"), { statusCode: 400 });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function safeDocument(value) {
|
|
23
|
+
if (typeof value !== "string" ||
|
|
24
|
+
!value ||
|
|
25
|
+
value.includes("\\") ||
|
|
26
|
+
value.startsWith("/") ||
|
|
27
|
+
value.split("/").includes("..")) {
|
|
28
|
+
throw Object.assign(new Error("Invalid document"), { statusCode: 400 });
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
export async function handleApi(request, response, url, origin, options, repository) {
|
|
33
|
+
if (!url.pathname.startsWith(MARKSITES_API_BASE_PATH))
|
|
34
|
+
return sendJson(response, 404, "Unknown internal API");
|
|
35
|
+
if (request.headers.origin && request.headers.origin !== origin)
|
|
36
|
+
return sendJson(response, 403, "Invalid Origin");
|
|
37
|
+
const route = url.pathname.slice(MARKSITES_API_BASE_PATH.length) || "/";
|
|
38
|
+
if (request.method === "GET" && route === "/health")
|
|
39
|
+
return sendJson(response, 200, { status: "ok" });
|
|
40
|
+
if (request.method === "GET" && route === "/runtime")
|
|
41
|
+
return sendJson(response, 200, {
|
|
42
|
+
service: "marksites",
|
|
43
|
+
apiVersion: 1,
|
|
44
|
+
projectId: options.projectId,
|
|
45
|
+
editable: options.editable ?? true,
|
|
46
|
+
capabilities: options.editable === false
|
|
47
|
+
? []
|
|
48
|
+
: ["annotations:read", "annotations:write"],
|
|
49
|
+
});
|
|
50
|
+
if (request.method === "GET" && route === "/project")
|
|
51
|
+
return sendJson(response, 200, {
|
|
52
|
+
id: options.projectId,
|
|
53
|
+
name: options.projectName,
|
|
54
|
+
});
|
|
55
|
+
if (request.method === "GET" && route === "/annotations")
|
|
56
|
+
return sendJson(response, 200, await repository.get(safeDocument(url.searchParams.get("document"))));
|
|
57
|
+
if (request.method === "GET" && route === "/annotations/export")
|
|
58
|
+
return sendJson(response, 200, await repository.exportProject());
|
|
59
|
+
if (request.headers["content-type"]?.split(";")[0] !== "application/json")
|
|
60
|
+
throw Object.assign(new Error("Content-Type must be application/json"), {
|
|
61
|
+
statusCode: 400,
|
|
62
|
+
});
|
|
63
|
+
const input = (await readBody(request));
|
|
64
|
+
if (request.method === "POST" && route === "/annotations")
|
|
65
|
+
return sendJson(response, 201, await repository.create(safeDocument(input.document), input));
|
|
66
|
+
if (request.method === "POST" && route === "/annotations/import") {
|
|
67
|
+
if (input.replace === true && input.confirmReplace !== true)
|
|
68
|
+
throw Object.assign(new Error("Replacing annotations requires confirmReplace"), { statusCode: 400 });
|
|
69
|
+
return sendJson(response, 200, await repository.importProject(input.export, input.replace === true));
|
|
70
|
+
}
|
|
71
|
+
const match = /^\/annotations\/([^/]+)$/.exec(route);
|
|
72
|
+
if (match && request.method === "PATCH")
|
|
73
|
+
return sendJson(response, 200, await repository.update(safeDocument(input.document), decodeURIComponent(match[1]), input));
|
|
74
|
+
if (match && request.method === "DELETE")
|
|
75
|
+
return sendJson(response, 200, await repository.delete(safeDocument(input.document), decodeURIComponent(match[1]), input.baseRevision));
|
|
76
|
+
return sendJson(response, 404, "Unknown API route");
|
|
77
|
+
}
|