longecho 0.3.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.
- longecho/__init__.py +29 -0
- longecho/__main__.py +5 -0
- longecho/build.py +250 -0
- longecho/checker.py +253 -0
- longecho/cli.py +315 -0
- longecho/discovery.py +94 -0
- longecho/templates/sfa.html +537 -0
- longecho-0.3.0.dist-info/METADATA +314 -0
- longecho-0.3.0.dist-info/RECORD +12 -0
- longecho-0.3.0.dist-info/WHEEL +4 -0
- longecho-0.3.0.dist-info/entry_points.txt +2 -0
- longecho-0.3.0.dist-info/licenses/LICENSE +21 -0
longecho/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""longecho -- compliance validator, discovery, and site builder for durable personal archives."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.3.0"
|
|
4
|
+
|
|
5
|
+
from .build import BuildResult, build_site
|
|
6
|
+
from .checker import (
|
|
7
|
+
DURABLE_FORMAT_CATEGORIES,
|
|
8
|
+
ComplianceResult,
|
|
9
|
+
EchoSource,
|
|
10
|
+
Readme,
|
|
11
|
+
check_compliance,
|
|
12
|
+
parse_readme,
|
|
13
|
+
)
|
|
14
|
+
from .discovery import discover_sources, matches_query, search_sources
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"BuildResult",
|
|
18
|
+
"ComplianceResult",
|
|
19
|
+
"DURABLE_FORMAT_CATEGORIES",
|
|
20
|
+
"EchoSource",
|
|
21
|
+
"Readme",
|
|
22
|
+
"__version__",
|
|
23
|
+
"build_site",
|
|
24
|
+
"check_compliance",
|
|
25
|
+
"discover_sources",
|
|
26
|
+
"matches_query",
|
|
27
|
+
"parse_readme",
|
|
28
|
+
"search_sources",
|
|
29
|
+
]
|
longecho/__main__.py
ADDED
longecho/build.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""longecho site builder -- generates a single-file application from a longecho archive."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import date, datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from jinja2 import Environment, PackageLoader, select_autoescape
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .checker import EchoSource, check_compliance
|
|
13
|
+
from .discovery import should_skip_directory
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class BuildResult:
|
|
18
|
+
"""Result of a site build operation."""
|
|
19
|
+
|
|
20
|
+
success: bool
|
|
21
|
+
output_path: Optional[Path] = None
|
|
22
|
+
sources_count: int = 0
|
|
23
|
+
error: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_jinja_env() -> Environment:
|
|
27
|
+
"""Create Jinja2 environment with templates."""
|
|
28
|
+
return Environment(
|
|
29
|
+
loader=PackageLoader("longecho", "templates"),
|
|
30
|
+
autoescape=select_autoescape(["html", "xml"]),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _sanitize_html(html: str) -> str:
|
|
35
|
+
"""Strip dangerous HTML elements from rendered markdown."""
|
|
36
|
+
# Remove dangerous tags with content
|
|
37
|
+
html = re.sub(
|
|
38
|
+
r"<(script|style|iframe|object|embed)\b[^>]*>.*?</\1>",
|
|
39
|
+
"", html, flags=re.DOTALL | re.IGNORECASE,
|
|
40
|
+
)
|
|
41
|
+
# Remove self-closing dangerous tags
|
|
42
|
+
html = re.sub(
|
|
43
|
+
r"<(script|style|iframe|object|embed|base|meta|link)\b[^>]*/?>",
|
|
44
|
+
"", html, flags=re.IGNORECASE,
|
|
45
|
+
)
|
|
46
|
+
# Remove opening-only dangerous tags (base, meta, link are void elements)
|
|
47
|
+
html = re.sub(
|
|
48
|
+
r"<(base|meta|link)\b[^>]*>",
|
|
49
|
+
"", html, flags=re.IGNORECASE,
|
|
50
|
+
)
|
|
51
|
+
# Remove form tags
|
|
52
|
+
html = re.sub(r"</?form\b[^>]*>", "", html, flags=re.IGNORECASE)
|
|
53
|
+
# Remove on* event handlers (quoted)
|
|
54
|
+
html = re.sub(r"\s+on\w+\s*=\s*[\"'][^\"']*[\"']", "", html, flags=re.IGNORECASE)
|
|
55
|
+
# Remove on* event handlers (unquoted, stop at > or whitespace)
|
|
56
|
+
html = re.sub(r"\s+on\w+\s*=\s*[^\s>]+", "", html, flags=re.IGNORECASE)
|
|
57
|
+
# Remove javascript: URIs in href/src/action attributes
|
|
58
|
+
html = re.sub(
|
|
59
|
+
r'(href|src|action)\s*=\s*["\']?\s*javascript:[^"\'>\s]*["\']?',
|
|
60
|
+
"", html, flags=re.IGNORECASE,
|
|
61
|
+
)
|
|
62
|
+
return html
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def markdown_to_html(content: str) -> str:
|
|
66
|
+
"""Convert markdown to sanitized HTML."""
|
|
67
|
+
import markdown
|
|
68
|
+
|
|
69
|
+
html: str = markdown.markdown(content, extensions=["fenced_code", "tables"])
|
|
70
|
+
return _sanitize_html(html)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _get_data_files(source: EchoSource, output_path: Path) -> list[dict]:
|
|
74
|
+
"""Get data files in a source directory, with relative paths from output_path."""
|
|
75
|
+
files = []
|
|
76
|
+
for fmt in source.durable_formats:
|
|
77
|
+
for f in source.path.glob(f"*{fmt}"):
|
|
78
|
+
if f.is_file():
|
|
79
|
+
try:
|
|
80
|
+
rel = Path(f.relative_to(output_path.parent))
|
|
81
|
+
except ValueError:
|
|
82
|
+
# output_path is outside the archive — use absolute file:// URI
|
|
83
|
+
rel = Path(f.as_uri()) if hasattr(f, "as_uri") else f
|
|
84
|
+
files.append({"name": f.name, "path": str(rel)})
|
|
85
|
+
return sorted(files, key=lambda x: x["name"])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def discover_sub_sources(source: EchoSource) -> list[EchoSource]:
|
|
89
|
+
"""Discover sub-sources using contents field or auto-discovery."""
|
|
90
|
+
path = source.path
|
|
91
|
+
sources: list[EchoSource] = []
|
|
92
|
+
|
|
93
|
+
if source.contents:
|
|
94
|
+
# Curated: only listed paths, in order
|
|
95
|
+
for entry in source.contents:
|
|
96
|
+
entry_path = entry.get("path", "")
|
|
97
|
+
sub_path = (path / entry_path).resolve()
|
|
98
|
+
# Prevent path traversal
|
|
99
|
+
if not sub_path.is_relative_to(path):
|
|
100
|
+
continue
|
|
101
|
+
if not sub_path.is_dir():
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
result = check_compliance(sub_path)
|
|
105
|
+
if result.compliant and result.source:
|
|
106
|
+
sources.append(result.source)
|
|
107
|
+
else:
|
|
108
|
+
# Auto-discover: all compliant subdirectories, alphabetical
|
|
109
|
+
for item in sorted(path.iterdir()):
|
|
110
|
+
if not item.is_dir():
|
|
111
|
+
continue
|
|
112
|
+
if item.name == "site" or should_skip_directory(item.name):
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
result = check_compliance(item)
|
|
116
|
+
if result.compliant and result.source:
|
|
117
|
+
sources.append(result.source)
|
|
118
|
+
|
|
119
|
+
return sources
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def make_json_safe(obj: object) -> object:
|
|
123
|
+
"""Recursively convert non-JSON-serializable types (e.g. datetime.date) to strings."""
|
|
124
|
+
if isinstance(obj, dict):
|
|
125
|
+
return {k: make_json_safe(v) for k, v in obj.items()}
|
|
126
|
+
if isinstance(obj, list):
|
|
127
|
+
return [make_json_safe(v) for v in obj]
|
|
128
|
+
if isinstance(obj, (date, datetime)):
|
|
129
|
+
return obj.isoformat()
|
|
130
|
+
return obj
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _source_to_json(source: EchoSource, output_path: Path) -> dict:
|
|
134
|
+
"""Convert an EchoSource to a JSON-serializable dict for the SFA, recursively."""
|
|
135
|
+
readme_html = ""
|
|
136
|
+
try:
|
|
137
|
+
content = source.readme_path.read_text(encoding="utf-8")
|
|
138
|
+
readme_html = markdown_to_html(content)
|
|
139
|
+
except (OSError, UnicodeDecodeError):
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
frontmatter: dict = make_json_safe(source.frontmatter or {}) # type: ignore[assignment]
|
|
143
|
+
|
|
144
|
+
# Recursively discover and convert children
|
|
145
|
+
child_sources = discover_sub_sources(source)
|
|
146
|
+
children = [_source_to_json(c, output_path) for c in child_sources]
|
|
147
|
+
|
|
148
|
+
# Compute relative path to source's site/index.html if it exists
|
|
149
|
+
site_url = None
|
|
150
|
+
if source.has_site and source.site_path:
|
|
151
|
+
try:
|
|
152
|
+
rel = source.site_path / "index.html"
|
|
153
|
+
site_url = str(rel.relative_to(output_path.parent))
|
|
154
|
+
except ValueError:
|
|
155
|
+
site_url = (source.site_path / "index.html").as_uri()
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
"name": source.name,
|
|
159
|
+
"description": source.description,
|
|
160
|
+
"formats": source.durable_formats,
|
|
161
|
+
"frontmatter": frontmatter,
|
|
162
|
+
"readme_html": readme_html,
|
|
163
|
+
"data_files": _get_data_files(source, output_path),
|
|
164
|
+
"site_url": site_url,
|
|
165
|
+
"children": children,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _generate_site_readme(
|
|
170
|
+
name: str,
|
|
171
|
+
sources: list[EchoSource],
|
|
172
|
+
output_path: Path,
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Generate a longecho-compliant README.md for the site/ directory."""
|
|
175
|
+
import yaml
|
|
176
|
+
|
|
177
|
+
source_names = ", ".join(s.name for s in sources)
|
|
178
|
+
now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
|
179
|
+
|
|
180
|
+
frontmatter = {
|
|
181
|
+
"name": f"{name} Site",
|
|
182
|
+
"description": "Static site generated from longecho archive",
|
|
183
|
+
"generator": f"longecho build v{__version__}",
|
|
184
|
+
"datetime": now,
|
|
185
|
+
"contents": [
|
|
186
|
+
{"path": "index.html", "description": "Single-file browsable archive"},
|
|
187
|
+
],
|
|
188
|
+
}
|
|
189
|
+
fm_yaml = yaml.dump(frontmatter, default_flow_style=False, sort_keys=False)
|
|
190
|
+
|
|
191
|
+
content = f"---\n{fm_yaml}---\n\nGenerated by longecho from {len(sources)} source(s): {source_names}.\nOpen index.html in any browser to explore.\n"
|
|
192
|
+
readme_path = output_path / "README.md"
|
|
193
|
+
readme_path.write_text(content, encoding="utf-8")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _count_sources(data: list[dict]) -> int:
|
|
197
|
+
"""Count all sources including nested children."""
|
|
198
|
+
total = len(data)
|
|
199
|
+
for s in data:
|
|
200
|
+
total += _count_sources(s.get("children", []))
|
|
201
|
+
return total
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def build_site(
|
|
205
|
+
path: Path,
|
|
206
|
+
output: Optional[Path] = None,
|
|
207
|
+
) -> BuildResult:
|
|
208
|
+
"""Build a single-file application for a longecho archive."""
|
|
209
|
+
path = Path(path).resolve()
|
|
210
|
+
|
|
211
|
+
if not path.exists():
|
|
212
|
+
return BuildResult(success=False, error=f"Path does not exist: {path}")
|
|
213
|
+
|
|
214
|
+
if not path.is_dir():
|
|
215
|
+
return BuildResult(success=False, error=f"Path is not a directory: {path}")
|
|
216
|
+
|
|
217
|
+
result = check_compliance(path)
|
|
218
|
+
if not result.compliant or not result.source:
|
|
219
|
+
return BuildResult(
|
|
220
|
+
success=False,
|
|
221
|
+
error=f"Not a longecho archive: {result.reason}"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
root_source = result.source
|
|
225
|
+
sources = discover_sub_sources(root_source)
|
|
226
|
+
|
|
227
|
+
output_path = Path(output).resolve() if output else path / "site"
|
|
228
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
229
|
+
|
|
230
|
+
sources_data = [_source_to_json(s, output_path) for s in sources]
|
|
231
|
+
|
|
232
|
+
env = get_jinja_env()
|
|
233
|
+
template = env.get_template("sfa.html")
|
|
234
|
+
html = template.render(
|
|
235
|
+
name=root_source.name,
|
|
236
|
+
description=root_source.description,
|
|
237
|
+
sources_data=sources_data,
|
|
238
|
+
generated_at=datetime.now().strftime("%Y-%m-%d %H:%M"),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
index_path = output_path / "index.html"
|
|
242
|
+
index_path.write_text(html, encoding="utf-8")
|
|
243
|
+
|
|
244
|
+
_generate_site_readme(root_source.name, sources, output_path)
|
|
245
|
+
|
|
246
|
+
return BuildResult(
|
|
247
|
+
success=True,
|
|
248
|
+
output_path=output_path,
|
|
249
|
+
sources_count=_count_sources(sources_data),
|
|
250
|
+
)
|
longecho/checker.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""longecho compliance checker."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
# Single source of truth for durable format categories.
|
|
10
|
+
# All display, spec, and compliance logic derives from this structure.
|
|
11
|
+
DURABLE_FORMAT_CATEGORIES: dict[str, list[str]] = {
|
|
12
|
+
"Structured data": [".db", ".sqlite", ".sqlite3", ".json", ".jsonl"],
|
|
13
|
+
"Documents": [".md", ".markdown", ".txt", ".text", ".rst", ".html", ".htm"],
|
|
14
|
+
"Archives": [".zip"],
|
|
15
|
+
"Images": [".jpg", ".jpeg", ".png", ".webp", ".gif"],
|
|
16
|
+
"Tabular / data": [".csv", ".tsv", ".xml", ".yaml", ".yml"],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
# Derived flat set for O(1) membership testing
|
|
20
|
+
DURABLE_EXTENSIONS: set[str] = {
|
|
21
|
+
ext for exts in DURABLE_FORMAT_CATEGORIES.values() for ext in exts
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
EXCLUDE_PATTERNS: set[str] = {
|
|
25
|
+
"README.md", "README.txt",
|
|
26
|
+
"CLAUDE.md", "CHANGELOG.md",
|
|
27
|
+
".gitignore", ".gitattributes",
|
|
28
|
+
"pyproject.toml", "setup.py", "setup.cfg",
|
|
29
|
+
"requirements.txt",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
DEFAULT_FORMAT_SCAN_DEPTH: int = 2
|
|
33
|
+
MAX_README_SUMMARY_LENGTH: int = 500
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Readme:
|
|
38
|
+
"""A README parsed into its constituent parts."""
|
|
39
|
+
|
|
40
|
+
frontmatter: Optional[dict]
|
|
41
|
+
body: str
|
|
42
|
+
title: Optional[str]
|
|
43
|
+
summary: Optional[str]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _split_frontmatter(content: str) -> tuple[Optional[dict], str]:
|
|
47
|
+
"""Split YAML frontmatter from markdown content.
|
|
48
|
+
|
|
49
|
+
Returns (frontmatter dict or None, remaining body).
|
|
50
|
+
"""
|
|
51
|
+
if not content.startswith("---"):
|
|
52
|
+
return None, content
|
|
53
|
+
|
|
54
|
+
lines = content.split("\n")
|
|
55
|
+
# Find closing ---
|
|
56
|
+
for i, line in enumerate(lines[1:], start=1):
|
|
57
|
+
if line.strip() == "---":
|
|
58
|
+
yaml_content = "\n".join(lines[1:i])
|
|
59
|
+
body = "\n".join(lines[i + 1 :])
|
|
60
|
+
try:
|
|
61
|
+
parsed = yaml.safe_load(yaml_content)
|
|
62
|
+
if isinstance(parsed, dict):
|
|
63
|
+
return parsed, body
|
|
64
|
+
return None, body
|
|
65
|
+
except yaml.YAMLError:
|
|
66
|
+
return None, body
|
|
67
|
+
|
|
68
|
+
# No closing delimiter found
|
|
69
|
+
return None, content
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_readme(readme_path: Path) -> Optional[Readme]:
|
|
73
|
+
"""Parse a README into frontmatter, title, and summary. Returns None if unreadable."""
|
|
74
|
+
try:
|
|
75
|
+
content = readme_path.read_text(encoding="utf-8")
|
|
76
|
+
except (OSError, UnicodeDecodeError):
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
frontmatter, body = _split_frontmatter(content)
|
|
80
|
+
|
|
81
|
+
title = None
|
|
82
|
+
summary_lines: list[str] = []
|
|
83
|
+
in_paragraph = False
|
|
84
|
+
|
|
85
|
+
for line in body.split("\n"):
|
|
86
|
+
stripped = line.strip()
|
|
87
|
+
|
|
88
|
+
if stripped.startswith("# ") and title is None:
|
|
89
|
+
title = stripped[2:].strip()
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
if stripped.startswith("#"):
|
|
93
|
+
if summary_lines:
|
|
94
|
+
break
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
if not stripped:
|
|
98
|
+
if in_paragraph:
|
|
99
|
+
break
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
in_paragraph = True
|
|
103
|
+
summary_lines.append(stripped)
|
|
104
|
+
|
|
105
|
+
summary = " ".join(summary_lines)[:MAX_README_SUMMARY_LENGTH] if summary_lines else None
|
|
106
|
+
return Readme(frontmatter=frontmatter, body=body, title=title, summary=summary)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class EchoSource:
|
|
111
|
+
"""A longecho-compliant data source."""
|
|
112
|
+
|
|
113
|
+
path: Path
|
|
114
|
+
readme_path: Path
|
|
115
|
+
name: str
|
|
116
|
+
description: str
|
|
117
|
+
durable_formats: list[str] = field(default_factory=list)
|
|
118
|
+
has_site: bool = False
|
|
119
|
+
site_path: Optional[Path] = None
|
|
120
|
+
frontmatter: Optional[dict] = None
|
|
121
|
+
contents: Optional[list[dict]] = None
|
|
122
|
+
|
|
123
|
+
def __str__(self) -> str:
|
|
124
|
+
desc = self.description or "No description"
|
|
125
|
+
if len(desc) > 60:
|
|
126
|
+
desc = desc[:57] + "..."
|
|
127
|
+
return f"{self.path}: {desc}"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class ComplianceResult:
|
|
132
|
+
"""Result of a longecho compliance check."""
|
|
133
|
+
|
|
134
|
+
compliant: bool
|
|
135
|
+
path: Path
|
|
136
|
+
reason: Optional[str] = None
|
|
137
|
+
source: Optional[EchoSource] = None # populated when compliant
|
|
138
|
+
|
|
139
|
+
def __str__(self) -> str:
|
|
140
|
+
if self.compliant:
|
|
141
|
+
return f"longecho-compliant: {self.path}"
|
|
142
|
+
return f"Not longecho-compliant: {self.path} ({self.reason})"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def find_readme(path: Path) -> Optional[Path]:
|
|
146
|
+
"""Find README file at the root of a directory."""
|
|
147
|
+
for name in ["README.md", "README.txt", "readme.md", "readme.txt"]:
|
|
148
|
+
readme = path / name
|
|
149
|
+
if readme.is_file():
|
|
150
|
+
return readme
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def detect_durable_formats(path: Path, max_depth: int = DEFAULT_FORMAT_SCAN_DEPTH) -> list[str]:
|
|
155
|
+
"""Detect durable file formats in a directory up to max_depth."""
|
|
156
|
+
found: set[str] = set()
|
|
157
|
+
|
|
158
|
+
def scan_directory(dir_path: Path, depth: int):
|
|
159
|
+
if depth > max_depth:
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
for item in dir_path.iterdir():
|
|
164
|
+
if item.name.startswith("."):
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
if item.is_file():
|
|
168
|
+
if item.name not in EXCLUDE_PATTERNS:
|
|
169
|
+
suffix = item.suffix.lower()
|
|
170
|
+
if suffix in DURABLE_EXTENSIONS:
|
|
171
|
+
found.add(suffix)
|
|
172
|
+
elif item.is_dir():
|
|
173
|
+
scan_directory(item, depth + 1)
|
|
174
|
+
except PermissionError:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
scan_directory(path, 0)
|
|
178
|
+
return sorted(found)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def is_durable_format(extension: str) -> bool:
|
|
182
|
+
"""Check if a file extension is a durable format (with or without leading dot)."""
|
|
183
|
+
ext = extension.lower()
|
|
184
|
+
if not ext.startswith("."):
|
|
185
|
+
ext = "." + ext
|
|
186
|
+
return ext in DURABLE_EXTENSIONS
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _parse_contents(frontmatter: dict) -> Optional[list[dict]]:
|
|
190
|
+
"""Parse the contents field from frontmatter into normalized dicts."""
|
|
191
|
+
raw = frontmatter.get("contents")
|
|
192
|
+
if not isinstance(raw, list):
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
entries = []
|
|
196
|
+
for item in raw:
|
|
197
|
+
if isinstance(item, dict) and "path" in item:
|
|
198
|
+
entries.append(item)
|
|
199
|
+
return entries if entries else None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def check_compliance(path: Path) -> ComplianceResult:
|
|
203
|
+
"""Check if a directory is longecho-compliant (has README + durable formats)."""
|
|
204
|
+
path = Path(path).resolve()
|
|
205
|
+
|
|
206
|
+
if not path.exists():
|
|
207
|
+
return ComplianceResult(compliant=False, path=path, reason="Path does not exist")
|
|
208
|
+
if not path.is_dir():
|
|
209
|
+
return ComplianceResult(compliant=False, path=path, reason="Path is not a directory")
|
|
210
|
+
|
|
211
|
+
readme_file = find_readme(path)
|
|
212
|
+
if not readme_file:
|
|
213
|
+
return ComplianceResult(
|
|
214
|
+
compliant=False, path=path, reason="No README.md or README.txt found"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
durable = detect_durable_formats(path)
|
|
218
|
+
|
|
219
|
+
if not durable:
|
|
220
|
+
return ComplianceResult(
|
|
221
|
+
compliant=False, path=path, reason="No durable data formats found"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
readme = parse_readme(readme_file)
|
|
225
|
+
|
|
226
|
+
# Name cascade: frontmatter name > # Heading > dirname
|
|
227
|
+
name = (readme.title if readme else None) or path.name
|
|
228
|
+
description = (readme.summary if readme else None) or ""
|
|
229
|
+
frontmatter = readme.frontmatter if readme else None
|
|
230
|
+
contents = None
|
|
231
|
+
|
|
232
|
+
if frontmatter:
|
|
233
|
+
name = frontmatter.get("name", name)
|
|
234
|
+
description = frontmatter.get("description", description)
|
|
235
|
+
contents = _parse_contents(frontmatter)
|
|
236
|
+
|
|
237
|
+
site_dir = path / "site"
|
|
238
|
+
has_site = site_dir.exists() and (site_dir / "index.html").exists()
|
|
239
|
+
site_path = site_dir if has_site else None
|
|
240
|
+
|
|
241
|
+
source = EchoSource(
|
|
242
|
+
path=path,
|
|
243
|
+
readme_path=readme_file,
|
|
244
|
+
name=name,
|
|
245
|
+
description=description,
|
|
246
|
+
durable_formats=durable,
|
|
247
|
+
has_site=has_site,
|
|
248
|
+
site_path=site_path,
|
|
249
|
+
frontmatter=frontmatter,
|
|
250
|
+
contents=contents,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
return ComplianceResult(compliant=True, path=path, source=source)
|