pylearnspec 0.4.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.
- pylearnspec/__init__.py +50 -0
- pylearnspec/badgemd/__init__.py +6 -0
- pylearnspec/badgemd/models.py +28 -0
- pylearnspec/badgemd/parser.py +67 -0
- pylearnspec/badgemd/validator.py +59 -0
- pylearnspec/certmd/__init__.py +6 -0
- pylearnspec/certmd/models.py +28 -0
- pylearnspec/certmd/parser.py +65 -0
- pylearnspec/certmd/validator.py +72 -0
- pylearnspec/common/__init__.py +0 -0
- pylearnspec/common/directives.py +130 -0
- pylearnspec/common/frontmatter.py +32 -0
- pylearnspec/common/validation.py +22 -0
- pylearnspec/diagrammd/__init__.py +6 -0
- pylearnspec/diagrammd/models.py +43 -0
- pylearnspec/diagrammd/parser.py +94 -0
- pylearnspec/diagrammd/validator.py +42 -0
- pylearnspec/flashmd/__init__.py +6 -0
- pylearnspec/flashmd/models.py +42 -0
- pylearnspec/flashmd/parser.py +74 -0
- pylearnspec/flashmd/validator.py +56 -0
- pylearnspec/glossarymd/__init__.py +6 -0
- pylearnspec/glossarymd/models.py +42 -0
- pylearnspec/glossarymd/parser.py +109 -0
- pylearnspec/glossarymd/validator.py +51 -0
- pylearnspec/learnmd/__init__.py +4 -0
- pylearnspec/learnmd/models.py +84 -0
- pylearnspec/learnmd/parser.py +156 -0
- pylearnspec/learnmd/validator.py +79 -0
- pylearnspec/mediamd/__init__.py +6 -0
- pylearnspec/mediamd/models.py +35 -0
- pylearnspec/mediamd/parser.py +74 -0
- pylearnspec/mediamd/validator.py +62 -0
- pylearnspec/nuggetmd/__init__.py +6 -0
- pylearnspec/nuggetmd/models.py +135 -0
- pylearnspec/nuggetmd/parser.py +272 -0
- pylearnspec/nuggetmd/validator.py +170 -0
- pylearnspec/quizmd/__init__.py +4 -0
- pylearnspec/quizmd/models.py +80 -0
- pylearnspec/quizmd/parser.py +277 -0
- pylearnspec/quizmd/validator.py +82 -0
- pylearnspec/trackmd/__init__.py +6 -0
- pylearnspec/trackmd/models.py +40 -0
- pylearnspec/trackmd/parser.py +66 -0
- pylearnspec/trackmd/validator.py +64 -0
- pylearnspec-0.4.0.dist-info/METADATA +144 -0
- pylearnspec-0.4.0.dist-info/RECORD +49 -0
- pylearnspec-0.4.0.dist-info/WHEEL +5 -0
- pylearnspec-0.4.0.dist-info/top_level.txt +1 -0
pylearnspec/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""pylearnspec — Python parser and validator for the LearnSpec suite.
|
|
2
|
+
|
|
3
|
+
Supported formats:
|
|
4
|
+
- LearnMD (parse_learn, validate_learn)
|
|
5
|
+
- QuizMD (parse_quiz, validate_quiz)
|
|
6
|
+
- FlashMD (parse_flash, validate_flash)
|
|
7
|
+
- TrackMD (parse_track, validate_track)
|
|
8
|
+
- DiagramMD (parse_diagram, validate_diagram)
|
|
9
|
+
- MediaMD (parse_media, validate_media)
|
|
10
|
+
- GlossaryMD (parse_glossary, validate_glossary)
|
|
11
|
+
- BadgeMD (parse_badge, validate_badge)
|
|
12
|
+
- CertMD (parse_cert, validate_cert)
|
|
13
|
+
- NuggetMD (parse_nugget, validate_nugget)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
__version__ = "0.4.0"
|
|
17
|
+
|
|
18
|
+
from pylearnspec.badgemd.parser import parse_badge
|
|
19
|
+
from pylearnspec.badgemd.validator import validate_badge
|
|
20
|
+
from pylearnspec.certmd.parser import parse_cert
|
|
21
|
+
from pylearnspec.certmd.validator import validate_cert
|
|
22
|
+
from pylearnspec.diagrammd.parser import parse_diagram
|
|
23
|
+
from pylearnspec.diagrammd.validator import validate_diagram
|
|
24
|
+
from pylearnspec.flashmd.parser import parse_flash
|
|
25
|
+
from pylearnspec.flashmd.validator import validate_flash
|
|
26
|
+
from pylearnspec.glossarymd.parser import parse_glossary
|
|
27
|
+
from pylearnspec.glossarymd.validator import validate_glossary
|
|
28
|
+
from pylearnspec.learnmd.parser import parse_learn
|
|
29
|
+
from pylearnspec.learnmd.validator import validate_learn
|
|
30
|
+
from pylearnspec.mediamd.parser import parse_media
|
|
31
|
+
from pylearnspec.mediamd.validator import validate_media
|
|
32
|
+
from pylearnspec.nuggetmd.parser import parse_nugget
|
|
33
|
+
from pylearnspec.nuggetmd.validator import validate_nugget
|
|
34
|
+
from pylearnspec.quizmd.parser import parse_quiz
|
|
35
|
+
from pylearnspec.quizmd.validator import validate_quiz
|
|
36
|
+
from pylearnspec.trackmd.parser import parse_track
|
|
37
|
+
from pylearnspec.trackmd.validator import validate_track
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"parse_quiz", "validate_quiz",
|
|
41
|
+
"parse_learn", "validate_learn",
|
|
42
|
+
"parse_flash", "validate_flash",
|
|
43
|
+
"parse_track", "validate_track",
|
|
44
|
+
"parse_diagram", "validate_diagram",
|
|
45
|
+
"parse_media", "validate_media",
|
|
46
|
+
"parse_glossary", "validate_glossary",
|
|
47
|
+
"parse_badge", "validate_badge",
|
|
48
|
+
"parse_cert", "validate_cert",
|
|
49
|
+
"parse_nugget", "validate_nugget",
|
|
50
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Data models for BadgeMD parsed output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Badge:
|
|
11
|
+
title: str = ""
|
|
12
|
+
level: str = "0"
|
|
13
|
+
frontmatter: dict[str, Any] = field(default_factory=dict)
|
|
14
|
+
image_path: str = "" # from the  line in the body
|
|
15
|
+
body_text: str = "" # narrative criteria
|
|
16
|
+
criteria: list[dict[str, Any]] = field(default_factory=list)
|
|
17
|
+
has_criteria_block: bool = False
|
|
18
|
+
|
|
19
|
+
def to_dict(self) -> dict[str, Any]:
|
|
20
|
+
return {
|
|
21
|
+
"title": self.title,
|
|
22
|
+
"level": self.level,
|
|
23
|
+
"frontmatter": self.frontmatter,
|
|
24
|
+
"image_path": self.image_path,
|
|
25
|
+
"body_text": self.body_text,
|
|
26
|
+
"criteria": self.criteria,
|
|
27
|
+
"has_criteria_block": self.has_criteria_block,
|
|
28
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""BadgeMD parser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from pylearnspec.badgemd.models import Badge
|
|
10
|
+
from pylearnspec.common.frontmatter import extract_frontmatter
|
|
11
|
+
|
|
12
|
+
_H1_RE = re.compile(r"^#\s+(.+)$", re.MULTILINE)
|
|
13
|
+
_IMAGE_LINE_RE = re.compile(r"^!\[[^\]]*\]\(([^)]+)\)", re.MULTILINE)
|
|
14
|
+
_CRITERIA_BLOCK_RE = re.compile(
|
|
15
|
+
r"^```criteria\s*\n(.*?)^```\s*$",
|
|
16
|
+
re.MULTILINE | re.DOTALL,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_badge(content: str) -> Badge:
|
|
21
|
+
frontmatter, body = extract_frontmatter(content)
|
|
22
|
+
|
|
23
|
+
title = frontmatter.get("name", "") or frontmatter.get("title", "")
|
|
24
|
+
if not title:
|
|
25
|
+
m = _H1_RE.search(body)
|
|
26
|
+
if m:
|
|
27
|
+
title = m.group(1).strip()
|
|
28
|
+
|
|
29
|
+
image_path = ""
|
|
30
|
+
if (m := _IMAGE_LINE_RE.search(body)):
|
|
31
|
+
image_path = m.group(1).strip()
|
|
32
|
+
|
|
33
|
+
criteria: list[dict] = []
|
|
34
|
+
has_criteria_block = False
|
|
35
|
+
if (m := _CRITERIA_BLOCK_RE.search(body)):
|
|
36
|
+
has_criteria_block = True
|
|
37
|
+
try:
|
|
38
|
+
loaded = yaml.safe_load(m.group(1))
|
|
39
|
+
if isinstance(loaded, dict):
|
|
40
|
+
criteria = [loaded]
|
|
41
|
+
elif isinstance(loaded, list):
|
|
42
|
+
criteria = [c for c in loaded if isinstance(c, dict)]
|
|
43
|
+
except yaml.YAMLError:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
# Strip image and criteria block from body to get narrative text
|
|
47
|
+
body_text = _CRITERIA_BLOCK_RE.sub("", body)
|
|
48
|
+
body_text = _IMAGE_LINE_RE.sub("", body_text, count=1)
|
|
49
|
+
body_text = body_text.strip()
|
|
50
|
+
|
|
51
|
+
has_fm = bool(frontmatter)
|
|
52
|
+
if has_criteria_block:
|
|
53
|
+
level = "2"
|
|
54
|
+
elif has_fm:
|
|
55
|
+
level = "1"
|
|
56
|
+
else:
|
|
57
|
+
level = "0"
|
|
58
|
+
|
|
59
|
+
return Badge(
|
|
60
|
+
title=title,
|
|
61
|
+
level=level,
|
|
62
|
+
frontmatter=frontmatter,
|
|
63
|
+
image_path=image_path,
|
|
64
|
+
body_text=body_text,
|
|
65
|
+
criteria=criteria,
|
|
66
|
+
has_criteria_block=has_criteria_block,
|
|
67
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""BadgeMD validator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from pylearnspec.badgemd.parser import parse_badge
|
|
8
|
+
from pylearnspec.common.validation import Diagnostic, severity_for
|
|
9
|
+
|
|
10
|
+
_DURATION_RE = re.compile(r"^P(?:\d+Y)?(?:\d+M)?(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?$")
|
|
11
|
+
|
|
12
|
+
KNOWN_CRITERIA_TYPES = {"track_complete", "quiz_pass", "checkpoint_reached", "manual"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_badge(content: str, *, strict: bool = False) -> list[Diagnostic]:
|
|
16
|
+
diags: list[Diagnostic] = []
|
|
17
|
+
try:
|
|
18
|
+
b = parse_badge(content)
|
|
19
|
+
except Exception as e:
|
|
20
|
+
diags.append(Diagnostic(level="error", message=f"Parse error: {e}"))
|
|
21
|
+
return diags
|
|
22
|
+
|
|
23
|
+
sev = severity_for(strict)
|
|
24
|
+
fm = b.frontmatter
|
|
25
|
+
|
|
26
|
+
if not fm.get("lang"):
|
|
27
|
+
diags.append(Diagnostic(level=sev, message="Missing 'lang' in frontmatter"))
|
|
28
|
+
if not fm.get("name"):
|
|
29
|
+
diags.append(Diagnostic(level="error", message="Missing required 'name'"))
|
|
30
|
+
if not fm.get("image"):
|
|
31
|
+
diags.append(Diagnostic(level="error", message="Missing required 'image'"))
|
|
32
|
+
elif not str(fm["image"]).lower().endswith(".svg"):
|
|
33
|
+
diags.append(Diagnostic(level=sev, message="'image' should point to an SVG file"))
|
|
34
|
+
|
|
35
|
+
issuer = fm.get("issuer") or {}
|
|
36
|
+
if not isinstance(issuer, dict) or not issuer.get("name"):
|
|
37
|
+
diags.append(Diagnostic(level="error", message="Missing required 'issuer.name'"))
|
|
38
|
+
if isinstance(issuer, dict) and not issuer.get("url"):
|
|
39
|
+
diags.append(Diagnostic(level=sev, message="Missing 'issuer.url'"))
|
|
40
|
+
|
|
41
|
+
if not b.image_path:
|
|
42
|
+
diags.append(Diagnostic(level=sev, message="No Markdown image line found in the body"))
|
|
43
|
+
|
|
44
|
+
if not b.has_criteria_block:
|
|
45
|
+
diags.append(Diagnostic(level=sev, message="No 'criteria' fenced block — award conditions are narrative only"))
|
|
46
|
+
else:
|
|
47
|
+
for i, c in enumerate(b.criteria, start=1):
|
|
48
|
+
t = c.get("type")
|
|
49
|
+
if t and t not in KNOWN_CRITERIA_TYPES:
|
|
50
|
+
diags.append(Diagnostic(level=sev, message=f"Criterion #{i}: unrecognised type '{t}'"))
|
|
51
|
+
|
|
52
|
+
expires = fm.get("expires")
|
|
53
|
+
if expires is not None and not _DURATION_RE.match(str(expires)):
|
|
54
|
+
diags.append(Diagnostic(
|
|
55
|
+
level="error",
|
|
56
|
+
message=f"'expires' must be an ISO 8601 duration (got {expires!r})",
|
|
57
|
+
))
|
|
58
|
+
|
|
59
|
+
return diags
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Data models for CertMD parsed output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Cert:
|
|
11
|
+
title: str = ""
|
|
12
|
+
level: str = "0"
|
|
13
|
+
frontmatter: dict[str, Any] = field(default_factory=dict)
|
|
14
|
+
image_path: str = ""
|
|
15
|
+
body_text: str = ""
|
|
16
|
+
requirements: list[dict[str, Any]] = field(default_factory=list)
|
|
17
|
+
has_requirements_block: bool = False
|
|
18
|
+
|
|
19
|
+
def to_dict(self) -> dict[str, Any]:
|
|
20
|
+
return {
|
|
21
|
+
"title": self.title,
|
|
22
|
+
"level": self.level,
|
|
23
|
+
"frontmatter": self.frontmatter,
|
|
24
|
+
"image_path": self.image_path,
|
|
25
|
+
"body_text": self.body_text,
|
|
26
|
+
"requirements": self.requirements,
|
|
27
|
+
"has_requirements_block": self.has_requirements_block,
|
|
28
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""CertMD parser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from pylearnspec.certmd.models import Cert
|
|
10
|
+
from pylearnspec.common.frontmatter import extract_frontmatter
|
|
11
|
+
|
|
12
|
+
_H1_RE = re.compile(r"^#\s+(.+)$", re.MULTILINE)
|
|
13
|
+
_IMAGE_LINE_RE = re.compile(r"^!\[[^\]]*\]\(([^)]+)\)", re.MULTILINE)
|
|
14
|
+
_REQUIREMENTS_BLOCK_RE = re.compile(
|
|
15
|
+
r"^```requirements\s*\n(.*?)^```\s*$",
|
|
16
|
+
re.MULTILINE | re.DOTALL,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_cert(content: str) -> Cert:
|
|
21
|
+
frontmatter, body = extract_frontmatter(content)
|
|
22
|
+
|
|
23
|
+
title = frontmatter.get("name", "") or frontmatter.get("title", "")
|
|
24
|
+
if not title:
|
|
25
|
+
m = _H1_RE.search(body)
|
|
26
|
+
if m:
|
|
27
|
+
title = m.group(1).strip()
|
|
28
|
+
|
|
29
|
+
image_path = ""
|
|
30
|
+
if (m := _IMAGE_LINE_RE.search(body)):
|
|
31
|
+
image_path = m.group(1).strip()
|
|
32
|
+
|
|
33
|
+
requirements: list[dict] = []
|
|
34
|
+
has_block = False
|
|
35
|
+
if (m := _REQUIREMENTS_BLOCK_RE.search(body)):
|
|
36
|
+
has_block = True
|
|
37
|
+
try:
|
|
38
|
+
loaded = yaml.safe_load(m.group(1))
|
|
39
|
+
if isinstance(loaded, dict):
|
|
40
|
+
requirements = [loaded]
|
|
41
|
+
elif isinstance(loaded, list):
|
|
42
|
+
requirements = [r for r in loaded if isinstance(r, dict)]
|
|
43
|
+
except yaml.YAMLError:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
body_text = _REQUIREMENTS_BLOCK_RE.sub("", body)
|
|
47
|
+
body_text = _IMAGE_LINE_RE.sub("", body_text, count=1).strip()
|
|
48
|
+
|
|
49
|
+
has_fm = bool(frontmatter)
|
|
50
|
+
if has_block:
|
|
51
|
+
level = "2"
|
|
52
|
+
elif has_fm:
|
|
53
|
+
level = "1"
|
|
54
|
+
else:
|
|
55
|
+
level = "0"
|
|
56
|
+
|
|
57
|
+
return Cert(
|
|
58
|
+
title=title,
|
|
59
|
+
level=level,
|
|
60
|
+
frontmatter=frontmatter,
|
|
61
|
+
image_path=image_path,
|
|
62
|
+
body_text=body_text,
|
|
63
|
+
requirements=requirements,
|
|
64
|
+
has_requirements_block=has_block,
|
|
65
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""CertMD validator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from pylearnspec.certmd.parser import parse_cert
|
|
8
|
+
from pylearnspec.common.validation import Diagnostic, severity_for
|
|
9
|
+
|
|
10
|
+
_DURATION_RE = re.compile(r"^P(?:\d+Y)?(?:\d+M)?(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def validate_cert(content: str, *, strict: bool = False) -> list[Diagnostic]:
|
|
14
|
+
diags: list[Diagnostic] = []
|
|
15
|
+
try:
|
|
16
|
+
c = parse_cert(content)
|
|
17
|
+
except Exception as e:
|
|
18
|
+
diags.append(Diagnostic(level="error", message=f"Parse error: {e}"))
|
|
19
|
+
return diags
|
|
20
|
+
|
|
21
|
+
sev = severity_for(strict)
|
|
22
|
+
fm = c.frontmatter
|
|
23
|
+
|
|
24
|
+
if not fm.get("lang"):
|
|
25
|
+
diags.append(Diagnostic(level=sev, message="Missing 'lang' in frontmatter"))
|
|
26
|
+
if not fm.get("name"):
|
|
27
|
+
diags.append(Diagnostic(level="error", message="Missing required 'name'"))
|
|
28
|
+
if not fm.get("image"):
|
|
29
|
+
diags.append(Diagnostic(level="error", message="Missing required 'image'"))
|
|
30
|
+
elif not str(fm["image"]).lower().endswith(".svg"):
|
|
31
|
+
diags.append(Diagnostic(level=sev, message="'image' should point to an SVG file"))
|
|
32
|
+
|
|
33
|
+
issuer = fm.get("issuer") or {}
|
|
34
|
+
if not isinstance(issuer, dict) or not issuer.get("name"):
|
|
35
|
+
diags.append(Diagnostic(level="error", message="Missing required 'issuer.name'"))
|
|
36
|
+
|
|
37
|
+
# grade_levels sanity
|
|
38
|
+
grades = fm.get("grade_levels") or {}
|
|
39
|
+
if isinstance(grades, dict):
|
|
40
|
+
previous = -1.0
|
|
41
|
+
for key in ("pass", "merit", "distinction"):
|
|
42
|
+
if key in grades:
|
|
43
|
+
try:
|
|
44
|
+
v = float(grades[key])
|
|
45
|
+
except (TypeError, ValueError):
|
|
46
|
+
diags.append(Diagnostic(level="error", message=f"grade_levels.{key} must be numeric"))
|
|
47
|
+
continue
|
|
48
|
+
if v < 0 or v > 1:
|
|
49
|
+
diags.append(Diagnostic(
|
|
50
|
+
level="error",
|
|
51
|
+
message=f"grade_levels.{key} must be between 0.0 and 1.0",
|
|
52
|
+
))
|
|
53
|
+
if v < previous:
|
|
54
|
+
diags.append(Diagnostic(
|
|
55
|
+
level=sev,
|
|
56
|
+
message=f"grade_levels.{key} ({v}) is below the previous threshold ({previous})",
|
|
57
|
+
))
|
|
58
|
+
previous = v
|
|
59
|
+
|
|
60
|
+
if not c.image_path:
|
|
61
|
+
diags.append(Diagnostic(level=sev, message="No Markdown image line found in the body"))
|
|
62
|
+
if not c.has_requirements_block:
|
|
63
|
+
diags.append(Diagnostic(level=sev, message="No 'requirements' block — award conditions are narrative only"))
|
|
64
|
+
|
|
65
|
+
expires = fm.get("expires")
|
|
66
|
+
if expires is not None and not _DURATION_RE.match(str(expires)):
|
|
67
|
+
diags.append(Diagnostic(
|
|
68
|
+
level="error",
|
|
69
|
+
message=f"'expires' must be an ISO 8601 duration (got {expires!r})",
|
|
70
|
+
))
|
|
71
|
+
|
|
72
|
+
return diags
|
|
File without changes
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Cross-format directive and reference extraction.
|
|
2
|
+
|
|
3
|
+
These directives are part of the LearnSpec Architecture Charter and shared
|
|
4
|
+
across all content formats:
|
|
5
|
+
|
|
6
|
+
- ``!import <path>`` — composition (inline another file)
|
|
7
|
+
- ``!ref <path>`` — context (declare a dependency without inline render)
|
|
8
|
+
- ``!checkpoint id:...`` — named progress marker
|
|
9
|
+
- ``media:slug`` — symbolic media reference resolved via MediaMD
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
_IMPORT_RE = re.compile(r"^!import[ \t]+(\S+)(?:[ \t]+([^\n]*))?$", re.MULTILINE)
|
|
19
|
+
_REF_RE = re.compile(r"^!ref[ \t]+(\S+)[ \t]*$", re.MULTILINE)
|
|
20
|
+
_CHECKPOINT_RE = re.compile(r"^!checkpoint[ \t]+([^\n]+)$", re.MULTILINE)
|
|
21
|
+
_MEDIA_SLUG_RE = re.compile(
|
|
22
|
+
r'!\[[^\]]*\]\(media:([a-z0-9][a-z0-9-]*)(?:\s+"([^"]*)")?\)'
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Attributes inside a directive line: key:value, key:"quoted value", key:true
|
|
26
|
+
_ATTR_RE = re.compile(r'(\w+):(?:"([^"]*)"|(\S+))')
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _parse_attrs(text: str) -> dict[str, Any]:
|
|
30
|
+
"""Parse `key:value` and `key:"quoted value"` attribute pairs from a string."""
|
|
31
|
+
out: dict[str, Any] = {}
|
|
32
|
+
for m in _ATTR_RE.finditer(text):
|
|
33
|
+
key = m.group(1)
|
|
34
|
+
value = m.group(2) if m.group(2) is not None else m.group(3)
|
|
35
|
+
if value in ("true", "false"):
|
|
36
|
+
out[key] = value == "true"
|
|
37
|
+
else:
|
|
38
|
+
out[key] = value
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ImportDirective:
|
|
44
|
+
path: str
|
|
45
|
+
attrs: dict[str, Any] = field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def kind(self) -> str:
|
|
49
|
+
"""Return the imported file kind: learn, quiz, flash, diagram, or unknown."""
|
|
50
|
+
for ext in ("learn", "quiz", "flash", "diagram", "track"):
|
|
51
|
+
if self.path.endswith(f".{ext}.md"):
|
|
52
|
+
return ext
|
|
53
|
+
return "unknown"
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict[str, Any]:
|
|
56
|
+
d: dict[str, Any] = {"path": self.path, "kind": self.kind}
|
|
57
|
+
if self.attrs:
|
|
58
|
+
d["attrs"] = self.attrs
|
|
59
|
+
return d
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class RefDirective:
|
|
64
|
+
path: str
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def kind(self) -> str:
|
|
68
|
+
for ext in ("media", "glossary"):
|
|
69
|
+
if self.path.endswith(f".{ext}.md"):
|
|
70
|
+
return ext
|
|
71
|
+
return "unknown"
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> dict[str, Any]:
|
|
74
|
+
return {"path": self.path, "kind": self.kind}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class Checkpoint:
|
|
79
|
+
id: str
|
|
80
|
+
label: str = ""
|
|
81
|
+
type: str = "milestone"
|
|
82
|
+
badge: str = ""
|
|
83
|
+
|
|
84
|
+
def to_dict(self) -> dict[str, Any]:
|
|
85
|
+
return {"id": self.id, "label": self.label, "type": self.type, "badge": self.badge}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class MediaRef:
|
|
90
|
+
"""Inline ``media:slug`` image reference."""
|
|
91
|
+
slug: str
|
|
92
|
+
fallback_url: str = ""
|
|
93
|
+
|
|
94
|
+
def to_dict(self) -> dict[str, Any]:
|
|
95
|
+
return {"slug": self.slug, "fallback_url": self.fallback_url}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def extract_imports(text: str) -> list[ImportDirective]:
|
|
99
|
+
out: list[ImportDirective] = []
|
|
100
|
+
for m in _IMPORT_RE.finditer(text):
|
|
101
|
+
path = m.group(1).strip()
|
|
102
|
+
attrs_text = (m.group(2) or "").strip()
|
|
103
|
+
out.append(ImportDirective(path=path, attrs=_parse_attrs(attrs_text)))
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def extract_refs(text: str) -> list[RefDirective]:
|
|
108
|
+
return [RefDirective(path=m.group(1).strip()) for m in _REF_RE.finditer(text)]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def extract_checkpoints(text: str) -> list[Checkpoint]:
|
|
112
|
+
out: list[Checkpoint] = []
|
|
113
|
+
for m in _CHECKPOINT_RE.finditer(text):
|
|
114
|
+
attrs = _parse_attrs(m.group(1))
|
|
115
|
+
if "id" not in attrs:
|
|
116
|
+
continue
|
|
117
|
+
out.append(Checkpoint(
|
|
118
|
+
id=str(attrs["id"]),
|
|
119
|
+
label=str(attrs.get("label", "")),
|
|
120
|
+
type=str(attrs.get("type", "milestone")),
|
|
121
|
+
badge=str(attrs.get("badge", "")),
|
|
122
|
+
))
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def extract_media_refs(text: str) -> list[MediaRef]:
|
|
127
|
+
return [
|
|
128
|
+
MediaRef(slug=m.group(1), fallback_url=m.group(2) or "")
|
|
129
|
+
for m in _MEDIA_SLUG_RE.finditer(text)
|
|
130
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""YAML frontmatter extraction shared by QuizMD and LearnMD parsers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
_FRONTMATTER_RE = re.compile(
|
|
11
|
+
r"\A\s*---[ \t]*\n(.*?\n)---[ \t]*\n",
|
|
12
|
+
re.DOTALL,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def extract_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
|
17
|
+
"""Return (frontmatter_dict, remaining_text).
|
|
18
|
+
|
|
19
|
+
If no frontmatter is found, returns ({}, text).
|
|
20
|
+
"""
|
|
21
|
+
m = _FRONTMATTER_RE.match(text)
|
|
22
|
+
if not m:
|
|
23
|
+
return {}, text
|
|
24
|
+
raw = m.group(1)
|
|
25
|
+
try:
|
|
26
|
+
data = yaml.safe_load(raw)
|
|
27
|
+
except yaml.YAMLError:
|
|
28
|
+
return {}, text
|
|
29
|
+
if not isinstance(data, dict):
|
|
30
|
+
return {}, text
|
|
31
|
+
rest = text[m.end() :]
|
|
32
|
+
return data, rest
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Shared validation primitives used across all LearnSpec formats."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
Severity = Literal["error", "warning"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Diagnostic:
|
|
13
|
+
level: Severity
|
|
14
|
+
message: str
|
|
15
|
+
|
|
16
|
+
def to_dict(self) -> dict:
|
|
17
|
+
return {"level": self.level, "message": self.message}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def severity_for(strict: bool) -> Severity:
|
|
21
|
+
"""Return the severity used for fields demoted to warning in lenient mode."""
|
|
22
|
+
return "error" if strict else "warning"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Data models for DiagramMD parsed output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Diagram:
|
|
11
|
+
type: str # mermaid, tikz, graphviz, plantuml, blockdiag, seqdiag, chess, abc, smiles, vega-lite, …
|
|
12
|
+
source: str = ""
|
|
13
|
+
id: str = ""
|
|
14
|
+
caption: str = ""
|
|
15
|
+
alt: str = ""
|
|
16
|
+
width: str = ""
|
|
17
|
+
extra: dict[str, Any] = field(default_factory=dict) # e.g. play, cursor, colors for abc
|
|
18
|
+
|
|
19
|
+
def to_dict(self) -> dict[str, Any]:
|
|
20
|
+
d: dict[str, Any] = {"type": self.type, "source": self.source}
|
|
21
|
+
for k in ("id", "caption", "alt", "width"):
|
|
22
|
+
v = getattr(self, k)
|
|
23
|
+
if v:
|
|
24
|
+
d[k] = v
|
|
25
|
+
if self.extra:
|
|
26
|
+
d["extra"] = self.extra
|
|
27
|
+
return d
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class DiagramFile:
|
|
32
|
+
title: str = ""
|
|
33
|
+
level: str = "0"
|
|
34
|
+
frontmatter: dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
diagrams: list[Diagram] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict[str, Any]:
|
|
38
|
+
return {
|
|
39
|
+
"title": self.title,
|
|
40
|
+
"level": self.level,
|
|
41
|
+
"frontmatter": self.frontmatter,
|
|
42
|
+
"diagrams": [d.to_dict() for d in self.diagrams],
|
|
43
|
+
}
|