Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 6 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
name: CI
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

jobs:
test:
Expand All @@ -14,14 +15,8 @@ jobs:
with:
submodules: recursive

- name: Install uv
uses: astral-sh/setup-uv@v5
- uses: astral-sh/setup-uv@v6

- name: Install Python
run: uv python install 3.12

- name: Install dependencies
run: uv sync

- name: Run tests
run: uv run pytest -v
- run: uv run ruff check
- run: uv run ruff format --check
- run: uv run pytest
8 changes: 6 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
__pycache__/
*.pyc
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
dist/
build/
*.egg-info/
7 changes: 1 addition & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,7 @@ Python implementation of Linked Markdown, packaged for PyPI.
## API

```python
from linked_markdown.extract import extract
from linked_markdown.errors import (
LinkedMarkdownError,
LMD_NO_FRONTMATTER,
LMD_INVALID_FRONTMATTER,
)
from linked_markdown import extract

result = extract(markdown)
# => ExtractResult(front_matter="...", body="...", attrs={...})
Expand Down
12 changes: 12 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,20 @@ Tracker = "https://github.com/wazootech/linked-markdown-py/issues"
[dependency-groups]
dev = [
"pytest>=8.0",
"rdflib>=7.0.0",
"ruff>=0.11.0",
]

[build-system]
requires = ["uv_build>=0.11.19,<0.12.0"]
build-backend = "uv_build"

[tool.ruff]
line-length = 100
target-version = "py312"

[tool.ruff.lint]
select = ["E", "F", "I", "N", "W"]

[tool.pytest.ini_options]
testpaths = ["tests"]
2 changes: 1 addition & 1 deletion src/linked_markdown/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .errors import LinkedMarkdownError, LMD_INVALID_FRONTMATTER, LMD_NO_FRONTMATTER
from .errors import LMD_INVALID_FRONTMATTER, LMD_NO_FRONTMATTER, LinkedMarkdownError
from .extract import ExtractResult, extract

__all__ = [
Expand Down
8 changes: 4 additions & 4 deletions src/linked_markdown/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import yaml

from .errors import LinkedMarkdownError, LMD_INVALID_FRONTMATTER, LMD_NO_FRONTMATTER
from .errors import LMD_INVALID_FRONTMATTER, LMD_NO_FRONTMATTER, LinkedMarkdownError

T = TypeVar("T")

Expand All @@ -22,8 +22,8 @@ class ExtractResult(Generic[T]):
_OPENER_RE = re.compile(
r"^(?:"
r"= yaml =|= json =|= toml =|" # equals delimiters
r"---yaml|---json|---toml|" # explicit markers
r"---|\+\+\+" # unmarked dash and plus
r"---yaml|---json|---toml|" # explicit markers
r"---|\+\+\+" # unmarked dash and plus
r")",
re.MULTILINE,
)
Expand Down Expand Up @@ -82,7 +82,7 @@ def _split_front_matter(
return format_, raw, body.lstrip("\n")
return None

raw = rest[:closer_idx + 1].lstrip("\n")
raw = rest[: closer_idx + 1].lstrip("\n")
body = rest[closer_idx + len(closer) + 1 :]
return format_, raw, body.lstrip("\n")

Expand Down
105 changes: 105 additions & 0 deletions src/linked_markdown/parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import json
import re
import tomllib
from typing import Any

import yaml

VALID_MARKERS = frozenset({"yaml", "json", "toml"})


class LinkedMarkdownError(Exception):
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code


def extract(content: str) -> dict[str, Any]:
content = content.replace("\ufeff", "").replace("\r\n", "\n")

_check_unknown_markers(content)

has_opener = bool(re.match(r"^(---|\+\+\+(?:\n|$)|= \w+ =(?:\n|$))", content))
if not has_opener:
raise LinkedMarkdownError(
"LMD_NO_FRONTMATTER", "Expected frontmatter at the start of the document."
)

patterns: list[tuple[str, str, str]] = [
(r"^---yaml\n", r"\n---", "yaml"),
(r"^---json\n", r"\n---", "json"),
(r"^---toml\n", r"\n---", "toml"),
(r"^\+\+\+\n", r"\n\+\+\+", "toml"),
(r"^= yaml =\n", r"\n= yaml =", "yaml"),
(r"^= json =\n", r"\n= json =", "json"),
(r"^= toml =\n", r"\n= toml =", "toml"),
]

for opener_pat, closer_pat, fmt in patterns:
opener_match = re.match(opener_pat, content)
if opener_match:
opener_len = len(opener_match.group(0))
remaining = content[opener_len:]
end_match = re.search(closer_pat, remaining)
if end_match is None:
raise LinkedMarkdownError(
"LMD_INVALID_FRONTMATTER", "Expected closing frontmatter delimiter."
)
raw_fm = remaining[: end_match.start()]
body = remaining[end_match.end() :].lstrip("\n")
return _build_extract_result(raw_fm, body, fmt)

if content.startswith("---\n"):
end = content.find("\n---", 3)
if end == -1:
raise LinkedMarkdownError(
"LMD_INVALID_FRONTMATTER", "Expected closing frontmatter delimiter."
)
raw_fm = content[4:end]
body = content[end + 4 :].lstrip("\n")
fmt = "json" if raw_fm.strip().startswith(("{", "[")) else "yaml"
return _build_extract_result(raw_fm, body, fmt)

raise LinkedMarkdownError(
"LMD_INVALID_FRONTMATTER", "Unrecognized frontmatter delimiter pattern."
)


def _check_unknown_markers(content: str) -> None:
marker_match = re.match(r"^---(\w+)", content)
if marker_match:
marker = marker_match.group(1).lower()
if marker and marker not in VALID_MARKERS:
raise LinkedMarkdownError(
"LMD_INVALID_FRONTMATTER", f"Unknown front matter marker: ---{marker}"
)

equals_match = re.match(r"^= (\w+) =", content)
if equals_match:
marker = equals_match.group(1).lower()
if marker not in VALID_MARKERS:
raise LinkedMarkdownError(
"LMD_INVALID_FRONTMATTER", f"Unknown front matter marker: = {marker} ="
)


def _build_extract_result(raw_fm: str, body: str, fmt: str) -> dict[str, Any]:
try:
if fmt == "json":
attrs = json.loads(raw_fm) if raw_fm.strip() else {}
elif fmt == "toml":
attrs = tomllib.loads(raw_fm) if raw_fm.strip() else {}
else:
attrs = yaml.safe_load(raw_fm) if raw_fm.strip() else {}
except (json.JSONDecodeError, tomllib.TOMLDecodeError, yaml.YAMLError) as e:
raise LinkedMarkdownError(
"LMD_INVALID_FRONTMATTER", f"Invalid {fmt} frontmatter: {e}"
) from e

if attrs is None:
attrs = {}
if not isinstance(attrs, dict):
raise LinkedMarkdownError("LMD_INVALID_FRONTMATTER", "Frontmatter must parse to an object.")

front_matter = raw_fm + "\n" if raw_fm else ""
return {"attrs": attrs, "frontMatter": front_matter, "body": body}
2 changes: 1 addition & 1 deletion test/linked-markdown-spec
2 changes: 1 addition & 1 deletion tests/test_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
from pathlib import Path

import pytest
from linked_markdown.errors import LinkedMarkdownError

from linked_markdown.errors import LinkedMarkdownError
from linked_markdown.extract import extract


Expand Down
2 changes: 1 addition & 1 deletion tests/test_extract.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import pytest
from linked_markdown.errors import LinkedMarkdownError, LMD_INVALID_FRONTMATTER, LMD_NO_FRONTMATTER

from linked_markdown.errors import LMD_INVALID_FRONTMATTER, LMD_NO_FRONTMATTER, LinkedMarkdownError
from linked_markdown.extract import extract


Expand Down
69 changes: 69 additions & 0 deletions tests/test_rdf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import json
from pathlib import Path

import rdflib
from rdflib import RDF, Literal, URIRef

from linked_markdown import extract

CONFORMANCE_ROOT = Path(__file__).parent.parent / "test" / "linked-markdown-spec" / "conformance"
SCHEMA = rdflib.Namespace("https://schema.org/")


def _read_fixture(path: str) -> str:
return (CONFORMANCE_ROOT / path).read_text(encoding="utf-8")


def test_valid_yaml_marker_loads_into_rdflib():
input_text = _read_fixture("cases/valid-yaml-marker/input.md")
result = extract(input_text)

g = rdflib.Graph()
g.parse(data=json.dumps(result.attrs), format="json-ld")

subject = URIRef("https://example.org/docs/fixture")
assert (subject, RDF.type, SCHEMA.Article) in g
assert (subject, SCHEMA.name, Literal("Delimiter Fixture")) in g


def test_missing_id_generates_blank_node():
input_text = _read_fixture("cases/missing-id/input.md")
result = extract(input_text)

g = rdflib.Graph()
g.parse(data=json.dumps(result.attrs), format="json-ld")

subjects = {s for s, _, _ in g}
assert any(isinstance(s, rdflib.BNode) for s in subjects)


def test_missing_type_produces_no_rdf_type():
input_text = _read_fixture("cases/missing-type/input.md")
result = extract(input_text)

g = rdflib.Graph()
g.parse(data=json.dumps(result.attrs), format="json-ld")

assert len(list(g.triples((None, RDF.type, None)))) == 0


def test_bare_keywords_produces_no_triples_without_context():
input_text = _read_fixture("cases/bare-keywords-preserved/input.md")
result = extract(input_text)

g = rdflib.Graph()
g.parse(data=json.dumps(result.attrs), format="json-ld")

assert len(g) == 0


def test_canonical_type():
input_text = _read_fixture("cases/canonical-type/input.md")
result = extract(input_text)

g = rdflib.Graph()
g.parse(data=json.dumps(result.attrs), format="json-ld")

subject = URIRef("https://example.org/docs/canonical-type")
assert (subject, RDF.type, SCHEMA.Note) in g
assert (subject, SCHEMA.name, Literal("Canonical Type")) in g
54 changes: 53 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading