kpress 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.
Files changed (107) hide show
  1. kpress/__init__.py +55 -0
  2. kpress/_version.py +14 -0
  3. kpress/cli.py +371 -0
  4. kpress/contract.py +396 -0
  5. kpress/errors.py +27 -0
  6. kpress/format/__init__.py +56 -0
  7. kpress/format/assets.py +301 -0
  8. kpress/format/markdown.py +993 -0
  9. kpress/format/model.py +267 -0
  10. kpress/format/pdf.py +152 -0
  11. kpress/format/render.py +614 -0
  12. kpress/format/sanitize.py +509 -0
  13. kpress/format/static/css/components.css +1840 -0
  14. kpress/format/static/css/document.css +459 -0
  15. kpress/format/static/css/page-reset.css +12 -0
  16. kpress/format/static/css/print.css +351 -0
  17. kpress/format/static/css/style-tokens.css +454 -0
  18. kpress/format/static/css/syntax.css +263 -0
  19. kpress/format/static/css/theme-dark.css +11 -0
  20. kpress/format/static/css/theme-light.css +14 -0
  21. kpress/format/static/fonts/pt-serif-latin-400-italic.woff2 +0 -0
  22. kpress/format/static/fonts/pt-serif-latin-400-normal.woff2 +0 -0
  23. kpress/format/static/fonts/pt-serif-latin-700-italic.woff2 +0 -0
  24. kpress/format/static/fonts/pt-serif-latin-700-normal.woff2 +0 -0
  25. kpress/format/static/fonts/source-sans-3-latin-wght-italic.woff2 +0 -0
  26. kpress/format/static/fonts/source-sans-3-latin-wght-normal.woff2 +0 -0
  27. kpress/format/static/icons/icons.svg +63 -0
  28. kpress/format/static/js/code-copy.js +60 -0
  29. kpress/format/static/js/diagrams.js +109 -0
  30. kpress/format/static/js/host.js +246 -0
  31. kpress/format/static/js/icons.js +15 -0
  32. kpress/format/static/js/menu.js +73 -0
  33. kpress/format/static/js/overlay.js +165 -0
  34. kpress/format/static/js/runtime.js +505 -0
  35. kpress/format/static/js/settings-widget.js +184 -0
  36. kpress/format/static/js/tables.js +45 -0
  37. kpress/format/static/js/tabs.js +175 -0
  38. kpress/format/static/js/theme-bootstrap.js +43 -0
  39. kpress/format/static/js/theme.js +148 -0
  40. kpress/format/static/js/toc.js +310 -0
  41. kpress/format/static/js/tooltips.js +795 -0
  42. kpress/format/static/js/video-popover.js +116 -0
  43. kpress/format/static/js/viewport.js +163 -0
  44. kpress/format/static/katex/VERSION +1 -0
  45. kpress/format/static/katex/auto-render.min.js +1 -0
  46. kpress/format/static/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  47. kpress/format/static/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  48. kpress/format/static/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  49. kpress/format/static/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  50. kpress/format/static/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  51. kpress/format/static/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  52. kpress/format/static/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  53. kpress/format/static/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  54. kpress/format/static/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  55. kpress/format/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  56. kpress/format/static/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  57. kpress/format/static/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  58. kpress/format/static/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  59. kpress/format/static/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  60. kpress/format/static/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  61. kpress/format/static/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  62. kpress/format/static/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  63. kpress/format/static/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  64. kpress/format/static/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  65. kpress/format/static/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  66. kpress/format/static/katex/katex-init.js +44 -0
  67. kpress/format/static/katex/katex.min.css +1 -0
  68. kpress/format/static/katex/katex.min.js +1 -0
  69. kpress/format/templates/page.html.jinja +33 -0
  70. kpress/format/templating.py +47 -0
  71. kpress/format/theme.py +49 -0
  72. kpress/format/validate.py +22 -0
  73. kpress/licenses/katex.txt +21 -0
  74. kpress/licenses/lucide.txt +59 -0
  75. kpress/licenses/pt-serif.txt +93 -0
  76. kpress/licenses/source-sans-3.txt +94 -0
  77. kpress/models.py +82 -0
  78. kpress/output.py +31 -0
  79. kpress/publish/__init__.py +43 -0
  80. kpress/publish/assets/__init__.py +20 -0
  81. kpress/publish/assets/copy.py +164 -0
  82. kpress/publish/build.py +758 -0
  83. kpress/publish/cache.py +77 -0
  84. kpress/publish/capability.py +189 -0
  85. kpress/publish/config.py +591 -0
  86. kpress/publish/discover.py +89 -0
  87. kpress/publish/frontmatter.py +108 -0
  88. kpress/publish/manifest.py +135 -0
  89. kpress/publish/optimize.py +466 -0
  90. kpress/publish/optimizer_tool/package-lock.json +650 -0
  91. kpress/publish/optimizer_tool/package.json +8 -0
  92. kpress/publish/routes.py +190 -0
  93. kpress/publish/site_files.py +72 -0
  94. kpress/py.typed +1 -0
  95. kpress/runtime.py +443 -0
  96. kpress/workflow/__init__.py +18 -0
  97. kpress/workflow/convert.py +66 -0
  98. kpress/workflow/export.py +37 -0
  99. kpress/workflow/format.py +49 -0
  100. kpress/workflow/paste.py +25 -0
  101. kpress/workflow/workspace.py +40 -0
  102. kpress-0.1.0.dist-info/METADATA +177 -0
  103. kpress-0.1.0.dist-info/RECORD +107 -0
  104. kpress-0.1.0.dist-info/WHEEL +4 -0
  105. kpress-0.1.0.dist-info/entry_points.txt +2 -0
  106. kpress-0.1.0.dist-info/licenses/LICENSE +235 -0
  107. kpress-0.1.0.dist-info/licenses/NOTICE.md +36 -0
