@riebeckite/plugin-garden-explorer 0.0.12 → 0.0.13
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 +93 -89
- package/README_ja.md +77 -69
- package/dist/client.d.ts +2 -0
- package/dist/client.js +478 -0
- package/dist/components/garden-explorer.js +1 -1
- package/dist/index.js +140 -86
- package/dist/src/garden-explorer-page.d.ts +2 -0
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -1,89 +1,93 @@
|
|
|
1
|
-
# @riebeckite/plugin-garden-explorer
|
|
2
|
-
|
|
3
|
-
Interactive note garden explorer: a graph, search box, tag/folder filters, and
|
|
4
|
-
note details in a single page.
|
|
5
|
-
|
|
6
|
-
[日本語](./README_ja.md)
|
|
7
|
-
|
|
8
|
-
## Overview
|
|
9
|
-
|
|
10
|
-
`gardenExplorer()` provides an interactive `GardenExplorer` component with three
|
|
11
|
-
panels:
|
|
12
|
-
|
|
13
|
-
- **Explorer** — search box plus tag and folder filter chips (with counts) and
|
|
14
|
-
a filtered note list
|
|
15
|
-
- **Graph** — an interactive radial SVG graph of notes and internal links with
|
|
16
|
-
zoom (`+`/`−`/wheel), pan (drag), and reset
|
|
17
|
-
- **Details** — the selected note's links, tags, excerpt, and related notes
|
|
18
|
-
|
|
19
|
-
Selection state is mirrored to the URL query (`?note=`, `?tag=`, `?folder=`),
|
|
20
|
-
so the view is shareable and the filter list adapts to the current selection.
|
|
21
|
-
|
|
22
|
-
`getGardenExplorerData()` builds the note set from published entries, including
|
|
23
|
-
headings, a plain-text body (truncated to 4,000 chars), tags, folders, outgoing
|
|
24
|
-
links, and backlinks. Its client-side note search and radial graph layout are
|
|
25
|
-
self-contained, so this plugin does not require the search plugin.
|
|
26
|
-
|
|
27
|
-
## Usage
|
|
28
|
-
|
|
29
|
-
```ts
|
|
30
|
-
import { defineConfig } from "@riebeckite/core";
|
|
31
|
-
import { gardenExplorerPlugin } from "@riebeckite/plugin-garden-explorer";
|
|
32
|
-
|
|
33
|
-
export default defineConfig({
|
|
34
|
-
// ...
|
|
35
|
-
plugins: [gardenExplorerPlugin()],
|
|
36
|
-
});
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
`gardenExplorerPlugin()` registers the
|
|
40
|
-
`style.css` into the app
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
import {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
- `
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
- `
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
-
|
|
84
|
-
`
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
1
|
+
# @riebeckite/plugin-garden-explorer
|
|
2
|
+
|
|
3
|
+
Interactive note garden explorer: a graph, search box, tag/folder filters, and
|
|
4
|
+
note details in a single page.
|
|
5
|
+
|
|
6
|
+
[日本語](./README_ja.md)
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
`gardenExplorer()` provides an interactive `GardenExplorer` component with three
|
|
11
|
+
panels:
|
|
12
|
+
|
|
13
|
+
- **Explorer** — search box plus tag and folder filter chips (with counts) and
|
|
14
|
+
a filtered note list
|
|
15
|
+
- **Graph** — an interactive radial SVG graph of notes and internal links with
|
|
16
|
+
zoom (`+`/`−`/wheel), pan (drag), and reset
|
|
17
|
+
- **Details** — the selected note's links, tags, excerpt, and related notes
|
|
18
|
+
|
|
19
|
+
Selection state is mirrored to the URL query (`?note=`, `?tag=`, `?folder=`),
|
|
20
|
+
so the view is shareable and the filter list adapts to the current selection.
|
|
21
|
+
|
|
22
|
+
`getGardenExplorerData()` builds the note set from published entries, including
|
|
23
|
+
headings, a plain-text body (truncated to 4,000 chars), tags, folders, outgoing
|
|
24
|
+
links, and backlinks. Its client-side note search and radial graph layout are
|
|
25
|
+
self-contained, so this plugin does not require the search plugin.
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { defineConfig } from "@riebeckite/core";
|
|
31
|
+
import { gardenExplorerPlugin } from "@riebeckite/plugin-garden-explorer";
|
|
32
|
+
|
|
33
|
+
export default defineConfig({
|
|
34
|
+
// ...
|
|
35
|
+
plugins: [gardenExplorerPlugin()],
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`gardenExplorerPlugin()` registers the `/explore` page type and bundles
|
|
40
|
+
`style.css` and its client hydrator into the app. A catch-all route using
|
|
41
|
+
`resolveRiebeckiteRoute()` and `pluginPageSsgParams()` renders and emits it;
|
|
42
|
+
no plugin-specific application route is needed. The page is server-rendered
|
|
43
|
+
first, then the registered client entry hydrates its graph, filters, and URL
|
|
44
|
+
state after loading.
|
|
45
|
+
|
|
46
|
+
### Embed the component elsewhere
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
import GardenExplorer, {
|
|
50
|
+
getGardenExplorerData,
|
|
51
|
+
} from "@riebeckite/plugin-garden-explorer";
|
|
52
|
+
import { config } from "../config";
|
|
53
|
+
import { content } from "../content";
|
|
54
|
+
import { getArticleTitle } from "../lib/article-title";
|
|
55
|
+
|
|
56
|
+
const manifest = await content.getManifest();
|
|
57
|
+
const data = getGardenExplorerData({
|
|
58
|
+
manifest,
|
|
59
|
+
config,
|
|
60
|
+
resolveTitle: getArticleTitle,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ...in a site-owned component
|
|
64
|
+
return <GardenExplorer data={data} />;
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The component is client-side interactive and expects `window` to be available
|
|
68
|
+
in the browser.
|
|
69
|
+
|
|
70
|
+
## Data
|
|
71
|
+
|
|
72
|
+
`getGardenExplorerData()` returns `GardenExplorerData`:
|
|
73
|
+
|
|
74
|
+
- `notes` — published notes sorted by title, with searchable fields plus
|
|
75
|
+
`folder`, `outgoing`, and `backlinks`
|
|
76
|
+
- `edges` — note-to-note graph edges between published notes
|
|
77
|
+
- `tags` — tag counts, most frequent first
|
|
78
|
+
- `folders` — folder counts, alphabetical; root-level notes are `"Root"`
|
|
79
|
+
|
|
80
|
+
## Exports
|
|
81
|
+
|
|
82
|
+
- `gardenExplorerPlugin()` — plugin factory
|
|
83
|
+
- `GardenExplorer` — interactive explorer component (default export of
|
|
84
|
+
`components/garden-explorer.tsx`)
|
|
85
|
+
- `getGardenExplorerData({ manifest, config, resolveTitle })` — builds the
|
|
86
|
+
explorer dataset
|
|
87
|
+
- Types: `GardenExplorerData`, `GardenExplorerEdge`, `GardenExplorerFolder`,
|
|
88
|
+
`GardenExplorerNote`, `GardenExplorerTag`
|
|
89
|
+
|
|
90
|
+
## See also
|
|
91
|
+
|
|
92
|
+
- [Plugin guide](../../../docs/en/reference/plugin-api.md)
|
|
93
|
+
- [`@riebeckite/plugin-local-graph`](../local-graph/README.md)
|
package/README_ja.md
CHANGED
|
@@ -1,69 +1,77 @@
|
|
|
1
|
-
# @riebeckite/plugin-garden-explorer
|
|
2
|
-
|
|
3
|
-
ノートの一覧、検索、タグ・フォルダーによる絞り込み、リンクグラフを一画面にまとめる探索画面です。公開済みのノートだけを対象にするため、そのまま公開サイトの `/explore` などに置けます。
|
|
4
|
-
|
|
5
|
-
[English](./README.md)
|
|
6
|
-
|
|
7
|
-
## 用意される画面
|
|
8
|
-
|
|
9
|
-
`gardenExplorerPlugin()` は、次の三つの領域を持つ `GardenExplorer` コンポーネントとスタイルを提供します。
|
|
10
|
-
|
|
11
|
-
- **一覧**: キーワード検索、タグとフォルダーのフィルター、該当ノートの一覧
|
|
12
|
-
- **グラフ**: ノートと内部リンクを表示する放射状 SVG グラフ。ズーム、ドラッグ、リセットに対応
|
|
13
|
-
- **詳細**: 選択したノートのリンク、タグ、抜粋、関連ノート
|
|
14
|
-
|
|
15
|
-
選択状態は `?note=`、`?tag=`、`?folder=` として URL に反映されます。表示中の状態をそのまま共有できます。
|
|
16
|
-
|
|
17
|
-
## 設定してページに配置する
|
|
18
|
-
|
|
19
|
-
```ts
|
|
20
|
-
import { defineConfig } from "@riebeckite/core";
|
|
21
|
-
import { gardenExplorerPlugin } from "@riebeckite/plugin-garden-explorer";
|
|
22
|
-
|
|
23
|
-
export default defineConfig({
|
|
24
|
-
// ...
|
|
25
|
-
plugins: [gardenExplorerPlugin()],
|
|
26
|
-
});
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
- `
|
|
62
|
-
- `
|
|
63
|
-
- `
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
-
|
|
1
|
+
# @riebeckite/plugin-garden-explorer
|
|
2
|
+
|
|
3
|
+
ノートの一覧、検索、タグ・フォルダーによる絞り込み、リンクグラフを一画面にまとめる探索画面です。公開済みのノートだけを対象にするため、そのまま公開サイトの `/explore` などに置けます。
|
|
4
|
+
|
|
5
|
+
[English](./README.md)
|
|
6
|
+
|
|
7
|
+
## 用意される画面
|
|
8
|
+
|
|
9
|
+
`gardenExplorerPlugin()` は、次の三つの領域を持つ `GardenExplorer` コンポーネントとスタイルを提供します。
|
|
10
|
+
|
|
11
|
+
- **一覧**: キーワード検索、タグとフォルダーのフィルター、該当ノートの一覧
|
|
12
|
+
- **グラフ**: ノートと内部リンクを表示する放射状 SVG グラフ。ズーム、ドラッグ、リセットに対応
|
|
13
|
+
- **詳細**: 選択したノートのリンク、タグ、抜粋、関連ノート
|
|
14
|
+
|
|
15
|
+
選択状態は `?note=`、`?tag=`、`?folder=` として URL に反映されます。表示中の状態をそのまま共有できます。
|
|
16
|
+
|
|
17
|
+
## 設定してページに配置する
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { defineConfig } from "@riebeckite/core";
|
|
21
|
+
import { gardenExplorerPlugin } from "@riebeckite/plugin-garden-explorer";
|
|
22
|
+
|
|
23
|
+
export default defineConfig({
|
|
24
|
+
// ...
|
|
25
|
+
plugins: [gardenExplorerPlugin()],
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`gardenExplorerPlugin()` は `/explore` の Page Type、スタイル、クライアント側の
|
|
30
|
+
hydrator を登録します。
|
|
31
|
+
`resolveRiebeckiteRoute()` と `pluginPageSsgParams()` を使う共通 catch-all route
|
|
32
|
+
が表示と SSG を担当するため、Plugin 固有の route は不要です。
|
|
33
|
+
ページはまずサーバーで描画し、読み込み後に登録済みの client entry がグラフ、
|
|
34
|
+
フィルター、URL 状態を hydration します。
|
|
35
|
+
|
|
36
|
+
コンポーネントを別の site-owned component に埋め込む場合だけ、manifest から
|
|
37
|
+
表示用データを作って渡します。
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
import GardenExplorer, {
|
|
41
|
+
getGardenExplorerData,
|
|
42
|
+
} from "@riebeckite/plugin-garden-explorer";
|
|
43
|
+
|
|
44
|
+
const manifest = await content.getManifest();
|
|
45
|
+
const data = getGardenExplorerData({
|
|
46
|
+
manifest,
|
|
47
|
+
config,
|
|
48
|
+
resolveTitle: getArticleTitle,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return <GardenExplorer data={data} />;
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`GardenExplorer` はブラウザで操作するクライアントコンポーネントです。サーバーで描画するだけの場所には置かず、クライアント側で動作するルートに配置してください。
|
|
55
|
+
|
|
56
|
+
## データに含まれる範囲
|
|
57
|
+
|
|
58
|
+
`getGardenExplorerData()` は公開済みエントリーから、見出し、本文テキストの先頭 4,000 文字、タグ、フォルダー、送信リンク、被リンクを集めます。本文全体をクライアントへ渡さないため、検索用のデータ量を抑えられます。
|
|
59
|
+
|
|
60
|
+
- `notes`: タイトル順の公開ノート。フォルダー、送信リンク、被リンクを含む
|
|
61
|
+
- `edges`: 公開ノート間のグラフの辺
|
|
62
|
+
- `tags`: 件数の多い順のタグ
|
|
63
|
+
- `folders`: 名前順のフォルダー。ルート直下のノートは `Root`
|
|
64
|
+
|
|
65
|
+
探索画面の検索はプラグイン内で完結しており、`@riebeckite/plugin-search` は不要です。
|
|
66
|
+
|
|
67
|
+
## 主なエクスポート
|
|
68
|
+
|
|
69
|
+
- `gardenExplorerPlugin()`: プラグインを作成する
|
|
70
|
+
- `GardenExplorer`: 探索画面のコンポーネント
|
|
71
|
+
- `getGardenExplorerData({ manifest, config, resolveTitle })`: 表示データを構築する
|
|
72
|
+
- 型: `GardenExplorerData`、`GardenExplorerEdge`、`GardenExplorerFolder`、`GardenExplorerNote`、`GardenExplorerTag`
|
|
73
|
+
|
|
74
|
+
## 関連資料
|
|
75
|
+
|
|
76
|
+
- [プラグインシステム](../../../docs/ja/reference/plugin-api.md)
|
|
77
|
+
- [`@riebeckite/plugin-local-graph`](../local-graph/README_ja.md)
|
package/dist/client.d.ts
ADDED
package/dist/client.js
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
// client.tsx
|
|
2
|
+
import { hydrateRoot } from "hono/jsx/dom/client";
|
|
3
|
+
|
|
4
|
+
// components/garden-explorer.tsx
|
|
5
|
+
import { layoutRadialGraph } from "@riebeckite/core/client";
|
|
6
|
+
import { useEffect, useMemo, useState } from "hono/jsx";
|
|
7
|
+
|
|
8
|
+
// src/search-notes.ts
|
|
9
|
+
var FIELD_WEIGHTS = {
|
|
10
|
+
slug: 64,
|
|
11
|
+
title: 56,
|
|
12
|
+
tags: 44,
|
|
13
|
+
headings: 32,
|
|
14
|
+
body: 10
|
|
15
|
+
};
|
|
16
|
+
function searchGardenExplorerNotes(notes, query) {
|
|
17
|
+
const normalizedQuery = normalizeSearchQuery(query);
|
|
18
|
+
if (normalizedQuery.length === 0) return [];
|
|
19
|
+
return notes.map((note) => scoreNote(note, normalizedQuery)).filter(
|
|
20
|
+
(result) => result !== null
|
|
21
|
+
).sort(
|
|
22
|
+
(a, b) => b.score - a.score || a.note.title.localeCompare(b.note.title, "ja")
|
|
23
|
+
).map(({ note }) => note);
|
|
24
|
+
}
|
|
25
|
+
function normalizeSearchQuery(value) {
|
|
26
|
+
return normalizeSearchText(value).replace(/^#+/, "");
|
|
27
|
+
}
|
|
28
|
+
function normalizeSearchText(value) {
|
|
29
|
+
return value.toLocaleLowerCase().normalize("NFKC").replace(
|
|
30
|
+
/[ァ-ン]/g,
|
|
31
|
+
(char) => String.fromCharCode(char.charCodeAt(0) - 96)
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
function scoreNote(note, normalizedQuery) {
|
|
35
|
+
const matches = [
|
|
36
|
+
scoreField("title", note.title, normalizedQuery),
|
|
37
|
+
scoreField("slug", note.slug, normalizedQuery),
|
|
38
|
+
...note.tags.map((tag) => scoreField("tags", tag, normalizedQuery)),
|
|
39
|
+
...note.headings.map(
|
|
40
|
+
(heading) => scoreField("headings", heading, normalizedQuery)
|
|
41
|
+
),
|
|
42
|
+
scoreField("body", note.body, normalizedQuery)
|
|
43
|
+
].filter((match) => match.score > 0);
|
|
44
|
+
if (matches.length === 0) return null;
|
|
45
|
+
return {
|
|
46
|
+
note,
|
|
47
|
+
score: matches.reduce((total, match) => total + match.score, 0)
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function scoreField(field, value, normalizedQuery) {
|
|
51
|
+
const normalizedValue = normalizeSearchText(value);
|
|
52
|
+
const index = normalizedValue.indexOf(normalizedQuery);
|
|
53
|
+
const weight = FIELD_WEIGHTS[field];
|
|
54
|
+
if (normalizedValue === normalizedQuery) return { score: weight * 3 };
|
|
55
|
+
if (index !== -1) return { score: weight * (index === 0 ? 2 : 1) };
|
|
56
|
+
const fuzzyScore = scoreFuzzyMatch(normalizedValue, normalizedQuery);
|
|
57
|
+
return { score: fuzzyScore > 0 ? Math.round(weight * fuzzyScore) : 0 };
|
|
58
|
+
}
|
|
59
|
+
function scoreFuzzyMatch(value, query) {
|
|
60
|
+
if (query.length < 2) return 0;
|
|
61
|
+
let valueIndex = 0;
|
|
62
|
+
let matched = 0;
|
|
63
|
+
let gaps = 0;
|
|
64
|
+
for (const queryCharacter of query) {
|
|
65
|
+
const nextIndex = value.indexOf(queryCharacter, valueIndex);
|
|
66
|
+
if (nextIndex === -1) return 0;
|
|
67
|
+
gaps += nextIndex - valueIndex;
|
|
68
|
+
valueIndex = nextIndex + 1;
|
|
69
|
+
matched += 1;
|
|
70
|
+
}
|
|
71
|
+
const coverage = matched / Math.max(value.length, query.length);
|
|
72
|
+
const continuityPenalty = Math.min(gaps / Math.max(value.length, 1), 0.8);
|
|
73
|
+
return Math.max(0, coverage * (1 - continuityPenalty));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// components/explorer-panels.tsx
|
|
77
|
+
import { jsx, jsxs } from "hono/jsx/jsx-runtime";
|
|
78
|
+
function FilterList(props) {
|
|
79
|
+
return /* @__PURE__ */ jsxs("div", { class: "garden-explorer__section", children: [
|
|
80
|
+
/* @__PURE__ */ jsx("p", { class: "garden-explorer__eyebrow", children: props.title }),
|
|
81
|
+
/* @__PURE__ */ jsx("div", { class: "garden-explorer__chips", children: props.items.slice(0, 28).map((item) => /* @__PURE__ */ jsxs(
|
|
82
|
+
"button",
|
|
83
|
+
{
|
|
84
|
+
type: "button",
|
|
85
|
+
class: "garden-explorer__chip",
|
|
86
|
+
"aria-pressed": props.selectedKey === item.key,
|
|
87
|
+
onClick: () => props.onSelect(item.key),
|
|
88
|
+
children: [
|
|
89
|
+
/* @__PURE__ */ jsx("span", { children: item.label }),
|
|
90
|
+
/* @__PURE__ */ jsx("span", { children: item.count })
|
|
91
|
+
]
|
|
92
|
+
},
|
|
93
|
+
item.key
|
|
94
|
+
)) })
|
|
95
|
+
] });
|
|
96
|
+
}
|
|
97
|
+
function NoteList(props) {
|
|
98
|
+
return /* @__PURE__ */ jsx("ul", { class: "garden-explorer__note-list", children: props.notes.map((note) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(
|
|
99
|
+
"button",
|
|
100
|
+
{
|
|
101
|
+
type: "button",
|
|
102
|
+
"aria-pressed": props.selectedSlug === note.slug,
|
|
103
|
+
onClick: () => props.onSelect(note.slug),
|
|
104
|
+
children: [
|
|
105
|
+
/* @__PURE__ */ jsx("span", { children: note.title }),
|
|
106
|
+
/* @__PURE__ */ jsx("small", { children: note.folder })
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
) }, note.slug)) });
|
|
110
|
+
}
|
|
111
|
+
function NoteDetails(props) {
|
|
112
|
+
return /* @__PURE__ */ jsxs("div", { class: "garden-explorer__details", children: [
|
|
113
|
+
/* @__PURE__ */ jsx("p", { class: "garden-explorer__eyebrow", children: "Selected Note" }),
|
|
114
|
+
/* @__PURE__ */ jsx("h2", { children: /* @__PURE__ */ jsx("a", { href: props.note.permalink, children: props.note.title }) }),
|
|
115
|
+
/* @__PURE__ */ jsx("p", { class: "garden-explorer__path", children: props.note.slug }),
|
|
116
|
+
props.note.excerpt && /* @__PURE__ */ jsx("p", { children: props.note.excerpt }),
|
|
117
|
+
/* @__PURE__ */ jsx("div", { class: "garden-explorer__tag-row", children: props.note.tags.map((tag) => /* @__PURE__ */ jsxs("span", { children: [
|
|
118
|
+
"#",
|
|
119
|
+
tag
|
|
120
|
+
] }, tag)) }),
|
|
121
|
+
/* @__PURE__ */ jsx(
|
|
122
|
+
LinkedNoteSection,
|
|
123
|
+
{
|
|
124
|
+
title: "Outgoing Links",
|
|
125
|
+
slugs: props.note.outgoing,
|
|
126
|
+
noteBySlug: props.noteBySlug,
|
|
127
|
+
onSelect: props.onSelect
|
|
128
|
+
}
|
|
129
|
+
),
|
|
130
|
+
/* @__PURE__ */ jsx(
|
|
131
|
+
LinkedNoteSection,
|
|
132
|
+
{
|
|
133
|
+
title: "Backlinks",
|
|
134
|
+
slugs: props.note.backlinks,
|
|
135
|
+
noteBySlug: props.noteBySlug,
|
|
136
|
+
onSelect: props.onSelect
|
|
137
|
+
}
|
|
138
|
+
),
|
|
139
|
+
/* @__PURE__ */ jsxs("div", { class: "garden-explorer__section garden-explorer__section--flush", children: [
|
|
140
|
+
/* @__PURE__ */ jsx("p", { class: "garden-explorer__eyebrow", children: "Related Notes" }),
|
|
141
|
+
/* @__PURE__ */ jsx(
|
|
142
|
+
NoteList,
|
|
143
|
+
{
|
|
144
|
+
notes: props.relatedNotes,
|
|
145
|
+
selectedSlug: props.note.slug,
|
|
146
|
+
onSelect: props.onSelect
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
] })
|
|
150
|
+
] });
|
|
151
|
+
}
|
|
152
|
+
function LinkedNoteSection(props) {
|
|
153
|
+
const notes = props.slugs.map((slug) => props.noteBySlug.get(slug)).filter((note) => note !== void 0);
|
|
154
|
+
return /* @__PURE__ */ jsxs("div", { class: "garden-explorer__section garden-explorer__section--flush", children: [
|
|
155
|
+
/* @__PURE__ */ jsx("p", { class: "garden-explorer__eyebrow", children: props.title }),
|
|
156
|
+
notes.length > 0 ? /* @__PURE__ */ jsx(NoteList, { notes, selectedSlug: "", onSelect: props.onSelect }) : /* @__PURE__ */ jsx("p", { class: "garden-explorer__empty", children: "No notes." })
|
|
157
|
+
] });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// components/garden-explorer.tsx
|
|
161
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "hono/jsx/jsx-runtime";
|
|
162
|
+
var GRAPH_WIDTH = 900;
|
|
163
|
+
var GRAPH_HEIGHT = 620;
|
|
164
|
+
var MIN_SCALE = 0.6;
|
|
165
|
+
var MAX_SCALE = 2.4;
|
|
166
|
+
function GardenExplorer(props) {
|
|
167
|
+
const noteBySlug = useMemo(
|
|
168
|
+
() => new Map(props.data.notes.map((note) => [note.slug, note])),
|
|
169
|
+
[props.data.notes]
|
|
170
|
+
);
|
|
171
|
+
const [query, setQuery] = useState("");
|
|
172
|
+
const [selectedTag, setSelectedTag] = useState(null);
|
|
173
|
+
const [selectedFolder, setSelectedFolder] = useState(null);
|
|
174
|
+
const [selectedSlug, setSelectedSlug] = useState(
|
|
175
|
+
() => props.data.notes[0]?.slug ?? ""
|
|
176
|
+
);
|
|
177
|
+
const [mobilePanel, setMobilePanel] = useState("graph");
|
|
178
|
+
const [view, setView] = useState({ x: 0, y: 0, scale: 1 });
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
const params = new URLSearchParams(window.location.search);
|
|
181
|
+
const note = params.get("note");
|
|
182
|
+
const tag = params.get("tag");
|
|
183
|
+
const folder = params.get("folder");
|
|
184
|
+
if (note && noteBySlug.has(note)) setSelectedSlug(note);
|
|
185
|
+
if (tag) setSelectedTag(tag);
|
|
186
|
+
if (folder) setSelectedFolder(folder);
|
|
187
|
+
}, [noteBySlug]);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
const params = new URLSearchParams();
|
|
190
|
+
if (selectedSlug) params.set("note", selectedSlug);
|
|
191
|
+
if (selectedTag) params.set("tag", selectedTag);
|
|
192
|
+
if (selectedFolder) params.set("folder", selectedFolder);
|
|
193
|
+
const next = params.toString() ? `/explore?${params}` : "/explore";
|
|
194
|
+
window.history.replaceState(null, "", next);
|
|
195
|
+
}, [selectedSlug, selectedTag, selectedFolder]);
|
|
196
|
+
const filteredNotes = useMemo(() => {
|
|
197
|
+
const base = props.data.notes.filter((note) => {
|
|
198
|
+
if (selectedTag && !note.tags.includes(selectedTag)) return false;
|
|
199
|
+
if (selectedFolder && note.folder !== selectedFolder) return false;
|
|
200
|
+
return true;
|
|
201
|
+
});
|
|
202
|
+
const searched = query ? searchGardenExplorerNotes(base, query) : base;
|
|
203
|
+
return searched.slice(0, 80);
|
|
204
|
+
}, [props.data.notes, query, selectedTag, selectedFolder]);
|
|
205
|
+
const visibleSlugSet = useMemo(
|
|
206
|
+
() => new Set(filteredNotes.map((note) => note.slug)),
|
|
207
|
+
[filteredNotes]
|
|
208
|
+
);
|
|
209
|
+
const visibleEdges = useMemo(
|
|
210
|
+
() => props.data.edges.filter(
|
|
211
|
+
(edge) => visibleSlugSet.has(edge.source) && visibleSlugSet.has(edge.target)
|
|
212
|
+
),
|
|
213
|
+
[props.data.edges, visibleSlugSet]
|
|
214
|
+
);
|
|
215
|
+
const graphNodes = useMemo(
|
|
216
|
+
() => layoutNodes(filteredNotes, selectedSlug),
|
|
217
|
+
[filteredNotes, selectedSlug]
|
|
218
|
+
);
|
|
219
|
+
const selectedNote = noteBySlug.get(selectedSlug) ?? filteredNotes[0] ?? null;
|
|
220
|
+
const relatedNotes = useMemo(
|
|
221
|
+
() => selectedNote ? getRelatedNotes(selectedNote, props.data.notes) : [],
|
|
222
|
+
[selectedNote, props.data.notes]
|
|
223
|
+
);
|
|
224
|
+
const selectNote = (slug) => {
|
|
225
|
+
setSelectedSlug(slug);
|
|
226
|
+
setMobilePanel("details");
|
|
227
|
+
};
|
|
228
|
+
const clearFilters = () => {
|
|
229
|
+
setQuery("");
|
|
230
|
+
setSelectedTag(null);
|
|
231
|
+
setSelectedFolder(null);
|
|
232
|
+
};
|
|
233
|
+
const zoom = (delta) => {
|
|
234
|
+
setView((current) => ({
|
|
235
|
+
...current,
|
|
236
|
+
scale: clamp(current.scale + delta, MIN_SCALE, MAX_SCALE)
|
|
237
|
+
}));
|
|
238
|
+
};
|
|
239
|
+
return /* @__PURE__ */ jsxs2("div", { class: "garden-explorer rr-garden-explorer", "data-garden-explorer": true, children: [
|
|
240
|
+
/* @__PURE__ */ jsxs2("fieldset", { class: "garden-explorer__mobile-tabs", children: [
|
|
241
|
+
/* @__PURE__ */ jsx2("legend", { class: "sr-only", children: "Explorer panels" }),
|
|
242
|
+
["graph", "explorer", "details"].map((panel) => /* @__PURE__ */ jsx2(
|
|
243
|
+
"button",
|
|
244
|
+
{
|
|
245
|
+
type: "button",
|
|
246
|
+
class: "garden-explorer__mobile-tab",
|
|
247
|
+
"aria-pressed": mobilePanel === panel,
|
|
248
|
+
onClick: () => setMobilePanel(panel),
|
|
249
|
+
children: panel
|
|
250
|
+
}
|
|
251
|
+
))
|
|
252
|
+
] }),
|
|
253
|
+
/* @__PURE__ */ jsxs2("aside", { class: panelClass("explorer", mobilePanel), children: [
|
|
254
|
+
/* @__PURE__ */ jsxs2("div", { class: "garden-explorer__section", children: [
|
|
255
|
+
/* @__PURE__ */ jsx2("p", { class: "garden-explorer__eyebrow", children: "Explorer" }),
|
|
256
|
+
/* @__PURE__ */ jsxs2("label", { class: "garden-explorer__search", children: [
|
|
257
|
+
/* @__PURE__ */ jsx2("span", { class: "sr-only", children: "Search notes" }),
|
|
258
|
+
/* @__PURE__ */ jsx2(
|
|
259
|
+
"input",
|
|
260
|
+
{
|
|
261
|
+
type: "search",
|
|
262
|
+
value: query,
|
|
263
|
+
placeholder: "Search notes...",
|
|
264
|
+
onInput: (event) => setQuery(event.currentTarget.value)
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
] }),
|
|
268
|
+
/* @__PURE__ */ jsx2(
|
|
269
|
+
"button",
|
|
270
|
+
{
|
|
271
|
+
type: "button",
|
|
272
|
+
class: "garden-explorer__text-button",
|
|
273
|
+
onClick: clearFilters,
|
|
274
|
+
children: "Clear filters"
|
|
275
|
+
}
|
|
276
|
+
)
|
|
277
|
+
] }),
|
|
278
|
+
/* @__PURE__ */ jsx2(
|
|
279
|
+
FilterList,
|
|
280
|
+
{
|
|
281
|
+
title: "Tags",
|
|
282
|
+
items: props.data.tags.map((tag) => ({
|
|
283
|
+
key: tag.name,
|
|
284
|
+
label: `#${tag.name}`,
|
|
285
|
+
count: tag.count
|
|
286
|
+
})),
|
|
287
|
+
selectedKey: selectedTag,
|
|
288
|
+
onSelect: (key) => setSelectedTag(key === selectedTag ? null : key)
|
|
289
|
+
}
|
|
290
|
+
),
|
|
291
|
+
/* @__PURE__ */ jsx2(
|
|
292
|
+
FilterList,
|
|
293
|
+
{
|
|
294
|
+
title: "Folders",
|
|
295
|
+
items: props.data.folders.map((folder) => ({
|
|
296
|
+
key: folder.path,
|
|
297
|
+
label: folder.path,
|
|
298
|
+
count: folder.count
|
|
299
|
+
})),
|
|
300
|
+
selectedKey: selectedFolder,
|
|
301
|
+
onSelect: (key) => setSelectedFolder(key === selectedFolder ? null : key)
|
|
302
|
+
}
|
|
303
|
+
),
|
|
304
|
+
/* @__PURE__ */ jsxs2("div", { class: "garden-explorer__section", children: [
|
|
305
|
+
/* @__PURE__ */ jsx2("p", { class: "garden-explorer__eyebrow", children: "Notes" }),
|
|
306
|
+
/* @__PURE__ */ jsx2(
|
|
307
|
+
NoteList,
|
|
308
|
+
{
|
|
309
|
+
notes: filteredNotes,
|
|
310
|
+
selectedSlug,
|
|
311
|
+
onSelect: selectNote
|
|
312
|
+
}
|
|
313
|
+
)
|
|
314
|
+
] })
|
|
315
|
+
] }),
|
|
316
|
+
/* @__PURE__ */ jsxs2("section", { class: panelClass("graph", mobilePanel), "aria-label": "Note graph", children: [
|
|
317
|
+
/* @__PURE__ */ jsxs2("div", { class: "garden-explorer__graph-toolbar", children: [
|
|
318
|
+
/* @__PURE__ */ jsxs2("span", { children: [
|
|
319
|
+
filteredNotes.length,
|
|
320
|
+
" notes"
|
|
321
|
+
] }),
|
|
322
|
+
/* @__PURE__ */ jsxs2("div", { children: [
|
|
323
|
+
/* @__PURE__ */ jsx2(
|
|
324
|
+
"button",
|
|
325
|
+
{
|
|
326
|
+
type: "button",
|
|
327
|
+
onClick: () => zoom(-0.2),
|
|
328
|
+
"aria-label": "Zoom out",
|
|
329
|
+
children: "−"
|
|
330
|
+
}
|
|
331
|
+
),
|
|
332
|
+
/* @__PURE__ */ jsx2(
|
|
333
|
+
"button",
|
|
334
|
+
{
|
|
335
|
+
type: "button",
|
|
336
|
+
onClick: () => zoom(0.2),
|
|
337
|
+
"aria-label": "Zoom in",
|
|
338
|
+
children: "+"
|
|
339
|
+
}
|
|
340
|
+
),
|
|
341
|
+
/* @__PURE__ */ jsx2(
|
|
342
|
+
"button",
|
|
343
|
+
{
|
|
344
|
+
type: "button",
|
|
345
|
+
onClick: () => setView({ x: 0, y: 0, scale: 1 }),
|
|
346
|
+
children: "Reset"
|
|
347
|
+
}
|
|
348
|
+
)
|
|
349
|
+
] })
|
|
350
|
+
] }),
|
|
351
|
+
/* @__PURE__ */ jsx2(
|
|
352
|
+
"svg",
|
|
353
|
+
{
|
|
354
|
+
class: "garden-graph",
|
|
355
|
+
viewBox: `0 0 ${GRAPH_WIDTH} ${GRAPH_HEIGHT}`,
|
|
356
|
+
role: "img",
|
|
357
|
+
"aria-label": "Interactive graph of notes and internal links",
|
|
358
|
+
onWheel: (event) => {
|
|
359
|
+
event.preventDefault();
|
|
360
|
+
zoom(event.deltaY > 0 ? -0.1 : 0.1);
|
|
361
|
+
},
|
|
362
|
+
onPointerMove: (event) => {
|
|
363
|
+
if (event.buttons !== 1) return;
|
|
364
|
+
setView((current) => ({
|
|
365
|
+
...current,
|
|
366
|
+
x: current.x + event.movementX / current.scale,
|
|
367
|
+
y: current.y + event.movementY / current.scale
|
|
368
|
+
}));
|
|
369
|
+
},
|
|
370
|
+
children: /* @__PURE__ */ jsxs2("g", { transform: `translate(${view.x} ${view.y}) scale(${view.scale})`, children: [
|
|
371
|
+
visibleEdges.map((edge) => {
|
|
372
|
+
const source = graphNodes.get(edge.source);
|
|
373
|
+
const target = graphNodes.get(edge.target);
|
|
374
|
+
if (!source || !target) return null;
|
|
375
|
+
return /* @__PURE__ */ jsx2(
|
|
376
|
+
"line",
|
|
377
|
+
{
|
|
378
|
+
class: "garden-graph__edge",
|
|
379
|
+
x1: source.x,
|
|
380
|
+
y1: source.y,
|
|
381
|
+
x2: target.x,
|
|
382
|
+
y2: target.y
|
|
383
|
+
},
|
|
384
|
+
`${edge.source}-${edge.target}`
|
|
385
|
+
);
|
|
386
|
+
}),
|
|
387
|
+
filteredNotes.map((note) => {
|
|
388
|
+
const node = graphNodes.get(note.slug);
|
|
389
|
+
if (!node) return null;
|
|
390
|
+
const selected = note.slug === selectedSlug;
|
|
391
|
+
return /* @__PURE__ */ jsx2("g", { class: "garden-graph__node", children: /* @__PURE__ */ jsxs2(
|
|
392
|
+
"a",
|
|
393
|
+
{
|
|
394
|
+
href: `/explore?note=${encodeURIComponent(note.slug)}`,
|
|
395
|
+
"aria-label": `Select ${note.title}`,
|
|
396
|
+
onClick: (event) => {
|
|
397
|
+
event.preventDefault();
|
|
398
|
+
selectNote(note.slug);
|
|
399
|
+
},
|
|
400
|
+
children: [
|
|
401
|
+
/* @__PURE__ */ jsx2(
|
|
402
|
+
"circle",
|
|
403
|
+
{
|
|
404
|
+
cx: node.x,
|
|
405
|
+
cy: node.y,
|
|
406
|
+
r: selected ? 15 : node.radius,
|
|
407
|
+
"data-selected": selected ? "true" : "false"
|
|
408
|
+
}
|
|
409
|
+
),
|
|
410
|
+
/* @__PURE__ */ jsx2("text", { x: node.x + 18, y: node.y + 5, children: note.title })
|
|
411
|
+
]
|
|
412
|
+
}
|
|
413
|
+
) }, note.slug);
|
|
414
|
+
})
|
|
415
|
+
] })
|
|
416
|
+
}
|
|
417
|
+
)
|
|
418
|
+
] }),
|
|
419
|
+
/* @__PURE__ */ jsx2("aside", { class: panelClass("details", mobilePanel), children: selectedNote ? /* @__PURE__ */ jsx2(
|
|
420
|
+
NoteDetails,
|
|
421
|
+
{
|
|
422
|
+
note: selectedNote,
|
|
423
|
+
noteBySlug,
|
|
424
|
+
relatedNotes,
|
|
425
|
+
onSelect: selectNote
|
|
426
|
+
}
|
|
427
|
+
) : /* @__PURE__ */ jsx2("p", { class: "garden-explorer__empty", children: "Select a note to inspect links." }) })
|
|
428
|
+
] });
|
|
429
|
+
}
|
|
430
|
+
function layoutNodes(notes, selectedSlug) {
|
|
431
|
+
return layoutRadialGraph(notes, {
|
|
432
|
+
width: GRAPH_WIDTH,
|
|
433
|
+
height: GRAPH_HEIGHT,
|
|
434
|
+
centerSlug: selectedSlug
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
function getRelatedNotes(selectedNote, notes) {
|
|
438
|
+
const directSlugs = /* @__PURE__ */ new Set([
|
|
439
|
+
...selectedNote.outgoing,
|
|
440
|
+
...selectedNote.backlinks
|
|
441
|
+
]);
|
|
442
|
+
const selectedTags = new Set(selectedNote.tags);
|
|
443
|
+
return notes.filter((note) => note.slug !== selectedNote.slug).map((note) => ({
|
|
444
|
+
note,
|
|
445
|
+
score: (directSlugs.has(note.slug) ? 8 : 0) + note.tags.filter((tag) => selectedTags.has(tag)).length * 3
|
|
446
|
+
})).filter((item) => item.score > 0).sort(
|
|
447
|
+
(a, b) => b.score - a.score || a.note.title.localeCompare(b.note.title, "ja")
|
|
448
|
+
).slice(0, 8).map((item) => item.note);
|
|
449
|
+
}
|
|
450
|
+
function panelClass(panel, activePanel) {
|
|
451
|
+
return `garden-explorer__panel garden-explorer__panel--${panel}${panel === activePanel ? " garden-explorer__panel--active" : ""}`;
|
|
452
|
+
}
|
|
453
|
+
function clamp(value, min, max) {
|
|
454
|
+
return Math.min(Math.max(value, min), max);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// client.tsx
|
|
458
|
+
import { jsx as jsx3 } from "hono/jsx/jsx-runtime";
|
|
459
|
+
var ROOT_SELECTOR = "[data-riebeckite-garden-explorer]";
|
|
460
|
+
var DATA_ID = "riebeckite-garden-explorer-data";
|
|
461
|
+
function initGardenExplorer() {
|
|
462
|
+
const root = document.querySelector(ROOT_SELECTOR);
|
|
463
|
+
const dataElement = document.getElementById(DATA_ID);
|
|
464
|
+
if (!root || !dataElement?.textContent) return;
|
|
465
|
+
const data = parseData(dataElement.textContent);
|
|
466
|
+
if (!data) return;
|
|
467
|
+
hydrateRoot(root, /* @__PURE__ */ jsx3(GardenExplorer, { data }));
|
|
468
|
+
}
|
|
469
|
+
function parseData(value) {
|
|
470
|
+
try {
|
|
471
|
+
return JSON.parse(value);
|
|
472
|
+
} catch {
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
export {
|
|
477
|
+
initGardenExplorer as default
|
|
478
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,99 @@
|
|
|
1
1
|
// index.ts
|
|
2
|
-
import { definePlugin } from "@riebeckite/core";
|
|
2
|
+
import { createClientEntry, definePlugin } from "@riebeckite/core";
|
|
3
|
+
|
|
4
|
+
// src/garden-explorer.server.ts
|
|
5
|
+
import {
|
|
6
|
+
buildGraphEdges,
|
|
7
|
+
isPublished
|
|
8
|
+
} from "@riebeckite/core";
|
|
9
|
+
var MAX_BODY_LENGTH = 4e3;
|
|
10
|
+
function getGardenExplorerData(args) {
|
|
11
|
+
const graph = args.manifest.graph;
|
|
12
|
+
const publishedEntries = graph.nodes().filter((entry) => isPublished(args.config, entry.frontmatter));
|
|
13
|
+
const publishedSlugs = new Set(publishedEntries.map((entry) => entry.slug));
|
|
14
|
+
const notes = publishedEntries.map(
|
|
15
|
+
(entry) => toGardenNote(entry, graph, publishedSlugs, args.resolveTitle)
|
|
16
|
+
).sort((a, b) => a.title.localeCompare(b.title, "ja"));
|
|
17
|
+
return {
|
|
18
|
+
notes,
|
|
19
|
+
edges: buildGraphEdges(notes, publishedSlugs),
|
|
20
|
+
tags: buildTags(notes),
|
|
21
|
+
folders: buildFolders(notes)
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function toGardenNote(entry, graph, publishedSlugs, resolveTitle) {
|
|
25
|
+
const { outgoingSlugs, incomingSlugs } = graph.filterNeighbors(
|
|
26
|
+
entry.slug,
|
|
27
|
+
(slug) => publishedSlugs.has(slug)
|
|
28
|
+
);
|
|
29
|
+
return {
|
|
30
|
+
slug: entry.slug,
|
|
31
|
+
permalink: entry.permalink,
|
|
32
|
+
title: resolveTitle(entry.slug, entry.frontmatter.title),
|
|
33
|
+
headings: extractHeadings(entry.html),
|
|
34
|
+
body: toPlainText(entry.html).slice(0, MAX_BODY_LENGTH),
|
|
35
|
+
excerpt: createExcerpt(entry),
|
|
36
|
+
tags: entry.tags,
|
|
37
|
+
date: getEntryDate(entry.frontmatter),
|
|
38
|
+
folder: getFolder(entry.slug),
|
|
39
|
+
outgoing: outgoingSlugs,
|
|
40
|
+
backlinks: incomingSlugs
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function buildTags(notes) {
|
|
44
|
+
const counts = /* @__PURE__ */ new Map();
|
|
45
|
+
for (const note of notes) {
|
|
46
|
+
for (const tag of note.tags) counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
|
47
|
+
}
|
|
48
|
+
return Array.from(counts, ([name, count]) => ({ name, count })).sort(
|
|
49
|
+
(a, b) => b.count - a.count || a.name.localeCompare(b.name, "ja")
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
function buildFolders(notes) {
|
|
53
|
+
const counts = /* @__PURE__ */ new Map();
|
|
54
|
+
for (const note of notes)
|
|
55
|
+
counts.set(note.folder, (counts.get(note.folder) ?? 0) + 1);
|
|
56
|
+
return Array.from(counts, ([path, count]) => ({ path, count })).sort(
|
|
57
|
+
(a, b) => a.path.localeCompare(b.path, "ja")
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
function getFolder(slug) {
|
|
61
|
+
const segments = slug.split("/");
|
|
62
|
+
if (segments.length <= 1) return "Root";
|
|
63
|
+
return segments.slice(0, -1).join("/");
|
|
64
|
+
}
|
|
65
|
+
function createExcerpt(entry) {
|
|
66
|
+
const description = entry.frontmatter.description;
|
|
67
|
+
const text = typeof description === "string" && description.trim() ? description.trim() : toPlainText(entry.html);
|
|
68
|
+
return text.slice(0, 180);
|
|
69
|
+
}
|
|
70
|
+
function extractHeadings(html) {
|
|
71
|
+
const headings = [];
|
|
72
|
+
const headingPattern = /<h([1-4])\b[^>]*>([\s\S]*?)<\/h\1>/g;
|
|
73
|
+
for (const match of html.matchAll(headingPattern)) {
|
|
74
|
+
const heading = toPlainText(match[2] ?? "");
|
|
75
|
+
if (heading) headings.push(heading);
|
|
76
|
+
}
|
|
77
|
+
return headings;
|
|
78
|
+
}
|
|
79
|
+
function getEntryDate(frontmatter) {
|
|
80
|
+
const value = frontmatter.published ?? frontmatter.date ?? frontmatter.created;
|
|
81
|
+
if (value instanceof Date && !Number.isNaN(value.getTime()))
|
|
82
|
+
return value.toISOString();
|
|
83
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
84
|
+
const date = new Date(value);
|
|
85
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
86
|
+
}
|
|
87
|
+
function toPlainText(html) {
|
|
88
|
+
return html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/\s+/g, " ").trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/garden-explorer-page.tsx
|
|
92
|
+
import { escapeScriptJson } from "@riebeckite/core";
|
|
93
|
+
import { renderToString } from "hono/jsx/dom/server";
|
|
3
94
|
|
|
4
95
|
// components/garden-explorer.tsx
|
|
5
|
-
import { layoutRadialGraph } from "@riebeckite/core";
|
|
96
|
+
import { layoutRadialGraph } from "@riebeckite/core/client";
|
|
6
97
|
import { useEffect, useMemo, useState } from "hono/jsx";
|
|
7
98
|
|
|
8
99
|
// src/search-notes.ts
|
|
@@ -454,92 +545,30 @@ function clamp(value, min, max) {
|
|
|
454
545
|
return Math.min(Math.max(value, min), max);
|
|
455
546
|
}
|
|
456
547
|
|
|
457
|
-
// src/garden-explorer.
|
|
458
|
-
import {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
entry.slug,
|
|
480
|
-
(slug) => publishedSlugs.has(slug)
|
|
481
|
-
);
|
|
482
|
-
return {
|
|
483
|
-
slug: entry.slug,
|
|
484
|
-
permalink: entry.permalink,
|
|
485
|
-
title: resolveTitle(entry.slug, entry.frontmatter.title),
|
|
486
|
-
headings: extractHeadings(entry.html),
|
|
487
|
-
body: toPlainText(entry.html).slice(0, MAX_BODY_LENGTH),
|
|
488
|
-
excerpt: createExcerpt(entry),
|
|
489
|
-
tags: entry.tags,
|
|
490
|
-
date: getEntryDate(entry.frontmatter),
|
|
491
|
-
folder: getFolder(entry.slug),
|
|
492
|
-
outgoing: outgoingSlugs,
|
|
493
|
-
backlinks: incomingSlugs
|
|
494
|
-
};
|
|
495
|
-
}
|
|
496
|
-
function buildTags(notes) {
|
|
497
|
-
const counts = /* @__PURE__ */ new Map();
|
|
498
|
-
for (const note of notes) {
|
|
499
|
-
for (const tag of note.tags) counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
|
500
|
-
}
|
|
501
|
-
return Array.from(counts, ([name, count]) => ({ name, count })).sort(
|
|
502
|
-
(a, b) => b.count - a.count || a.name.localeCompare(b.name, "ja")
|
|
503
|
-
);
|
|
504
|
-
}
|
|
505
|
-
function buildFolders(notes) {
|
|
506
|
-
const counts = /* @__PURE__ */ new Map();
|
|
507
|
-
for (const note of notes)
|
|
508
|
-
counts.set(note.folder, (counts.get(note.folder) ?? 0) + 1);
|
|
509
|
-
return Array.from(counts, ([path, count]) => ({ path, count })).sort(
|
|
510
|
-
(a, b) => a.path.localeCompare(b.path, "ja")
|
|
548
|
+
// src/garden-explorer-page.tsx
|
|
549
|
+
import { jsx as jsx3, jsxs as jsxs3 } from "hono/jsx/jsx-runtime";
|
|
550
|
+
function renderGardenExplorerPage(data, siteTitle) {
|
|
551
|
+
return renderToString(
|
|
552
|
+
/* @__PURE__ */ jsxs3("main", { class: "garden-explorer-page", children: [
|
|
553
|
+
/* @__PURE__ */ jsxs3("header", { class: "garden-explorer-page__header", children: [
|
|
554
|
+
/* @__PURE__ */ jsx3("p", { class: "garden-explorer-page__eyebrow", children: siteTitle }),
|
|
555
|
+
/* @__PURE__ */ jsx3("h1", { class: "garden-explorer-page__title", children: "Garden Explorer" }),
|
|
556
|
+
/* @__PURE__ */ jsx3("p", { class: "garden-explorer-page__description", children: "Graph, Search, Tags, Folders, Backlinks, and Related Notes を横断して Digital Garden を探索できます。" })
|
|
557
|
+
] }),
|
|
558
|
+
/* @__PURE__ */ jsx3("div", { "data-riebeckite-garden-explorer": true, children: /* @__PURE__ */ jsx3(GardenExplorer, { data }) }),
|
|
559
|
+
/* @__PURE__ */ jsx3(
|
|
560
|
+
"script",
|
|
561
|
+
{
|
|
562
|
+
id: "riebeckite-garden-explorer-data",
|
|
563
|
+
type: "application/json",
|
|
564
|
+
dangerouslySetInnerHTML: {
|
|
565
|
+
__html: escapeScriptJson(JSON.stringify(data))
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
)
|
|
569
|
+
] })
|
|
511
570
|
);
|
|
512
571
|
}
|
|
513
|
-
function getFolder(slug) {
|
|
514
|
-
const segments = slug.split("/");
|
|
515
|
-
if (segments.length <= 1) return "Root";
|
|
516
|
-
return segments.slice(0, -1).join("/");
|
|
517
|
-
}
|
|
518
|
-
function createExcerpt(entry) {
|
|
519
|
-
const description = entry.frontmatter.description;
|
|
520
|
-
const text = typeof description === "string" && description.trim() ? description.trim() : toPlainText(entry.html);
|
|
521
|
-
return text.slice(0, 180);
|
|
522
|
-
}
|
|
523
|
-
function extractHeadings(html) {
|
|
524
|
-
const headings = [];
|
|
525
|
-
const headingPattern = /<h([1-4])\b[^>]*>([\s\S]*?)<\/h\1>/g;
|
|
526
|
-
for (const match of html.matchAll(headingPattern)) {
|
|
527
|
-
const heading = toPlainText(match[2] ?? "");
|
|
528
|
-
if (heading) headings.push(heading);
|
|
529
|
-
}
|
|
530
|
-
return headings;
|
|
531
|
-
}
|
|
532
|
-
function getEntryDate(frontmatter) {
|
|
533
|
-
const value = frontmatter.published ?? frontmatter.date ?? frontmatter.created;
|
|
534
|
-
if (value instanceof Date && !Number.isNaN(value.getTime()))
|
|
535
|
-
return value.toISOString();
|
|
536
|
-
if (typeof value !== "string" || !value.trim()) return null;
|
|
537
|
-
const date = new Date(value);
|
|
538
|
-
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
539
|
-
}
|
|
540
|
-
function toPlainText(html) {
|
|
541
|
-
return html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/\s+/g, " ").trim();
|
|
542
|
-
}
|
|
543
572
|
|
|
544
573
|
// index.ts
|
|
545
574
|
function gardenExplorerPlugin() {
|
|
@@ -551,6 +580,31 @@ function gardenExplorerPlugin() {
|
|
|
551
580
|
kind: "style",
|
|
552
581
|
moduleSpecifier: "@riebeckite/plugin-garden-explorer/style.css"
|
|
553
582
|
}
|
|
583
|
+
],
|
|
584
|
+
clientEntries: [createClientEntry("garden-explorer")],
|
|
585
|
+
pageTypes: [
|
|
586
|
+
{
|
|
587
|
+
id: "garden-explorer",
|
|
588
|
+
paths: ["/explore"],
|
|
589
|
+
resolve: ({ pathname, manifest, config }) => {
|
|
590
|
+
if (pathname !== "/explore" || !config) return null;
|
|
591
|
+
const siteTitle = config.site.title;
|
|
592
|
+
return {
|
|
593
|
+
type: "garden-explorer",
|
|
594
|
+
pathname,
|
|
595
|
+
title: "Garden Explorer",
|
|
596
|
+
description: "Explore notes through links, backlinks, tags, and folders.",
|
|
597
|
+
body: renderGardenExplorerPage(
|
|
598
|
+
getGardenExplorerData({
|
|
599
|
+
manifest,
|
|
600
|
+
config,
|
|
601
|
+
resolveTitle: (slug, title) => typeof title === "string" && title.trim() ? title : slug
|
|
602
|
+
}),
|
|
603
|
+
siteTitle
|
|
604
|
+
)
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
}
|
|
554
608
|
]
|
|
555
609
|
});
|
|
556
610
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@riebeckite/plugin-garden-explorer",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -18,11 +18,17 @@
|
|
|
18
18
|
"source": "./components/garden-explorer.tsx",
|
|
19
19
|
"default": "./dist/components/garden-explorer.js"
|
|
20
20
|
},
|
|
21
|
+
"./client": {
|
|
22
|
+
"types": "./dist/client.d.ts",
|
|
23
|
+
"import": "./dist/client.js",
|
|
24
|
+
"source": "./client.tsx",
|
|
25
|
+
"default": "./dist/client.js"
|
|
26
|
+
},
|
|
21
27
|
"./style.css": "./style.css"
|
|
22
28
|
},
|
|
23
29
|
"dependencies": {
|
|
24
30
|
"hono": "^4.12.25",
|
|
25
|
-
"@riebeckite/core": "0.0.
|
|
31
|
+
"@riebeckite/core": "0.0.13"
|
|
26
32
|
},
|
|
27
33
|
"publishConfig": {
|
|
28
34
|
"access": "public"
|