marksites 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,11 +25,11 @@ const html = markdownToHtml(markdown, {
25
25
  });
26
26
  ```
27
27
 
28
- 見出しレベル2から6までの目次と、GitHub互換の見出しアンカーが自動生成されます。ファイルツリーはGitHubのFilesペインと同様、画面左端へ接する全高のサイドバーとして常時表示し、本文を中央、`目次`とコメントを画面右側へ配置します。左サイドバーはヘッダーの四角いペインアイコンで折り畳むことができ、閉じると本文が左へ広がります。閉じた場合はページ上部に同形の復帰アイコンと、従来どおりポップオーバーでファイル一覧を開く`ファイル`ボタンを並べます。サイドバーの閉状態は`file-sidebar=closed`、ポップオーバーは`marksites-files=open`、開いたフォルダは短縮IDを値とする`open` GETパラメータでページ間に引き継がれます。ファイルパス直後のアイコンでパスをコピーでき、右端にはMarkdownの更新日時をブラウザのローカルタイムゾーンで`YYYY-MM-DD HH:mm:ss`形式にして表示します。タイムゾーン名やUTCオフセットは表示しません。JavaScript実行前はUTCの日時を同じ形式で表示します。現在位置に対応する見出しもハイライトされます。狭い画面では右サイドバーを本文上部、ファイルサイドバーを必要なときだけ開く固定オーバーレイとして表示します。
28
+ 見出しレベル2から6までの目次と、GitHub互換の見出しアンカーが自動生成されます。ファイルツリーはGitHubのFilesペインと同様、画面左端へ接する全高のサイドバーとして常時表示し、本文を中央、`目次`とコメントを画面右側へ配置します。ファイル一覧は階層ツリーと更新順を切り替えられ、更新順ではMarkdownのローカルファイル更新日時(mtime)を新しい順に日付でまとめ、時刻を左側の固定幅列、濃い色・太字のファイル名とフォルダアイコン付きの親ディレクトリをその右の2段表示にします。親ディレクトリは変換ルートを`/`、配下を`/src/`のように表示します。同じ日付で同一フォルダのファイルが2件以上連続する場合は、ルート直下を含め、更新順を変えずにフォルダ見出しへまとめます。日付見出しではファイル件数を確認でき、クリックで折り畳めます。初期状態では最新日と現在ファイルを含む日を開き、検索中は一致する日を一時的に展開します。完全な相対パスは各行のツールチップでも確認できます。左サイドバーはヘッダーの四角いペインアイコンで折り畳むことができ、閉じると本文が左へ広がります。閉じた場合はページ上部に同形の復帰アイコンと、従来どおりポップオーバーでファイル一覧を開く`ファイル`ボタンを並べます。サイドバーの閉状態は`file-sidebar=closed`、ポップオーバーは`marksites-files=open`、更新順表示は`file-view=recent`、開いたフォルダは短縮IDを値とする`open` GETパラメータでページ間に引き継がれます。ファイルパス直後のアイコンでパスをコピーできます。Markdownの更新日時は本文カード上部で、ブラウザのローカルタイムゾーンを使った`YYYY-MM-DD HH:mm:ss`形式にして表示します。タイムゾーン名やUTCオフセットは表示しません。JavaScript実行前はUTCの日時を同じ形式で表示します。現在位置に対応する見出しもハイライトされます。狭い画面では右サイドバーを本文上部、ファイルサイドバーを必要なときだけ開く固定オーバーレイとして表示します。
29
29
 
30
- 左ファイルサイドバーは幅280pxとし、開閉アイコンは枠線なしで表示します。
30
+ 左ファイルサイドバーは幅280pxとし、開閉アイコンは枠線なしで表示します。更新日時は本文カード上部へ表示します。
31
31
 
32
- 画面上端には固定ヘッダーを表示します。右上の月・太陽アイコンでライト/ダークモードを切り替え、地球アイコンで生成UIの日本語/英語を切り替えます。Markdown本文、見出し、ファイル名、コメント本文は翻訳対象にしません。選択状態は`theme`と`lang` GETパラメータでページ間に引き継がれます。
32
+ 画面上端には固定ヘッダーを表示します。濃色背景と白文字の`marksites`ロゴ、その右に現在のファイルパスを表示します。ファイルパスから開くポップオーバーは左ファイルサイドバーを閉じている場合だけ利用できます。ブラウザのタブ名は`marksites | <ファイル名>`形式です。本文カードは更新日時の下からMarkdown本文を開始します。右上の月・太陽アイコンでライト/ダークモードを切り替え、地球アイコンで生成UIの日本語/英語を切り替えます。Markdown本文、見出し、ファイル名、コメント本文は翻訳対象にしません。選択状態は`theme`と`lang` GETパラメータでページ間に引き継がれます。
33
33
 
34
34
  ```ts
