passageway-connector-markdown 0.3.0__tar.gz

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.
@@ -0,0 +1,11 @@
1
+ docs/superpowers/
2
+ .superpowers/
3
+ .env
4
+ .DS_Store
5
+ .venv/
6
+ __pycache__/
7
+ .pytest_cache/
8
+ .models/
9
+ dist/
10
+ site/
11
+ .playwright-mcp/
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.5
2
+ Name: passageway-connector-markdown
3
+ Version: 0.3.0
4
+ Summary: passageway connector for a folder of markdown files with front matter
5
+ License-Expression: PolyForm-Noncommercial-1.0.0
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: passageway-sdk~=0.3.0
8
+ Requires-Dist: pyyaml>=6.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # passageway-connector-markdown
12
+
13
+ A [passageway](https://github.com/getpassageway/passageway#readme) connector that turns a folder of markdown files into searchable items: a docs folder, an Obsidian vault, a Jekyll or Hugo site, or the articles of the frozen Wikipedia corpus.
14
+
15
+ ## What it does
16
+
17
+ - **Each markdown file is one item.** Its id is the path relative to the folder (or an `id` in its front matter) and its version is a hash of the file, so an edit re-indexes just that note.
18
+ - **Front matter gives the metadata:** title, address, date, tags, id and weight (see the table below).
19
+ - **Headings become sections.** Every heading level starts a section whose heading is the trail of its parents, such as `Setup › Linux`, so a hit says where in the note it is.
20
+ - **Folders become the breadcrumb**, and **links between notes become related items:** `[text](other.md)` and `[[Other]]`.
21
+ - **Files that cannot be indexed are skipped, not fatal.** A file that is not UTF-8 text, holds binary data or is too big is skipped with a warning on stderr, and the rest of the folder is indexed.
22
+
23
+ ## Install
24
+
25
+ Add the connector to the environment that runs passageway (see [Install](https://github.com/getpassageway/passageway/blob/main/docs/install.md)):
26
+
27
+ ```bash
28
+ uv tool install \
29
+ --with 'passageway-connector-markdown @ git+https://github.com/getpassageway/passageway#subdirectory=connectors/markdown' \
30
+ git+https://github.com/getpassageway/passageway
31
+ ```
32
+
33
+ ## An example
34
+
35
+ A note `ops/rotating-certificates.md`:
36
+
37
+ ```markdown
38
+ ---
39
+ title: Rotating certificates
40
+ tags: [ops, security]
41
+ url: https://wiki.example.org/ops/certs
42
+ ---
43
+ # Rotating certificates
44
+
45
+ Rotate the signing certificate every year.
46
+
47
+ ## Steps
48
+
49
+ 1. Create the new certificate.
50
+ 2. Follow [the deploy guide](deploy.md).
51
+ ```
52
+
53
+ becomes this item (shortened):
54
+
55
+ ```json
56
+ {
57
+ "id": "ops/rotating-certificates.md",
58
+ "title": "Rotating certificates",
59
+ "url": "https://wiki.example.org/ops/certs",
60
+ "path": ["ops"],
61
+ "labels": ["ops", "security"],
62
+ "links": ["ops/deploy.md"],
63
+ "sections": [
64
+ {
65
+ "heading": null,
66
+ "text": "Rotate the signing certificate every year."
67
+ },
68
+ {
69
+ "heading": "Steps",
70
+ "text": "1. Create the new certificate.\n2. Follow [the deploy guide](deploy.md)."
71
+ }
72
+ ]
73
+ }
74
+ ```
75
+
76
+ ## Configure
77
+
78
+ In `passageway.toml` (use the interpreter that has passageway installed, see [Install](https://github.com/getpassageway/passageway/blob/main/docs/install.md)):
79
+
80
+ ```toml
81
+ [[connector]]
82
+ name = "notes"
83
+ command = ["/path/to/python", "-m", "passageway_connector_markdown"]
84
+ env = { FOLDER = "/Users/you/notes", EXCLUDE = "drafts/**|*.draft.md" }
85
+ ```
86
+
87
+ | Setting | Default | Meaning |
88
+ |---|---|---|
89
+ | `FOLDER` | required | The folder to index, including its subfolders |
90
+ | `INCLUDE` | `*.md\|*.markdown` | Glob patterns for the files to index, separated by `\|`. `*` stays within a folder, `**` crosses folders, and a pattern without `/` matches the file name at any depth. Replaces the default |
91
+ | `EXCLUDE` | | Glob patterns for files to leave out, the same syntax |
92
+ | `MAX_FILE_BYTES` | `1048576` | Files larger than this are skipped with a warning. The core accepts at most 4 MiB per item line and the output escapes non-ASCII text, which can triple its size, so keep this at or below 1 MiB unless your notes are plain ASCII |
93
+
94
+ Always skipped: hidden files and folders (names starting with `.`, such as `.git` and `.obsidian`) and `node_modules`. Symlinked folders are not followed, and a symlinked file counts only if it points inside `FOLDER`.
95
+
96
+ ## Front matter
97
+
98
+ A file may start with a YAML block between `---` lines. These keys are read; everything else is ignored.
99
+
100
+ | Key | Used for |
101
+ |---|---|
102
+ | `title` | The title. Without it: the first `# heading`, then the file name |
103
+ | `url` | The address shown with a hit. Without it: the `file://` address of the file |
104
+ | `updated` | The date shown with a hit. Without it: the file's modification time |
105
+ | `tags` | Labels. A list, or a comma-separated string |
106
+ | `id` | A stable id that survives renames and moves. Without it: the relative path. Two files with the same id fail the call and the message names both |
107
+ | `weight` | A multiplier for ranking, from 0 to 2 (default 1) |
108
+
109
+ All values are read as text, so `no` stays `no` and `2026-10-28` stays a date string. Front matter that is not valid YAML is reported on stderr and the file is indexed without it.
110
+
111
+ ## Behavior
112
+
113
+ - **Sections:** every heading level (`#` to `######`) starts a section whose heading is the trail of its parents. Text before the first heading is its own section. The first `# heading` is the title (when the front matter has no title, or the same one) and is not repeated; a different `# heading` stays a section heading. A `#` inside a fenced code block is not a heading.
114
+ - **Links:** `[text](other.md#part)` and `[[Other]]` or `[[Other|text]]` become related items when they point at a note in the folder. Wiki links match the file name without case; a name shared by two files links to neither. Links in code are ignored, and the text itself is not changed.
115
+ - **Version:** a hash of the whole file and of the notes it links to, so any change to the file, front matter included, and a link that starts to resolve (its target was created) re-indexes the note.
116
+ - **Bad files:** a file that is not UTF-8 text (a BOM is removed), contains binary data or is over `MAX_FILE_BYTES` is skipped with a warning on stderr, not an error.
117
+ - **`changed`:** notes modified or moved in since the given time, from the later of each file's modification and change time, so edits appear within a minute, also for files copied in with their old dates.
118
+ - **Cost:** every call reads the whole folder, because ids and links need all the notes. A skipped file is reported on every call.
119
+
120
+ ## Not supported
121
+
122
+ - Headings underlined with `===` or `---` (setext); only `#` headings split. A `#` line inside an HTML comment still counts as a heading.
123
+ - Glob character classes (`[ab]`) and braces (`{a,b}`); `*.md` is case sensitive, so `NOTE.MD` is not found without `INCLUDE=*.MD`. A pattern with a `/` is anchored at `FOLDER`, so `drafts/**` matches only the top-level `drafts`; use `**/drafts/**` for any depth.
124
+ - Reference-style links (`[text][ref]`) and brackets inside link text.
125
+ - File names in a different Unicode form than the link (`Café` typed with a combining accent).
126
+
127
+ ## Test
128
+
129
+ ```bash
130
+ uv sync # installs this connector too
131
+ uv run pytest connectors/markdown # offline, on temporary folders
132
+ ```
@@ -0,0 +1,122 @@
1
+ # passageway-connector-markdown
2
+
3
+ A [passageway](https://github.com/getpassageway/passageway#readme) connector that turns a folder of markdown files into searchable items: a docs folder, an Obsidian vault, a Jekyll or Hugo site, or the articles of the frozen Wikipedia corpus.
4
+
5
+ ## What it does
6
+
7
+ - **Each markdown file is one item.** Its id is the path relative to the folder (or an `id` in its front matter) and its version is a hash of the file, so an edit re-indexes just that note.
8
+ - **Front matter gives the metadata:** title, address, date, tags, id and weight (see the table below).
9
+ - **Headings become sections.** Every heading level starts a section whose heading is the trail of its parents, such as `Setup › Linux`, so a hit says where in the note it is.
10
+ - **Folders become the breadcrumb**, and **links between notes become related items:** `[text](other.md)` and `[[Other]]`.
11
+ - **Files that cannot be indexed are skipped, not fatal.** A file that is not UTF-8 text, holds binary data or is too big is skipped with a warning on stderr, and the rest of the folder is indexed.
12
+
13
+ ## Install
14
+
15
+ Add the connector to the environment that runs passageway (see [Install](https://github.com/getpassageway/passageway/blob/main/docs/install.md)):
16
+
17
+ ```bash
18
+ uv tool install \
19
+ --with 'passageway-connector-markdown @ git+https://github.com/getpassageway/passageway#subdirectory=connectors/markdown' \
20
+ git+https://github.com/getpassageway/passageway
21
+ ```
22
+
23
+ ## An example
24
+
25
+ A note `ops/rotating-certificates.md`:
26
+
27
+ ```markdown
28
+ ---
29
+ title: Rotating certificates
30
+ tags: [ops, security]
31
+ url: https://wiki.example.org/ops/certs
32
+ ---
33
+ # Rotating certificates
34
+
35
+ Rotate the signing certificate every year.
36
+
37
+ ## Steps
38
+
39
+ 1. Create the new certificate.
40
+ 2. Follow [the deploy guide](deploy.md).
41
+ ```
42
+
43
+ becomes this item (shortened):
44
+
45
+ ```json
46
+ {
47
+ "id": "ops/rotating-certificates.md",
48
+ "title": "Rotating certificates",
49
+ "url": "https://wiki.example.org/ops/certs",
50
+ "path": ["ops"],
51
+ "labels": ["ops", "security"],
52
+ "links": ["ops/deploy.md"],
53
+ "sections": [
54
+ {
55
+ "heading": null,
56
+ "text": "Rotate the signing certificate every year."
57
+ },
58
+ {
59
+ "heading": "Steps",
60
+ "text": "1. Create the new certificate.\n2. Follow [the deploy guide](deploy.md)."
61
+ }
62
+ ]
63
+ }
64
+ ```
65
+
66
+ ## Configure
67
+
68
+ In `passageway.toml` (use the interpreter that has passageway installed, see [Install](https://github.com/getpassageway/passageway/blob/main/docs/install.md)):
69
+
70
+ ```toml
71
+ [[connector]]
72
+ name = "notes"
73
+ command = ["/path/to/python", "-m", "passageway_connector_markdown"]
74
+ env = { FOLDER = "/Users/you/notes", EXCLUDE = "drafts/**|*.draft.md" }
75
+ ```
76
+
77
+ | Setting | Default | Meaning |
78
+ |---|---|---|
79
+ | `FOLDER` | required | The folder to index, including its subfolders |
80
+ | `INCLUDE` | `*.md\|*.markdown` | Glob patterns for the files to index, separated by `\|`. `*` stays within a folder, `**` crosses folders, and a pattern without `/` matches the file name at any depth. Replaces the default |
81
+ | `EXCLUDE` | | Glob patterns for files to leave out, the same syntax |
82
+ | `MAX_FILE_BYTES` | `1048576` | Files larger than this are skipped with a warning. The core accepts at most 4 MiB per item line and the output escapes non-ASCII text, which can triple its size, so keep this at or below 1 MiB unless your notes are plain ASCII |
83
+
84
+ Always skipped: hidden files and folders (names starting with `.`, such as `.git` and `.obsidian`) and `node_modules`. Symlinked folders are not followed, and a symlinked file counts only if it points inside `FOLDER`.
85
+
86
+ ## Front matter
87
+
88
+ A file may start with a YAML block between `---` lines. These keys are read; everything else is ignored.
89
+
90
+ | Key | Used for |
91
+ |---|---|
92
+ | `title` | The title. Without it: the first `# heading`, then the file name |
93
+ | `url` | The address shown with a hit. Without it: the `file://` address of the file |
94
+ | `updated` | The date shown with a hit. Without it: the file's modification time |
95
+ | `tags` | Labels. A list, or a comma-separated string |
96
+ | `id` | A stable id that survives renames and moves. Without it: the relative path. Two files with the same id fail the call and the message names both |
97
+ | `weight` | A multiplier for ranking, from 0 to 2 (default 1) |
98
+
99
+ All values are read as text, so `no` stays `no` and `2026-10-28` stays a date string. Front matter that is not valid YAML is reported on stderr and the file is indexed without it.
100
+
101
+ ## Behavior
102
+
103
+ - **Sections:** every heading level (`#` to `######`) starts a section whose heading is the trail of its parents. Text before the first heading is its own section. The first `# heading` is the title (when the front matter has no title, or the same one) and is not repeated; a different `# heading` stays a section heading. A `#` inside a fenced code block is not a heading.
104
+ - **Links:** `[text](other.md#part)` and `[[Other]]` or `[[Other|text]]` become related items when they point at a note in the folder. Wiki links match the file name without case; a name shared by two files links to neither. Links in code are ignored, and the text itself is not changed.
105
+ - **Version:** a hash of the whole file and of the notes it links to, so any change to the file, front matter included, and a link that starts to resolve (its target was created) re-indexes the note.
106
+ - **Bad files:** a file that is not UTF-8 text (a BOM is removed), contains binary data or is over `MAX_FILE_BYTES` is skipped with a warning on stderr, not an error.
107
+ - **`changed`:** notes modified or moved in since the given time, from the later of each file's modification and change time, so edits appear within a minute, also for files copied in with their old dates.
108
+ - **Cost:** every call reads the whole folder, because ids and links need all the notes. A skipped file is reported on every call.
109
+
110
+ ## Not supported
111
+
112
+ - Headings underlined with `===` or `---` (setext); only `#` headings split. A `#` line inside an HTML comment still counts as a heading.
113
+ - Glob character classes (`[ab]`) and braces (`{a,b}`); `*.md` is case sensitive, so `NOTE.MD` is not found without `INCLUDE=*.MD`. A pattern with a `/` is anchored at `FOLDER`, so `drafts/**` matches only the top-level `drafts`; use `**/drafts/**` for any depth.
114
+ - Reference-style links (`[text][ref]`) and brackets inside link text.
115
+ - File names in a different Unicode form than the link (`Café` typed with a combining accent).
116
+
117
+ ## Test
118
+
119
+ ```bash
120
+ uv sync # installs this connector too
121
+ uv run pytest connectors/markdown # offline, on temporary folders
122
+ ```
@@ -0,0 +1,3 @@
1
+ from passageway_connector_markdown.connector import main
2
+
3
+ main()
@@ -0,0 +1,244 @@
1
+ """passageway connector for a folder of markdown files with front matter."""
2
+
3
+ import hashlib
4
+ import os
5
+ import posixpath
6
+ import re
7
+ from collections.abc import Iterator, Mapping
8
+ from dataclasses import dataclass, replace
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path, PurePosixPath
11
+ from urllib.parse import unquote
12
+
13
+ import yaml
14
+ from passageway_sdk import Connector, SettingsError, SourceError, first_h1, split_markdown, strip_code
15
+ from passageway_sdk.settings import names, whole
16
+
17
+ DEFAULT_INCLUDE = ("*.md", "*.markdown")
18
+ SKIP_DIRS = frozenset({"node_modules"})
19
+ FRONT_MATTER = re.compile(r"\A---[ \t]*\r?\n(?:(.*?)\r?\n)?(?:---|\.\.\.)[ \t]*(?:\r?\n|\Z)", re.S)
20
+ # [[wiki link]] or [text](target); no brackets or line breaks inside, which also keeps the matching linear
21
+ LINK = re.compile(r"\[\[([^\[\]\n]+)\]\]|\[[^\[\]\n]*\]\(([^()\n]*)\)")
22
+ SCHEME = re.compile(r"[a-zA-Z][a-zA-Z0-9+.-]*:")
23
+
24
+
25
+ def _glob(pattern: str) -> re.Pattern:
26
+ """A glob over the path relative to the folder. `**` crosses folders; a pattern without `/` matches the file name anywhere."""
27
+ out, i = "", 0
28
+ while i < len(pattern):
29
+ if pattern.startswith("**/", i):
30
+ out, i = out + "(?:.*/)?", i + 3
31
+ elif pattern.startswith("**", i):
32
+ out, i = out + ".*", i + 2
33
+ elif pattern[i] == "*":
34
+ out, i = out + "[^/]*", i + 1
35
+ elif pattern[i] == "?":
36
+ out, i = out + "[^/]", i + 1
37
+ else:
38
+ out, i = out + re.escape(pattern[i]), i + 1
39
+ return re.compile(("" if "/" in pattern else "(?:.*/)?") + out + r"\Z")
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Note:
44
+ rel: str # the path relative to the folder, with `/`
45
+ path: Path
46
+ id: str
47
+ meta: dict
48
+ body: str
49
+ version: str # the content and the links it resolves to: a link that starts to resolve changes it
50
+ mtime: float
51
+ changed: float # the later of modification and change time: a note moved in keeps its old mtime
52
+ links: tuple[str, ...] = ()
53
+
54
+
55
+ class Markdown(Connector):
56
+ name = "markdown connector"
57
+ expected_errors = (OSError,)
58
+
59
+ def __init__(self, env: Mapping[str, str]):
60
+ folder = env.get("FOLDER", "").strip()
61
+ if not folder or not Path(folder).is_dir():
62
+ raise SettingsError(f"FOLDER must be an existing folder, got {folder!r}")
63
+ self.root = Path(folder).resolve()
64
+ self.include = [_glob(p) for p in names(env, "INCLUDE") or DEFAULT_INCLUDE]
65
+ self.exclude = [_glob(p) for p in names(env, "EXCLUDE")]
66
+ self.max_bytes = whole(env, "MAX_FILE_BYTES", 1024 * 1024)
67
+
68
+ # -- the protocol ------------------------------------------------------------------------
69
+
70
+ def versions(self) -> dict[str, str]:
71
+ return {note.id: note.version for note in self._notes().values()}
72
+
73
+ def fetch(self, ids: list[str]) -> Iterator[dict]:
74
+ notes = self._notes()
75
+ for item_id in ids:
76
+ if item_id in notes:
77
+ yield self._item(notes[item_id])
78
+
79
+ def changed(self, since: datetime) -> Iterator[dict]:
80
+ # ponytail: every call reads the whole folder (ids and links need it); a cache if a vault ever has tens of thousands of notes
81
+ for note in self._notes().values():
82
+ if note.changed >= since.timestamp():
83
+ yield self._item(note)
84
+
85
+ # -- the folder --------------------------------------------------------------------------
86
+
87
+ def _wanted(self, rel: str) -> bool:
88
+ return any(g.match(rel) for g in self.include) and not any(g.match(rel) for g in self.exclude)
89
+
90
+ def _paths(self) -> Iterator[tuple[str, Path]]:
91
+ def unreadable(e: OSError) -> None:
92
+ self.warn(f"skipped {Path(e.filename).relative_to(self.root).as_posix()}/: {e.strerror}")
93
+
94
+ for dirpath, dirnames, filenames in os.walk(self.root, onerror=unreadable): # does not follow symlinked folders
95
+ dirnames[:] = sorted(d for d in dirnames if not d.startswith(".") and d not in SKIP_DIRS
96
+ and not os.path.islink(os.path.join(dirpath, d)))
97
+ for name in sorted(filenames):
98
+ path = Path(dirpath, name)
99
+ rel = path.relative_to(self.root).as_posix()
100
+ if name.startswith(".") or not self._wanted(rel) or not path.is_file():
101
+ continue
102
+ if path.is_symlink() and not path.resolve().is_relative_to(self.root):
103
+ continue # a link out of the folder would index files that are not in it
104
+ yield rel, path
105
+
106
+ def _notes(self) -> dict[str, Note]:
107
+ notes: dict[str, Note] = {}
108
+ for rel, path in self._paths():
109
+ note = self._read(rel, path)
110
+ if note is None:
111
+ continue
112
+ if note.id in notes:
113
+ raise SourceError(f"two notes have the id {note.id!r}: {notes[note.id].rel} and {rel}")
114
+ notes[note.id] = note
115
+ finder = _Links(notes)
116
+ for item_id, note in notes.items():
117
+ found = tuple(finder.of(note))
118
+ digest = hashlib.sha256((note.version + "\n".join(found)).encode()).hexdigest()[:16]
119
+ notes[item_id] = replace(note, links=found, version=digest)
120
+ return notes
121
+
122
+ def _read(self, rel: str, path: Path) -> Note | None:
123
+ try:
124
+ stat = path.stat()
125
+ if stat.st_size > self.max_bytes:
126
+ self.warn(f"skipped {rel}: larger than MAX_FILE_BYTES ({self.max_bytes})")
127
+ return None
128
+ raw = path.read_bytes()
129
+ except OSError as e: # gone since the listing, or unreadable
130
+ self.warn(f"skipped {rel}: {e.strerror or e}")
131
+ return None
132
+ try:
133
+ if b"\0" in raw:
134
+ raise UnicodeDecodeError("utf-8", b"", 0, 1, "binary data")
135
+ text = raw.decode("utf-8-sig") # also removes a BOM
136
+ except UnicodeDecodeError:
137
+ self.warn(f"skipped {rel}: not UTF-8 text")
138
+ return None
139
+ meta, body = self._front_matter(rel, text)
140
+ return Note(rel, path, _text(meta, "id") or rel, meta, body, hashlib.sha256(raw).hexdigest(), stat.st_mtime,
141
+ max(stat.st_mtime, stat.st_ctime))
142
+
143
+ def _front_matter(self, rel: str, text: str) -> tuple[dict, str]:
144
+ match = FRONT_MATTER.match(text)
145
+ if not match:
146
+ return {}, text
147
+ body = text[match.end():]
148
+ try:
149
+ # BaseLoader keeps every scalar a string, so `no` stays "no" and a date stays "2026-10-28"; it builds no objects
150
+ meta = yaml.load(match.group(1) or "", Loader=yaml.BaseLoader)
151
+ except (yaml.YAMLError, RecursionError): # RecursionError: a few thousand nested brackets
152
+ self.warn(f"{rel}: front matter is not valid YAML, indexed without it")
153
+ return {}, body
154
+ if meta is not None and not isinstance(meta, dict):
155
+ self.warn(f"{rel}: front matter is not a mapping of keys, indexed without it")
156
+ return {}, body
157
+ return meta or {}, body
158
+
159
+ # -- the item ----------------------------------------------------------------------------
160
+
161
+ def _item(self, note: Note) -> dict:
162
+ meta = note.meta
163
+ title = _text(meta, "title")
164
+ h1 = first_h1(note.body)
165
+ is_title = h1 is not None and (not title or h1.casefold() == title.casefold()) # then it is not repeated as a heading
166
+ item = {
167
+ "id": note.id,
168
+ "title": title or h1 or PurePosixPath(note.rel).stem,
169
+ "url": _text(meta, "url") or note.path.as_uri(),
170
+ "version": note.version,
171
+ "updated": _text(meta, "updated") or datetime.fromtimestamp(note.mtime, UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
172
+ "sections": split_markdown(note.body, skip_first_h1=is_title),
173
+ }
174
+ if folders := list(PurePosixPath(note.rel).parent.parts):
175
+ item["path"] = folders
176
+ if labels := _tags(meta.get("tags")):
177
+ item["labels"] = labels
178
+ if note.links:
179
+ item["links"] = list(note.links)
180
+ weight = _text(meta, "weight")
181
+ if weight is not None:
182
+ try:
183
+ value = float(weight)
184
+ if not 0 <= value <= 2:
185
+ raise ValueError
186
+ item["weight"] = value
187
+ except ValueError:
188
+ self.warn(f"{note.rel}: weight must be a number from 0 to 2, got {weight!r}; ignored")
189
+ return item
190
+
191
+
192
+ def _text(meta: dict, key: str) -> str | None:
193
+ value = meta.get(key)
194
+ return value.strip() or None if isinstance(value, str) else None
195
+
196
+
197
+ def _tags(value) -> list[str]:
198
+ if isinstance(value, str):
199
+ value = value.split(",")
200
+ return [t.strip() for t in value if isinstance(t, str) and t.strip()] if isinstance(value, list) else []
201
+
202
+
203
+ class _Links:
204
+ """Finds the notes a note links to: by path for [text](file.md), by file name for [[Name]]."""
205
+
206
+ def __init__(self, notes: dict[str, Note]):
207
+ self.by_path = {n.rel: n.id for n in notes.values()}
208
+ stems: dict[str, list[str]] = {}
209
+ for n in notes.values():
210
+ stems.setdefault(PurePosixPath(n.rel).stem.casefold(), []).append(n.id)
211
+ self.by_stem = {stem: ids[0] for stem, ids in stems.items() if len(ids) == 1} # an ambiguous name links to nothing
212
+
213
+ def of(self, note: Note) -> list[str]:
214
+ found: list[str] = []
215
+ for wiki, target in LINK.findall(strip_code(note.body)):
216
+ item_id = self._wiki(wiki) if wiki else self._path(note, target)
217
+ if item_id and item_id != note.id and item_id not in found:
218
+ found.append(item_id)
219
+ return found
220
+
221
+ def _wiki(self, wiki: str) -> str | None:
222
+ target = re.split(r"\\?\|", wiki)[0].split("#")[0].strip() # `\|` is how a table cell escapes the pipe
223
+ target = target[:-3] if target.lower().endswith(".md") else target
224
+ for candidate in (f"{target}.md", f"{target}.markdown"): # [[folder/Name]] names the folder when the name is ambiguous
225
+ if candidate in self.by_path:
226
+ return self.by_path[candidate]
227
+ return self.by_stem.get(PurePosixPath(target).name.casefold())
228
+
229
+ def _path(self, note: Note, target: str) -> str | None:
230
+ target = target.strip()
231
+ if target.startswith("<") and ">" in target: # <a target with spaces> and an optional title after it
232
+ target = target[1:target.index(">")]
233
+ else:
234
+ target = target.split()[0] if target else ""
235
+ target = unquote(target.split("#")[0].split("?")[0])
236
+ if not target or SCHEME.match(target):
237
+ return None
238
+ joined = target.lstrip("/") if target.startswith("/") else posixpath.join(posixpath.dirname(note.rel), target)
239
+ resolved = posixpath.normpath(joined)
240
+ return None if resolved.startswith("..") else self.by_path.get(resolved)
241
+
242
+
243
+ def main() -> None:
244
+ Markdown.main()
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["hatchling==1.33.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "passageway-connector-markdown"
7
+ version = "0.3.0"
8
+ description = "passageway connector for a folder of markdown files with front matter"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "PolyForm-Noncommercial-1.0.0"
12
+ dependencies = ["passageway-sdk~=0.3.0", "pyyaml>=6.0"]
13
+
14
+ [tool.hatch.build.targets.wheel]
15
+ packages = ["passageway_connector_markdown"]
16
+
17
+ [dependency-groups]
18
+ dev = ["jsonschema>=4.23", "pytest>=8.3", "passageway"]
19
+
20
+ [tool.uv.sources]
21
+ passageway = { workspace = true }
22
+ passageway-sdk = { workspace = true }
@@ -0,0 +1,40 @@
1
+ import io
2
+ import json
3
+
4
+ import pytest
5
+
6
+ from passageway_connector_markdown.connector import Markdown
7
+
8
+
9
+ class Folder:
10
+ """A folder of notes and a way to run the connector on it in-process."""
11
+
12
+ def __init__(self, root):
13
+ self.root = root
14
+
15
+ def write(self, name, text, *, binary=False):
16
+ path = self.root / name
17
+ path.parent.mkdir(parents=True, exist_ok=True)
18
+ path.write_bytes(text) if binary else path.write_text(text, encoding="utf-8")
19
+ return path
20
+
21
+ def call(self, mode, stdin="", *, extra=(), **settings):
22
+ out, err = io.StringIO(), io.StringIO()
23
+ env = {"FOLDER": str(self.root), **settings}
24
+ code = Markdown.run(["markdown", mode, *extra], env, io.StringIO(stdin), out, err)
25
+ return code, [json.loads(line) for line in out.getvalue().splitlines()], err.getvalue()
26
+
27
+ def fetch(self, *ids, **settings):
28
+ code, records, err = self.call("fetch", "".join(json.dumps({"id": i}) + "\n" for i in ids), **settings)
29
+ assert code == 0, err
30
+ return {r["id"]: r for r in records}
31
+
32
+ def ids(self, **settings):
33
+ code, records, err = self.call("list", **settings)
34
+ assert code == 0, err
35
+ return sorted(r["id"] for r in records)
36
+
37
+
38
+ @pytest.fixture
39
+ def folder(tmp_path):
40
+ return Folder(tmp_path)
@@ -0,0 +1,341 @@
1
+ import os
2
+ import time
3
+ from datetime import datetime, timezone
4
+
5
+ import pytest
6
+
7
+ from passageway.protocol import parse_item
8
+
9
+
10
+ # ---- which files ----------------------------------------------------------------------------------
11
+
12
+ def test_markdown_files_are_found_in_subfolders_and_other_files_are_not(folder):
13
+ folder.write("a.md", "A")
14
+ folder.write("sub/b.markdown", "B")
15
+ folder.write("c.txt", "C")
16
+ folder.write("sub/deeper/d.md", "D")
17
+ assert folder.ids() == ["a.md", "sub/b.markdown", "sub/deeper/d.md"]
18
+
19
+
20
+ def test_hidden_folders_and_files_and_node_modules_are_always_skipped(folder):
21
+ folder.write("keep.md", "K")
22
+ folder.write(".git/notes.md", "x")
23
+ folder.write(".obsidian/plugins/readme.md", "x")
24
+ folder.write(".hidden.md", "x")
25
+ folder.write("node_modules/pkg/readme.md", "x")
26
+ folder.write("sub/.trash/old.md", "x")
27
+ assert folder.ids() == ["keep.md"]
28
+
29
+
30
+ def test_a_symlinked_folder_is_not_followed_and_a_symlink_loop_ends(folder):
31
+ folder.write("real/a.md", "A")
32
+ os.symlink(folder.root / "real", folder.root / "alias")
33
+ os.symlink(folder.root, folder.root / "real" / "loop")
34
+ assert folder.ids() == ["real/a.md"]
35
+
36
+
37
+ def test_a_symlinked_file_counts_only_if_it_points_inside_the_folder(folder, tmp_path_factory):
38
+ outside = tmp_path_factory.mktemp("outside") / "secret.md"
39
+ outside.write_text("secret")
40
+ folder.write("inside.md", "I")
41
+ os.symlink(folder.root / "inside.md", folder.root / "link-in.md")
42
+ os.symlink(outside, folder.root / "link-out.md")
43
+ assert folder.ids() == ["inside.md", "link-in.md"]
44
+
45
+
46
+ def test_include_replaces_the_default_and_exclude_removes(folder):
47
+ for name in ("a.md", "docs/b.md", "docs/c.md", "docs/c.draft.md", "other/d.md", "e.txt"):
48
+ folder.write(name, "x")
49
+ assert folder.ids(INCLUDE="docs/**/*.md") == ["docs/b.md", "docs/c.draft.md", "docs/c.md"]
50
+ assert folder.ids(INCLUDE="docs/*.md|a.md") == ["a.md", "docs/b.md", "docs/c.draft.md", "docs/c.md"]
51
+ assert folder.ids(EXCLUDE="*.draft.md|other/**") == ["a.md", "docs/b.md", "docs/c.md"] # no slash: any depth
52
+ assert folder.ids(INCLUDE="*.txt") == ["e.txt"]
53
+
54
+
55
+ # ---- the item --------------------------------------------------------------------------------------
56
+
57
+ def test_the_item_is_valid_and_has_the_path_as_id_and_a_content_hash_as_version(folder):
58
+ folder.write("guides/setup.md", "# Setup guide\n\nInstall it.\n")
59
+ item = folder.fetch("guides/setup.md")["guides/setup.md"]
60
+ parsed = parse_item(item) # the core's own parser accepts it
61
+ assert parsed.id == "guides/setup.md" and parsed.title == "Setup guide"
62
+ assert parsed.path == ("guides",) and parsed.url.startswith("file://") and parsed.url.endswith("/guides/setup.md")
63
+ assert [(s.heading, s.text) for s in parsed.sections] == [(None, "Install it.")]
64
+ folder.write("guides/setup.md", "# Setup guide\n\nInstall it, then run it.\n")
65
+ assert folder.fetch("guides/setup.md")["guides/setup.md"]["version"] != item["version"]
66
+
67
+
68
+ def test_the_version_ignores_nothing_a_change_to_the_front_matter_changes_it(folder):
69
+ folder.write("a.md", "---\ntags: [x]\n---\nBody")
70
+ before = folder.fetch("a.md")["a.md"]["version"]
71
+ folder.write("a.md", "---\ntags: [y]\n---\nBody")
72
+ assert folder.fetch("a.md")["a.md"]["version"] != before
73
+
74
+
75
+ def test_the_title_is_the_front_matter_title_else_the_first_heading_else_the_file_name(folder):
76
+ folder.write("a.md", "---\ntitle: From front matter\n---\n# Heading\nbody")
77
+ folder.write("b.md", "intro\n\n# From heading\nbody")
78
+ folder.write("c-note.md", "just text")
79
+ got = folder.fetch("a.md", "b.md", "c-note.md")
80
+ assert [got[i]["title"] for i in ("a.md", "b.md", "c-note.md")] == ["From front matter", "From heading", "c-note"]
81
+
82
+
83
+ def test_front_matter_gives_url_updated_tags_weight_and_id(folder):
84
+ folder.write("a.md", "---\nurl: https://example.org/a\nupdated: 2026-10-05\ntags: [ops, security]\n"
85
+ "weight: 1.5\nid: stable-a\n---\nBody")
86
+ item = folder.fetch("stable-a")["stable-a"]
87
+ assert (item["url"], item["updated"], item["labels"], item["weight"]) == (
88
+ "https://example.org/a", "2026-10-05", ["ops", "security"], 1.5)
89
+
90
+
91
+ def test_tags_may_be_a_comma_separated_string_and_scalars_stay_text(folder):
92
+ folder.write("a.md", "---\ntags: ops, security\n---\nBody")
93
+ folder.write("b.md", "---\ntags:\n - no\n - 2026-10-28\n---\nBody") # YAML 1.1 would make these False and a date
94
+ got = folder.fetch("a.md", "b.md")
95
+ assert got["a.md"]["labels"] == ["ops", "security"] and got["b.md"]["labels"] == ["no", "2026-10-28"]
96
+
97
+
98
+ def test_updated_defaults_to_the_modification_time_in_utc(folder):
99
+ path = folder.write("a.md", "Body")
100
+ os.utime(path, (1_700_000_000, 1_700_000_000))
101
+ assert folder.fetch("a.md")["a.md"]["updated"] == "2023-11-14T22:13:20Z"
102
+
103
+
104
+ def test_the_front_matter_is_not_indexed_as_text(folder):
105
+ folder.write("a.md", "---\ntitle: T\nsecret_key: zebra-key\n---\nBody text")
106
+ sections = folder.fetch("a.md")["a.md"]["sections"]
107
+ assert [s["text"] for s in sections] == ["Body text"]
108
+
109
+
110
+ def test_broken_front_matter_warns_and_the_note_is_still_indexed(folder):
111
+ folder.write("a.md", "---\ntitle: [unclosed\n---\nBody text")
112
+ code, records, err = folder.call("fetch", '{"id": "a.md"}\n')
113
+ assert code == 0 and records[0]["title"] == "a" and records[0]["sections"][0]["text"] == "Body text"
114
+ assert "a.md" in err and "front matter" in err
115
+
116
+
117
+ def test_a_front_matter_that_is_not_a_mapping_is_ignored_with_a_warning(folder):
118
+ folder.write("a.md", "---\n- just\n- a list\n---\nBody")
119
+ code, records, err = folder.call("fetch", '{"id": "a.md"}\n')
120
+ assert code == 0 and records[0]["sections"][0]["text"] == "Body" and "front matter" in err
121
+
122
+
123
+ def test_an_unclosed_fence_means_no_front_matter(folder):
124
+ folder.write("a.md", "---\nnot front matter, a rule above text\n")
125
+ assert folder.fetch("a.md")["a.md"]["sections"][0]["text"].startswith("---")
126
+
127
+
128
+ def test_a_bad_weight_is_ignored_with_a_warning(folder):
129
+ folder.write("a.md", "---\nweight: heavy\n---\nBody")
130
+ code, records, err = folder.call("fetch", '{"id": "a.md"}\n')
131
+ assert code == 0 and "weight" not in records[0] and "weight" in err
132
+
133
+
134
+ def test_sections_follow_the_headings_and_the_first_h1_is_the_title(folder):
135
+ folder.write("a.md", "# Guide\nIntro.\n\n## Setup\nInstall.\n\n### Linux\nApt.\n")
136
+ item = folder.fetch("a.md")["a.md"]
137
+ assert item["title"] == "Guide"
138
+ assert [(s["heading"], s["text"]) for s in item["sections"]] == [
139
+ (None, "Intro."), ("Setup", "Install."), ("Setup › Linux", "Apt.")]
140
+
141
+
142
+ # ---- ids ------------------------------------------------------------------------------------------
143
+
144
+ def test_two_notes_with_the_same_id_fail_the_call_naming_both(folder):
145
+ folder.write("a.md", "---\nid: same\n---\nA")
146
+ folder.write("sub/b.md", "---\nid: same\n---\nB")
147
+ code, records, err = folder.call("list")
148
+ assert code == 1 and records == [] and "same" in err and "a.md" in err and "sub/b.md" in err
149
+
150
+
151
+ # ---- links ----------------------------------------------------------------------------------------
152
+
153
+ def test_links_to_other_notes_become_ids_of_existing_files(folder):
154
+ folder.write("guides/setup.md", "Setup")
155
+ folder.write("guides/usage.md", "Usage")
156
+ folder.write("index.md", "See [setup](guides/setup.md#linux), [usage](guides/usage.md?x=1), "
157
+ "[web](https://example.org/x.md), [gone](missing.md), [anchor](#top), "
158
+ "and [encoded](guides/set%75p.md).")
159
+ assert folder.fetch("index.md")["index.md"]["links"] == ["guides/setup.md", "guides/usage.md"]
160
+
161
+
162
+ def test_relative_links_resolve_from_the_notes_own_folder(folder):
163
+ folder.write("a/one.md", "[two](../b/two.md) and [three](./three.md)")
164
+ folder.write("b/two.md", "Two")
165
+ folder.write("a/three.md", "Three")
166
+ assert folder.fetch("a/one.md")["a/one.md"]["links"] == ["b/two.md", "a/three.md"]
167
+
168
+
169
+ def test_wiki_links_match_the_file_name_ignoring_case_and_skip_ambiguous_names(folder):
170
+ folder.write("Setup.md", "S")
171
+ folder.write("x/Dup.md", "D1")
172
+ folder.write("y/dup.md", "D2")
173
+ folder.write("n.md", "[[setup]] [[Setup|the guide]] [[Dup]] [[Nothing]] [[Setup#Linux]]")
174
+ assert folder.fetch("n.md")["n.md"]["links"] == ["Setup.md"]
175
+
176
+
177
+ def test_links_use_front_matter_ids_and_ignore_code(folder):
178
+ folder.write("target.md", "---\nid: stable-target\n---\nT")
179
+ folder.write("other.md", "O")
180
+ folder.write("n.md", "[t](target.md)\n```\n[o](other.md)\n```\nand `[o](other.md)` inline")
181
+ assert folder.fetch("n.md")["n.md"]["links"] == ["stable-target"]
182
+
183
+
184
+ def test_a_note_does_not_link_to_itself(folder):
185
+ folder.write("a.md", "[me](a.md) [[a]]")
186
+ assert "links" not in folder.fetch("a.md")["a.md"]
187
+
188
+
189
+ # ---- bad files --------------------------------------------------------------------------------------
190
+
191
+ def test_a_bom_is_removed(folder):
192
+ folder.write("a.md", b"\xef\xbb\xbf# Title\n\nBody", binary=True)
193
+ item = folder.fetch("a.md")["a.md"]
194
+ assert item["title"] == "Title" and item["sections"][0]["text"] == "Body"
195
+
196
+
197
+ def test_files_that_are_not_text_are_skipped_with_a_warning(folder):
198
+ folder.write("ok.md", "fine")
199
+ folder.write("latin.md", "caf\xe9".encode("latin-1"), binary=True)
200
+ folder.write("binary.md", b"abc\x00def", binary=True)
201
+ code, records, err = folder.call("list")
202
+ assert code == 0 and [r["id"] for r in records] == ["ok.md"]
203
+ assert "latin.md" in err and "binary.md" in err
204
+
205
+
206
+ def test_a_file_over_the_limit_is_skipped_with_a_warning(folder):
207
+ folder.write("small.md", "x" * 100)
208
+ folder.write("big.md", "x" * 101)
209
+ code, records, err = folder.call("list", MAX_FILE_BYTES="100")
210
+ assert code == 0 and [r["id"] for r in records] == ["small.md"] and "big.md" in err
211
+
212
+
213
+ def test_a_file_that_vanishes_between_list_and_fetch_is_skipped(folder):
214
+ path = folder.write("a.md", "A")
215
+ folder.write("b.md", "B")
216
+ assert folder.ids() == ["a.md", "b.md"]
217
+ path.unlink()
218
+ assert list(folder.fetch("a.md", "b.md")) == ["b.md"]
219
+
220
+
221
+ def test_non_ascii_text_is_written_ascii_safe(folder):
222
+ folder.write("a.md", "# Köln\n\nGröße", )
223
+ code, records, _ = folder.call("fetch", '{"id": "a.md"}\n')
224
+ assert records[0]["title"] == "Köln" and records[0]["sections"][0]["text"] == "Größe"
225
+
226
+
227
+ # ---- check and changed ------------------------------------------------------------------------------
228
+
229
+ def test_check_counts_the_notes(folder):
230
+ folder.write("a.md", "A")
231
+ folder.write("b.md", "B")
232
+ assert folder.call("check")[:2] == (0, [{"ok": True, "count": 2, "protocol": "1"}])
233
+
234
+
235
+ def test_changed_returns_the_notes_modified_or_moved_in_since(folder):
236
+ soon = int(time.time()) + 3600 # whole seconds: a fraction can be stored a few nanoseconds short
237
+ old = folder.write("old.md", "Old")
238
+ new = folder.write("new.md", "New")
239
+ os.utime(old, (1_700_000_000, 1_700_000_000)) # an old mtime, but its ctime is now: it was just put here
240
+ os.utime(new, (soon, soon))
241
+ now = datetime.fromtimestamp(time.time() - 60, timezone.utc).isoformat()
242
+ assert sorted(r["id"] for r in folder.call("changed", extra=["--since", now])[1]) == ["new.md", "old.md"]
243
+ later = datetime.fromtimestamp(time.time() + 60, timezone.utc).isoformat() # after the move, before the mtime of new
244
+ assert [r["id"] for r in folder.call("changed", extra=["--since", later])[1]] == ["new.md"]
245
+ exact = datetime.fromtimestamp(soon, timezone.utc).isoformat()
246
+ assert [r["id"] for r in folder.call("changed", extra=["--since", exact])[1]] == ["new.md"] # inclusive
247
+
248
+
249
+ # ---- settings ---------------------------------------------------------------------------------------
250
+
251
+ @pytest.mark.parametrize("settings,message", [
252
+ ({"FOLDER": ""}, "FOLDER"),
253
+ ({"FOLDER": "/no/such/folder"}, "FOLDER"),
254
+ ({"MAX_FILE_BYTES": "lots"}, "MAX_FILE_BYTES"),
255
+ ({"MAX_FILE_BYTES": "0"}, "MAX_FILE_BYTES"),
256
+ ])
257
+ def test_bad_settings_exit_2_with_a_message_and_no_output(folder, settings, message):
258
+ import io
259
+
260
+ from passageway_connector_markdown.connector import Markdown
261
+ out, err = io.StringIO(), io.StringIO()
262
+ env = {"FOLDER": str(folder.root), **settings}
263
+ assert Markdown.run(["markdown", "list"], env, io.StringIO(), out, err) == 2
264
+ assert out.getvalue() == "" and message in err.getvalue()
265
+
266
+
267
+ # ---- review findings ------------------------------------------------------------------------------
268
+
269
+ def test_deeply_nested_front_matter_warns_and_does_not_fail_the_source(folder):
270
+ folder.write("deep.md", "---\nx: " + "[" * 5000 + "]" * 5000 + "\n---\nBody")
271
+ folder.write("good.md", "Good")
272
+ code, records, err = folder.call("list")
273
+ assert code == 0 and sorted(r["id"] for r in records) == ["deep.md", "good.md"] and "deep.md" in err
274
+
275
+
276
+ @pytest.mark.parametrize("text", ["[" * 300_000, "[[" * 150_000, "[a](" * 75_000])
277
+ def test_a_pathological_line_does_not_make_link_finding_slow(folder, text):
278
+ folder.write("a.md", text)
279
+ started = time.monotonic()
280
+ assert folder.fetch("a.md")["a.md"]["id"] == "a.md"
281
+ assert time.monotonic() - started < 5
282
+
283
+
284
+ def test_the_version_changes_when_a_link_starts_to_resolve(folder):
285
+ folder.write("a.md", "see [[New Idea]] and [x](other.md)")
286
+ before = folder.call("list")[1][0]["version"]
287
+ folder.write("New Idea.md", "N")
288
+ folder.write("other.md", "O")
289
+ after = {r["id"]: r["version"] for r in folder.call("list")[1]}
290
+ assert after["a.md"] != before
291
+ assert folder.fetch("a.md")["a.md"]["links"] == ["New Idea.md", "other.md"]
292
+
293
+
294
+ def test_an_h1_that_is_not_the_title_stays_a_heading(folder):
295
+ folder.write("a.md", "---\ntitle: Front\n---\n# Intro\ntext\n## Setup\nx")
296
+ item = folder.fetch("a.md")["a.md"]
297
+ assert item["title"] == "Front"
298
+ assert [(s["heading"], s["text"]) for s in item["sections"]] == [("Intro", "text"), ("Intro › Setup", "x")]
299
+
300
+
301
+ @pytest.mark.skipif(os.geteuid() == 0, reason="root can read anything")
302
+ def test_an_unreadable_folder_is_reported_not_silently_dropped(folder):
303
+ folder.write("top.md", "T")
304
+ folder.write("locked/a.md", "A")
305
+ (folder.root / "locked").chmod(0)
306
+ try:
307
+ code, records, err = folder.call("list")
308
+ finally:
309
+ (folder.root / "locked").chmod(0o755)
310
+ assert code == 0 and [r["id"] for r in records] == ["top.md"] and "locked" in err
311
+
312
+
313
+ def test_a_wiki_link_with_a_folder_picks_the_right_one_of_two_same_named_notes(folder):
314
+ folder.write("a/Note.md", "A")
315
+ folder.write("b/Note.md", "B")
316
+ folder.write("n.md", "[[a/Note]] and [[b/Note|the other]] and [[Note]]")
317
+ assert folder.fetch("n.md")["n.md"]["links"] == ["a/Note.md", "b/Note.md"]
318
+
319
+
320
+ def test_a_link_target_in_angle_brackets_may_contain_spaces(folder):
321
+ folder.write("my note.md", "M")
322
+ folder.write("n.md", "[x](<my note.md>) and [y](<my note.md> \"title\")")
323
+ assert folder.fetch("n.md")["n.md"]["links"] == ["my note.md"]
324
+
325
+
326
+ def test_an_escaped_pipe_in_a_wiki_link_inside_a_table(folder):
327
+ folder.write("Setup.md", "S")
328
+ folder.write("n.md", "| a |\n|---|\n| [[Setup\\|the guide]] |")
329
+ assert folder.fetch("n.md")["n.md"]["links"] == ["Setup.md"]
330
+
331
+
332
+ def test_the_yaml_document_end_marker_closes_the_front_matter(folder):
333
+ folder.write("a.md", "---\ntitle: T\n...\nBody")
334
+ item = folder.fetch("a.md")["a.md"]
335
+ assert item["title"] == "T" and item["sections"][0]["text"] == "Body"
336
+
337
+
338
+ def test_an_h1_equal_to_the_front_matter_title_is_not_repeated_in_the_headings(folder):
339
+ folder.write("a.md", "---\ntitle: Rotating Certificates\n---\n# rotating certificates\nIntro.\n\n## Steps\nx")
340
+ item = folder.fetch("a.md")["a.md"]
341
+ assert [(s["heading"], s["text"]) for s in item["sections"]] == [(None, "Intro."), ("Steps", "x")]
@@ -0,0 +1,80 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import jsonschema
9
+
10
+ from passageway.protocol import Connector
11
+ from passageway.testing.contract import check_connector
12
+
13
+ README = Path(__file__).resolve().parents[1] / "README.md"
14
+ SCHEMA = Path(__file__).resolve().parents[3] / "docs" / "reference" / "connector-protocol.schema.json"
15
+
16
+
17
+ def notes(root):
18
+ (root / "guides").mkdir()
19
+ (root / "a.md").write_text("---\ntitle: Alpha\n---\n# Alpha\nAbout lighthouses.\n\n## Care\nPaint them. See [b](guides/b.md).")
20
+ (root / "guides" / "b.md").write_text("# Beta\nAbout harbours.")
21
+
22
+
23
+ def command():
24
+ return [sys.executable, "-m", "passageway_connector_markdown"]
25
+
26
+
27
+ def test_the_connector_passes_the_shared_contract_as_a_real_subprocess(tmp_path):
28
+ notes(tmp_path)
29
+ connector = Connector("markdown", command(), env={"FOLDER": str(tmp_path)})
30
+ check_connector(connector)
31
+ items = {i.id: i for i in connector.fetch(["a.md", "guides/b.md"])}
32
+ assert items["a.md"].links == ("guides/b.md",) and items["a.md"].title == "Alpha"
33
+
34
+
35
+ def test_non_ascii_survives_a_subprocess_whose_stdout_is_ascii(tmp_path):
36
+ (tmp_path / "a.md").write_text("# Köln Süd\n\nGröße und Maße.", encoding="utf-8")
37
+ connector = Connector("markdown", command(), env={"FOLDER": str(tmp_path), "PYTHONIOENCODING": "ascii"})
38
+ item = connector.fetch(["a.md"])[0]
39
+ assert item.title == "Köln Süd" and item.sections[0].text == "Größe und Maße."
40
+
41
+
42
+ def test_the_output_validates_against_the_published_json_schema(tmp_path):
43
+ notes(tmp_path)
44
+ schema = json.loads(SCHEMA.read_text())
45
+
46
+ def valid(name, record):
47
+ return jsonschema.Draft202012Validator({"$ref": f"#/$defs/{name}", "$defs": schema["$defs"]}).is_valid(record)
48
+
49
+ def lines(mode, stdin=""):
50
+ out = subprocess.run(command() + [mode], env={**os.environ, "FOLDER": str(tmp_path)}, input=stdin,
51
+ capture_output=True, text=True, check=True).stdout
52
+ return [json.loads(line) for line in out.splitlines()]
53
+
54
+ assert lines("check") and all(valid("check", r) for r in lines("check"))
55
+ assert lines("list") and all(valid("listRecord", r) for r in lines("list"))
56
+ items = lines("fetch", '{"id": "a.md"}\n{"id": "guides/b.md"}\n')
57
+ assert len(items) == 2 and all(valid("item", r) for r in items)
58
+
59
+
60
+ def test_every_setting_is_in_the_readme():
61
+ code = (Path(__file__).resolve().parents[1] / "passageway_connector_markdown" / "connector.py").read_text()
62
+ names = sorted(set(re.findall(r'(?:env\.get|names|whole)\(\s*(?:env,\s*)?"([A-Z_]+)"', code)))
63
+ assert len(names) >= 4, names # the pattern must actually find the settings
64
+ readme = README.read_text()
65
+ for name in names:
66
+ assert f"`{name}`" in readme, f"{name} is read by the markdown connector but missing from its README"
67
+
68
+
69
+ def test_the_example_in_the_readme_is_what_the_connector_really_produces(tmp_path):
70
+ readme = README.read_text()
71
+ note = re.search(r"```markdown\n(.*?)```", readme, re.S).group(1)
72
+ shown = json.loads(re.search(r"```json\n(.*?)```", readme, re.S).group(1))
73
+ (tmp_path / "ops").mkdir()
74
+ (tmp_path / "ops" / "rotating-certificates.md").write_text(note)
75
+ (tmp_path / "ops" / "deploy.md").write_text("# Deploy\nHow to deploy.")
76
+ out = subprocess.run(command() + ["fetch"], env={**os.environ, "FOLDER": str(tmp_path)},
77
+ input='{"id": "ops/rotating-certificates.md"}\n', capture_output=True, text=True, check=True).stdout
78
+ real = json.loads(out)
79
+ for key, value in shown.items():
80
+ assert real[key] == value, f"the README shows {key} = {value!r} but the connector prints {real[key]!r}"