epresso 0.1.1__tar.gz

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 (49) hide show
  1. epresso-0.1.1/.gitignore +50 -0
  2. epresso-0.1.1/LICENSE +21 -0
  3. epresso-0.1.1/PKG-INFO +521 -0
  4. epresso-0.1.1/README.md +486 -0
  5. epresso-0.1.1/pyproject.toml +69 -0
  6. epresso-0.1.1/src/epresso/__init__.py +31 -0
  7. epresso-0.1.1/src/epresso/assets.py +153 -0
  8. epresso-0.1.1/src/epresso/cli.py +450 -0
  9. epresso-0.1.1/src/epresso/components.py +451 -0
  10. epresso-0.1.1/src/epresso/config.py +311 -0
  11. epresso-0.1.1/src/epresso/content/__init__.py +38 -0
  12. epresso-0.1.1/src/epresso/content/collections.py +90 -0
  13. epresso-0.1.1/src/epresso/content/docs.py +214 -0
  14. epresso-0.1.1/src/epresso/content/loaders.py +145 -0
  15. epresso-0.1.1/src/epresso/content/store.py +126 -0
  16. epresso-0.1.1/src/epresso/css.py +93 -0
  17. epresso-0.1.1/src/epresso/deploy.py +56 -0
  18. epresso-0.1.1/src/epresso/devtoolbar.py +600 -0
  19. epresso-0.1.1/src/epresso/docs_source.py +45 -0
  20. epresso-0.1.1/src/epresso/docsgen.py +195 -0
  21. epresso-0.1.1/src/epresso/document.py +150 -0
  22. epresso-0.1.1/src/epresso/enforce.py +87 -0
  23. epresso-0.1.1/src/epresso/errors.py +61 -0
  24. epresso-0.1.1/src/epresso/fmt.py +294 -0
  25. epresso-0.1.1/src/epresso/gitrepo.py +65 -0
  26. epresso-0.1.1/src/epresso/highlight.py +68 -0
  27. epresso-0.1.1/src/epresso/images.py +191 -0
  28. epresso-0.1.1/src/epresso/incremental.py +331 -0
  29. epresso-0.1.1/src/epresso/jsx.py +162 -0
  30. epresso-0.1.1/src/epresso/jsxattrs.py +60 -0
  31. epresso-0.1.1/src/epresso/links.py +69 -0
  32. epresso-0.1.1/src/epresso/logger.py +97 -0
  33. epresso-0.1.1/src/epresso/markdown.py +571 -0
  34. epresso-0.1.1/src/epresso/minify.py +76 -0
  35. epresso-0.1.1/src/epresso/outputs.py +184 -0
  36. epresso-0.1.1/src/epresso/pipeline.py +72 -0
  37. epresso-0.1.1/src/epresso/plugins.py +378 -0
  38. epresso-0.1.1/src/epresso/private.py +14 -0
  39. epresso-0.1.1/src/epresso/pygments.py +117 -0
  40. epresso-0.1.1/src/epresso/render.py +236 -0
  41. epresso-0.1.1/src/epresso/routing.py +447 -0
  42. epresso-0.1.1/src/epresso/scoped.py +135 -0
  43. epresso-0.1.1/src/epresso/search.py +115 -0
  44. epresso-0.1.1/src/epresso/server.py +363 -0
  45. epresso-0.1.1/src/epresso/site.py +507 -0
  46. epresso-0.1.1/src/epresso/static/epresso.svg +1 -0
  47. epresso-0.1.1/src/epresso/static/favicon.ico +0 -0
  48. epresso-0.1.1/src/epresso/templates.py +296 -0
  49. epresso-0.1.1/src/epresso/themes.py +86 -0
