@chappibunny/repolens 1.12.4 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -1
- package/README.md +21 -32
- package/package.json +2 -1
- package/src/core/config-schema.js +47 -1
- package/src/doctor.js +14 -1
- package/src/init.js +61 -21
- package/src/publishers/index.js +21 -1
- package/src/publishers/obsidian.js +716 -0
- package/src/utils/branch.js +16 -0
- package/src/utils/validate.js +63 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to RepoLens will be documented in this file.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 1.13.0
|
|
6
|
+
|
|
7
|
+
### Obsidian vault publisher
|
|
8
|
+
|
|
9
|
+
Publish generated docs into a folder in a local Obsidian vault. No network calls and no credentials.
|
|
10
|
+
|
|
11
|
+
- New publisher: `obsidian` (`src/publishers/obsidian.js`)
|
|
12
|
+
- Notes get YAML frontmatter (`title`, `aliases`, `tags`, `type`, `project`, `branch`) and an index note (`RepoLens.md`) with wikilinks
|
|
13
|
+
- Markdown links to generated pages become `[[wikilinks]]`. External URLs and fenced code blocks are left alone
|
|
14
|
+
- Re-running publish is idempotent: identical notes are not rewritten, and notes RepoLens previously published but no longer generates are removed. Other files in the folder stay
|
|
15
|
+
- `obsidian.folder` must resolve inside `obsidian.vault`. Absolute folders, `..`, and symlinks that leave the vault are rejected
|
|
16
|
+
- Optional config on schema v1: `obsidian.vault`, `obsidian.folder`, `obsidian.branches`, `obsidian.index`, `obsidian.frontmatter`, `obsidian.wikilinks`. Existing configs keep working
|
|
17
|
+
- `repolens init` can select the Obsidian publisher and ask for a vault path
|
|
18
|
+
- `repolens doctor` checks that `obsidian.vault` is set and does not require env vars for it
|
|
19
|
+
- Wikilink rewriting is a linear scan with bounded label and target lengths. The previous link regex was polynomial on hostile markdown (CodeQL)
|
|
6
20
|
|
|
7
21
|
### Security
|
|
8
22
|
|
|
@@ -10,8 +24,12 @@ All notable changes to RepoLens will be documented in this file.
|
|
|
10
24
|
|
|
11
25
|
### Docs
|
|
12
26
|
|
|
27
|
+
- `docs/ROADMAP.md` now summarizes what shipped from v1.1.0 through v1.12.4, and marks the VS Code extension and the Obsidian publisher as shipped
|
|
28
|
+
- README roadmap matches `docs/ROADMAP.md`
|
|
13
29
|
- The README no longer hard-codes the release version. It points at `package.json`, which is the source of the version
|
|
14
30
|
- Test totals are no longer hard-coded in the README, development guide, or security guide. The tests badge follows the `test.yml` workflow status
|
|
31
|
+
- Handbook pages (README, `docs/*`, `SECURITY.md`, `CONTRIBUTING.md`, and `.github/copilot-instructions.md`) describe 1.13.0 and the Obsidian publisher
|
|
32
|
+
- Corrected drift against the code: `scan.ignore` (there is no `scan.exclude`), `publish` has no `--dry-run`, CI installs with `npm ci`, the publish workflow is `repolens.yml`, diagrams are Unicode, and local files use two filename schemes. `outputs.pages` stays the frozen allowlist; the generator still emits the full 17-document plan unless `documentation.sections` or `features` filters it
|
|
15
33
|
|
|
16
34
|
## 1.12.4
|
|
17
35
|
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
**Your architecture docs are already outdated.** RepoLens fixes that.
|
|
17
17
|
|
|
18
|
-
RepoLens scans your repository, generates living architecture documentation, and publishes it to Notion, Confluence, GitHub Wiki, or Markdown — automatically on every push. Engineers get technical docs. Stakeholders get readable system overviews. Nobody writes a word.
|
|
18
|
+
RepoLens scans your repository, generates living architecture documentation, and publishes it to Notion, Confluence, GitHub Wiki, Obsidian, or Markdown — automatically on every push. Engineers get technical docs. Stakeholders get readable system overviews. Nobody writes a word.
|
|
19
19
|
|
|
20
20
|
> Stable as of v1.0 — [API guarantees](docs/STABILITY.md) · [Security hardened](SECURITY.md) · version from [`package.json`](package.json)
|
|
21
21
|
|
|
@@ -83,7 +83,7 @@ CONFLUENCE_SPACE_KEY=DOCS
|
|
|
83
83
|
npx @chappibunny/repolens publish
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
**Done!**
|
|
86
|
+
**Done!** Numbered notes and the Markdown publisher’s files are in `.repolens/`. Notion, Confluence, GitHub Wiki, and Obsidian publish only when you configure them. Obsidian needs `obsidian.vault` in `.repolens.yml` and does not use an API key.
|
|
87
87
|
|
|
88
88
|
**🔄 Upgrading from v0.3.0 or earlier?**
|
|
89
89
|
Run `npx @chappibunny/repolens migrate` to automatically update your workflow files. See [MIGRATION.md](docs/MIGRATION.md) for details.
|
|
@@ -96,9 +96,9 @@ Run `npx @chappibunny/repolens migrate` to automatically update your workflow fi
|
|
|
96
96
|
|
|
97
97
|
| Audience | Documents |
|
|
98
98
|
|---|---|
|
|
99
|
-
| **Stakeholders**
|
|
100
|
-
| **
|
|
101
|
-
| **Engineers** | Architecture Overview · Module Catalog · API Surface ·
|
|
99
|
+
| **Stakeholders** | Executive Summary |
|
|
100
|
+
| **Mixed** | System Overview · Business Domains · Route Map · Data Flows · Change Impact · System Map · Architecture Drift · Code Health |
|
|
101
|
+
| **Engineers** | Architecture Overview · Module Catalog · API Surface · Developer Onboarding · GraphQL Schema · TypeScript Type Graph · Dependency Graph · Security Hotspots |
|
|
102
102
|
|
|
103
103
|
**Two modes:** Deterministic (free, fast, always works) or AI-Enhanced (optional — GitHub Models, OpenAI, Anthropic, Google, Azure, Ollama).
|
|
104
104
|
|
|
@@ -110,7 +110,7 @@ Run `npx @chappibunny/repolens migrate` to automatically update your workflow fi
|
|
|
110
110
|
|---|---|
|
|
111
111
|
| 🤖 **Autonomous** | Runs on every push via GitHub Actions — docs stay evergreen |
|
|
112
112
|
| 👥 **Multi-Audience** | Technical docs + stakeholder-readable overviews from one scan |
|
|
113
|
-
| 📤 **Multi-Publisher** | Notion, Confluence, GitHub Wiki, Markdown — or
|
|
113
|
+
| 📤 **Multi-Publisher** | Notion, Confluence, GitHub Wiki, Obsidian, Markdown — together or one at a time |
|
|
114
114
|
| 🧠 **AI-Assisted** | Optional AI with zero-hallucination policy (structured context only) |
|
|
115
115
|
| 🔍 **Smart Detection** | Frameworks, domains, data flows, dependencies, drift — all automatic |
|
|
116
116
|
| 🔌 **Extensible** | Plugin system for custom renderers, publishers, and hooks |
|
|
@@ -155,7 +155,7 @@ The extension reads your `.repolens.yml` configuration and provides an interacti
|
|
|
155
155
|
|
|
156
156
|
## 🎓 Onboarding Guide
|
|
157
157
|
|
|
158
|
-
Step-by-step setup for publishers, AI features, Notion, Confluence, GitHub Wiki, Discord, and CI/CD automation.
|
|
158
|
+
Step-by-step setup for publishers, AI features, Notion, Confluence, GitHub Wiki, Obsidian, Discord, and CI/CD automation.
|
|
159
159
|
|
|
160
160
|
**[→ Full Onboarding Guide](docs/ONBOARDING.md)**
|
|
161
161
|
|
|
@@ -183,28 +183,11 @@ Step-by-step setup for publishers, AI features, Notion, Confluence, GitHub Wiki,
|
|
|
183
183
|
|
|
184
184
|
## 📸 Example Output
|
|
185
185
|
|
|
186
|
-
### System
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
Publishers[src/publishers<br/>6 files] --> Core
|
|
192
|
-
Publishers --> Renderers[src/renderers<br/>4 files]
|
|
193
|
-
Publishers --> Utils[src/utils<br/>10 files]
|
|
194
|
-
Renderers --> Core
|
|
195
|
-
Delivery[src/delivery<br/>1 file] --> Publishers
|
|
196
|
-
Tests[tests<br/>15 files] -. tests .-> CLI
|
|
197
|
-
Tests -. tests .-> Core
|
|
198
|
-
Tests -. tests .-> Publishers
|
|
199
|
-
|
|
200
|
-
style CLI fill:#9b59b6,color:#fff
|
|
201
|
-
style Core fill:#f39c12,color:#000
|
|
202
|
-
style Publishers fill:#27ae60,color:#fff
|
|
203
|
-
style Renderers fill:#27ae60,color:#fff
|
|
204
|
-
style Delivery fill:#16a085,color:#fff
|
|
205
|
-
style Utils fill:#95a5a6,color:#000
|
|
206
|
-
style Tests fill:#e67e22,color:#fff
|
|
207
|
-
```
|
|
186
|
+
### System map
|
|
187
|
+
|
|
188
|
+
Published system maps are Unicode diagrams drawn from import edges. RepoLens does not emit Mermaid.
|
|
189
|
+
|
|
190
|
+
The publisher set in 1.13.0 is Notion, Confluence, GitHub Wiki, Obsidian, and Markdown (`src/publishers`, 7 files). Layout and counts are in [ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
208
191
|
|
|
209
192
|
### System Overview (Technical Profile)
|
|
210
193
|
|
|
@@ -255,7 +238,7 @@ When you open a pull request, RepoLens posts:
|
|
|
255
238
|
|
|
256
239
|
| Guide | Description |
|
|
257
240
|
|---|---|
|
|
258
|
-
| [Onboarding Guide](docs/ONBOARDING.md) | Step-by-step setup: publishers, AI, Notion, Confluence, Discord |
|
|
241
|
+
| [Onboarding Guide](docs/ONBOARDING.md) | Step-by-step setup: publishers, AI, Notion, Confluence, GitHub Wiki, Obsidian, Discord |
|
|
259
242
|
| [Configuration](docs/CONFIGURATION.md) | Complete `.repolens.yml` schema and examples |
|
|
260
243
|
| [Environment Variables](docs/ENVIRONMENT.md) | All env vars by publisher and feature |
|
|
261
244
|
| [Architecture](docs/ARCHITECTURE.md) | Pipeline diagram, project structure |
|
|
@@ -285,10 +268,16 @@ v1.0+ features complete — CLI, config schema, and plugin interface are frozen.
|
|
|
285
268
|
|
|
286
269
|
**Completed:**
|
|
287
270
|
- [x] VS Code extension ([available on Marketplace](https://marketplace.visualstudio.com/items?itemName=CHAPIBUNNY.repolens-architecture))
|
|
271
|
+
- [x] Obsidian vault publisher (local notes with wikilinks and frontmatter)
|
|
288
272
|
|
|
289
273
|
**Next:**
|
|
290
|
-
- [ ]
|
|
291
|
-
- [ ]
|
|
274
|
+
- [ ] GitHub App for automated setup and status checks
|
|
275
|
+
- [ ] Slack notifications
|
|
276
|
+
- [ ] Custom webhook support
|
|
277
|
+
- [ ] Cross-repository architecture analysis
|
|
278
|
+
- [ ] Security boundary validation
|
|
279
|
+
- [ ] Interactive HTML documentation output
|
|
280
|
+
- [ ] Architecture decision records (ADR) generation
|
|
292
281
|
|
|
293
282
|
See [ROADMAP.md](docs/ROADMAP.md) for detailed planning.
|
|
294
283
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chappibunny/repolens",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.0",
|
|
4
4
|
"description": "AI-assisted documentation intelligence system for technical and non-technical audiences",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"metrics",
|
|
37
37
|
"team-collaboration",
|
|
38
38
|
"discord",
|
|
39
|
+
"obsidian",
|
|
39
40
|
"ai-documentation"
|
|
40
41
|
],
|
|
41
42
|
"repository": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const CURRENT_SCHEMA_VERSION = 1;
|
|
11
|
-
const SUPPORTED_PUBLISHERS = ["notion", "markdown", "confluence", "github_wiki"];
|
|
11
|
+
const SUPPORTED_PUBLISHERS = ["notion", "markdown", "confluence", "github_wiki", "obsidian"];
|
|
12
12
|
const SUPPORTED_PAGE_KEYS = [
|
|
13
13
|
"system_overview",
|
|
14
14
|
"module_catalog",
|
|
@@ -218,6 +218,51 @@ export function validateConfig(config) {
|
|
|
218
218
|
}
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
// Validate Obsidian configuration (optional, local vault — no network)
|
|
222
|
+
if (config.obsidian !== undefined) {
|
|
223
|
+
if (typeof config.obsidian !== "object" || Array.isArray(config.obsidian) || config.obsidian === null) {
|
|
224
|
+
errors.push("obsidian must be an object");
|
|
225
|
+
} else {
|
|
226
|
+
if (config.obsidian.vault !== undefined) {
|
|
227
|
+
if (typeof config.obsidian.vault !== "string" || config.obsidian.vault.trim() === "") {
|
|
228
|
+
errors.push("obsidian.vault must be a non-empty string");
|
|
229
|
+
} else if (config.obsidian.vault.includes("\0")) {
|
|
230
|
+
errors.push("obsidian.vault contains a null byte");
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (config.obsidian.folder !== undefined) {
|
|
234
|
+
if (typeof config.obsidian.folder !== "string" || config.obsidian.folder.trim() === "") {
|
|
235
|
+
errors.push("obsidian.folder must be a non-empty string");
|
|
236
|
+
} else if (config.obsidian.folder.includes("\0")) {
|
|
237
|
+
errors.push("obsidian.folder contains a null byte");
|
|
238
|
+
} else if (
|
|
239
|
+
config.obsidian.folder.startsWith("/") ||
|
|
240
|
+
config.obsidian.folder.startsWith("\\") ||
|
|
241
|
+
/^[A-Za-z]:[\\/]/.test(config.obsidian.folder) ||
|
|
242
|
+
config.obsidian.folder.split(/[\\/]/).some((part) => part === "..")
|
|
243
|
+
) {
|
|
244
|
+
errors.push("obsidian.folder must be a relative path inside the vault");
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (config.obsidian.branches !== undefined) {
|
|
248
|
+
if (!Array.isArray(config.obsidian.branches)) {
|
|
249
|
+
errors.push("obsidian.branches must be an array");
|
|
250
|
+
} else {
|
|
251
|
+
config.obsidian.branches.forEach((branch, idx) => {
|
|
252
|
+
if (typeof branch !== "string") {
|
|
253
|
+
errors.push(`obsidian.branches[${idx}] must be a string`);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
for (const flag of ["index", "frontmatter", "wikilinks"]) {
|
|
259
|
+
if (config.obsidian[flag] !== undefined && typeof config.obsidian[flag] !== "boolean") {
|
|
260
|
+
errors.push(`obsidian.${flag} must be a boolean`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
221
266
|
// Validate Discord configuration (optional)
|
|
222
267
|
if (config.discord !== undefined) {
|
|
223
268
|
if (typeof config.discord !== "object" || Array.isArray(config.discord)) {
|
|
@@ -379,6 +424,7 @@ const VALID_CONFIG_KEYS = [
|
|
|
379
424
|
"notion",
|
|
380
425
|
"confluence",
|
|
381
426
|
"github_wiki",
|
|
427
|
+
"obsidian",
|
|
382
428
|
"discord",
|
|
383
429
|
"features",
|
|
384
430
|
"ai",
|
package/src/doctor.js
CHANGED
|
@@ -204,6 +204,14 @@ export async function runDoctor(targetDir = process.cwd()) {
|
|
|
204
204
|
);
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
if (cfg.publishers.includes("obsidian")) {
|
|
208
|
+
if (typeof cfg.obsidian?.vault === "string" && cfg.obsidian.vault.trim() !== "") {
|
|
209
|
+
ok(`Obsidian vault configured (${cfg.obsidian.vault}) — local publisher, no credentials`);
|
|
210
|
+
} else {
|
|
211
|
+
warn("obsidian.vault is not set — required to publish into an Obsidian vault");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
207
215
|
if (cfg.ai?.enabled || process.env.REPOLENS_AI_ENABLED === "true") {
|
|
208
216
|
const aiProvider = process.env.REPOLENS_AI_PROVIDER || "openai_compatible";
|
|
209
217
|
if (aiProvider === "github") {
|
|
@@ -218,7 +226,12 @@ export async function runDoctor(targetDir = process.cwd()) {
|
|
|
218
226
|
}
|
|
219
227
|
|
|
220
228
|
if (envChecks.length === 0) {
|
|
221
|
-
|
|
229
|
+
const localPublishers = [];
|
|
230
|
+
if (cfg.publishers.includes("markdown")) localPublishers.push("Markdown");
|
|
231
|
+
if (cfg.publishers.includes("obsidian")) localPublishers.push("Obsidian");
|
|
232
|
+
ok(localPublishers.length > 0
|
|
233
|
+
? `No publisher-specific env vars required (${localPublishers.join(" and ")} only)`
|
|
234
|
+
: "No publisher-specific env vars required");
|
|
222
235
|
}
|
|
223
236
|
|
|
224
237
|
for (const check of envChecks) {
|
package/src/init.js
CHANGED
|
@@ -5,7 +5,7 @@ import { spawn } from "node:child_process";
|
|
|
5
5
|
import { info, warn } from "./utils/logger.js";
|
|
6
6
|
import { isNonInteractiveEnvironment } from "./utils/interactive.js";
|
|
7
7
|
|
|
8
|
-
const PUBLISHER_CHOICES = ["markdown", "notion", "confluence", "github_wiki"];
|
|
8
|
+
const PUBLISHER_CHOICES = ["markdown", "notion", "confluence", "github_wiki", "obsidian"];
|
|
9
9
|
|
|
10
10
|
// ============================================================================
|
|
11
11
|
// URL PARSING HELPERS
|
|
@@ -435,6 +435,10 @@ If you configured Notion credentials during setup, documentation will publish au
|
|
|
435
435
|
- \`NOTION_TOKEN\` — Get from https://www.notion.so/my-integrations
|
|
436
436
|
- \`NOTION_PARENT_PAGE_ID\` — The page where docs will be published
|
|
437
437
|
|
|
438
|
+
### Obsidian and GitHub Wiki
|
|
439
|
+
|
|
440
|
+
Obsidian writes notes into a folder in a local vault. Set \`obsidian.vault\` in \`.repolens.yml\`. No API key and no network. GitHub Wiki needs \`github_wiki\` in \`publishers\` and \`GITHUB_TOKEN\`.
|
|
441
|
+
|
|
438
442
|
### Confluence Publishing
|
|
439
443
|
|
|
440
444
|
To publish documentation to Atlassian Confluence:
|
|
@@ -472,23 +476,20 @@ The workflow token is \`contents: read\` plus \`pull-requests: write\`. That is
|
|
|
472
476
|
|
|
473
477
|
## 📊 Generated Documentation
|
|
474
478
|
|
|
475
|
-
RepoLens generates
|
|
479
|
+
RepoLens generates 17 documents by default. Diagrams are Unicode.
|
|
476
480
|
|
|
477
|
-
###
|
|
478
|
-
- **System Overview**
|
|
479
|
-
- **
|
|
480
|
-
- **API Surface** — Internal endpoints + external integrations
|
|
481
|
-
- **Route Map** — Application routes
|
|
482
|
-
- **System Map** — Unicode architecture diagram
|
|
483
|
-
- **Architecture Diff** — Change tracking
|
|
481
|
+
### Always written
|
|
482
|
+
- **System Overview**, **Module Catalog**, **API Surface**, **Route Map**, **System Map** (Unicode diagram), **Architecture Diff**
|
|
483
|
+
- **GraphQL Schema**, **Type Graph**, **Dependency Graph**, **Architecture Drift**, **Security Hotspots**, **Code Health**
|
|
484
484
|
|
|
485
|
-
### AI-Enhanced Mode (Optional
|
|
486
|
-
|
|
485
|
+
### AI-Enhanced Mode (Optional)
|
|
486
|
+
These documents use AI when it is enabled, and a deterministic fallback when it is not:
|
|
487
487
|
- **Executive Summary** — Project overview for leadership
|
|
488
488
|
- **Business Domains** — What the system does by function
|
|
489
489
|
- **Architecture Overview** — Layered technical analysis
|
|
490
|
-
- **Data Flows** — How information moves through system
|
|
490
|
+
- **Data Flows** — How information moves through the system
|
|
491
491
|
- **Developer Onboarding** — Getting started guide
|
|
492
|
+
- **Change Impact** and the hybrid pages above (module catalog, route map, API surface, system map)
|
|
492
493
|
|
|
493
494
|
## 🤖 Enabling AI Features
|
|
494
495
|
|
|
@@ -529,11 +530,7 @@ No API key signup needed. Uses \`gpt-4o-mini\` by default.
|
|
|
529
530
|
|
|
530
531
|
**Cost estimate**: $0.10-$0.40 per run for typical projects (or free with GitHub Models)
|
|
531
532
|
|
|
532
|
-
See [AI.md](https://github.com/CHAPIBUNNY/repolens/blob/main/AI.md) for full documentation
|
|
533
|
-
- **Module Catalog** — Detected code modules
|
|
534
|
-
- **API Surface** — REST API endpoints
|
|
535
|
-
- **Route Map** — Application routes
|
|
536
|
-
- **System Map** — Architecture diagram (Unicode ASCII art)
|
|
533
|
+
See [AI.md](https://github.com/CHAPIBUNNY/repolens/blob/main/docs/AI.md) for full documentation.
|
|
537
534
|
|
|
538
535
|
## ⚙️ Configuration
|
|
539
536
|
|
|
@@ -547,8 +544,8 @@ Edit \`.repolens.yml\` to customize:
|
|
|
547
544
|
## 📚 Learn More
|
|
548
545
|
|
|
549
546
|
- [RepoLens GitHub](https://github.com/CHAPIBUNNY/repolens)
|
|
550
|
-
- [Configuration
|
|
551
|
-
- [Troubleshooting](https://github.com/CHAPIBUNNY/repolens
|
|
547
|
+
- [Configuration](https://github.com/CHAPIBUNNY/repolens/blob/main/docs/CONFIGURATION.md)
|
|
548
|
+
- [Troubleshooting](https://github.com/CHAPIBUNNY/repolens/blob/main/docs/TROUBLESHOOTING.md)
|
|
552
549
|
`;
|
|
553
550
|
|
|
554
551
|
async function fileExists(filePath) {
|
|
@@ -754,6 +751,17 @@ function buildRepoLensConfig(projectName, detectedRoots, options = {}) {
|
|
|
754
751
|
`# - main # Only publish from main branch`,
|
|
755
752
|
`# - develop # Or add specific branches`,
|
|
756
753
|
``,
|
|
754
|
+
`# Optional: Publish into a local Obsidian vault (no network)`,
|
|
755
|
+
`# publishers also needs: - obsidian`,
|
|
756
|
+
`# obsidian:`,
|
|
757
|
+
`# vault: "/path/to/vault" # absolute, or relative to this repo`,
|
|
758
|
+
`# folder: "RepoLens/my-app" # stays inside the vault`,
|
|
759
|
+
`# branches:`,
|
|
760
|
+
`# - main`,
|
|
761
|
+
`# index: true`,
|
|
762
|
+
`# frontmatter: true`,
|
|
763
|
+
`# wikilinks: true`,
|
|
764
|
+
``,
|
|
757
765
|
`# Optional: GitHub integration for SVG diagram hosting`,
|
|
758
766
|
`# github:`,
|
|
759
767
|
`# owner: "your-username"`,
|
|
@@ -1156,6 +1164,20 @@ async function runInteractiveWizard(repoRoot) {
|
|
|
1156
1164
|
githubSecretsNeeded.push("CONFLUENCE_URL", "CONFLUENCE_EMAIL", "CONFLUENCE_API_TOKEN", "CONFLUENCE_SPACE_KEY", "CONFLUENCE_PARENT_PAGE_ID");
|
|
1157
1165
|
}
|
|
1158
1166
|
|
|
1167
|
+
// Obsidian vault (local path only — no credentials)
|
|
1168
|
+
let obsidian = null;
|
|
1169
|
+
if (publishers.includes("obsidian")) {
|
|
1170
|
+
info("\n📝 Obsidian Vault");
|
|
1171
|
+
info(" Notes are written into a folder in your vault. Nothing is uploaded.");
|
|
1172
|
+
const vault = (await ask(" Vault path (absolute, or relative to the repo): ")).trim();
|
|
1173
|
+
const folderDefault = `RepoLens/${projectName}`;
|
|
1174
|
+
const folder = (await ask(` Folder inside the vault (${folderDefault}): `)).trim();
|
|
1175
|
+
obsidian = { vault, folder: folder || folderDefault };
|
|
1176
|
+
if (!vault) {
|
|
1177
|
+
warn(" No vault path yet. Add obsidian.vault to .repolens.yml before publishing.");
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1159
1181
|
// GitHub Wiki setup
|
|
1160
1182
|
if (publishers.includes("github_wiki")) {
|
|
1161
1183
|
info("\n📝 GitHub Wiki Setup");
|
|
@@ -1278,7 +1300,7 @@ async function runInteractiveWizard(repoRoot) {
|
|
|
1278
1300
|
|
|
1279
1301
|
// 6. Branch filtering
|
|
1280
1302
|
info("\n🌿 Branch Filtering");
|
|
1281
|
-
info(" Limits which branches can publish to Notion
|
|
1303
|
+
info(" Limits which branches can publish to Notion, Confluence, and Obsidian.");
|
|
1282
1304
|
const branchInput = (await ask(" Allowed branches (comma-separated, default: main): ")).trim() || "main";
|
|
1283
1305
|
const branches = branchInput.split(",").map((b) => b.trim()).filter(Boolean);
|
|
1284
1306
|
|
|
@@ -1336,7 +1358,8 @@ async function runInteractiveWizard(repoRoot) {
|
|
|
1336
1358
|
branches,
|
|
1337
1359
|
enableDiscord,
|
|
1338
1360
|
credentials,
|
|
1339
|
-
githubSecretsNeeded: uniqueSecrets
|
|
1361
|
+
githubSecretsNeeded: uniqueSecrets,
|
|
1362
|
+
obsidian,
|
|
1340
1363
|
};
|
|
1341
1364
|
} finally {
|
|
1342
1365
|
rl.close();
|
|
@@ -1348,6 +1371,7 @@ const PUBLISHER_DESCRIPTIONS = {
|
|
|
1348
1371
|
notion: "Notion workspace pages",
|
|
1349
1372
|
confluence: "Atlassian Confluence pages",
|
|
1350
1373
|
github_wiki: "Repository wiki pages",
|
|
1374
|
+
obsidian: "Local Obsidian vault folder",
|
|
1351
1375
|
};
|
|
1352
1376
|
|
|
1353
1377
|
/**
|
|
@@ -1380,6 +1404,22 @@ function buildWizardConfig(answers) {
|
|
|
1380
1404
|
lines.push(` branches:`);
|
|
1381
1405
|
for (const b of answers.branches) lines.push(` - ${b}`);
|
|
1382
1406
|
}
|
|
1407
|
+
if (answers.publishers.includes("obsidian")) {
|
|
1408
|
+
lines.push(``);
|
|
1409
|
+
lines.push(`obsidian:`);
|
|
1410
|
+
if (answers.obsidian?.vault) {
|
|
1411
|
+
lines.push(` vault: ${JSON.stringify(answers.obsidian.vault)}`);
|
|
1412
|
+
}
|
|
1413
|
+
const folder = answers.obsidian?.folder || `RepoLens/${answers.projectName}`;
|
|
1414
|
+
lines.push(` folder: ${JSON.stringify(folder)}`);
|
|
1415
|
+
if (answers.branches?.length) {
|
|
1416
|
+
lines.push(` branches:`);
|
|
1417
|
+
for (const b of answers.branches) lines.push(` - ${b}`);
|
|
1418
|
+
}
|
|
1419
|
+
lines.push(` index: true`);
|
|
1420
|
+
lines.push(` frontmatter: true`);
|
|
1421
|
+
lines.push(` wikilinks: true`);
|
|
1422
|
+
}
|
|
1383
1423
|
|
|
1384
1424
|
if (answers.enableDiscord) {
|
|
1385
1425
|
lines.push(``);
|
package/src/publishers/index.js
CHANGED
|
@@ -2,7 +2,8 @@ import { publishToNotion } from "./publish.js";
|
|
|
2
2
|
import { publishToMarkdown } from "./markdown.js";
|
|
3
3
|
import { publishToConfluence, hasConfluenceSecrets } from "./confluence.js";
|
|
4
4
|
import { publishToGitHubWiki, hasGitHubWikiSecrets } from "./github-wiki.js";
|
|
5
|
-
import {
|
|
5
|
+
import { publishToObsidian } from "./obsidian.js";
|
|
6
|
+
import { shouldPublishToNotion, shouldPublishToConfluence, shouldPublishToGitHubWiki, shouldPublishToObsidian, getCurrentBranch, branchMatches } from "../utils/branch.js";
|
|
6
7
|
import { resolveOutputDir } from "../utils/safe-path.js";
|
|
7
8
|
import { info, warn } from "../utils/logger.js";
|
|
8
9
|
import { trackPublishing } from "../utils/telemetry.js";
|
|
@@ -96,6 +97,25 @@ export async function publishDocs(cfg, renderedPages, scanResult, pluginManager
|
|
|
96
97
|
}
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
// Obsidian vault (local files only — no credentials, no network)
|
|
101
|
+
if (publishers.includes("obsidian")) {
|
|
102
|
+
if (shouldPublishToObsidian(cfg, currentBranch)) {
|
|
103
|
+
info(`Publishing to Obsidian vault from branch: ${currentBranch}`);
|
|
104
|
+
try {
|
|
105
|
+
await publishToObsidian(cfg, renderedPages);
|
|
106
|
+
publishedTo.push("obsidian");
|
|
107
|
+
} catch (err) {
|
|
108
|
+
warn(`Obsidian publish failed: ${err.message}`);
|
|
109
|
+
publishErrors.push({ publisher: "obsidian", error: err });
|
|
110
|
+
publishStatus = "partial";
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
const allowedBranches = cfg.obsidian?.branches?.join(", ") || "none configured";
|
|
114
|
+
warn(`Skipping Obsidian publish: branch "${currentBranch}" not in allowed list (${allowedBranches})`);
|
|
115
|
+
info("To publish from this branch, add it to obsidian.branches in .repolens.yml");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
99
119
|
// GitHub Wiki publishing (opt-in if secrets configured)
|
|
100
120
|
if (publishers.includes("github_wiki") || hasGitHubWikiSecrets() && publishers.includes("github_wiki")) {
|
|
101
121
|
if (!hasGitHubWikiSecrets()) {
|
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
// Obsidian vault publisher for RepoLens.
|
|
2
|
+
//
|
|
3
|
+
// Writes generated notes into a folder inside a local Obsidian vault.
|
|
4
|
+
// Notes use YAML frontmatter and wikilinks. Updates are in-place and
|
|
5
|
+
// idempotent. Every path is contained inside the vault. No network.
|
|
6
|
+
//
|
|
7
|
+
// Config (.repolens.yml), all fields optional except vault at publish time:
|
|
8
|
+
// obsidian:
|
|
9
|
+
// vault: "/path/to/vault" # absolute, or relative to the repo
|
|
10
|
+
// folder: "RepoLens/my-app" # relative directory inside the vault
|
|
11
|
+
// branches: [main] # optional branch filter (enforced by the orchestrator)
|
|
12
|
+
// index: true # map-of-content note (default: true)
|
|
13
|
+
// frontmatter: true # YAML properties (default: true)
|
|
14
|
+
// wikilinks: true # rewrite markdown links (default: true)
|
|
15
|
+
|
|
16
|
+
import fs from "node:fs/promises";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { info, warn } from "../utils/logger.js";
|
|
19
|
+
import { getCurrentBranch } from "../utils/branch.js";
|
|
20
|
+
import { isInsideRoot, safeFileName } from "../utils/safe-path.js";
|
|
21
|
+
|
|
22
|
+
const INDEX_NOTE = "RepoLens";
|
|
23
|
+
const MANIFEST_FILE = ".repolens-obsidian.json";
|
|
24
|
+
|
|
25
|
+
const PAGE_ORDER = [
|
|
26
|
+
"executive_summary",
|
|
27
|
+
"system_overview",
|
|
28
|
+
"business_domains",
|
|
29
|
+
"architecture_overview",
|
|
30
|
+
"module_catalog",
|
|
31
|
+
"route_map",
|
|
32
|
+
"api_surface",
|
|
33
|
+
"data_flows",
|
|
34
|
+
"change_impact",
|
|
35
|
+
"system_map",
|
|
36
|
+
"developer_onboarding",
|
|
37
|
+
"graphql_schema",
|
|
38
|
+
"type_graph",
|
|
39
|
+
"dependency_graph",
|
|
40
|
+
"architecture_drift",
|
|
41
|
+
"arch_diff",
|
|
42
|
+
"security_hotspots",
|
|
43
|
+
"code_health",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const PAGE_TITLES = {
|
|
47
|
+
executive_summary: "Executive Summary",
|
|
48
|
+
system_overview: "System Overview",
|
|
49
|
+
business_domains: "Business Domains",
|
|
50
|
+
architecture_overview: "Architecture Overview",
|
|
51
|
+
module_catalog: "Module Catalog",
|
|
52
|
+
route_map: "Route Map",
|
|
53
|
+
api_surface: "API Surface",
|
|
54
|
+
data_flows: "Data Flows",
|
|
55
|
+
change_impact: "Change Impact",
|
|
56
|
+
system_map: "System Map",
|
|
57
|
+
developer_onboarding: "Developer Onboarding",
|
|
58
|
+
graphql_schema: "GraphQL Schema",
|
|
59
|
+
type_graph: "Type Graph",
|
|
60
|
+
dependency_graph: "Dependency Graph",
|
|
61
|
+
architecture_drift: "Architecture Drift",
|
|
62
|
+
arch_diff: "Architecture Diff",
|
|
63
|
+
security_hotspots: "Security Hotspots",
|
|
64
|
+
code_health: "Code Health",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const PAGE_DESCRIPTIONS = {
|
|
68
|
+
executive_summary: "High-level project summary for non-technical readers.",
|
|
69
|
+
system_overview: "Snapshot of stack, scale, structure, and detected capabilities.",
|
|
70
|
+
business_domains: "Functional areas inferred from the repository.",
|
|
71
|
+
architecture_overview: "Layered technical view of the system.",
|
|
72
|
+
module_catalog: "Inventory of modules and responsibilities.",
|
|
73
|
+
route_map: "Frontend routes and page structure.",
|
|
74
|
+
api_surface: "Detected endpoints, handlers, and API structure.",
|
|
75
|
+
data_flows: "How information moves through the system.",
|
|
76
|
+
change_impact: "Architecture changes and likely downstream effects.",
|
|
77
|
+
system_map: "Visual map of system relationships.",
|
|
78
|
+
developer_onboarding: "What a new engineer needs to know first.",
|
|
79
|
+
graphql_schema: "GraphQL types, operations, and schema structure.",
|
|
80
|
+
type_graph: "Type-level relationships across the codebase.",
|
|
81
|
+
dependency_graph: "Module and package dependency relationships.",
|
|
82
|
+
architecture_drift: "Drift between intended and current architecture.",
|
|
83
|
+
arch_diff: "Architecture-level diff across revisions.",
|
|
84
|
+
security_hotspots: "Security anti-patterns with CWE classification.",
|
|
85
|
+
code_health: "Complexity and unified health scores.",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const PAGE_TAGS = {
|
|
89
|
+
executive_summary: "stakeholders",
|
|
90
|
+
system_overview: "overview",
|
|
91
|
+
business_domains: "stakeholders",
|
|
92
|
+
architecture_overview: "engineers",
|
|
93
|
+
module_catalog: "engineers",
|
|
94
|
+
route_map: "engineers",
|
|
95
|
+
api_surface: "engineers",
|
|
96
|
+
data_flows: "stakeholders",
|
|
97
|
+
change_impact: "engineers",
|
|
98
|
+
system_map: "overview",
|
|
99
|
+
developer_onboarding: "onboarding",
|
|
100
|
+
graphql_schema: "engineers",
|
|
101
|
+
type_graph: "engineers",
|
|
102
|
+
dependency_graph: "engineers",
|
|
103
|
+
architecture_drift: "engineers",
|
|
104
|
+
arch_diff: "engineers",
|
|
105
|
+
security_hotspots: "security",
|
|
106
|
+
code_health: "engineers",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const AUDIENCE_GROUPS = [
|
|
110
|
+
{
|
|
111
|
+
title: "For Stakeholders",
|
|
112
|
+
keys: ["executive_summary", "business_domains", "data_flows"],
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
title: "For Engineers",
|
|
116
|
+
keys: [
|
|
117
|
+
"architecture_overview", "module_catalog", "api_surface",
|
|
118
|
+
"route_map", "system_map", "graphql_schema", "type_graph",
|
|
119
|
+
"dependency_graph", "architecture_drift", "security_hotspots", "code_health",
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
title: "For New Contributors",
|
|
124
|
+
keys: ["developer_onboarding", "system_overview"],
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
title: "Change Tracking",
|
|
128
|
+
keys: ["change_impact", "arch_diff"],
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
function displayTitle(key) {
|
|
133
|
+
return PAGE_TITLES[key] || String(key).replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Reduce a note title to a single path segment Obsidian and Windows can store.
|
|
138
|
+
*/
|
|
139
|
+
export function sanitizeNoteName(name) {
|
|
140
|
+
const cleaned = String(name ?? "")
|
|
141
|
+
.replace(/[<>:"|?*\u0000-\u001f\\/]/g, " ")
|
|
142
|
+
.replace(/\.\.+/g, " ")
|
|
143
|
+
.replace(/\s+/g, " ")
|
|
144
|
+
.trim()
|
|
145
|
+
.replace(/[. ]+$/g, "")
|
|
146
|
+
.replace(/^\.+/, "");
|
|
147
|
+
if (!cleaned || cleaned === "." || cleaned === "..") return "Note";
|
|
148
|
+
return cleaned;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function noteStemForKey(key, useWikilinks) {
|
|
152
|
+
if (!useWikilinks) {
|
|
153
|
+
const file = safeFileName(`${key}.md`, "document.md");
|
|
154
|
+
return sanitizeNoteName(file.replace(/\.md$/i, ""));
|
|
155
|
+
}
|
|
156
|
+
return sanitizeNoteName(displayTitle(key));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function allocateNotes(keys, useWikilinks) {
|
|
160
|
+
const used = new Set([INDEX_NOTE]);
|
|
161
|
+
const notes = [];
|
|
162
|
+
for (const key of keys) {
|
|
163
|
+
const base = noteStemForKey(key, useWikilinks);
|
|
164
|
+
let name = base;
|
|
165
|
+
let n = 2;
|
|
166
|
+
while (used.has(name)) {
|
|
167
|
+
const suffix = n === 2 ? key : `${key} ${n}`;
|
|
168
|
+
name = sanitizeNoteName(`${base} (${suffix})`);
|
|
169
|
+
n += 1;
|
|
170
|
+
if (n > 50) {
|
|
171
|
+
throw new Error(`Could not allocate a unique Obsidian note name for ${key}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
used.add(name);
|
|
175
|
+
notes.push({ key, name, fileName: `${name}.md` });
|
|
176
|
+
}
|
|
177
|
+
return notes;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function yamlQuote(value) {
|
|
181
|
+
return JSON.stringify(String(value));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function buildFrontmatter(fields) {
|
|
185
|
+
const lines = ["---"];
|
|
186
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
187
|
+
if (value == null) continue;
|
|
188
|
+
if (Array.isArray(value)) {
|
|
189
|
+
const items = value.filter((item) => item != null && String(item).length > 0);
|
|
190
|
+
if (items.length === 0) continue;
|
|
191
|
+
lines.push(`${key}:`);
|
|
192
|
+
for (const item of items) lines.push(` - ${yamlQuote(item)}`);
|
|
193
|
+
} else if (typeof value === "boolean" || typeof value === "number") {
|
|
194
|
+
lines.push(`${key}: ${value}`);
|
|
195
|
+
} else {
|
|
196
|
+
lines.push(`${key}: ${yamlQuote(value)}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
lines.push("---", "");
|
|
200
|
+
return lines.join("\n");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function ensureTrailingNewline(text) {
|
|
204
|
+
return text.endsWith("\n") ? text : `${text}\n`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const MAX_LINK_LABEL = 500;
|
|
208
|
+
const MAX_LINK_TARGET = 2048;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Rewrite inline markdown links to Obsidian wikilinks.
|
|
212
|
+
* Fenced code blocks are left untouched. http(s) and other URLs stay as links.
|
|
213
|
+
*
|
|
214
|
+
* The scan is a single forward pass. Label and target lengths are capped so
|
|
215
|
+
* a hostile document cannot trigger polynomial backtracking.
|
|
216
|
+
* @param {string} markdown
|
|
217
|
+
* @param {Map<string, string>} linkMap basename/stem → note name
|
|
218
|
+
*/
|
|
219
|
+
export function rewriteMarkdownToWikilinks(markdown, linkMap) {
|
|
220
|
+
const source = String(markdown ?? "");
|
|
221
|
+
const fences = fencedCodeRanges(source);
|
|
222
|
+
const parts = [];
|
|
223
|
+
let i = 0;
|
|
224
|
+
let fenceIndex = 0;
|
|
225
|
+
|
|
226
|
+
while (i < source.length) {
|
|
227
|
+
if (fenceIndex < fences.length && i === fences[fenceIndex][0]) {
|
|
228
|
+
parts.push(source.slice(fences[fenceIndex][0], fences[fenceIndex][1]));
|
|
229
|
+
i = fences[fenceIndex][1];
|
|
230
|
+
fenceIndex += 1;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const nextFence = fenceIndex < fences.length ? fences[fenceIndex][0] : source.length;
|
|
235
|
+
const link = scanInlineLink(source, i);
|
|
236
|
+
if (link && link.end <= nextFence) {
|
|
237
|
+
const converted = toWikilink(link.embed, link.label, link.target, linkMap);
|
|
238
|
+
parts.push(converted ?? link.raw);
|
|
239
|
+
i = link.end;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
parts.push(source[i]);
|
|
244
|
+
i += 1;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return parts.join("");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Pairs of ``` fences, as [start, end) indexes. An unclosed fence is not a range.
|
|
252
|
+
* Each search starts at the previous match, so this is linear in the input length.
|
|
253
|
+
*/
|
|
254
|
+
function fencedCodeRanges(source) {
|
|
255
|
+
const ranges = [];
|
|
256
|
+
let i = 0;
|
|
257
|
+
while (i < source.length) {
|
|
258
|
+
const open = source.indexOf("```", i);
|
|
259
|
+
if (open === -1) break;
|
|
260
|
+
const close = source.indexOf("```", open + 3);
|
|
261
|
+
if (close === -1) break;
|
|
262
|
+
ranges.push([open, close + 3]);
|
|
263
|
+
i = close + 3;
|
|
264
|
+
}
|
|
265
|
+
return ranges;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Match `` or `[label](target)` at `index`.
|
|
270
|
+
* Label: 0–500 chars, no brackets or newlines. Target: 1–2048 chars, no
|
|
271
|
+
* parentheses or whitespace. Returns null when the text at `index` is not a link.
|
|
272
|
+
*/
|
|
273
|
+
function scanInlineLink(source, index) {
|
|
274
|
+
let cursor = index;
|
|
275
|
+
let embed = false;
|
|
276
|
+
if (source[cursor] === "!" && source[cursor + 1] === "[") {
|
|
277
|
+
embed = true;
|
|
278
|
+
cursor += 1;
|
|
279
|
+
}
|
|
280
|
+
if (source[cursor] !== "[") return null;
|
|
281
|
+
|
|
282
|
+
const labelStart = cursor + 1;
|
|
283
|
+
const labelEnd = readLinkLabel(source, labelStart);
|
|
284
|
+
if (labelEnd < 0 || source[labelEnd + 1] !== "(") return null;
|
|
285
|
+
|
|
286
|
+
const targetStart = labelEnd + 2;
|
|
287
|
+
const targetEnd = readLinkTarget(source, targetStart);
|
|
288
|
+
if (targetEnd < 0) return null;
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
embed,
|
|
292
|
+
label: source.slice(labelStart, labelEnd),
|
|
293
|
+
target: source.slice(targetStart, targetEnd),
|
|
294
|
+
raw: source.slice(index, targetEnd + 1),
|
|
295
|
+
end: targetEnd + 1,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function readLinkLabel(source, start) {
|
|
300
|
+
const limit = Math.min(source.length, start + MAX_LINK_LABEL);
|
|
301
|
+
let i = start;
|
|
302
|
+
while (i < limit) {
|
|
303
|
+
const ch = source[i];
|
|
304
|
+
if (ch === "]" || ch === "[" || ch === "\n") break;
|
|
305
|
+
i += 1;
|
|
306
|
+
}
|
|
307
|
+
if (i >= source.length || source[i] !== "]") return -1;
|
|
308
|
+
return i;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function readLinkTarget(source, start) {
|
|
312
|
+
const limit = Math.min(source.length, start + MAX_LINK_TARGET);
|
|
313
|
+
let i = start;
|
|
314
|
+
while (i < limit) {
|
|
315
|
+
const ch = source[i];
|
|
316
|
+
if (ch === "(" || ch === ")" || ch.charCodeAt(0) <= 32) break;
|
|
317
|
+
i += 1;
|
|
318
|
+
}
|
|
319
|
+
if (i === start || i >= source.length || source[i] !== ")") return -1;
|
|
320
|
+
return i;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function toWikilink(isEmbed, label, rawTarget, linkMap) {
|
|
324
|
+
const target = String(rawTarget ?? "").trim();
|
|
325
|
+
if (!target) return null;
|
|
326
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//")) return null;
|
|
327
|
+
|
|
328
|
+
if (target.startsWith("#")) {
|
|
329
|
+
const heading = safeHeading(target.slice(1));
|
|
330
|
+
if (!heading) return null;
|
|
331
|
+
return isEmbed ? `![[#${heading}]]` : `[[#${heading}]]`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const hashAt = target.indexOf("#");
|
|
335
|
+
const pathPart = hashAt === -1 ? target : target.slice(0, hashAt);
|
|
336
|
+
const hash = hashAt === -1 ? "" : target.slice(hashAt + 1);
|
|
337
|
+
let decoded = pathPart;
|
|
338
|
+
try {
|
|
339
|
+
decoded = decodeURIComponent(pathPart);
|
|
340
|
+
} catch {
|
|
341
|
+
decoded = pathPart;
|
|
342
|
+
}
|
|
343
|
+
if (path.isAbsolute(decoded) || /^[A-Za-z]:[\\/]/.test(decoded)) return null;
|
|
344
|
+
|
|
345
|
+
const base = path.posix.basename(decoded.replace(/\\/g, "/"));
|
|
346
|
+
if (!base || base === "." || base === "..") return null;
|
|
347
|
+
|
|
348
|
+
const stem = base.replace(/\.md$/i, "");
|
|
349
|
+
const known = lookupNote(linkMap, base) || lookupNote(linkMap, stem);
|
|
350
|
+
const isMarkdown = /\.md$/i.test(base);
|
|
351
|
+
|
|
352
|
+
if (!known && !isMarkdown && !isEmbed) return null;
|
|
353
|
+
if (!known && isEmbed && !isMarkdown) {
|
|
354
|
+
return `![[${base}]]`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const dest = known || sanitizeNoteName(stem);
|
|
358
|
+
const heading = hash ? safeHeading(hash) : "";
|
|
359
|
+
const targetNote = heading ? `${dest}#${heading}` : dest;
|
|
360
|
+
if (isEmbed) return `![[${targetNote}]]`;
|
|
361
|
+
const alias = String(label ?? "").trim();
|
|
362
|
+
if (!alias || alias === dest || alias === targetNote) return `[[${targetNote}]]`;
|
|
363
|
+
return `[[${targetNote}|${alias.replaceAll("|", "\\|")}]]`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function safeHeading(raw) {
|
|
367
|
+
let decoded = raw;
|
|
368
|
+
try {
|
|
369
|
+
decoded = decodeURIComponent(raw);
|
|
370
|
+
} catch {
|
|
371
|
+
decoded = raw;
|
|
372
|
+
}
|
|
373
|
+
return decoded.replace(/[\]|#^]/g, "").trim();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function lookupNote(linkMap, token) {
|
|
377
|
+
if (!token) return null;
|
|
378
|
+
return linkMap.get(token) || linkMap.get(token.toLowerCase()) || null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function buildLinkMap(notes) {
|
|
382
|
+
const map = new Map();
|
|
383
|
+
for (const note of notes) {
|
|
384
|
+
const aliases = [note.name, note.fileName, note.key, `${note.key}.md`, displayTitle(note.key)];
|
|
385
|
+
for (const alias of aliases) {
|
|
386
|
+
map.set(alias, note.name);
|
|
387
|
+
map.set(alias.toLowerCase(), note.name);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return map;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function defaultFolder(cfg) {
|
|
394
|
+
const raw = cfg.project?.name || "project";
|
|
395
|
+
const slug = sanitizeNoteName(String(raw).replace(/^\.+/, "")) || "project";
|
|
396
|
+
return path.join("RepoLens", slug);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function assertRelativeFolder(folder) {
|
|
400
|
+
if (typeof folder !== "string" || folder.trim() === "") {
|
|
401
|
+
throw new Error("obsidian.folder must be a non-empty relative path inside the vault");
|
|
402
|
+
}
|
|
403
|
+
if (folder.includes("\0")) {
|
|
404
|
+
throw new Error("obsidian.folder contains a null byte");
|
|
405
|
+
}
|
|
406
|
+
if (path.isAbsolute(folder) || /^[A-Za-z]:[\\/]/.test(folder)) {
|
|
407
|
+
throw new Error(`obsidian.folder must stay inside the vault (received absolute path ${folder})`);
|
|
408
|
+
}
|
|
409
|
+
const parts = path.normalize(folder).split(path.sep).filter((part) => part && part !== ".");
|
|
410
|
+
if (parts.length === 0 || parts.some((part) => part === "..")) {
|
|
411
|
+
throw new Error(`obsidian.folder escapes the vault: ${folder}`);
|
|
412
|
+
}
|
|
413
|
+
return parts.join(path.sep);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Resolve the vault directory. Relative paths are resolved from the repo root.
|
|
418
|
+
* The directory must already exist. The returned path is its realpath.
|
|
419
|
+
*/
|
|
420
|
+
export async function resolveVaultRoot(cfg, vault) {
|
|
421
|
+
if (typeof vault !== "string" || vault.trim() === "") {
|
|
422
|
+
throw new Error(
|
|
423
|
+
"obsidian.vault is required. Set it to your Obsidian vault path in .repolens.yml"
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
if (vault.includes("\0")) {
|
|
427
|
+
throw new Error("obsidian.vault contains a null byte");
|
|
428
|
+
}
|
|
429
|
+
const base = cfg.__repoRoot || process.cwd();
|
|
430
|
+
const lexical = path.resolve(base, vault);
|
|
431
|
+
let real;
|
|
432
|
+
try {
|
|
433
|
+
real = await fs.realpath(lexical);
|
|
434
|
+
} catch (err) {
|
|
435
|
+
if (err.code === "ENOENT") {
|
|
436
|
+
throw new Error(`Obsidian vault does not exist: ${lexical}`);
|
|
437
|
+
}
|
|
438
|
+
throw err;
|
|
439
|
+
}
|
|
440
|
+
const stat = await fs.stat(real);
|
|
441
|
+
if (!stat.isDirectory()) {
|
|
442
|
+
throw new Error(`Obsidian vault is not a directory: ${lexical}`);
|
|
443
|
+
}
|
|
444
|
+
return real;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Resolve a folder inside the vault and create it.
|
|
449
|
+
* Symlinks that point outside the vault are rejected.
|
|
450
|
+
*/
|
|
451
|
+
export async function resolveVaultFolder(vaultRoot, folder) {
|
|
452
|
+
const relative = assertRelativeFolder(folder);
|
|
453
|
+
const lexical = path.resolve(vaultRoot, relative);
|
|
454
|
+
if (!isInsideRoot(vaultRoot, lexical)) {
|
|
455
|
+
throw new Error(`obsidian.folder escapes the vault: ${folder}`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
let current = vaultRoot;
|
|
459
|
+
for (const part of relative.split(path.sep)) {
|
|
460
|
+
current = path.join(current, part);
|
|
461
|
+
let listed;
|
|
462
|
+
try {
|
|
463
|
+
listed = await fs.lstat(current);
|
|
464
|
+
} catch (err) {
|
|
465
|
+
if (err.code === "ENOENT") break;
|
|
466
|
+
throw err;
|
|
467
|
+
}
|
|
468
|
+
if (listed.isSymbolicLink()) {
|
|
469
|
+
const real = await fs.realpath(current);
|
|
470
|
+
if (!isInsideRoot(vaultRoot, real)) {
|
|
471
|
+
throw new Error(`obsidian.folder escapes the vault through a symlink: ${folder}`);
|
|
472
|
+
}
|
|
473
|
+
} else if (!listed.isDirectory()) {
|
|
474
|
+
throw new Error(`obsidian.folder is not a directory: ${folder}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
await fs.mkdir(lexical, { recursive: true });
|
|
479
|
+
const realFolder = await fs.realpath(lexical);
|
|
480
|
+
if (!isInsideRoot(vaultRoot, realFolder)) {
|
|
481
|
+
throw new Error(`obsidian.folder escapes the vault: ${folder}`);
|
|
482
|
+
}
|
|
483
|
+
return realFolder;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function containedFile(root, fileName) {
|
|
487
|
+
const base = safeFileName(fileName, "");
|
|
488
|
+
if (!base || base === MANIFEST_FILE) {
|
|
489
|
+
throw new Error(`Refusing to write an unsafe note name: ${fileName}`);
|
|
490
|
+
}
|
|
491
|
+
const resolved = path.resolve(root, base);
|
|
492
|
+
if (!isInsideRoot(root, resolved) || path.basename(resolved) !== base) {
|
|
493
|
+
throw new Error(`Note path escapes the vault folder: ${fileName}`);
|
|
494
|
+
}
|
|
495
|
+
return resolved;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function writeIfChanged(filePath, content) {
|
|
499
|
+
let listed;
|
|
500
|
+
try {
|
|
501
|
+
listed = await fs.lstat(filePath);
|
|
502
|
+
} catch (err) {
|
|
503
|
+
if (err.code !== "ENOENT") throw err;
|
|
504
|
+
}
|
|
505
|
+
if (listed?.isSymbolicLink()) {
|
|
506
|
+
throw new Error(`Refusing to write through a symlink: ${filePath}`);
|
|
507
|
+
}
|
|
508
|
+
if (listed && !listed.isFile()) {
|
|
509
|
+
throw new Error(`Refusing to overwrite a non-file: ${filePath}`);
|
|
510
|
+
}
|
|
511
|
+
if (listed?.isFile()) {
|
|
512
|
+
const existing = await fs.readFile(filePath, "utf8");
|
|
513
|
+
if (existing === content) return false;
|
|
514
|
+
}
|
|
515
|
+
await fs.writeFile(filePath, content, "utf8");
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function readManifest(folder) {
|
|
520
|
+
const manifestPath = path.join(folder, MANIFEST_FILE);
|
|
521
|
+
try {
|
|
522
|
+
const raw = await fs.readFile(manifestPath, "utf8");
|
|
523
|
+
const parsed = JSON.parse(raw);
|
|
524
|
+
return Array.isArray(parsed?.files) ? parsed.files.filter((item) => typeof item === "string") : [];
|
|
525
|
+
} catch (err) {
|
|
526
|
+
if (err.code !== "ENOENT") {
|
|
527
|
+
warn(`Ignoring unreadable Obsidian manifest: ${err.message}`);
|
|
528
|
+
}
|
|
529
|
+
return [];
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function manifestPathFor(folder) {
|
|
534
|
+
const resolved = path.resolve(folder, MANIFEST_FILE);
|
|
535
|
+
if (!isInsideRoot(folder, resolved)) {
|
|
536
|
+
throw new Error("Obsidian manifest path escapes the vault folder");
|
|
537
|
+
}
|
|
538
|
+
return resolved;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function removableManagedFile(folder, rel) {
|
|
542
|
+
if (typeof rel !== "string" || rel.includes("\0") || path.isAbsolute(rel)) return null;
|
|
543
|
+
const parts = rel.split(/[\\/]/).filter(Boolean);
|
|
544
|
+
if (parts.length !== 1 || parts[0] === "." || parts[0] === ".." || parts[0] === MANIFEST_FILE) {
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
const resolved = path.resolve(folder, parts[0]);
|
|
548
|
+
if (!isInsideRoot(folder, resolved)) return null;
|
|
549
|
+
return resolved;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function syncManifest(vaultRoot, folder, fileNames) {
|
|
553
|
+
const previous = await readManifest(folder);
|
|
554
|
+
const next = new Set(fileNames);
|
|
555
|
+
let removed = 0;
|
|
556
|
+
for (const rel of previous) {
|
|
557
|
+
if (next.has(rel)) continue;
|
|
558
|
+
const target = removableManagedFile(folder, rel);
|
|
559
|
+
if (!target || !isInsideRoot(vaultRoot, target)) continue;
|
|
560
|
+
let listed;
|
|
561
|
+
try {
|
|
562
|
+
listed = await fs.lstat(target);
|
|
563
|
+
} catch (err) {
|
|
564
|
+
if (err.code === "ENOENT") continue;
|
|
565
|
+
throw err;
|
|
566
|
+
}
|
|
567
|
+
if (listed.isSymbolicLink() || !listed.isFile()) continue;
|
|
568
|
+
await fs.unlink(target);
|
|
569
|
+
removed += 1;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const body = `${JSON.stringify({ version: 1, files: [...fileNames].sort() }, null, 2)}\n`;
|
|
573
|
+
await writeIfChanged(manifestPathFor(folder), body);
|
|
574
|
+
return removed;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function renderPage(note, markdown, options) {
|
|
578
|
+
const body = options.useWikilinks
|
|
579
|
+
? rewriteMarkdownToWikilinks(markdown, options.linkMap)
|
|
580
|
+
: String(markdown);
|
|
581
|
+
const backlink = options.useIndex
|
|
582
|
+
? (options.useWikilinks ? `← [[${INDEX_NOTE}|Index]]` : `← [Index](${INDEX_NOTE}.md)`)
|
|
583
|
+
: "";
|
|
584
|
+
const parts = [];
|
|
585
|
+
if (options.useFrontmatter) {
|
|
586
|
+
const aliases = [note.key];
|
|
587
|
+
const title = displayTitle(note.key);
|
|
588
|
+
if (title !== note.name) aliases.push(title);
|
|
589
|
+
parts.push(buildFrontmatter({
|
|
590
|
+
title: note.name,
|
|
591
|
+
aliases,
|
|
592
|
+
tags: ["repolens", PAGE_TAGS[note.key]].filter(Boolean),
|
|
593
|
+
type: note.key,
|
|
594
|
+
project: options.projectName,
|
|
595
|
+
branch: options.branch,
|
|
596
|
+
publisher: "repolens",
|
|
597
|
+
}));
|
|
598
|
+
}
|
|
599
|
+
if (backlink) parts.push(`${backlink}\n`);
|
|
600
|
+
parts.push(body.trimEnd());
|
|
601
|
+
return ensureTrailingNewline(parts.join("\n"));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function renderIndex(notes, options) {
|
|
605
|
+
const byKey = new Map(notes.map((note) => [note.key, note]));
|
|
606
|
+
const listed = new Set();
|
|
607
|
+
const sections = [];
|
|
608
|
+
|
|
609
|
+
for (const group of AUDIENCE_GROUPS) {
|
|
610
|
+
const items = group.keys.filter((key) => byKey.has(key));
|
|
611
|
+
if (items.length === 0) continue;
|
|
612
|
+
sections.push(`## ${group.title}`, "");
|
|
613
|
+
for (const key of items) {
|
|
614
|
+
listed.add(key);
|
|
615
|
+
sections.push(indexItem(byKey.get(key), options.useWikilinks));
|
|
616
|
+
}
|
|
617
|
+
sections.push("");
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const custom = notes.filter((note) => !listed.has(note.key));
|
|
621
|
+
if (custom.length > 0) {
|
|
622
|
+
sections.push("## Custom", "");
|
|
623
|
+
for (const note of custom) sections.push(indexItem(note, options.useWikilinks));
|
|
624
|
+
sections.push("");
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const intro = [
|
|
628
|
+
`# ${options.projectName}`,
|
|
629
|
+
"",
|
|
630
|
+
"Architecture notes published by RepoLens into this Obsidian vault.",
|
|
631
|
+
"Re-running publish updates these notes in place.",
|
|
632
|
+
"",
|
|
633
|
+
];
|
|
634
|
+
|
|
635
|
+
const parts = [];
|
|
636
|
+
if (options.useFrontmatter) {
|
|
637
|
+
const aliases = options.projectName && options.projectName !== INDEX_NOTE
|
|
638
|
+
? [options.projectName]
|
|
639
|
+
: [];
|
|
640
|
+
parts.push(buildFrontmatter({
|
|
641
|
+
title: INDEX_NOTE,
|
|
642
|
+
aliases,
|
|
643
|
+
tags: ["repolens", "index"],
|
|
644
|
+
type: "index",
|
|
645
|
+
project: options.projectName,
|
|
646
|
+
branch: options.branch,
|
|
647
|
+
publisher: "repolens",
|
|
648
|
+
}));
|
|
649
|
+
}
|
|
650
|
+
parts.push([...intro, ...sections].join("\n").trimEnd());
|
|
651
|
+
return ensureTrailingNewline(parts.join("\n"));
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function indexItem(note, useWikilinks) {
|
|
655
|
+
const description = PAGE_DESCRIPTIONS[note.key] || "Generated by RepoLens.";
|
|
656
|
+
const link = useWikilinks ? `[[${note.name}]]` : `[${note.name}](${note.fileName})`;
|
|
657
|
+
return `- ${link} — ${description}`;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function orderedKeys(renderedPages) {
|
|
661
|
+
const present = Object.entries(renderedPages)
|
|
662
|
+
.filter(([, markdown]) => typeof markdown === "string" && markdown.trim() !== "")
|
|
663
|
+
.map(([key]) => key);
|
|
664
|
+
const known = PAGE_ORDER.filter((key) => present.includes(key));
|
|
665
|
+
const custom = present.filter((key) => !PAGE_ORDER.includes(key));
|
|
666
|
+
return [...known, ...custom];
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Publish rendered pages into an Obsidian vault folder.
|
|
671
|
+
* @returns {Promise<{ folder: string, files: string[], removed: number, changed: number }>}
|
|
672
|
+
*/
|
|
673
|
+
export async function publishToObsidian(cfg, renderedPages) {
|
|
674
|
+
const obsidian = cfg.obsidian || {};
|
|
675
|
+
const vaultRoot = await resolveVaultRoot(cfg, obsidian.vault);
|
|
676
|
+
const folderRel = obsidian.folder == null || obsidian.folder === ""
|
|
677
|
+
? defaultFolder(cfg)
|
|
678
|
+
: obsidian.folder;
|
|
679
|
+
const folder = await resolveVaultFolder(vaultRoot, folderRel);
|
|
680
|
+
|
|
681
|
+
const useFrontmatter = obsidian.frontmatter !== false;
|
|
682
|
+
const useWikilinks = obsidian.wikilinks !== false;
|
|
683
|
+
const useIndex = obsidian.index !== false;
|
|
684
|
+
const projectName = cfg.project?.name || "RepoLens";
|
|
685
|
+
const branch = getCurrentBranch();
|
|
686
|
+
|
|
687
|
+
const notes = allocateNotes(orderedKeys(renderedPages || {}), useWikilinks);
|
|
688
|
+
const linkMap = buildLinkMap(notes);
|
|
689
|
+
const fileNames = [];
|
|
690
|
+
let changed = 0;
|
|
691
|
+
|
|
692
|
+
for (const note of notes) {
|
|
693
|
+
const filePath = containedFile(folder, note.fileName);
|
|
694
|
+
const content = renderPage(note, renderedPages[note.key], {
|
|
695
|
+
useFrontmatter,
|
|
696
|
+
useWikilinks,
|
|
697
|
+
useIndex,
|
|
698
|
+
linkMap,
|
|
699
|
+
projectName,
|
|
700
|
+
branch,
|
|
701
|
+
});
|
|
702
|
+
if (await writeIfChanged(filePath, content)) changed += 1;
|
|
703
|
+
fileNames.push(note.fileName);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (useIndex) {
|
|
707
|
+
const indexPath = containedFile(folder, `${INDEX_NOTE}.md`);
|
|
708
|
+
const content = renderIndex(notes, { useFrontmatter, useWikilinks, projectName, branch });
|
|
709
|
+
if (await writeIfChanged(indexPath, content)) changed += 1;
|
|
710
|
+
fileNames.push(`${INDEX_NOTE}.md`);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const removed = await syncManifest(vaultRoot, folder, fileNames);
|
|
714
|
+
info(`obsidian notes written to ${folder} (${fileNames.length} notes, ${changed} updated, ${removed} removed)`);
|
|
715
|
+
return { folder, files: fileNames, removed, changed };
|
|
716
|
+
}
|
package/src/utils/branch.js
CHANGED
|
@@ -118,6 +118,22 @@ export function shouldPublishToGitHubWiki(config, currentBranch = getCurrentBran
|
|
|
118
118
|
return matchesAnyBranch(config.github_wiki.branches, currentBranch);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Check if current branch should publish to an Obsidian vault.
|
|
123
|
+
* Based on config.obsidian.branches setting.
|
|
124
|
+
*/
|
|
125
|
+
export function shouldPublishToObsidian(config, currentBranch = getCurrentBranch()) {
|
|
126
|
+
if (!config.obsidian) {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!config.obsidian.branches || config.obsidian.branches.length === 0) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return matchesAnyBranch(config.obsidian.branches, currentBranch);
|
|
135
|
+
}
|
|
136
|
+
|
|
121
137
|
/**
|
|
122
138
|
* Get branch-qualified page title
|
|
123
139
|
*/
|
package/src/utils/validate.js
CHANGED
|
@@ -49,6 +49,12 @@ export function validateConfig(config) {
|
|
|
49
49
|
warnings.push(...notionWarnings);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// Validate Obsidian configuration (local vault path; no credentials)
|
|
53
|
+
if (config.obsidian) {
|
|
54
|
+
const obsidianErrors = validateObsidianConfig(config.obsidian);
|
|
55
|
+
errors.push(...obsidianErrors);
|
|
56
|
+
}
|
|
57
|
+
|
|
52
58
|
// Validate domains (if present)
|
|
53
59
|
if (config.domains) {
|
|
54
60
|
const domainErrors = validateDomains(config.domains);
|
|
@@ -142,7 +148,7 @@ function validatePublishers(publishers) {
|
|
|
142
148
|
return errors;
|
|
143
149
|
}
|
|
144
150
|
|
|
145
|
-
const validPublishers = ["notion", "markdown", "confluence", "github_wiki"];
|
|
151
|
+
const validPublishers = ["notion", "markdown", "confluence", "github_wiki", "obsidian"];
|
|
146
152
|
|
|
147
153
|
if (Array.isArray(publishers)) {
|
|
148
154
|
for (const pub of publishers) {
|
|
@@ -196,6 +202,62 @@ function validateNotionConfig(notion) {
|
|
|
196
202
|
return errors;
|
|
197
203
|
}
|
|
198
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Validate Obsidian vault configuration.
|
|
207
|
+
* The vault may be absolute (it lives outside the repo). The folder must
|
|
208
|
+
* stay inside that vault.
|
|
209
|
+
*/
|
|
210
|
+
function validateObsidianConfig(obsidian) {
|
|
211
|
+
const errors = [];
|
|
212
|
+
|
|
213
|
+
if (typeof obsidian !== "object" || Array.isArray(obsidian)) {
|
|
214
|
+
errors.push("obsidian must be an object");
|
|
215
|
+
return errors;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (obsidian.vault !== undefined) {
|
|
219
|
+
if (typeof obsidian.vault !== "string" || obsidian.vault.trim() === "") {
|
|
220
|
+
errors.push("obsidian.vault must be a non-empty string");
|
|
221
|
+
} else if (obsidian.vault.includes("\0")) {
|
|
222
|
+
errors.push("obsidian.vault contains a null byte");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (obsidian.folder !== undefined) {
|
|
227
|
+
if (typeof obsidian.folder !== "string" || obsidian.folder.trim() === "") {
|
|
228
|
+
errors.push("obsidian.folder must be a non-empty string");
|
|
229
|
+
} else if (obsidian.folder.includes("\0")) {
|
|
230
|
+
errors.push("obsidian.folder contains a null byte");
|
|
231
|
+
} else if (
|
|
232
|
+
path.isAbsolute(obsidian.folder) ||
|
|
233
|
+
/^[A-Za-z]:[\\/]/.test(obsidian.folder) ||
|
|
234
|
+
obsidian.folder.split(/[\\/]/).some((part) => part === "..")
|
|
235
|
+
) {
|
|
236
|
+
errors.push("obsidian.folder must be a relative path inside the vault");
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (obsidian.branches !== undefined) {
|
|
241
|
+
if (!Array.isArray(obsidian.branches)) {
|
|
242
|
+
errors.push("obsidian.branches must be an array");
|
|
243
|
+
} else {
|
|
244
|
+
for (const branch of obsidian.branches) {
|
|
245
|
+
if (typeof branch !== "string") {
|
|
246
|
+
errors.push(`Invalid branch name: ${branch} (must be string)`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
for (const flag of ["index", "frontmatter", "wikilinks"]) {
|
|
253
|
+
if (obsidian[flag] !== undefined && typeof obsidian[flag] !== "boolean") {
|
|
254
|
+
errors.push(`obsidian.${flag} must be a boolean`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return errors;
|
|
259
|
+
}
|
|
260
|
+
|
|
199
261
|
/**
|
|
200
262
|
* Check for Notion configuration warnings
|
|
201
263
|
*/
|