kpress/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ """KPress document rendering and publishing package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from kpress._version import __version__
6
+ from kpress.contract import (
7
+ ASSET_MANIFEST_SCHEMA_VERSION,
8
+ BUILD_MANIFEST_SCHEMA_VERSION,
9
+ CONTRACT_VERSION,
10
+ )
11
+ from kpress.errors import (
12
+ KPressAssetNotFoundError,
13
+ KPressInvalidRequestError,
14
+ KPressMissingOptionalDependencyError,
15
+ KPressOptimizerError,
16
+ KPressPublishError,
17
+ KPressRenderError,
18
+ )
19
+ from kpress.models import (
20
+ KPressAsset,
21
+ KPressExportRequest,
22
+ KPressRenderRequest,
23
+ PrintProfile,
24
+ ThemeMode,
25
+ )
26
+ from kpress.runtime import (
27
+ clear_render_cache,
28
+ export_document,
29
+ get_static_asset,
30
+ render_view,
31
+ set_static_root_for_tests,
32
+ )
33
+
34
+ __all__ = [
35
+ "ASSET_MANIFEST_SCHEMA_VERSION",
36
+ "BUILD_MANIFEST_SCHEMA_VERSION",
37
+ "CONTRACT_VERSION",
38
+ "KPressAsset",
39
+ "KPressAssetNotFoundError",
40
+ "KPressExportRequest",
41
+ "KPressInvalidRequestError",
42
+ "KPressMissingOptionalDependencyError",
43
+ "KPressOptimizerError",
44
+ "KPressPublishError",
45
+ "KPressRenderError",
46
+ "KPressRenderRequest",
47
+ "PrintProfile",
48
+ "ThemeMode",
49
+ "__version__",
50
+ "clear_render_cache",
51
+ "export_document",
52
+ "get_static_asset",
53
+ "render_view",
54
+ "set_static_root_for_tests",
55
+ ]
kpress/_version.py ADDED
@@ -0,0 +1,14 @@
1
+ """KPress package version.
2
+
3
+ The canonical version comes from the git tag via uv-dynamic-versioning at
4
+ build time; at runtime it is read from the installed package metadata.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from importlib.metadata import PackageNotFoundError, version
10
+
11
+ try:
12
+ __version__ = version("kpress")
13
+ except PackageNotFoundError: # Source tree without an installed distribution.
14
+ __version__ = "0.0.0+unknown"
kpress/cli.py ADDED
@@ -0,0 +1,371 @@
1
+ """KPress command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import shutil
8
+ import sys
9
+ from collections.abc import Callable, Sequence
10
+ from pathlib import Path
11
+ from typing import cast
12
+
13
+ from kpress._version import __version__
14
+ from kpress.errors import KPressMissingOptionalDependencyError
15
+ from kpress.format import DocumentInput, RenderOptions, render_page
16
+ from kpress.output import write_text_atomic
17
+ from kpress.publish import BuildOptions, build_site
18
+ from kpress.publish.build import STANDALONE_ASSET_PREFIX, emit_standalone_assets
19
+ from kpress.publish.capability import CAPABILITY_NAMES, platform_info, probe_all
20
+ from kpress.publish.config import load_config
21
+ from kpress.publish.frontmatter import read_document_source
22
+ from kpress.publish.optimize import optimize_text
23
+ from kpress.workflow import convert_document, export_document, format_document, paste_document
24
+ from kpress.workflow.workspace import WORK_ROOT_MARKER, WorkflowResult, prepare_work_root
25
+
26
+
27
+ def _print_result(result: WorkflowResult) -> int:
28
+ print(json.dumps(result.as_dict(), indent=2, sort_keys=True))
29
+ return 0 if not result.diagnostics else 2
30
+
31
+
32
+ def _print_missing_extra(command: str, exc: KPressMissingOptionalDependencyError) -> int:
33
+ print(
34
+ json.dumps(
35
+ {
36
+ "command": command,
37
+ "diagnostics": [str(exc)],
38
+ "error": type(exc).__name__,
39
+ },
40
+ indent=2,
41
+ sort_keys=True,
42
+ )
43
+ )
44
+ return 2
45
+
46
+
47
+ def _cmd_init(args: argparse.Namespace) -> int:
48
+ root = prepare_work_root(args.work_root)
49
+ config = Path(args.config)
50
+ if not config.exists():
51
+ write_text_atomic(
52
+ config,
53
+ "sources:\n - path: .\n\npublish:\n output_dir: public\n",
54
+ )
55
+ return _print_result(WorkflowResult(command="init", work_root=root, outputs=[config]))
56
+
57
+
58
+ def _cmd_convert(args: argparse.Namespace) -> int:
59
+ return _print_result(convert_document(args.input, output=args.output, work_root=args.work_root))
60
+
61
+
62
+ def _cmd_format(args: argparse.Namespace) -> int:
63
+ return _print_result(
64
+ format_document(
65
+ args.input,
66
+ output_dir=args.output_dir,
67
+ work_root=args.work_root,
68
+ asset_mode=args.asset_mode,
69
+ )
70
+ )
71
+
72
+
73
+ def _cmd_render(args: argparse.Namespace) -> int:
74
+ """Render one Markdown input to a single KPress HTML file at ``--output``."""
75
+
76
+ root = prepare_work_root(args.work_root)
77
+ source = Path(args.input)
78
+ doc = read_document_source(source)
79
+ title = doc.metadata.get("title") or source.stem.replace("-", " ").title()
80
+ page = render_page(
81
+ DocumentInput(
82
+ title=str(title),
83
+ source_text=doc.body,
84
+ body_markdown=doc.body,
85
+ source_path=str(source),
86
+ frontmatter=doc.metadata,
87
+ sidematter=doc.sidematter,
88
+ ),
89
+ RenderOptions(asset_mode=args.asset_mode, asset_url_prefix=STANDALONE_ASSET_PREFIX),
90
+ )
91
+ output = Path(args.output) if args.output else root / "exports" / f"{source.stem}.html"
92
+ write_text_atomic(output, page.html)
93
+ asset_files = emit_standalone_assets(
94
+ output, asset_mode=args.asset_mode, page=page, source=source
95
+ )
96
+ return _print_result(
97
+ WorkflowResult(command="render", work_root=root, outputs=[output, *asset_files])
98
+ )
99
+
100
+
101
+ def _cmd_paste(args: argparse.Namespace) -> int:
102
+ return _print_result(paste_document(title=args.title, text=args.text, work_root=args.work_root))
103
+
104
+
105
+ def _cmd_files(args: argparse.Namespace) -> int:
106
+ return _print_result(
107
+ WorkflowResult(
108
+ command="files",
109
+ work_root=prepare_work_root(args.work_root),
110
+ outputs=sorted(Path(args.work_root).rglob("*")),
111
+ )
112
+ )
113
+
114
+
115
+ def _cmd_export(args: argparse.Namespace) -> int:
116
+ return _print_result(
117
+ export_document(args.input, html=args.html, pdf=args.pdf, work_root=args.work_root)
118
+ )
119
+
120
+
121
+ def _cmd_clean(args: argparse.Namespace) -> int:
122
+ """Remove the build output directory and the work root.
123
+
124
+ The output directory resolves relative to the config file (exactly like
125
+ ``build_site``) and is only removed when it carries the KPress build
126
+ manifest (or is empty). The work root is only removed when it has the
127
+ default ``.kpress`` name or carries the KPress work-root marker. Anything
128
+ else is refused so a misconfigured path can never delete unrelated files.
129
+ """
130
+
131
+ removed: list[Path] = []
132
+ diagnostics: list[str] = []
133
+
134
+ config_path = Path(args.config)
135
+ if config_path.exists():
136
+ config = load_config(str(config_path))
137
+ base = (config.config_path.parent if config.config_path else Path.cwd()).resolve()
138
+ output_dir = base / config.publish.output_dir
139
+ if output_dir.is_dir():
140
+ manifest = output_dir / "_kpress" / "kpress-manifest.json"
141
+ if manifest.exists() or not any(output_dir.iterdir()):
142
+ shutil.rmtree(output_dir)
143
+ removed.append(output_dir)
144
+ else:
145
+ diagnostics.append(
146
+ f"Refusing to remove {output_dir}: it has no KPress build manifest "
147
+ "(_kpress/kpress-manifest.json). Remove it manually if intended."
148
+ )
149
+
150
+ work_root = Path(args.work_root)
151
+ if work_root.is_dir():
152
+ if work_root.name == ".kpress" or (work_root / WORK_ROOT_MARKER).exists():
153
+ shutil.rmtree(work_root)
154
+ removed.append(work_root)
155
+ else:
156
+ diagnostics.append(
157
+ f"Refusing to remove work root {work_root}: it is not named .kpress and "
158
+ f"has no {WORK_ROOT_MARKER} marker. Remove it manually if intended."
159
+ )
160
+
161
+ print(
162
+ json.dumps(
163
+ {
164
+ "command": "clean",
165
+ "diagnostics": diagnostics,
166
+ "removed": [str(path) for path in removed],
167
+ },
168
+ indent=2,
169
+ sort_keys=True,
170
+ )
171
+ )
172
+ return 2 if diagnostics else 0
173
+
174
+
175
+ def build_parser() -> argparse.ArgumentParser:
176
+ parser = argparse.ArgumentParser(
177
+ prog="kpress",
178
+ description="Render Markdown into polished HTML documents and publish static sites.",
179
+ )
180
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
181
+ parser.add_argument("--work-root", default=".kpress", help="Workspace directory for outputs.")
182
+ sub = parser.add_subparsers(dest="command", required=True)
183
+
184
+ init = sub.add_parser("init", help="Write a starter kpress.yml config.")
185
+ init.add_argument("--config", default="kpress.yml")
186
+ init.set_defaults(func=_cmd_init)
187
+
188
+ convert = sub.add_parser("convert", help="Copy a Markdown-compatible input into the workspace.")
189
+ convert.add_argument("input")
190
+ convert.add_argument("--output")
191
+ convert.set_defaults(func=_cmd_convert)
192
+
193
+ # `inline` is deliberately not offered: it is not yet self-contained.
194
+ asset_mode_choices = ["hosted", "linked", "hashed"]
195
+
196
+ fmt = sub.add_parser(
197
+ "format", help="Render a Markdown file to paired Markdown and HTML outputs."
198
+ )
199
+ fmt.add_argument("input")
200
+ fmt.add_argument("--output-dir")
201
+ fmt.add_argument("--asset-mode", choices=asset_mode_choices, default="linked")
202
+ fmt.set_defaults(func=_cmd_format)
203
+
204
+ render = sub.add_parser("render", help="Render one Markdown input to a single HTML file.")
205
+ render.add_argument("input")
206
+ render.add_argument("--output")
207
+ render.add_argument("--asset-mode", choices=asset_mode_choices, default="linked")
208
+ render.set_defaults(func=_cmd_render)
209
+
210
+ paste = sub.add_parser("paste", help="Create a document from pasted or piped text.")
211
+ paste.add_argument("--title", default="Pasted Document")
212
+ paste.add_argument("--text")
213
+ paste.set_defaults(func=_cmd_paste)
214
+
215
+ files = sub.add_parser("files", help="List files in the work root.")
216
+ files.set_defaults(func=_cmd_files)
217
+
218
+ export = sub.add_parser("export", help="Export a document to HTML and optional PDF.")
219
+ export.add_argument("input")
220
+ export.add_argument("--html")
221
+ export.add_argument("--pdf")
222
+ export.set_defaults(func=_cmd_export)
223
+
224
+ clean = sub.add_parser("clean", help="Remove the build output directory and work root.")
225
+ clean.add_argument("--config", default="kpress.yml")
226
+ clean.set_defaults(func=_cmd_clean)
227
+
228
+ build = sub.add_parser("build", help="Build the static site from kpress.yml.")
229
+ build.add_argument("--config", default="kpress.yml")
230
+ build.add_argument("--output-dir")
231
+ build.add_argument("--asset-mode", choices=asset_mode_choices)
232
+ build.add_argument("--optimizer", choices=["none", "full"])
233
+ build.add_argument("--precompress", action="append", choices=["gzip", "br"])
234
+ build.set_defaults(func=_cmd_build)
235
+
236
+ optimize = sub.add_parser("optimize", help="Minify an HTML/CSS/JS file.")
237
+ optimize.add_argument("input")
238
+ optimize.add_argument("--output")
239
+ optimize.add_argument("--kind", choices=["html", "css", "js"], default="html")
240
+ optimize.add_argument("--backend", choices=["none", "full"], default="none")
241
+ optimize.set_defaults(func=_cmd_optimize)
242
+
243
+ doctor = sub.add_parser(
244
+ "doctor", help="Probe optional capabilities (render, publish, optimizer, PDF)."
245
+ )
246
+ doctor.add_argument("--profile", choices=["render", "publish", "optimize", "pdf"])
247
+ doctor.add_argument("--config")
248
+ doctor.add_argument("--allow-network", action="store_true")
249
+ doctor.add_argument("--json", action="store_true", dest="as_json")
250
+ doctor.set_defaults(func=_cmd_doctor)
251
+
252
+ return parser
253
+
254
+
255
+ def _cmd_build(args: argparse.Namespace) -> int:
256
+ try:
257
+ report = build_site(
258
+ args.config,
259
+ BuildOptions(
260
+ output_dir=Path(args.output_dir) if args.output_dir else None,
261
+ asset_mode=args.asset_mode,
262
+ optimizer=args.optimizer,
263
+ precompress=args.precompress,
264
+ ),
265
+ )
266
+ except KPressMissingOptionalDependencyError as exc:
267
+ return _print_missing_extra("build", exc)
268
+ print(json.dumps(report.as_dict(), indent=2, sort_keys=True))
269
+ return 0
270
+
271
+
272
+ def _cmd_optimize(args: argparse.Namespace) -> int:
273
+ source = Path(args.input)
274
+ output = Path(args.output) if args.output else source
275
+ try:
276
+ optimized = optimize_text(
277
+ source.read_text(encoding="utf-8"),
278
+ kind=args.kind,
279
+ backend=args.backend,
280
+ )
281
+ except KPressMissingOptionalDependencyError as exc:
282
+ return _print_missing_extra("optimize", exc)
283
+ write_text_atomic(output, optimized)
284
+ print(
285
+ json.dumps(
286
+ {
287
+ "backend": args.backend,
288
+ "kind": args.kind,
289
+ "output": str(output),
290
+ },
291
+ indent=2,
292
+ sort_keys=True,
293
+ )
294
+ )
295
+ return 0
296
+
297
+
298
+ _DOCTOR_LABELS = {
299
+ "render": "Core render",
300
+ "publish": "Static publish",
301
+ "optimizer_full": "Optimizer full",
302
+ "precompress_brotli": "Brotli precompress",
303
+ "pdf_browser": "PDF browser",
304
+ }
305
+ _PROFILE_CAPABILITY = {
306
+ "render": "render",
307
+ "publish": "publish",
308
+ "optimize": "optimizer_full",
309
+ "pdf": "pdf_browser",
310
+ }
311
+
312
+
313
+ def _required_capabilities(config_path: str) -> set[str]:
314
+ """Capabilities the resolved config actually exercises."""
315
+
316
+ config = load_config(config_path)
317
+ required = {"render", "publish"}
318
+ if config.optimizer.mode == "full":
319
+ required.add("optimizer_full")
320
+ if "br" in config.optimizer.precompress:
321
+ required.add("precompress_brotli")
322
+ if config.pdf.enabled:
323
+ required.add("pdf_browser")
324
+ return required
325
+
326
+
327
+ def _cmd_doctor(args: argparse.Namespace) -> int:
328
+ results = probe_all(allow_network=args.allow_network)
329
+
330
+ required: set[str]
331
+ if args.profile:
332
+ required = {_PROFILE_CAPABILITY[args.profile]}
333
+ elif args.config:
334
+ required = _required_capabilities(args.config)
335
+ else:
336
+ required = set() # bare discovery never fails
337
+
338
+ if args.as_json:
339
+ payload = {
340
+ **platform_info(),
341
+ "capabilities": {
342
+ name: (
343
+ {"status": r.status, "reason": r.reason} if r.reason else {"status": r.status}
344
+ )
345
+ for name, r in results.items()
346
+ },
347
+ }
348
+ print(json.dumps(payload, indent=2, sort_keys=True))
349
+ else:
350
+ for name in CAPABILITY_NAMES:
351
+ r = results[name]
352
+ detail = f" - {r.reason}" if r.reason else ""
353
+ print(f"{_DOCTOR_LABELS[name] + ':':<20}{r.status}{detail}")
354
+
355
+ failed = [n for n in required if results[n].status != "ok"]
356
+ return 2 if failed else 0
357
+
358
+
359
+ def main(argv: Sequence[str] | None = None) -> int:
360
+ parser = build_parser()
361
+ args = parser.parse_args(argv)
362
+ handler = cast(Callable[[argparse.Namespace], int], args.func)
363
+ try:
364
+ return handler(args)
365
+ except KeyboardInterrupt:
366
+ print("Interrupted.", file=sys.stderr)
367
+ return 130
368
+
369
+
370
+ if __name__ == "__main__":
371
+ raise SystemExit(main())