35
35
  const html = markdownToHtml(markdown, {
@@ -74,7 +74,9 @@ npx marksites
74
74
 
75
75
  単一Markdownの出力先を省略した場合も、`./marksites/`へ同じbasenameのHTMLを保存します。
76
76
 
77
- フォルダを指定すると、配下の `.md` と `.markdown` を再帰的に変換します。出力先でも元のフォルダ階層を維持し、各ページの左側にファイル名で絞り込めるファイルツリー、本文上部に現在のファイルパスを示すパンくずを表示します。コメントが1件以上あるファイルには件数バッジを表示し、上位フォルダには配下の有効コメント合計件数を表示します。Markdownファイル間の相対リンクは `.html` へ書き換えられます。
77
+ フォルダを指定すると、配下の `.md` と `.markdown` を再帰的に変換します。出力先でも元のフォルダ階層を維持し、各ページの左側にファイル名で絞り込めるファイルツリーと相対パスで検索できる更新順一覧、固定ヘッダーに現在のファイルパスを示すパンくずを表示します。コメントが1件以上あるファイルには件数バッジを表示し、上位フォルダには配下の有効コメント合計件数を表示します。Markdownファイル間の相対リンクは `.html` へ書き換えられます。
78
+
79
+ 相対パスで参照したローカル画像は、内容ハッシュ付きの名前で出力先の`_marksites-assets/`へコピーされます。同名画像の衝突を避け、画像変更時は参照するHTMLだけを更新します。外部URL、data URL、ルート相対URLはそのまま保持します。本文画像をクリックすると全画面ビューアーが開き、ボタンまたはホイールで拡大・縮小でき、ドラッグで位置を移動できます。Escapeまたは右上の閉じるボタンで閉じます。
78
80
 
79
81
  ```sh
80
82
  npx marksites docs public
@@ -113,7 +115,16 @@ npx marksites serve
113
115
 
114
116
  `--host`、`--port`、`--open`を指定できます。ポートを省略すると3000を使用し、すでに使用中なら3001、3002の順に空きポートを自動選択します。`--port`を明示した場合は指定ポートだけを使用し、競合時はエラーになります。`--port 0`ではOSに空きポートを割り当てさせることもできます。実際のURLは起動時に表示され、`--open`もそのURLを開きます。
115
117
 
116
- サーバーは起動時に差分変換を行い、アイドル中のファイル監視や定期処理は行いません。停止後もコメントはHTMLへ埋め込まれているため、生成HTMLを`file://`で閲覧できます。この場合、コメントは閲覧専用です。
118
+ ディレクトリ変換または`serve`へ`--watch`を付けると、Markdownや参照画像の変更を検知して差分変換します。Markdownの新規追加や削除、新しいサブディレクトリにも対応します。
119
+
120
+ `--verbose`を付けると、文書ごとの変換・スキップ結果を表示します。`--watch`と併用した場合は、検知したイベント種別・対象パスと再変換の開始も表示します。
121
+
122
+ ```sh
123
+ npx marksites docs public/docs --watch
124
+ npx marksites serve docs public/docs --port 4000 --watch --verbose
125
+ ```
126
+
127
+ サーバーは起動時に差分変換を行い、`--watch`指定時だけイベント駆動で入力を監視します。ポーリングや常駐文書キャッシュは使用しません。停止後もコメントはHTMLへ埋め込まれているため、生成HTMLを`file://`で閲覧できます。この場合、コメントは閲覧専用です。
117
128
 
118
129
  `--open`はWindows、macOS、Linuxに加えてWSLからWindows側のブラウザを開く処理にも対応します。ブラウザ起動コマンドが見つからない場合もサーバーは停止せず、表示されたURLを手動で開けます。
119
130
 
@@ -146,6 +157,8 @@ Viteを使用する場合は、`next build` を `vite build` に置き換えま
146
157
 
147
158
  ## 開発
148
159
 
160
+ 依存パッケージは、未確認の新バージョンを自動取得しないよう`latest`を使用せず、検証済みバージョンを`package.json`と`package-lock.json`へ記録します。更新時はバージョンを明示的に変更し、テストを実行します。
161
+
149
162
  ```sh
150
163
  npm test
151
164
  ```
package/dist/cli.js CHANGED
@@ -7,8 +7,9 @@ import { convertFile } from "./conversion/single-file.js";
7
7
  import { MARKSITES_RESERVED_PATH } from "./server/constants.js";
8
8
  import { startMarksitesServer } from "./server/server.js";
9
9
  import { openBrowser } from "./cli/open-browser.js";
10
+ import { watchConversionInput } from "./conversion/watch.js";
10
11
  function usage() {
11
- console.error("Usage:\n marksites [input.md|input-directory] [output.html|output-directory]\n marksites serve [input-directory] [output-directory] [--host HOST] [--port PORT] [--open]");
12
+ console.error("Usage:\n marksites [input.md|input-directory] [output.html|output-directory] [--watch] [--verbose]\n marksites serve [input-directory] [output-directory] [--host HOST] [--port PORT] [--open] [--watch] [--verbose]");
12
13
  process.exit(1);
13
14
  }
14
15
  function report(result) {
@@ -19,12 +20,22 @@ function report(result) {
19
20
  async function serve(args) {
20
21
  const positional = [];
21
22
  let host = "127.0.0.1", port, shouldOpen = false;
23
+ let shouldWatch = false;
24
+ let verbose = false;
22
25
  for (let index = 0; index < args.length; index++) {
23
26
  const argument = args[index];
24
27
  if (argument === "--open") {
25
28
  shouldOpen = true;
26
29
  continue;
27
30
  }
31
+ if (argument === "--watch") {
32
+ shouldWatch = true;
33
+ continue;
34
+ }
35
+ if (argument === "--verbose") {
36
+ verbose = true;
37
+ continue;
38
+ }
28
39
  if (argument === "--host" || argument === "--port") {
29
40
  const value = args[++index];
30
41
  if (!value)
@@ -48,7 +59,10 @@ async function serve(args) {
48
59
  const input = resolve(inputArgument);
49
60
  if (!(await stat(input)).isDirectory())
50
61
  throw new Error(`serve input must be a directory: ${inputArgument}`);
51
- const initial = await convertDirectoryDetailed(input, positional[1]);
62
+ const conversionOptions = {
63
+ onLog: verbose ? (message) => console.log(message) : undefined,
64
+ };
65
+ const initial = await convertDirectoryDetailed(input, positional[1], conversionOptions);
52
66
  report(initial);
53
67
  const output = initial.outputRoot;
54
68
  try {
@@ -82,10 +96,22 @@ async function serve(args) {
82
96
  projectName: basename(input),
83
97
  documents,
84
98
  onAnnotationsChange: async () => {
85
- await convertDirectoryDetailed(input, output);
99
+ await convertDirectoryDetailed(input, output, conversionOptions);
86
100
  },
87
101
  });
88
102
  console.log(`marksites server: ${server.url}`);
103
+ const watcher = shouldWatch
104
+ ? await watchConversionInput(input, output, async () => {
105
+ const result = await convertDirectoryDetailed(input, output, conversionOptions);
106
+ report(result);
107
+ const nextManifest = JSON.parse(await readFile(join(output, ".marksites-build.json"), "utf8"));
108
+ documents.clear();
109
+ for (const [document, value] of Object.entries(nextManifest.files))
110
+ documents.set(document, value.annotations);
111
+ }, conversionOptions)
112
+ : undefined;
113
+ if (watcher)
114
+ console.log(`Watching ${input}`);
89
115
  if (shouldOpen) {
90
116
  const opened = await openBrowser(server.url);
91
117
  if (!opened) {
@@ -98,6 +124,7 @@ async function serve(args) {
98
124
  if (closing)
99
125
  return;
100
126
  closing = true;
127
+ watcher?.close();
101
128
  server.close().then(done, fail);
102
129
  };
103
130
  process.once("SIGINT", close);
@@ -107,16 +134,47 @@ async function serve(args) {
107
134
  async function main() {
108
135
  if (process.argv[2] === "serve")
109
136
  return serve(process.argv.slice(3));
110
- const inputArgument = process.argv[2] ?? ".";
137
+ const args = process.argv.slice(2);
138
+ const shouldWatch = args.includes("--watch");
139
+ const verbose = args.includes("--verbose");
140
+ const positional = args.filter((argument) => argument !== "--watch" && argument !== "--verbose");
141
+ const unknown = positional.find((argument) => argument.startsWith("--"));
142
+ if (unknown)
143
+ throw new Error(`Unknown option: ${unknown}`);
144
+ if (positional.length > 2)
145
+ usage();
146
+ const inputArgument = positional[0] ?? ".";
111
147
  const input = resolve(inputArgument);
112
148
  const inputStat = await stat(input);
113
- const outputArgument = process.argv[3];
149
+ const outputArgument = positional[1];
150
+ const conversionOptions = {
151
+ onLog: verbose ? (message) => console.log(message) : undefined,
152
+ };
114
153
  if (inputStat.isDirectory()) {
115
- report(await convertDirectoryDetailed(input, outputArgument));
154
+ const initial = await convertDirectoryDetailed(input, outputArgument, conversionOptions);
155
+ report(initial);
156
+ if (shouldWatch) {
157
+ const watcher = await watchConversionInput(input, initial.outputRoot, async () => {
158
+ report(await convertDirectoryDetailed(input, initial.outputRoot, conversionOptions));
159
+ }, conversionOptions);
160
+ console.log(`Watching ${input}`);
161
+ await new Promise((done) => {
162
+ const close = () => {
163
+ watcher.close();
164
+ done();
165
+ };
166
+ process.once("SIGINT", close);
167
+ process.once("SIGTERM", close);
168
+ });
169
+ }
116
170
  return;
117
171
  }
118
172
  if (!inputStat.isFile())
119
173
  throw new Error(`Input is not a file or directory: ${inputArgument}`);
174
+ if (shouldWatch)
175
+ throw new Error("--watch input must be a directory");
176
+ if (verbose)
177
+ console.log(`Converting ${input}`);
120
178
  console.log(`Created ${await convertFile(input, outputArgument)}`);
121
179
  }
122
180
  main().catch((error) => {
@@ -0,0 +1,8 @@
1
+ import { type Token } from "marked";
2
+ export declare const ASSET_DIRECTORY = "_marksites-assets";
3
+ export interface PreparedAssets {
4
+ hash: string;
5
+ outputs: string[];
6
+ rewrite(token: Token): void;
7
+ }
8
+ export declare function prepareImageAssets(markdown: string, sourcePath: string, outputPath: string, outputRoot: string): Promise<PreparedAssets>;
@@ -0,0 +1,62 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { dirname, extname, join, posix, resolve } from "node:path";
3
+ import { marked } from "marked";
4
+ import { atomicWriteFile } from "../utils/files.js";
5
+ import { contentHash } from "./rendering.js";
6
+ import { pathExists } from "./paths.js";
7
+ export const ASSET_DIRECTORY = "_marksites-assets";
8
+ function localPath(href, sourcePath) {
9
+ const raw = href.split(/[?#]/, 1)[0];
10
+ if (!raw ||
11
+ raw.startsWith("/") ||
12
+ raw.startsWith("//") ||
13
+ /^[a-z][a-z\d+.-]*:/i.test(raw))
14
+ return undefined;
15
+ try {
16
+ return resolve(dirname(sourcePath), decodeURIComponent(raw));
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ }
22
+ export async function prepareImageAssets(markdown, sourcePath, outputPath, outputRoot) {
23
+ const hrefs = new Set();
24
+ marked.walkTokens(marked.lexer(markdown), (token) => {
25
+ if (token.type === "image")
26
+ hrefs.add(token.href);
27
+ });
28
+ const rewrites = new Map();
29
+ const fingerprints = [];
30
+ const outputs = new Set();
31
+ for (const href of [...hrefs].sort()) {
32
+ const source = localPath(href, sourcePath);
33
+ if (!source || !(await pathExists(source)))
34
+ continue;
35
+ let data;
36
+ try {
37
+ data = await readFile(source);
38
+ }
39
+ catch {
40
+ continue;
41
+ }
42
+ const digest = contentHash(data).slice("sha256:".length);
43
+ const extension = extname(source).toLowerCase().replace(/[^.a-z0-9]/g, "");
44
+ const assetPath = posix.join(ASSET_DIRECTORY, `${digest}${extension}`);
45
+ const suffix = href.slice(href.split(/[?#]/, 1)[0].length);
46
+ const relativeHref = posix.relative(posix.dirname(outputPath), assetPath);
47
+ rewrites.set(href, `${relativeHref || posix.basename(assetPath)}${suffix}`);
48
+ fingerprints.push(`${href}\0${digest}`);
49
+ outputs.add(assetPath);
50
+ const destination = join(outputRoot, ...assetPath.split("/"));
51
+ if (!(await pathExists(destination)))
52
+ await atomicWriteFile(destination, data);
53
+ }
54
+ return {
55
+ hash: contentHash(fingerprints.join("\n")),
56
+ outputs: [...outputs].sort(),
57
+ rewrite(token) {
58
+ if (token.type === "image")
59
+ token.href = rewrites.get(token.href) ?? token.href;
60
+ },
61
+ };
62
+ }
@@ -1,3 +1,3 @@
1
- import type { ConversionResult } from "./types.js";
2
- export declare function convertDirectoryDetailed(input: string, outputArgument?: string): Promise<ConversionResult>;
1
+ import type { ConversionResult, ConversionOptions } from "./types.js";
2
+ export declare function convertDirectoryDetailed(input: string, outputArgument?: string, options?: ConversionOptions): Promise<ConversionResult>;
3
3
  export declare function convertDirectory(input: string, outputArgument?: string): Promise<number>;
@@ -8,6 +8,7 @@ import { BUILD_MANIFEST, assertNoOutputCollisions, loadManifest, writeManifest,
8
8
  import { buildBreadcrumbs, buildFileTree } from "./navigation.js";
9
9
  import { DEFAULT_OUTPUT_DIRECTORY, findMarkdownFiles, firstExistingPath, pathExists, toLegacyMetadataPaths, } from "./paths.js";
10
10
  import { OUTPUT_COMPATIBILITY_VERSION, contentHash, renderFingerprint, rewriteMarkdownLinks, } from "./rendering.js";
11
+ import { prepareImageAssets } from "./assets.js";
11
12
  async function migrateLegacyMetadata(files, output) {
12
13
  let moved = 0;
13
14
  for (const file of files) {
@@ -34,6 +35,22 @@ async function loadSources(files) {
34
35
  file.modifiedAt = sourceStat.mtime.toISOString();
35
36
  }
36
37
  }
38
+ async function prepareAssets(files, output) {
39
+ for (const file of files) {
40
+ const assets = await prepareImageAssets(file.source, file.sourcePath, file.outputPath, output);
41
+ file.assetHash = assets.hash;
42
+ file.assetOutputs = assets.outputs;
43
+ file.rewriteImages = assets.rewrite;
44
+ }
45
+ }
46
+ async function removeUnusedAssets(previous, files, output) {
47
+ const current = new Set(files.flatMap((file) => file.assetOutputs ?? []));
48
+ const old = new Set(Object.values(previous?.files ?? {}).flatMap((file) => file.assets ?? []));
49
+ for (const asset of old) {
50
+ if (!current.has(asset))
51
+ await rm(join(output, ...asset.split("/")), { force: true });
52
+ }
53
+ }
37
54
  async function moveRenamedDocuments(files, previous, output, removed) {
38
55
  let deleted = 0;
39
56
  let metadataMoved = 0;
@@ -104,7 +121,7 @@ async function removeDeletedHtml(removed, previous, output) {
104
121
  }
105
122
  return { deleted, orphaned };
106
123
  }
107
- async function renderChangedFiles(files, input, output, previous, full) {
124
+ async function renderChangedFiles(files, input, output, previous, full, options) {
108
125
  let converted = 0;
109
126
  let skipped = 0;
110
127
  const manifestFiles = {};
@@ -116,6 +133,7 @@ async function renderChangedFiles(files, input, output, previous, full) {
116
133
  old.sourceHash !== file.sourceHash ||
117
134
  old.modifiedAt !== file.modifiedAt ||
118
135
  old.annotationHash !== file.annotationHash ||
136
+ old.assetHash !== file.assetHash ||
119
137
  !(await pathExists(destination));
120
138
  if (changed) {
121
139
  await atomicWriteFile(destination, renderMarkdown(file.source, {
@@ -126,12 +144,19 @@ async function renderChangedFiles(files, input, output, previous, full) {
126
144
  items: buildFileTree(files, file.outputPath),
127
145
  breadcrumbs: buildBreadcrumbs(files, file),
128
146
  },
129
- markedOptions: { walkTokens: rewriteMarkdownLinks },
147
+ markedOptions: {
148
+ walkTokens: (token) => {
149
+ rewriteMarkdownLinks(token);
150
+ file.rewriteImages?.(token);
151
+ },
152
+ },
130
153
  }, file.annotations));
131
154
  converted++;
155
+ options.onLog?.(`Converted ${file.relativePath} -> ${file.outputPath}`);
132
156
  }
133
157
  else {
134
158
  skipped++;
159
+ options.onLog?.(`Skipped ${file.relativePath}`);
135
160
  }
136
161
  manifestFiles[file.relativePath] = {
137
162
  sourceHash: file.sourceHash,
@@ -139,11 +164,13 @@ async function renderChangedFiles(files, input, output, previous, full) {
139
164
  annotationHash: file.annotationHash,
140
165
  output: file.outputPath,
141
166
  annotations: file.metadataPath,
167
+ assetHash: file.assetHash,
168
+ assets: file.assetOutputs,
142
169
  };
143
170
  }
144
171
  return { converted, skipped, files: manifestFiles };
145
172
  }
146
- export async function convertDirectoryDetailed(input, outputArgument) {
173
+ export async function convertDirectoryDetailed(input, outputArgument, options = {}) {
147
174
  const output = outputArgument
148
175
  ? resolve(outputArgument)
149
176
  : resolve(DEFAULT_OUTPUT_DIRECTORY);
@@ -163,6 +190,7 @@ export async function convertDirectoryDetailed(input, outputArgument) {
163
190
  console.warn(loaded.warning);
164
191
  const previous = loaded.manifest;
165
192
  await loadSources(files);
193
+ await prepareAssets(files, output);
166
194
  let annotationsMoved = await migrateLegacyMetadata(files, output);
167
195
  const currentPaths = new Set(files.map((file) => file.relativePath));
168
196
  const removed = Object.keys(previous?.files ?? {}).filter((path) => !currentPaths.has(path));
@@ -170,7 +198,7 @@ export async function convertDirectoryDetailed(input, outputArgument) {
170
198
  annotationsMoved += renamed.metadataMoved;
171
199
  const annotationsCreated = await loadMetadata(files, output);
172
200
  const treeHash = contentHash(files
173
- .map((file) => `${file.relativePath}\0${file.annotations ? countActiveAnnotations(file.annotations) : 0}`)
201
+ .map((file) => `${file.relativePath}\0${file.modifiedAt}\0${file.annotations ? countActiveAnnotations(file.annotations) : 0}`)
174
202
  .sort()
175
203
  .join("\n"));
176
204
  const fingerprint = renderFingerprint();
@@ -181,12 +209,13 @@ export async function convertDirectoryDetailed(input, outputArgument) {
181
209
  previous.generator.renderFingerprint !== fingerprint ||
182
210
  previous.treeHash !== treeHash;
183
211
  const removedResult = await removeDeletedHtml(removed, previous, output);
184
- const rendered = await renderChangedFiles(files, input, output, previous, full);
212
+ const rendered = await renderChangedFiles(files, input, output, previous, full, options);
213
+ await removeUnusedAssets(previous, files, output);
185
214
  await writeManifest(manifestPath, {
186
215
  schemaVersion: 1,
187
216
  generator: {
188
217
  name: "marksites",
189
- version: "0.1.0",
218
+ version: "0.2.0",
190
219
  outputCompatibilityVersion: OUTPUT_COMPATIBILITY_VERSION,
191
220
  renderFingerprint: fingerprint,
192
221
  },
@@ -33,6 +33,8 @@ export function buildFileTree(files, currentOutputPath) {
33
33
  .map((file) => ({
34
34
  type: "file",
35
35
  name: basename(file.relativePath),
36
+ path: file.relativePath,
37
+ modifiedAt: file.modifiedAt,
36
38
  href: encodeRelativeHref(posix.relative(posix.dirname(currentOutputPath), file.outputPath) ||
37
39
  posix.basename(file.outputPath)),
38
40
  current: file.outputPath === currentOutputPath,
@@ -8,3 +8,4 @@ export declare function toPosix(path: string): string;
8
8
  export declare function pathExists(path: string): Promise<boolean>;
9
9
  export declare function firstExistingPath(paths: string[]): Promise<string | undefined>;
10
10
  export declare function findMarkdownFiles(root: string, excludedDirectory?: string): Promise<MarkdownFile[]>;
11
+ export declare function findWatchDirectories(root: string, excludedDirectory?: string): Promise<string[]>;
@@ -84,3 +84,26 @@ export async function findMarkdownFiles(root, excludedDirectory) {
84
84
  await visit(root, []);
85
85
  return files;
86
86
  }
87
+ export async function findWatchDirectories(root, excludedDirectory) {
88
+ const directories = [];
89
+ async function visit(directory, inheritedRules) {
90
+ if (directory !== root && (await pathExists(join(directory, BUILD_MANIFEST))))
91
+ return;
92
+ directories.push(directory);
93
+ const rules = await loadGitignoreRules(root, directory, inheritedRules);
94
+ const entries = await readdir(directory, { withFileTypes: true });
95
+ for (const entry of entries) {
96
+ if (!entry.isDirectory())
97
+ continue;
98
+ const absolute = join(directory, entry.name);
99
+ const relativePath = toPosix(relative(root, absolute));
100
+ if (resolve(absolute) === excludedDirectory ||
101
+ EXCLUDED_DIRECTORY_NAMES.has(entry.name) ||
102
+ isGitignored(relativePath, true, rules))
103
+ continue;
104
+ await visit(absolute, rules);
105
+ }
106
+ }
107
+ await visit(root, []);
108
+ return directories;
109
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Token } from "marked";
2
- export declare const OUTPUT_COMPATIBILITY_VERSION = 5;
2
+ export declare const OUTPUT_COMPATIBILITY_VERSION = 7;
3
3
  export declare function contentHash(value: string | Buffer): string;
4
4
  export declare function rewriteMarkdownLinks(token: Token): void;
5
5
  export declare function renderFingerprint(): string;
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { emptyAnnotationDocument } from "../annotations/model.js";
3
3
  import { renderMarkdown } from "../markdown-to-html.js";
4
- export const OUTPUT_COMPATIBILITY_VERSION = 5;
4
+ export const OUTPUT_COMPATIBILITY_VERSION = 7;
5
5
  export function contentHash(value) {
6
6
  return `sha256:${createHash("sha256").update(value).digest("hex")}`;
7
7
  }
@@ -18,7 +18,7 @@ export function rewriteMarkdownLinks(token) {
18
18
  token.href = rewriteMarkdownHref(token.href);
19
19
  }
20
20
  export function renderFingerprint() {
21
- const representativeHtml = renderMarkdown("## Heading\n\n[Document](guide.md)\n\n```js\nconst value = 1;\n```\n", {
21
+ const representativeHtml = renderMarkdown("## Heading\n\n[Document](guide.md)\n\n![Image](image.png)\n\n```js\nconst value = 1;\n```\n", {
22
22
  title: "marksites-render-fingerprint",
23
23
  modifiedAt: "2026-01-01T00:00:00.000Z",
24
24
  fileTree: {
@@ -27,8 +27,10 @@ export function renderFingerprint() {
27
27
  {
28
28
  type: "file",
29
29
  name: "index.md",
30
+ path: "index.md",
30
31
  href: "index.html",
31
32
  current: true,
33
+ modifiedAt: "2026-01-01T00:00:00.000Z",
32
34
  },
33
35
  ],
34
36
  breadcrumbs: [{ name: "docs" }, { name: "index.md", current: true }],
@@ -4,6 +4,7 @@ import { readAnnotations } from "../annotations/storage.js";
4
4
  import { renderMarkdown } from "../markdown-to-html.js";
5
5
  import { atomicWriteFile } from "../utils/files.js";
6
6
  import { DEFAULT_OUTPUT_DIRECTORY, firstExistingPath, isMarkdown, pathExists, } from "./paths.js";
7
+ import { prepareImageAssets } from "./assets.js";
7
8
  export async function convertFile(input, outputArgument) {
8
9
  if (!isMarkdown(input))
9
10
  throw new Error(`Input file must use .md or .markdown: ${input}`);
@@ -27,9 +28,13 @@ export async function convertFile(input, outputArgument) {
27
28
  stat(input),
28
29
  ]);
29
30
  await mkdir(dirname(output), { recursive: true });
31
+ const assets = await prepareImageAssets(markdown, input, basename(output), dirname(output));
30
32
  await atomicWriteFile(output, renderMarkdown(markdown, {
31
33
  title: basename(input, extname(input)),
32
34
  modifiedAt: sourceStat.mtime.toISOString(),
35
+ markedOptions: {
36
+ walkTokens: assets.rewrite,
37
+ },
33
38
  }, annotations));
34
39
  return output;
35
40
  }
@@ -9,6 +9,9 @@ export interface MarkdownFile {
9
9
  modifiedAt?: string;
10
10
  annotations?: AnnotationDocument;
11
11
  annotationHash?: string;
12
+ assetHash?: string;
13
+ assetOutputs?: string[];
14
+ rewriteImages?: (token: import("marked").Token) => void;
12
15
  }
13
16
  export interface ManifestFile {
14
17
  sourceHash: string;
@@ -16,6 +19,8 @@ export interface ManifestFile {
16
19
  annotationHash: string;
17
20
  output: string;
18
21
  annotations: string;
22
+ assetHash?: string;
23
+ assets?: string[];
19
24
  }
20
25
  export interface BuildManifest {
21
26
  schemaVersion: 1;
@@ -37,3 +42,6 @@ export interface ConversionResult {
37
42
  orphanedAnnotations: string[];
38
43
  outputRoot: string;
39
44
  }
45
+ export interface ConversionOptions {
46
+ onLog?: (message: string) => void;
47
+ }
@@ -0,0 +1,7 @@
1
+ export interface ConversionWatcher {
2
+ close(): void;
3
+ }
4
+ export interface ConversionWatchOptions {
5
+ onLog?: (message: string) => void;
6
+ }
7
+ export declare function watchConversionInput(input: string, output: string, rebuild: () => Promise<void>, options?: ConversionWatchOptions): Promise<ConversionWatcher>;
@@ -0,0 +1,69 @@
1
+ import { watch } from "node:fs";
2
+ import { isAbsolute, relative, resolve, sep } from "node:path";
3
+ import { findWatchDirectories } from "./paths.js";
4
+ export async function watchConversionInput(input, output, rebuild, options = {}) {
5
+ const watchers = new Map();
6
+ let timer;
7
+ let building = false;
8
+ let pending = false;
9
+ const outputRelative = relative(input, output);
10
+ const outputInsideInput = outputRelative !== "" &&
11
+ !outputRelative.startsWith(`..${sep}`) &&
12
+ outputRelative !== ".." &&
13
+ !isAbsolute(outputRelative);
14
+ const refresh = async () => {
15
+ const directories = new Set(await findWatchDirectories(input, outputInsideInput ? resolve(output) : undefined));
16
+ for (const [directory, active] of watchers) {
17
+ if (!directories.has(directory)) {
18
+ active.close();
19
+ watchers.delete(directory);
20
+ }
21
+ }
22
+ for (const directory of directories) {
23
+ if (watchers.has(directory))
24
+ continue;
25
+ watchers.set(directory, watch(directory, (eventType, filename) => {
26
+ const target = filename ? String(filename) : "(unknown)";
27
+ options.onLog?.(`Watch event ${eventType}: ${relative(input, resolve(directory, target)) || "."}`);
28
+ schedule();
29
+ }));
30
+ }
31
+ };
32
+ const run = async () => {
33
+ if (building) {
34
+ pending = true;
35
+ return;
36
+ }
37
+ building = true;
38
+ try {
39
+ options.onLog?.("Watch rebuild started");
40
+ await rebuild();
41
+ await refresh();
42
+ }
43
+ catch (error) {
44
+ console.error(`Watch rebuild failed: ${error instanceof Error ? error.message : String(error)}`);
45
+ }
46
+ finally {
47
+ building = false;
48
+ if (pending) {
49
+ pending = false;
50
+ schedule();
51
+ }
52
+ }
53
+ };
54
+ const schedule = () => {
55
+ if (timer)
56
+ clearTimeout(timer);
57
+ timer = setTimeout(run, 100);
58
+ };
59
+ await refresh();
60
+ return {
61
+ close() {
62
+ if (timer)
63
+ clearTimeout(timer);
64
+ for (const active of watchers.values())
65
+ active.close();
66
+ watchers.clear();
67
+ },
68
+ };
69
+ }
@@ -2,6 +2,6 @@ import type { FileBreadcrumb, FileTreeOptions } from "../../types.js";
2
2
  export declare function renderFileTree(options?: FileTreeOptions): string;
3
3
  export declare function renderFileSidebar(options?: FileTreeOptions): string;
4
4
  export declare function renderFileTreeScript(enabled: boolean): string;
5
- export declare function renderBreadcrumbs(breadcrumbs?: FileBreadcrumb[], modifiedAt?: string): string;
5
+ export declare function renderBreadcrumbs(breadcrumbs?: FileBreadcrumb[]): string;
6
6
  export declare function renderModifiedAt(modifiedAt?: string): string;
7
7
  export declare function renderModifiedAtScript(enabled: boolean): string;