@@ -0,0 +1,50 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ dist/
11
+ build/
12
+
13
+ # docs/guides/build is a docs category, not a build artifact
14
+ !docs/guides/build/
15
+ !docs/guides/build/**
16
+ *.egg-info/
17
+ .eggs/
18
+ *.egg
19
+ MANIFEST
20
+
21
+ # Virtual environments
22
+ .venv/
23
+ venv/
24
+ env/
25
+ ENV/
26
+
27
+ # Test / coverage artifacts
28
+ .coverage
29
+ .coverage.*
30
+ htmlcov/
31
+ .pytest_cache/
32
+ .cache/
33
+ .coverage.xml
34
+ *.cover
35
+
36
+ # Type checking
37
+ .mypy_cache/
38
+ .pyright/
39
+ .pytype/
40
+
41
+ # Linting
42
+ .ruff_cache/
43
+
44
+ # Build
45
+ *.tar.gz
46
+ *.whl
47
+
48
+ # Tooling / misc
49
+ .DS_Store
50
+ *.log
epresso-0.1.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nikoshell
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.
epresso-0.1.1/PKG-INFO ADDED
@@ -0,0 +1,521 @@
1
+ Metadata-Version: 2.5
2
+ Name: epresso
3
+ Version: 0.1.1
4
+ Summary: epresso — a modern, Python-first static site generator. Routes-as-code, content collections, deterministic + incremental builds.
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: jinja2>=3.1
9
+ Requires-Dist: markdown-it-py>=3.0
10
+ Requires-Dist: pydantic>=2.7
11
+ Requires-Dist: pygments>=2.17
12
+ Requires-Dist: pyyaml>=6.0
13
+ Requires-Dist: starlette>=0.37
14
+ Requires-Dist: tinycss2>=1.5.1
15
+ Requires-Dist: typer>=0.12
16
+ Requires-Dist: uvicorn>=0.30
17
+ Requires-Dist: watchfiles>=0.21
18
+ Requires-Dist: websockets>=12.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: httpx>=0.27; extra == 'dev'
21
+ Requires-Dist: pillow>=10.0; extra == 'dev'
22
+ Requires-Dist: pyright>=1.1.360; extra == 'dev'
23
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
24
+ Requires-Dist: pytest-snapshot; extra == 'dev'
25
+ Requires-Dist: pytest>=8.0; extra == 'dev'
26
+ Requires-Dist: ruff>=0.5; extra == 'dev'
27
+ Provides-Extra: fmt
28
+ Requires-Dist: cssbeautifier>=1.14; extra == 'fmt'
29
+ Requires-Dist: djhtml>=1.4; extra == 'fmt'
30
+ Requires-Dist: jsbeautifier>=1.14; extra == 'fmt'
31
+ Requires-Dist: ruff>=0.5; extra == 'fmt'
32
+ Provides-Extra: images
33
+ Requires-Dist: pillow>=10.0; extra == 'images'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # epresso
37
+
38
+ A modern, **Python-first static site generator** — routes-as-code, content collections, deterministic + incremental builds, and a no-JavaScript-by-default philosophy.
39
+
40
+ ```bash
41
+ pip install epresso
42
+ epresso new blog myblog && cd myblog
43
+ epresso dev # develop with live reload
44
+ epresso build # deterministic, incremental → dist/
45
+ ```
46
+
47
+ ## Why epresso?
48
+
49
+ - **Content and routes are separate.** `content/` is data (collections, validated with Pydantic); `pages/` is where routes live as templates that consume the data — a content-first model in Python.
50
+ - **Incremental from day one.** A content digest + build graph + reverse index means a content edit re-renders only the affected pages.
51
+ - **Deterministic builds.** Same source + config → same output (hashed filenames, stable ordering).
52
+ - **JS is optional.** A pure-Python site needs zero Node. esbuild/Tailwind/PostCSS are external binaries used only when you declare JS/CSS.
53
+ - **No arbitrary Python in templates.** Templates see a curated set of globals — safe and predictable.
54
+
55
+ ## Features
56
+
57
+ - Content collections (glob + Python/remote loaders) with **Pydantic schemas**, data collections (JSON/YAML/TOML), content references, drafts & scheduled-content exclusion.
58
+ - Routes-as-code: filesystem routing, `{param}` / `{...param}` dynamic routes, `get_static_paths()` sidecars, **`.ep` single-file routes** (Python frontmatter + Jinja), static endpoints (JSON/XML/text), clean URLs, redirects.
59
+ - **Jinja2** templates with inheritance, curated globals (`url`, `asset`, `image`, `picture`, `seo`, `get_collection`, `get_entry`, …).
60
+ - **`.ep` components** with strict Pydantic `Props` validation, **scoped CSS** (`<style>` blocks, `:global()` opt-out), and **client `<script>` blocks** (bundled page-level JS).
61
+ - Dev server (Starlette + watchfiles) with **WebSocket live reload**, sharing the production incremental engine.
62
+ - Assets: content-hashed `asset()`, `public/` passthrough, esbuild JS bundling, **PostCSS/Tailwind CSS** pipeline, **Pillow responsive images** (`image()` → WebP srcset).
63
+ - **First-class client behavior** — a `.ep` component's `<script>` block is bundled (esbuild) page-level JS, injected before `</body>`. No separate islands/ dir.
64
+ - Generated outputs: `sitemap.xml`, `robots.txt`, `404.html`, `search-index.json`, and an RSS/Atom helper.
65
+ - **Plugin API** — a capability registry: named plugins with lifecycle hooks that receive a scoped `Capabilities` handle (never the raw `Site`), so extensions are deterministic and isolated. See the [plugin guide](docs/guides/extending/plugins.md).
66
+ - Theme scaffolding — `epresso new docs|blog`.
67
+
68
+ ## Installation
69
+
70
+ Requires **Python 3.12+**.
71
+
72
+ ```bash
73
+ pip install epresso # or: uv tool install epresso
74
+ ```
75
+
76
+ ## Quickstart
77
+
78
+ ```bash
79
+ epresso new blog mysite # scaffold from the blog theme (or: epresso new docs)
80
+ cd mysite
81
+ epresso dev # http://127.0.0.1:4321 with live reload (next free port if busy)
82
+ epresso build # deterministic + incremental build → dist/
83
+ epresso preview # build then serve dist/ (production preview)
84
+ epresso check # validate config + content, list routes
85
+ ```
86
+
87
+ A blank project:
88
+
89
+ ```bash
90
+ epresso init mysite && cd mysite
91
+ epresso build
92
+ ```
93
+
94
+ ## Project layout
95
+
96
+ ```
97
+ site.toml # configuration (TOML)
98
+ content.config.py # define collections + schemas (Pydantic)
99
+ content/ # data — collections (markdown / JSON / YAML / TOML)
100
+ pages/ # routes (.ep, .md, or .py endpoints)
101
+ layouts/ # layout templates (.ep)
102
+ components/ # reusable components (.ep), grouped into subdirs
103
+ ui/ # presentational building blocks
104
+ layout/ # page/site structure (Header, Nav, Footer, Search)
105
+ sections/ # visual page regions
106
+ behavior/ # client-side enhancement / interaction
107
+ features/ # business-feature components
108
+ styles/ # global stylesheets (CSS)
109
+ assets/ # buildable assets (images, js); top-level files land at root
110
+ public/ # files copied verbatim to the output root
111
+ ```
112
+
113
+ > Back-compat: a legacy `templates/` root (with `components/` + `layouts/` subdirs) is
114
+ > still loaded when present.
115
+
116
+ ## Configuration (`site.toml`)
117
+
118
+ ### Environments
119
+
120
+ epresso supports per-environment configuration with a
121
+ `.env.development` / `.env.preview` / `.env.production` split:
122
+
123
+ * `site.<env>.toml` — deep-merged over `site.toml` (per-env URL, toggles, …)
124
+ * `.env.<env>` — dotenv-style vars (`EP_SESSIONS_API=...`) exposed to content
125
+ loaders/plugins via `os.environ` and to templates via `env_vars`.
126
+
127
+ Select with `epresso build --env <name>` (or `EPRESSO_ENV`); `epresso dev` defaults to
128
+ `development`, `epresso build`/`preview`/`check` to `production`. In templates,
129
+ `{{ env }}` is the active env name and `{{ env_vars.KEY }}` any env var.
130
+
131
+ ```toml
132
+ [site]
133
+ name = "My Site"
134
+ url = "https://example.com"
135
+ language = "en"
136
+
137
+ [build]
138
+ output = "dist"
139
+ trailing_slash = "always" # always | never
140
+
141
+ [assets]
142
+ css = ["css/main.css"] # entry points (esbuild / postcss / tailwind)
143
+ js = ["js/app.js"]
144
+
145
+ [seo]
146
+ sitemap = true
147
+ robots = true
148
+
149
+ [search]
150
+ enabled = true
151
+ index = "search-index.json"
152
+
153
+ plugins = ["mypkg:MyPlugin"] # dotted-path plugin specs
154
+ ```
155
+
156
+ ## Content collections
157
+
158
+ Define collections in `content.config.py`:
159
+
160
+ ```python
161
+ from pydantic import BaseModel
162
+ from epresso.content import define_collection, reference
163
+
164
+ class Post(BaseModel):
165
+ title: str
166
+ date: str
167
+ tags: list[str] = []
168
+ author: reference("authors") # content reference
169
+ draft: bool = False
170
+
171
+ posts = define_collection("posts", glob="*.md", base="./content/posts", schema=Post)
172
+ authors = define_collection("authors", loader=fetch_authors) # remote/derived
173
+ ```
174
+
175
+ Fenced code blocks in Markdown are syntax-highlighted with **Pygments** by
176
+ default (``markdown.highlight = false`` opts out). Highlighted blocks use
177
+ `<code class="language-<lang>">` with token spans; include the generated CSS in
178
+ your layout:
179
+
180
+ ```jinja
181
+ <style>{{ pygments_css() }}</style>
182
+ ```
183
+
184
+ A markdown post with YAML front matter:
185
+
186
+ ```markdown
187
+ ---
188
+ title: Hello
189
+ date: 2026-01-01
190
+ tags: [python]
191
+ ---
192
+ # Hello
193
+
194
+ Your **content** here.
195
+ ```
196
+
197
+ `get_collection("posts")`, `get_entry("posts", id)`, and `get_entries(...)` resolve data in templates and in `get_static_paths()`. In production builds, `draft: true` and future-dated entries are automatically excluded.
198
+
199
+ ## Routing (routes-as-code)
200
+
201
+ `content/` is data; `pages/` produces URLs. Three kinds of route:
202
+
203
+ 1. **Direct Markdown page** — `pages/about.md` → `/about/` (layout via front matter).
204
+ 2. **`.ep` single-file route** — `pages/blog/[slug].ep` with Python frontmatter + Jinja body (recommended):
205
+
206
+ ```epresso
207
+ ---
208
+ from epresso.routing import Route
209
+
210
+ def get_static_paths():
211
+ return [Route(path=f"/blog/{p.id}/", params={"slug": p.id}, data=p)
212
+ for p in site.get_collection("posts")]
213
+ ---
214
+ {% extends 'base.html' %}
215
+ {% block title %}{{ props.title }}{% endblock %}
216
+ {% block content %}<h1>{{ props.title }}</h1>{{ content|safe }}{% endblock %}
217
+ ```
218
+
219
+ The `--- … ---` block is **Python** (run with `site` injected); its variables are
220
+ exposed to the template. A static `.ep` route needs no
221
+ `get_static_paths()`.
222
+
223
+ 3. **Template + sidecar** — `pages/blog/[slug].html` + `pages/blog/[slug].py` (legacy):
224
+
225
+ ```python
226
+ from epresso.routing import Route
227
+
228
+ def get_static_paths():
229
+ return [Route(path=f"/blog/{p.id}/", params={"slug": p.id}, data=p)
230
+ for p in site.get_collection("posts")]
231
+ ```
232
+
233
+ 4. **Static endpoint** — `pages/robots.txt.py` exporting `get()`:
234
+
235
+ ```python
236
+ def get():
237
+ return "text/plain", "User-agent: *\nAllow: /\n"
238
+ ```
239
+
240
+ Pagination is a helper, not magic:
241
+
242
+ ```python
243
+ from epresso.routing import paginate
244
+
245
+ def get_static_paths():
246
+ return paginate(site.get_collection("posts"), per_page=10, base_path="/blog/")
247
+ ```
248
+
249
+ ### Redirects (Option C)
250
+
251
+ Redirects are configured in `site.toml` and emitted as routes in the build graph
252
+ (so they participate in incremental builds). Targets may be a string (permanent
253
+ 301) or a `{destination, status}` dict for 302:
254
+
255
+ ```toml
256
+ [[redirects]]
257
+ "/old-home/" = "/"
258
+
259
+ [[redirects]]
260
+ "/legacy/" = { destination = "/new/", status = 302 }
261
+ ```
262
+
263
+ Each emits a browser-safe meta-refresh page under `dist/<from>/index.html`;
264
+ `data-epresso-status` reflects the configured HTTP status for edge/host rewrites.
265
+ Set `[build] redirects = false` to disable. Invalid targets (missing
266
+ `destination`) are rejected at load time.
267
+
268
+ ## The `.ep` file format
269
+
270
+ `.ep` files unify routes, layouts, and components into a single file:
271
+ **Python frontmatter** (`--- … ---`) + **Jinja body**. Content stays in
272
+ Markdown; data stays in YAML/JSON/TOML.
273
+
274
+ ### Components with typed props
275
+
276
+ `components/Card.ep` validates props against a strict Pydantic model
277
+ before rendering — no unvalidated kwargs reach the template:
278
+
279
+ ```epresso
280
+ ---
281
+ from pydantic import BaseModel
282
+
283
+ class Props(BaseModel):
284
+ title: str
285
+ level: int = 3
286
+ ---
287
+ <div class="card"><h{{ props.level }}>{{ props.title }}</h{{ props.level }}>
288
+ {{ content }}</div>
289
+ ```
290
+
291
+ Used from any template with the `{% component %}` tag:
292
+
293
+ ```jinja
294
+ {% component "Card", title="Hi", level=2 %}Body text{% endcomponent %}
295
+ ```
296
+
297
+ ### JSX-style component tags
298
+
299
+ Components can also be written with HTML/JSX-like syntax — epresso rewrites these
300
+ to `{% component %}` blocks before parsing, so you don't need the tag at all:
301
+
302
+ ```jinja
303
+ <Header />
304
+
305
+ <main>
306
+ <Hero />
307
+ <FeatureSection layout="three-column" count={items|length} />
308
+ <Card title="Hi">Body <strong>text</strong></Card>
309
+ </main>
310
+ ```
311
+
312
+ * Only tags that resolve to a registered component are converted — `<main>`,
313
+ `<div>`, `<section>` etc. pass through as plain HTML.
314
+ * Attributes are JSX-style: `key="value"` / `key='value'` (string), `key={expr}`
315
+ (expression), or a bare `key` (boolean `true`).
316
+ * Paired components take children: `<Card>…</Card>` renders `…` as the
317
+ component's `content`, and children may themselves contain nested components.
318
+ * The legacy `{% component %}` tag still works and can be mixed freely.
319
+
320
+ ### Scoped CSS
321
+
322
+ A `<style>` block in a `.ep` file is extracted, scoped to the component/route's
323
+ output (a `data-epresso-<hash>` attribute), and linked from the head:
324
+
325
+ ```epresso
326
+ ---
327
+ ---
328
+ <style>
329
+ .card { border: 1px solid #ccc; }
330
+ :global(.reset) { margin: 0; } /* opt out of scoping */
331
+ </style>
332
+ <div class="card">{{ content }}</div>
333
+ ```
334
+
335
+ The scoped CSS is written to `dist/_scoped/epresso-<hash>.css` and a `<link>` is
336
+ injected into any page that uses it. Legacy `.html` components and `.html`+`.py`
337
+ routes continue to work unchanged.
338
+
339
+ #### Layout components are automatically unscoped
340
+
341
+ A `.ep` component is **scoped only when it contains a scoped `<style>` block**:
342
+ it is then wrapped in a `data-epresso-*` div and its selectors are rewritten. A
343
+ component with no scoped CSS — or only `<style is:global>` — renders **unscoped**
344
+ (no wrapper). So a layout shell that emits a full `<!doctype html>` document is
345
+ automatically unscoped, and any layout `<style>` is made global with
346
+ `<style is:global>` or by linking a global stylesheet —
347
+ a layout is just a component whose body renders `{{ content }}` (the default
348
+ slot):
349
+
350
+ ```epresso
351
+ ---
352
+ ---
353
+ <!doctype html><html><head><title>{{ title }}</title></head>
354
+ <body>{{ content }}</body></html>
355
+ ```
356
+
357
+ ```jinja
358
+ <BaseLayout title={title}>
359
+ <Header />
360
+ <main>…</main>
361
+ <Footer />
362
+ </BaseLayout>
363
+ ```
364
+
365
+ Layout components may live in `layouts/` (resolved as a component in
366
+ addition to `components/`), so `BaseLayout` can stay alongside your
367
+ layouts while being composed as a component.
368
+
369
+ ### Slots
370
+
371
+ Components get a **default slot** (`{{ content }}`, the children between the
372
+ open/close tags) plus **named slots** mirroring `<slot name=…>`: a child
373
+ `<Fragment slot="name">…</Fragment>` contributes to `slots["name"]` and is
374
+ removed from the default `content`.
375
+
376
+ ```jinja
377
+ <Card title="Hi">
378
+ <Fragment slot="header">Header content</Fragment>
379
+ Default body content
380
+ </Card>
381
+ ```
382
+
383
+ ```epresso
384
+ ---
385
+ ---
386
+ <div class="card"><h3>{{ props.title }}</h3><header>{{ slot('header') }}</header>{{ content }}</div>
387
+ ```
388
+
389
+ Use `{{ slot('name') or 'fallback' }}` for slot fallback content. Works for both
390
+ `.ep` and `.html` components.
391
+
392
+ ### Client scripts
393
+
394
+ A `<script>` block in a `.ep` file is extracted, bundled with esbuild (falling
395
+ back to a raw module when esbuild isn't installed — JS stays optional), and
396
+ loaded on any page that uses the route/component:
397
+
398
+ ```epresso
399
+ ---
400
+ ---
401
+ <style>.count { color: red; }</style>
402
+ <script>
403
+ document.querySelector('.count').addEventListener('click', () => alert('hi'));
404
+ </script>
405
+ <button class="count">{{ content }}</button>
406
+ ```
407
+
408
+ The bundle is written to `dist/_epresso/scripts/<hash>.js` and a
409
+ `<script type="module" src="/_epresso/scripts/<hash>.js">` tag is injected before
410
+ `</body>` on the pages that use it. Identical script blocks dedupe to one file.
411
+
412
+ So a single `.ep` file bundles: **Python frontmatter
413
+ (template logic) + Jinja body (markup) + `<style>` (scoped CSS) + `<script>`
414
+ (page-level client JS)**.
415
+
416
+ ## Templates
417
+
418
+ Jinja2 with curated globals — no arbitrary Python:
419
+
420
+ ```jinja
421
+ {% extends "layouts/base.html" %}
422
+ {% block title %}{{ props.title }}{% endblock %}
423
+ {% block content %}
424
+ {{ seo(title=props.title, path=route.path) }}
425
+ <article>
426
+ <h1>{{ props.title }}</h1>
427
+ <img src="{{ image('photos/hero.jpg', widths=[400,800,1200], alt='Hero') }}">
428
+ {{ content | safe }}
429
+ </article>
430
+ {% endblock %}
431
+ ```
432
+
433
+ Globals: `site`, `url()`, `asset()`, `image()`, `seo()`, `get_collection()`, `get_entry()`, `route`, `params`, `props`, `content`.
434
+
435
+ ## Plugins
436
+
437
+ ```python
438
+ from epresso.plugins import Plugin
439
+
440
+ def greeter(*, text="hello"):
441
+ def on_setup(caps):
442
+ caps.add_global("greeting", lambda: text)
443
+ return Plugin(name="greeter", hooks={"on_setup": on_setup})
444
+ ```
445
+
446
+ Plugins are **deduplicated by name** and run in **`priority` order**; each hook
447
+ receives a narrow `Capabilities` handle rather than the `Site`, and can add
448
+ template globals/filters, register content collections & Markdown extensions,
449
+ and transform rendered HTML. Enable them from a project `plugins.py` (build with
450
+ options via a factory) or `[plugins]` dotted paths in `site.toml`. Lifecycle
451
+ hooks: `before_load`, `on_setup`, `after_load`, `before_build`, `after_build`,
452
+ `on_assets`. See `examples/plugins/` and the [plugin guide](docs/guides/extending/plugins.md).
453
+
454
+ ## Themes
455
+
456
+ ```bash
457
+ epresso new docs mydocs # docs theme (sidebar nav, ToC-friendly)
458
+ epresso new blog myblog # blog theme (posts, index, RSS feed)
459
+ ```
460
+
461
+ Themes are git-cloned from their own repos and given to you as a starter project you fully own.
462
+
463
+ ## CLI
464
+
465
+ | Command | Description |
466
+ |---|---|
467
+ | `epresso init` | Scaffold a blank project |
468
+ | `epresso new <theme> <dest>` | Scaffold from a theme (docs/blog) |
469
+ | `epresso dev` | Development server with live reload |
470
+ | `epresso build` | Deterministic + incremental production build |
471
+ | `epresso preview` | Build then serve `dist/` (production preview) |
472
+ | `epresso docs` | Build + serve the documentation (port 4321) |
473
+ | `epresso clean` | Remove `dist/` and the build cache |
474
+ | `epresso check` | Validate config + content, list routes |
475
+ | `epresso version` | Print the version |
476
+
477
+ ## Development
478
+
479
+ ```bash
480
+ git clone https://github.com/epresso-ssg/epresso
481
+ cd epresso
482
+ uv sync --extra dev
483
+ uv run pytest # 276 tests
484
+ uv run ruff check .
485
+ uv run pyright src/epresso
486
+ uv run pytest --cov=epresso
487
+ ```
488
+
489
+ ### Run the bundled themes
490
+
491
+ The repo ships runnable theme projects under `themes/` (`basic`, `blog`,
492
+ `docs`, `website`). From the repo root, point any epresso command at one with
493
+ `--project .`:
494
+
495
+ ```bash
496
+ uv run --project . epresso dev themes/basic # dev server with live reload
497
+ uv run --project . epresso build themes/basic # build → themes/basic/dist
498
+ uv run --project . epresso check themes/basic # validate config + content
499
+ uv run --project . epresso preview themes/basic # build + serve dist/
500
+ ```
501
+
502
+ The `website` example is the most feature-rich — it exercises `.ep` components
503
+ and scoped CSS.
504
+
505
+ ## Editor support
506
+
507
+ Neovim / Vim syntax highlighting for `.ep` files (Python frontmatter +
508
+ Jinja2 body) ships in `extras/nvim/`. Add it to your runtimepath once and every
509
+ epresso project is highlighted automatically:
510
+
511
+ ```lua
512
+ -- ~/.config/nvim/init.lua
513
+ vim.opt.rtp:append("/path/to/epresso/extras/nvim")
514
+ ```
515
+
516
+ See [`extras/nvim/README.md`](extras/nvim/README.md) for details and LazyVim
517
+ instructions.
518
+
519
+ ## License
520
+
521
+ MIT