dgconvert 0.1.0__py3-none-any.whl
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.
- dgconvert/__init__.py +5 -0
- dgconvert/body_snatchers.py +39 -0
- dgconvert/dgpkm.py +62 -0
- dgconvert/frontmatter.py +121 -0
- dgconvert/incremental.py +117 -0
- dgconvert/links.py +59 -0
- dgconvert/slugs.py +20 -0
- dgconvert-0.1.0.dist-info/METADATA +63 -0
- dgconvert-0.1.0.dist-info/RECORD +11 -0
- dgconvert-0.1.0.dist-info/WHEEL +4 -0
- dgconvert-0.1.0.dist-info/licenses/LICENSE +21 -0
dgconvert/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Body-snatcher (find/replace) content transforms, sourced from a
|
|
2
|
+
seed's own docufig.seed.yaml.
|
|
3
|
+
|
|
4
|
+
`bodySnatchers` is a required field in dgpkm's own seed config JSON
|
|
5
|
+
Schema (validated by `dgpkm doctor schema seed`), not a decorative
|
|
6
|
+
one -- so it's read directly from that already-schema-validated
|
|
7
|
+
manifest rather than requiring a seed's own Python code to keep a
|
|
8
|
+
second, hand-maintained copy in sync (a real, seen-in-practice failure
|
|
9
|
+
mode: a seed's manifest drifting out of sync with its own Python-side
|
|
10
|
+
transform table after a code-only change, caught only by a direct
|
|
11
|
+
question, not by any tooling).
|
|
12
|
+
|
|
13
|
+
Generalized out of two seeds' identical apply_body_snatchers once a
|
|
14
|
+
second seed needed the exact same shape a first one already had --
|
|
15
|
+
the same "second consumer" trigger this library's other modules were
|
|
16
|
+
each generalized under.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load(seed_config_path: Path) -> tuple[tuple[str, str], ...]:
|
|
27
|
+
"""Reads bodySnatchers from a seed's docufig.seed.yaml. Each entry's
|
|
28
|
+
{find, replace} shape matches dgpkm's own seed config JSON Schema
|
|
29
|
+
exactly. A missing bodySnatchers key (defensive -- the schema
|
|
30
|
+
requires it, but this shouldn't hard-fail a caller that hasn't
|
|
31
|
+
validated yet) behaves like an empty list."""
|
|
32
|
+
data = yaml.safe_load(seed_config_path.read_text(encoding="utf-8")) or {}
|
|
33
|
+
return tuple((s["find"], s["replace"]) for s in data.get("bodySnatchers", []))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def apply(text: str, snatchers: tuple[tuple[str, str], ...]) -> str:
|
|
37
|
+
for find, replace in snatchers:
|
|
38
|
+
text = text.replace(find, replace)
|
|
39
|
+
return text
|
dgconvert/dgpkm.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Wraps the `dgpkm` binary for verification.
|
|
2
|
+
|
|
3
|
+
`dgpkm doctor backlinks` and `dgpkm doctor schema <type>` always exit 0,
|
|
4
|
+
even when they report findings -- findings are only visible in
|
|
5
|
+
stdout/stderr text ("Found N broken link(s)", "✗ ..."). This module
|
|
6
|
+
parses that text so the converter can fail loudly (non-zero exit) when a
|
|
7
|
+
conversion isn't doctor-clean, which dgpkm itself won't do for you.
|
|
8
|
+
|
|
9
|
+
Generalized beyond its original prototype in one way: `verify`'s schema
|
|
10
|
+
check is now optional (`check_schema=None` skips it) since not every
|
|
11
|
+
consumer has a `docufig.seed.json` to validate -- a standalone
|
|
12
|
+
converter tool can write straight into a greenhouse's root garden,
|
|
13
|
+
not a separate seed.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def dgpkm_bin() -> str:
|
|
24
|
+
return os.environ.get("DGPKM_BIN", "dgpkm")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _run(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
|
|
28
|
+
return subprocess.run(
|
|
29
|
+
[dgpkm_bin(), *args],
|
|
30
|
+
cwd=cwd,
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
check=False,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def verify(root: Path, *, check_schema: str | None = "seed") -> list[str]:
|
|
38
|
+
"""Run dgpkm doctor checks against the garden at `root`. Returns a list
|
|
39
|
+
of human-readable problem descriptions (empty = clean).
|
|
40
|
+
|
|
41
|
+
`check_schema` names the config type to pass to `dgpkm doctor schema
|
|
42
|
+
<type>` (e.g. "seed" for a `docufig.seed.json`); pass None to skip
|
|
43
|
+
that check entirely for a tool that has no such config file.
|
|
44
|
+
"""
|
|
45
|
+
problems: list[str] = []
|
|
46
|
+
|
|
47
|
+
backlinks = _run(["doctor", "backlinks"], root)
|
|
48
|
+
if backlinks.returncode != 0:
|
|
49
|
+
problems.append(f"dgpkm doctor backlinks failed to run:\n{backlinks.stderr}")
|
|
50
|
+
elif "All backlinks are valid" not in backlinks.stdout:
|
|
51
|
+
problems.append(f"backlinks: broken links found\n{backlinks.stdout}")
|
|
52
|
+
|
|
53
|
+
if check_schema:
|
|
54
|
+
schema = _run(["doctor", "schema", check_schema], root)
|
|
55
|
+
if schema.returncode != 0:
|
|
56
|
+
problems.append(f"dgpkm doctor schema {check_schema} failed to run:\n{schema.stderr}")
|
|
57
|
+
elif "✓" not in schema.stdout or "✗" in schema.stderr:
|
|
58
|
+
problems.append(
|
|
59
|
+
f"{check_schema} config schema invalid:\n{schema.stdout}{schema.stderr}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return problems
|
dgconvert/frontmatter.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Single-line frontmatter emission, and a naive line-based parser kept
|
|
2
|
+
only for reading back this module's own prior output.
|
|
3
|
+
|
|
4
|
+
Historically dgpkm's frontmatter reader was itself naive -- a raw
|
|
5
|
+
`strings.SplitN(content, "---", 3)` plus a
|
|
6
|
+
per-line `key.partition(":")` scanner -- so this emitter avoided a real
|
|
7
|
+
YAML library entirely and just sanitized every value to a single line.
|
|
8
|
+
dgpkm's reader is now real YAML both directions
|
|
9
|
+
(`go.yaml.in/yaml/v4`, a line-anchored frontmatter split), which
|
|
10
|
+
surfaced a real bug here: an *unquoted* value that happens to look like
|
|
11
|
+
another YAML type (`title: 2022-09-09` parses as a date, not a string;
|
|
12
|
+
`desc: A | B: C` breaks the scanner outright on the embedded `:`) was
|
|
13
|
+
silently wrong. `emit_note` now runs every known-field value through
|
|
14
|
+
`yaml.safe_dump` (preserving `int`s as real YAML integers so
|
|
15
|
+
`updated`/`created` still round-trip as numbers) so quoting is decided
|
|
16
|
+
correctly by the same library dgpkm itself now uses, while keeping
|
|
17
|
+
`sanitize()`'s single-line collapse and `---`-neutralization as a
|
|
18
|
+
defense-in-depth measure. `parse_note` below stays naive -- it only
|
|
19
|
+
reads back `id`/`created`/`sourceFileHash` for incremental hash-checking
|
|
20
|
+
(dgconvert.incremental), and none of those three fields are ever
|
|
21
|
+
ambiguous enough for `yaml.safe_dump` to quote in practice (hex digests,
|
|
22
|
+
real ints, and Dendron/TGDP's actual id formats) -- but a value in that
|
|
23
|
+
position that *did* need quoting would round-trip with the quote
|
|
24
|
+
characters still literally in it here.
|
|
25
|
+
|
|
26
|
+
Generalized out of a first seed's own emitter once a second, independent
|
|
27
|
+
consumer needed the same thing.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import yaml
|
|
33
|
+
|
|
34
|
+
KEY_ORDER = (
|
|
35
|
+
"id",
|
|
36
|
+
"title",
|
|
37
|
+
"desc",
|
|
38
|
+
"canonicalUrl",
|
|
39
|
+
"license",
|
|
40
|
+
"sourceFileHash",
|
|
41
|
+
"sourceFileUrl",
|
|
42
|
+
"sourceRelease",
|
|
43
|
+
"sourceContributors",
|
|
44
|
+
"generator",
|
|
45
|
+
"frontmatterVersion",
|
|
46
|
+
"updated",
|
|
47
|
+
"created",
|
|
48
|
+
# dgpkm's own native "status" field, for a source format whose items
|
|
49
|
+
# have a task-like status to map onto it (e.g. an imported vault's
|
|
50
|
+
# own task notes). dgpkm's other two optional native fields, "type"
|
|
51
|
+
# (a YAML list) and "alias", aren't added here: no current caller
|
|
52
|
+
# sets them, and "type"'s list value would need emit_note's
|
|
53
|
+
# single-line-scalar sanitize() to grow flow-sequence handling first
|
|
54
|
+
# -- add both once a real caller needs them, not speculatively.
|
|
55
|
+
"status",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def sanitize(value: object) -> str:
|
|
60
|
+
"""Collapse to a single line; neutralize dgpkm's frontmatter delimiter."""
|
|
61
|
+
text = " ".join(str(value).split())
|
|
62
|
+
return text.replace("---", "—")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _dump_line(key: str, value: object) -> str:
|
|
66
|
+
return yaml.safe_dump(
|
|
67
|
+
{key: value}, default_flow_style=False, sort_keys=False, allow_unicode=True
|
|
68
|
+
).rstrip("\n")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def emit_note(
|
|
72
|
+
frontmatter: dict[str, object], body: str, extra: dict[str, object] | None = None
|
|
73
|
+
) -> str:
|
|
74
|
+
"""Emits a dgpkm-compatible note. `frontmatter`'s keys are limited to
|
|
75
|
+
KEY_ORDER's known fields. String values are single-line-collapsed and
|
|
76
|
+
`---`-neutralized via `sanitize()` first; non-string values (e.g. the
|
|
77
|
+
`updated`/`created` ints) pass through unchanged so they stay real
|
|
78
|
+
YAML numbers. Either way, the final line is produced by
|
|
79
|
+
`yaml.safe_dump`, so a value that happens to look like another YAML
|
|
80
|
+
type (a bare date, a string containing an unescaped `:`) is quoted
|
|
81
|
+
correctly instead of corrupting the frontmatter block.
|
|
82
|
+
|
|
83
|
+
`extra` carries arbitrary passthrough keys beyond KEY_ORDER -- e.g. a
|
|
84
|
+
source format's own per-note provenance/task fields dgpkm doesn't
|
|
85
|
+
model natively. Extra values may additionally be nested (dicts/lists)
|
|
86
|
+
-- dgpkm's frontmatter reader is real YAML both directions
|
|
87
|
+
(go.yaml.in/yaml/v4). Keys are sorted for deterministic output,
|
|
88
|
+
matching how dgpkm's own writer orders its equivalent `Extra` map.
|
|
89
|
+
"""
|
|
90
|
+
lines = ["---"]
|
|
91
|
+
for key in KEY_ORDER:
|
|
92
|
+
if key in frontmatter and frontmatter[key] not in (None, ""):
|
|
93
|
+
value = frontmatter[key]
|
|
94
|
+
if isinstance(value, str):
|
|
95
|
+
value = sanitize(value)
|
|
96
|
+
lines.append(_dump_line(key, value))
|
|
97
|
+
if extra:
|
|
98
|
+
for key in sorted(extra):
|
|
99
|
+
value = extra[key]
|
|
100
|
+
if value is None or value == "":
|
|
101
|
+
continue
|
|
102
|
+
lines.append(_dump_line(key, value))
|
|
103
|
+
lines.append("---")
|
|
104
|
+
return "\n".join(lines) + "\n\n" + body.rstrip("\n") + "\n"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_note(text: str) -> tuple[dict[str, str], str]:
|
|
108
|
+
"""Mirror dgpkm's parseNote: SplitN(content, "---", 3) then `key: value`
|
|
109
|
+
lines split on the first colon."""
|
|
110
|
+
parts = text.split("---", 2)
|
|
111
|
+
if len(parts) < 3:
|
|
112
|
+
raise ValueError("missing frontmatter (no '---' delimited block)")
|
|
113
|
+
_, fm_text, body = parts
|
|
114
|
+
frontmatter: dict[str, str] = {}
|
|
115
|
+
for line in fm_text.splitlines():
|
|
116
|
+
if not line.strip():
|
|
117
|
+
continue
|
|
118
|
+
key, sep, value = line.partition(":")
|
|
119
|
+
if sep:
|
|
120
|
+
frontmatter[key.strip()] = value.strip()
|
|
121
|
+
return frontmatter, body.strip()
|
dgconvert/incremental.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Generic incremental-conversion driver.
|
|
2
|
+
|
|
3
|
+
Generalized out of a first seed's own conversion loop once a second,
|
|
4
|
+
independent consumer needed the same create/skip/update/delete shape.
|
|
5
|
+
|
|
6
|
+
State lives in each note's own frontmatter (a `sourceFileHash` key), not a
|
|
7
|
+
separate cache file: unchanged hash -> skip entirely (byte-identical file
|
|
8
|
+
untouched); changed hash -> rewrite in place (same id, same created,
|
|
9
|
+
bumped updated); source gone -> delete the note and report it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Callable, Iterable
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from . import frontmatter as fm
|
|
19
|
+
|
|
20
|
+
# Called only for a new-or-changed item (never for an unchanged skip).
|
|
21
|
+
# Receives the previous note's id/created if one existed (None for a
|
|
22
|
+
# brand-new note) so a rewritten note keeps its identity. Returns
|
|
23
|
+
# (frontmatter, body, extra); frontmatter may set "created"/"updated" as
|
|
24
|
+
# this item's source-derived defaults -- convert() only overrides
|
|
25
|
+
# "created" when an old value exists, and always overrides "id" (when an
|
|
26
|
+
# old value exists) and "sourceFileHash".
|
|
27
|
+
BuildFn = Callable[
|
|
28
|
+
["str | None", "int | None"], "tuple[dict[str, object], str, dict[str, object] | None]"
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class ConvertItem:
|
|
34
|
+
slug: str
|
|
35
|
+
source_hash: str
|
|
36
|
+
build: BuildFn
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ConvertResult:
|
|
41
|
+
counts: dict[str, int] = field(
|
|
42
|
+
default_factory=lambda: {"created": 0, "updated": 0, "unchanged": 0, "deleted": 0}
|
|
43
|
+
)
|
|
44
|
+
deleted: list[str] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def read_existing(notes_dir: Path, glob_patterns: Iterable[str]) -> dict[str, dict[str, str]]:
|
|
48
|
+
"""Reads back every previously-emitted note matching glob_patterns,
|
|
49
|
+
keyed by slug (filename stem) -> its parsed frontmatter dict."""
|
|
50
|
+
existing: dict[str, dict[str, str]] = {}
|
|
51
|
+
if notes_dir.is_dir():
|
|
52
|
+
for pattern in glob_patterns:
|
|
53
|
+
for path in notes_dir.glob(pattern):
|
|
54
|
+
frontmatter, _ = fm.parse_note(path.read_text(encoding="utf-8"))
|
|
55
|
+
existing[path.stem] = frontmatter
|
|
56
|
+
return existing
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def convert(
|
|
60
|
+
notes_dir: Path,
|
|
61
|
+
items: Iterable[ConvertItem],
|
|
62
|
+
existing: dict[str, dict[str, str]],
|
|
63
|
+
*,
|
|
64
|
+
now_ms: int,
|
|
65
|
+
stable_id: Callable[[str], str],
|
|
66
|
+
) -> ConvertResult:
|
|
67
|
+
"""Runs the create/skip/update/delete loop, writing/deleting files
|
|
68
|
+
under `notes_dir`. `existing` is `read_existing`'s output -- passed in
|
|
69
|
+
rather than read internally so callers can glob exactly the slugs
|
|
70
|
+
their own hierarchy owns."""
|
|
71
|
+
result = ConvertResult()
|
|
72
|
+
desired_slugs: set[str] = set()
|
|
73
|
+
notes_dir.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
|
|
75
|
+
for item in items:
|
|
76
|
+
desired_slugs.add(item.slug)
|
|
77
|
+
old = existing.get(item.slug)
|
|
78
|
+
|
|
79
|
+
if old and old.get("sourceFileHash") == item.source_hash:
|
|
80
|
+
result.counts["unchanged"] += 1
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
# Only trust `old` for identity (id/created) if it actually carries
|
|
84
|
+
# a sourceFileHash -- i.e. it was itself written by a prior run of
|
|
85
|
+
# *this* incremental pipeline, not some unrelated pre-existing note
|
|
86
|
+
# that happens to occupy the same slug (e.g. `dgpkm init`'s own
|
|
87
|
+
# bootstrap notes/root.md, before any converter has ever run).
|
|
88
|
+
# Otherwise a foreign note's id/created would get silently adopted
|
|
89
|
+
# as if it were this item's own prior state.
|
|
90
|
+
own_old = old if (old and old.get("sourceFileHash")) else None
|
|
91
|
+
old_id = (own_old or {}).get("id") or None
|
|
92
|
+
old_created_raw = (own_old or {}).get("created")
|
|
93
|
+
old_created = int(old_created_raw) if old_created_raw else None
|
|
94
|
+
|
|
95
|
+
built_fm, body, extra = item.build(old_id, old_created)
|
|
96
|
+
|
|
97
|
+
note_id = old_id or built_fm.get("id") or stable_id(item.slug)
|
|
98
|
+
created = old_created or built_fm.get("created") or now_ms
|
|
99
|
+
merged_fm: dict[str, object] = {
|
|
100
|
+
**built_fm,
|
|
101
|
+
"id": note_id,
|
|
102
|
+
"created": created,
|
|
103
|
+
"updated": built_fm.get("updated", now_ms),
|
|
104
|
+
"sourceFileHash": item.source_hash,
|
|
105
|
+
}
|
|
106
|
+
(notes_dir / f"{item.slug}.md").write_text(
|
|
107
|
+
fm.emit_note(merged_fm, body, extra=extra), encoding="utf-8"
|
|
108
|
+
)
|
|
109
|
+
result.counts["updated" if old else "created"] += 1
|
|
110
|
+
|
|
111
|
+
deleted = sorted(set(existing) - desired_slugs)
|
|
112
|
+
for slug in deleted:
|
|
113
|
+
(notes_dir / f"{slug}.md").unlink()
|
|
114
|
+
result.counts["deleted"] += 1
|
|
115
|
+
result.deleted = deleted
|
|
116
|
+
|
|
117
|
+
return result
|
dgconvert/links.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Markdown link scanning and rewriting.
|
|
2
|
+
|
|
3
|
+
dgpkm's markdown-link extractor is
|
|
4
|
+
`\\]\\(\\.?\\/?([^#\\)]+)\\.md(#[^\\)]*)?\\)` -- it matches ANY `](....md)`
|
|
5
|
+
or `](....md#anchor)`, including full http(s) URLs, and treats the captured
|
|
6
|
+
path as a note slug to validate. `doctor backlinks` then reports it broken
|
|
7
|
+
if no note has that exact "slug". A link to unconverted or external content
|
|
8
|
+
that happens to bare-end in ".md" must therefore carry a query string --
|
|
9
|
+
appending one defeats the regex, since it requires ".md" to be immediately
|
|
10
|
+
followed by "#" or the closing paren. This is confirmed necessary against
|
|
11
|
+
real converted content: some source formats reference other projects'
|
|
12
|
+
CHANGELOG.md/README.md by full https URL with no query string.
|
|
13
|
+
|
|
14
|
+
Generalized out of a first seed's own link-rewriting module, unmodified,
|
|
15
|
+
once a second, independent consumer needed the same thing.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
|
|
23
|
+
LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)\s]+)\)")
|
|
24
|
+
_BARE_MD_RE = re.compile(r"\.md(#[^?]*)?$")
|
|
25
|
+
|
|
26
|
+
Resolver = Callable[[str, str], "str | None"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def defeat_md_regex(href: str) -> str:
|
|
30
|
+
"""Append a harmless query string if href bare-ends in .md or .md#anchor."""
|
|
31
|
+
if "?" in href:
|
|
32
|
+
return href
|
|
33
|
+
match = _BARE_MD_RE.search(href)
|
|
34
|
+
if not match:
|
|
35
|
+
return href
|
|
36
|
+
anchor = match.group(1) or ""
|
|
37
|
+
base = href[: match.start()]
|
|
38
|
+
return f"{base}.md?ref_type=tags{anchor}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def rewrite_links(body: str, resolve: Resolver) -> str:
|
|
42
|
+
"""resolve(text, href) -> replacement markdown-link string, or None to
|
|
43
|
+
leave the original link untouched (beyond the generic http(s) fixup
|
|
44
|
+
below)."""
|
|
45
|
+
|
|
46
|
+
def _sub(match: re.Match[str]) -> str:
|
|
47
|
+
text, href = match.group(1), match.group(2)
|
|
48
|
+
if href.startswith(("mailto:", "#")):
|
|
49
|
+
return match.group(0)
|
|
50
|
+
replacement = resolve(text, href)
|
|
51
|
+
if replacement is not None:
|
|
52
|
+
return replacement
|
|
53
|
+
if href.startswith(("http://", "https://")):
|
|
54
|
+
fixed = defeat_md_regex(href)
|
|
55
|
+
if fixed != href:
|
|
56
|
+
return f"[{text}]({fixed})"
|
|
57
|
+
return match.group(0)
|
|
58
|
+
|
|
59
|
+
return LINK_RE.sub(_sub, body)
|
dgconvert/slugs.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Slug joining and dgpkm link builders (alias-after only -- dgpkm cannot
|
|
2
|
+
parse Dendron's alias-first on-disk form).
|
|
3
|
+
|
|
4
|
+
Generalized out of a first seed's own slug module, unmodified, once a
|
|
5
|
+
second, independent consumer needed the same thing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def slug(*parts: str) -> str:
|
|
12
|
+
return ".".join(p.strip(".") for p in parts if p)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def wikilink(target_slug: str, anchor: str | None = None, alias: str | None = None) -> str:
|
|
16
|
+
frag = f"#{anchor}" if anchor else ""
|
|
17
|
+
if alias and alias != target_slug:
|
|
18
|
+
safe_alias = alias.replace("|", "/").replace("]]", ")")
|
|
19
|
+
return f"[[{target_slug}{frag}|{safe_alias}]]"
|
|
20
|
+
return f"[[{target_slug}{frag}]]"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dgconvert
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Helper library for building dgpkm-compatible seeds (frontmatter, slugs, link rewriting, dgpkm doctor verification)
|
|
5
|
+
Project-URL: Homepage, https://github.com/ScriptAutomate/dgconvert
|
|
6
|
+
Project-URL: Repository, https://github.com/ScriptAutomate/dgconvert
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Requires-Python: >=3.12
|
|
15
|
+
Requires-Dist: pyyaml>=6
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# dgconvert
|
|
19
|
+
|
|
20
|
+
A small Python **helper library** for building
|
|
21
|
+
[dgpkm](https://github.com/ScriptAutomate/dgpkm)-compatible **seeds**
|
|
22
|
+
(pre-created gardens, ready to plant into a greenhouse).
|
|
23
|
+
|
|
24
|
+
`dgconvert` is not a task runner. Each seed repo owns its own
|
|
25
|
+
`docufig.seed.json` and its own plain-Python conversion script;
|
|
26
|
+
`dgconvert` supplies the pieces every seed needs regardless of source
|
|
27
|
+
format — dotted-slug file naming, single-line frontmatter with stable
|
|
28
|
+
ids, markdown-link → dgpkm-wikilink rewriting, and a wrapper that
|
|
29
|
+
verifies output with `dgpkm doctor`.
|
|
30
|
+
|
|
31
|
+
See `DESIGN.md` for the full architecture and the verified
|
|
32
|
+
dgpkm-compatibility constraints it's built against. The library was
|
|
33
|
+
generalized out of a first seed's own inline conversion code once a
|
|
34
|
+
second, independent seed needed the same pieces.
|
|
35
|
+
|
|
36
|
+
## Installing
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv add dgconvert
|
|
40
|
+
# or
|
|
41
|
+
pip install dgconvert
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Development
|
|
45
|
+
|
|
46
|
+
Managed with [uv](https://docs.astral.sh/uv/) and [just](https://just.systems/),
|
|
47
|
+
using [Spec Kit](https://github.com/github/spec-kit) for feature specs
|
|
48
|
+
(`specs/`, `.specify/`) — unlike seed repos, which don't use Spec Kit:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
just # list tasks
|
|
52
|
+
just sync # uv sync
|
|
53
|
+
just test # uv run pytest
|
|
54
|
+
just check # format + lint
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Container-based development mirrors dgpkm: set `USE_CONTAINER_DEV` to
|
|
58
|
+
`podman`, `docker`, or `container`, then `just build-devcontainer` once and
|
|
59
|
+
every build/test recipe runs inside the dev container automatically.
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
dgconvert/__init__.py,sha256=rNzJg-RSjn_T-XVkP_9eXzgWl7caxwe68a86cBUH62U,163
|
|
2
|
+
dgconvert/body_snatchers.py,sha256=U3MovEeprdSqK5bLm9bhkfhhetVhHsSJnOdz490z9Ps,1629
|
|
3
|
+
dgconvert/dgpkm.py,sha256=VhhgqcKwHVZU5xJROlXKRXqfL4wUCpB1vOU-muYj9v0,2291
|
|
4
|
+
dgconvert/frontmatter.py,sha256=DPoscMdLVVgUVEQuFHAkKLKN6gmqmkC-bsUql18HduI,5059
|
|
5
|
+
dgconvert/incremental.py,sha256=1eOA0WGAzf-iZzWaU6yL-jVC2jxMTlxCgmDarJY3BVQ,4474
|
|
6
|
+
dgconvert/links.py,sha256=-eRHr-CIvZAF7hSF-b2J-XpZTWFuJGV9t_WqqsR4wAg,2189
|
|
7
|
+
dgconvert/slugs.py,sha256=QcSFkrVR80vzPrl7gOMX7sAwSWBgZD1XIRln6BS5qVc,698
|
|
8
|
+
dgconvert-0.1.0.dist-info/METADATA,sha256=gYX4jOXCvX4Ax-BVs95AowkIiQ0OltTXlzV4hebEB_M,2190
|
|
9
|
+
dgconvert-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
10
|
+
dgconvert-0.1.0.dist-info/licenses/LICENSE,sha256=iVk1IgRYica_g42kpWgHz9xFsghhc2_DjNyonzLWpMI,1071
|
|
11
|
+
dgconvert-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ScriptAutomate
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|