diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8d8fc8d..6a2d311 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,22 +2,32 @@ name: Build
on:
push:
+ branches: [main]
pull_request:
+concurrency:
+ group: build-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
permissions:
contents: read
jobs:
+ documentation:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - run: ./scripts/check-docs.sh
compile-tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
- environment: ["test_template_engine_8266", "test_template_engine_esp32"]
+ platform: [esp8266, esp32]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: python -m pip install --upgrade platformio==6.1.19
- - run: pio test -e ${{ matrix.environment }} --without-uploading --without-testing
+ - run: ./scripts/test.sh compile --platform ${{ matrix.platform }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9126114..07debb4 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -17,7 +17,11 @@ jobs:
with:
python-version: '3.11'
- run: python -m pip install --upgrade platformio==6.1.19
+ - run: ./scripts/test.sh compile --platform esp8266
+ - run: ./scripts/test.sh compile --platform esp32
+ - run: ./scripts/check-docs.sh
- run: ./scripts/prepare-release.sh "$GITHUB_REF_NAME"
- - run: gh release create "$GITHUB_REF_NAME" --generate-notes --title "$GITHUB_REF_NAME"
+ - run: ./scripts/release-notes.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/release-notes.md"
+ - run: gh release create "$GITHUB_REF_NAME" --title "DeviceFrameworkTemplateEngine $GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/release-notes.md"
env:
GH_TOKEN: ${{ github.token }}
diff --git a/README.md b/README.md
index 89e26eb..08934c0 100644
--- a/README.md
+++ b/README.md
@@ -1,333 +1,70 @@
-# Device Framework Template Engine (DFTE)
+# Device Framework Template Engine
-DFTE is a lightweight C++ template engine for ESP8266 and ESP32 Arduino
-projects that need to render HTML dashboards or textual feeds without allocating
-giant buffers. It streams HTML over chunked HTTP, stitches together nested
-layouts from PROGMEM, and injects live device data from RAM getters while
-keeping the microcontroller responsive.
+DFTE streams HTML and text for ESP8266 and ESP32 without constructing a giant response in RAM. It combines PROGMEM templates, live getters, nested layouts, conditions, and iterators with request-safe asynchronous HTTP rendering.
-The published package and CI support ESP8266 and ESP32. The code may be useful
-on other Arduino-compatible targets, but those targets are not currently part
-of the supported release matrix and should be validated by the consuming
-project.
+## Why use it
-## Quickstart
+- **Predictable memory:** render fixed-size chunks instead of one large `String`.
+- **Reusable templates:** keep layouts, partials, CSS, and HTML in flash.
+- **Live device data:** resolve values from getters only when a chunk is rendered.
+- **Safe async responses:** use one render context per HTTP request while sharing one prepared registry.
-1. Define your root template in PROGMEM (or RAM if you prefer):
-
- ```
- static const char ROOT_TEMPLATE_PROGMEM[] PROGMEM = R"DFTE(
-
-
%APP_TITLE%
-
- %APP_TITLE%
- Uptime: %UPTIME%
-
-
- )DFTE";
- ```
-
-2. In your sketch, register placeholders and stream them out:
-
- ```
- #include
-
- PlaceholderRegistry registry;
- TemplateContext ctx;
-
- void setup() {
- Serial.begin(115200);
-
- registry.registerProgmemData(PSTR("%APP_TITLE%"), PSTR("DFTE Quickstart"));
- registry.registerRamData(PSTR("%UPTIME%"), []() -> const char* {
- static char buffer[16];
- snprintf(buffer, sizeof(buffer), "%lus", millis() / 1000);
- return buffer;
- });
- registry.registerProgmemTemplate(PSTR("%ROOT%"), ROOT_TEMPLATE_PROGMEM);
-
- ctx.setRegistry(®istry);
- TemplateRenderer::initializeContext(ctx, PSTR("%ROOT%"));
- }
-
- void loop() {
- static uint8_t buffer[128];
- if (!TemplateRenderer::isComplete(ctx) && !TemplateRenderer::hasError(ctx)) {
- size_t written = TemplateRenderer::renderNextChunk(ctx, buffer, sizeof(buffer));
- Serial.write(buffer, written);
- }
- }
- ```
-
-3. Open the serial monitor (or send chunks to `AsyncWebServer`) to watch the template stream without ever allocating the full HTML in RAM.
-
-## Why DFTE vs Alternatives?
-
-| Approach | Streaming | Dynamic data | Template reuse | Notes |
-|----------|-----------|--------------|---------------|-------|
-| DFTE (this library) | ✅ Streams in 128–512 byte chunks | ✅ Registry handles placeholders, conditionals, iterators | ✅ PROGMEM templates shared across requests | Built for async HTTP; keeps RAM usage predictable |
-| Static SPIFFS/LittleFS files | ❌ Full file send only | ⚠️ Requires pre-generated HTML | ✅ Stored once on flash filesystem | Great for static assets, not ideal for live telemetry |
-| Arduino `String` concatenation | ❌ Concatenates into one RAM buffer | ✅ Manual `String` inserts | ❌ Template duplicated per build | Fast to prototype but fragments heap on longer sketches |
-| AsyncWebServer template callback | ⚠️ Streams callback output but copies per placeholder | ✅ Values supplied in callback | ❌ No shared layout or nesting support | Fine for small pages; scales poorly with complex UIs |
-| Server-side proxy (external backend) | ✅ Offloaded to external service | ✅ Managed by backend | ❌ Device ships only proxy stub | Requires constant connectivity and extra infrastructure |
-
-## Core Types & API
-- `PlaceholderRegistry`
- - `registerProgmemData(const char*, const char*)` – link `%TOKEN%` to flash-resident data.
- - `registerRamData(const char*, PlaceholderDataGetter)` – provide dynamic strings from getters.
- - `registerProgmemTemplate(const char*, const char*)` – nest other templates.
- - `registerDynamicTemplate(const char*, const DynamicTemplateDescriptor*)` – compute template fragments at render time.
- - `registerConditional(const char*, const ConditionalDescriptor*)` – choose between delegates (`TRUE_BRANCH`, `FALSE_BRANCH`, `SKIP`).
- - `registerIterator(const char*, const IteratorDescriptor*)` – stream repeated sections item-by-item.
- - `getPlaceholder`, `getCount`, `clear` – inspection/utilities used throughout the tests.
-
-- `TemplateContext`
- - Holds the render stack, buffers, and statistics.
- - `setRegistry(PlaceholderRegistry*)` – inject the registry you populated.
- - `reset()` – reuse the context without re-allocating buffers.
- - `isComplete()`, `hasError()`, `getStateString()` – status helpers.
-
-- `TemplateRenderer`
- - `initializeContext(TemplateContext&, const char*, bool templateInProgmem = true)` – prime the context with the root template.
- - `renderNextChunk(TemplateContext&, uint8_t* buf, size_t len)` – stream out the next chunk; returns written bytes.
- - `isComplete(const TemplateContext&)`, `hasError(const TemplateContext&)` – convenience checks.
-
-- `DeviceFrameworkTemplateEngineDebug`
- - Optional logging interface; create a `DeviceFrameworkTemplateEngineLogger` subclass and call `deviceFrameworkTemplateEngineEnableLogging(&logger)` (or the two-argument overload with an owner tag, e.g. `this`, for `deviceFrameworkTemplateEngineDisableLoggingForOwner`).
-
-All public headers are re-exported from `TemplateEngine.h`, so typical sketches only include that file.
-
-## Usage Overview
-1. Include `TemplateEngine.h`.
-
- ```
- #include
- ```
-
-2. Register placeholders on a `PlaceholderRegistry`.
-
- ```
- PlaceholderRegistry registry;
- registry.registerProgmemData(PSTR("%APP_TITLE%"), PSTR("DFTE Dashboard"));
- registry.registerRamData(PSTR("%UPTIME%"), []() -> const char* {
- static char buffer[16];
- snprintf(buffer, sizeof(buffer), "%lus", millis() / 1000);
- return buffer;
- });
- registry.registerProgmemTemplate(PSTR("%ROOT%"), ROOT_TEMPLATE_PROGMEM);
- ```
-
-3. Attach the registry to a `TemplateContext`.
-
- ```
- TemplateContext ctx;
- ctx.setRegistry(®istry);
- ```
-
-4. Feed the root template to `TemplateRenderer::initializeContext`.
-
- ```
- TemplateRenderer::initializeContext(ctx, PSTR("%ROOT%"));
- ```
-
-5. Loop on `renderNextChunk` until `TemplateRenderer::isComplete(ctx)` is `true`.
-
- ```
- uint8_t buffer[128];
- while (!TemplateRenderer::isComplete(ctx) && !TemplateRenderer::hasError(ctx)) {
- size_t written = TemplateRenderer::renderNextChunk(ctx, buffer, sizeof(buffer));
- Serial.write(buffer, written);
- }
- ```
-
-6. Optionally reuse the same context for additional templates by calling `ctx.reset()`.
-
- ```
- ctx.reset();
- TemplateRenderer::initializeContext(ctx, PSTR("%DETAIL_PANEL%"));
- ```
-
-### Async Streaming Pattern
-
-When serving requests with ESPAsyncWebServer, include `TemplateEngineAsyncWeb.h` and give every request its own `TemplateContext` so chunked rendering cannot be corrupted by overlapping clients. Build and cache your `PlaceholderRegistry` once during setup, then share it across handlers. The same pattern powers the DeviceFramework web UI and the DFTE examples:
+## Try it
```cpp
#include
-#include
-/** Global registry prepared during setup() */
-std::shared_ptr registry;
-
-void setupRegistry() {
- registry = std::make_shared();
- registry->registerProgmemData(PSTR("%APP_TITLE%"), PSTR("DFTE Async Portal"));
- registry->registerRamData(PSTR("%UPTIME%"), []() -> const char* {
- static char buffer[16];
- snprintf(buffer, sizeof(buffer), "%lus", millis() / 1000);
- return buffer;
- });
- registry->registerProgmemTemplate(PSTR("%ROOT%"), ROOT_TEMPLATE_PROGMEM);
-}
-
-void streamTemplate(AsyncWebServerRequest* request, const char* rootTemplate) {
- auto ctx = std::make_shared();
- ctx->setRegistry(registry.get());
- TemplateRenderer::initializeContext(*ctx, rootTemplate);
-
- request->onDisconnect([ctx]() mutable { ctx.reset(); });
-
- AsyncWebServerResponse* response =
- TemplateEngineAsyncWeb::beginSafeTemplateResponse(
- request, "text/html; charset=utf-8", ctx, 128);
-
- response->addHeader("Cache-Control", "no-cache, no-store, must-revalidate");
- response->addHeader("Pragma", "no-cache");
- response->addHeader("Expires", "-1");
-
- request->send(response);
-}
+static const char PAGE[] PROGMEM = "%TITLE%
";
+ Serial.begin(115200);
+PlaceholderRegistry registry;
+TemplateContext context;
void setup() {
- setupRegistry();
- server.on("/", [](AsyncWebServerRequest* request) {
- streamTemplate(request, PSTR("%ROOT%"));
- });
- server.begin();
+ registry.registerProgmemData(PSTR("%TITLE%"), PSTR("Hello DFTE"));
+ registry.registerProgmemTemplate(PSTR("%PAGE%"), PAGE);
+ context.setRegistry(®istry);
+ TemplateRenderer::initializeContext(context, PSTR("%PAGE%"));
+}
+
+void loop() {
+ uint8_t chunk[128];
+ if (!TemplateRenderer::isComplete(context) && !TemplateRenderer::hasError(context)) {
+ Serial.write(chunk, TemplateRenderer::renderNextChunk(context, chunk, sizeof(chunk)));
+ }
}
```
-1. Build and cache the `PlaceholderRegistry` during startup so heavy PROGMEM registration runs once.
-2. Allocate a request-scoped `TemplateContext`, initialise it with the shared registry, and render inside the chunked callback.
-3. Tear everything down on completion or disconnect to avoid state bleed between clients.
+## Featured examples
-### Template Syntax
+| Goal | Example |
+| --- | --- |
+| Smallest placeholder render | [HelloPlaceholder](examples/HelloPlaceholder/) |
+| Layouts, partials, and conditions | [NestedLayouts](examples/NestedLayouts/) |
+| Async HTTP streaming | [StreamingAsync](examples/StreamingAsync/) |
+| Dashboard, iterators, and telemetry | [AsyncDashboardDemo](examples/AsyncDashboardDemo/) |
-Every placeholder in a template uses `%NAME%`. DFTE looks up `NAME` in the registry and decides how to render it based on the registered type. Templates can be nested arbitrarily (up to `DFTE_MAX_STACK_DEPTH_DEFAULT` unless you raise it).
-
-### Placeholder Types
-
-- **Static data** – `registerProgmemData("%CSS%", PROGMEM_BLOCK)` streams literal content from flash or RAM.
-- **Nested template** – `registerProgmemTemplate("%HEADER%", HEADER_TEMPLATE)` injects another template that can contain its own placeholders.
-- **Dynamic value** – `registerRamData("%UPTIME%", getter)` calls a function that returns the current value as a `const char*`.
-- **Dynamic template** – `registerDynamicTemplate("%CONTENT%", &DynamicTemplateDescriptor{getter, getLength, userData})` asks your getter to return template text at render time.
-- **Conditional** – `registerConditional("%IS_ONLINE%", &ConditionalDescriptor{evaluate, "%ONLINE%", "%OFFLINE%", userData})` chooses which delegate placeholder to render based on the evaluator result.
-- **Iterator** – `registerIterator("%SENSORS%", &IteratorDescriptor{open, next, close, userData})` opens a handle, streams each item template through `IteratorItemView`, and finalises with `close`.
-
-### Buildable Examples
-
-All demos under `examples/` are standalone PlatformIO projects that use the library via `lib_extra_dirs`. Each contains a `platformio.ini` with ready-to-build environments, so you can compile and upload without touching your primary application.
-
-- `examples/AsyncDashboardDemo/` – Full SoftAP dashboard with iterators, conditionals, and runtime telemetry.
-- `examples/StreamingAsync/` – Minimal captive portal that streams the template directly to the HTTP response.
-- `examples/NestedLayouts/` – Demonstrates layout stacking, partials, and conditional fragments.
-- `examples/HelloPlaceholder/` – Smallest possible sketch that renders a single placeholder.
-
-Typical workflow (replace the path/env as needed):
-
-```
-# Build
-pio run -d examples/StreamingAsync -e example_esp32
-
-# Flash
-pio run -d examples/StreamingAsync -e example_esp32 -t upload
-
-# Monitor (optional)
-pio run -d examples/StreamingAsync -e example_esp32 -t monitor
-```
-
-ESP8266 variants use the `example_esp8266` or `dashboard_esp8266` environments, while ESP32 boards use `example_esp32` or `dashboard_esp32`. Update Wi-Fi credentials inside each example’s `src/main.cpp`, then connect to the serial monitor or SoftAP as documented in the per-example README.
-
-Refer to the Unity tests in `test/test_template_engine/tests` for exhaustive combinations of placeholders, nested templates, conditionals, and iterators.
-
-### Memory & Configuration Knobs
-
-DFTE has two kinds of limits. Set build-wide limits with PlatformIO `build_flags`, so the library and every consuming translation unit use the exact same object layout. Do not set these only in a sketch header.
-
-- `DFTE_BUFFER_SIZE` (default 512) – streaming buffer inside `TemplateContext`.
-- `DFTE_MAX_STACK_DEPTH` (default 16) – rendering stack frames. A nested template generally uses two frames, so set this to at least twice the intended nesting plus one.
-- `DFTE_PLACEHOLDER_NAME_SIZE` (default 24) – maximum placeholder-token length including `%` characters.
-- `DFTE_PROGMEM_CHUNK_SIZE` (default 512) and `DFTE_RAM_CHUNK_SIZE` (default 128) – source copy windows.
-- `DFTE_MAX_ITERATIONS` (default 50) – safety cap for an individual render call.
-- `DFTE_MAX_PLACEHOLDERS_DEFAULT` (default 16) – constructor default only; alternatively pass an explicit capacity to `PlaceholderRegistry`.
-
-```ini
-; platformio.ini
-build_flags =
- -DDFTE_BUFFER_SIZE=768
- -DDFTE_MAX_STACK_DEPTH=24
- -DDFTE_PLACEHOLDER_NAME_SIZE=32
- -DDFTE_MAX_ITERATIONS=80
-```
-
-DFTE is deliberately independent of DeviceFramework's runtime `CONFIG_template*` values. DeviceFramework may choose a registry capacity at runtime, but any fixed DFTE layout must be configured by the shared `DFTE_*` build flags above. This prevents an application header and the compiled library from disagreeing about object size.
-
-## PlatformIO Usage
-
-### Consume as a dependency
-
-Pin a released Git tag in the application's `platformio.ini`:
+## Install
```ini
lib_deps =
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.0.2
```
-The suffix after `#` is a Git ref. PlatformIO clones the repository and checks
-out that tag; GitHub Release assets are unrelated. A tag is reproducible,
-whereas `#main` deliberately follows moving development. Use a local
-`symlink://` or `file://` dependency only while working on DFTE itself.
+PlatformIO checks out the Git ref after `#`; GitHub Release assets are unrelated. DFTE’s supported release targets are ESP8266 and ESP32.
-### Test without hardware
+## Documentation
-The test environments compile the full Unity suites without uploading or
-executing them, so no board is required:
+Read the [documentation index](docs/README.md) for template syntax, async web responses, ABI-safe build flags, examples, tests, and releases.
-```bash
-pio test -e test_template_engine_8266 --without-uploading --without-testing
-pio test -e test_template_engine_esp32 --without-uploading --without-testing
-```
-
-Both environments set `test_build_src = yes`, so the library sources are
-included. CI runs the two commands above on every push and pull request.
-
-### Publish a release
-
-Update `library.json`, `CHANGELOG.md`, and the public documentation, run
-both compile checks, then create the annotated tag:
+## Development and releases
```bash
+./scripts/test.sh compile --platform esp8266
+./scripts/test.sh compile --platform esp32
+./scripts/check-docs.sh
./scripts/prepare-release.sh vMAJOR.MINOR.PATCH --tag
```
-Push the branch and tag. The tag workflow validates the PlatformIO package
-again and creates the GitHub Release.
+Tagging repeats the board-free compile checks, validates the package, and creates a GitHub Release from the matching changelog section. It does not publish to the PlatformIO Registry or deploy firmware.
-### Debug Logging
-
-DFTE’s logger is opt-in and costs nothing until you enable it. Implement `DeviceFrameworkTemplateEngineLogger`, register it once, and all internal `DFTE_LOG_*` calls stream through your logger.
-
-```
-#include
-#include
-
-class SerialLogger : public DeviceFrameworkTemplateEngineLogger {
-public:
- void error(const String& msg) override { Serial.println("[DFTE][E] " + msg); }
- void warn(const String& msg) override { Serial.println("[DFTE][W] " + msg); }
- void info(const String& msg) override { Serial.println("[DFTE][I] " + msg); }
- void debug(const String& msg) override { Serial.println("[DFTE][D] " + msg); }
-};
-
-SerialLogger logger;
-
-void setup() {
- Serial.begin(115200);
- deviceFrameworkTemplateEngineEnableLogging(&logger, static_cast(&logger));
-}
-```
-
-Use `deviceFrameworkTemplateEngineDisableLogging()` to silence output, `deviceFrameworkTemplateEngineDisableLoggingForOwner(tag)` to tear down only the registration made with that owner (for example WiFiManager’s server instance), or `deviceFrameworkTemplateEngineIsLoggingEnabled()` to inspect the current state.
-
-## License
-
-This project is released under the MIT License. See `LICENSE` for details.
+See the [changelog](CHANGELOG.md) and [licence](LICENSE).
diff --git a/docs/ASYNC_WEB.md b/docs/ASYNC_WEB.md
new file mode 100644
index 0000000..3619651
--- /dev/null
+++ b/docs/ASYNC_WEB.md
@@ -0,0 +1,29 @@
+# Async web responses
+
+Build a `PlaceholderRegistry` once during setup, but allocate a fresh `TemplateContext` for each request. A shared context would mix rendering state when clients overlap.
+
+```cpp
+#include
+#include
+
+std::shared_ptr registry;
+
+void sendTemplate(AsyncWebServerRequest* request, const char* root) {
+ auto context = std::make_shared();
+ context->setRegistry(registry.get());
+ TemplateRenderer::initializeContext(*context, root);
+ request->onDisconnect([context]() mutable { context.reset(); });
+
+ AsyncWebServerResponse* response =
+ TemplateEngineAsyncWeb::beginSafeTemplateResponse(
+ request, "text/html; charset=utf-8", context, 128
+ );
+ request->send(response);
+}
+```
+
+The response retains the context while it streams. Releasing the request-owned `shared_ptr` on disconnect prevents state from leaking into later requests.
+
+Use [StreamingAsync](../examples/StreamingAsync/) for a complete SoftAP/captive-portal project.
+
+Back to [documentation](README.md) · [project overview](../README.md).
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
new file mode 100644
index 0000000..d7c9259
--- /dev/null
+++ b/docs/CONFIGURATION.md
@@ -0,0 +1,25 @@
+# Configuration and memory limits
+
+DFTE object layouts depend on fixed `DFTE_*` compile-time limits. Define any changes through shared PlatformIO `build_flags` so the library and every consuming translation unit agree on the same layout.
+
+```ini
+build_flags =
+ -DDFTE_BUFFER_SIZE=768
+ -DDFTE_MAX_STACK_DEPTH=24
+ -DDFTE_PLACEHOLDER_NAME_SIZE=32
+ -DDFTE_MAX_ITERATIONS=80
+```
+
+| Flag | Default | Meaning |
+| --- | --- | --- |
+| `DFTE_BUFFER_SIZE` | 512 | Streaming buffer size in `TemplateContext` |
+| `DFTE_MAX_STACK_DEPTH` | 16 | Render stack frames; nested templates usually need two frames each |
+| `DFTE_PLACEHOLDER_NAME_SIZE` | 24 | Maximum token length including `%` characters |
+| `DFTE_PROGMEM_CHUNK_SIZE` | 512 | PROGMEM source copy window |
+| `DFTE_RAM_CHUNK_SIZE` | 128 | RAM source copy window |
+| `DFTE_MAX_ITERATIONS` | 50 | Safety cap for one render call |
+| `DFTE_MAX_PLACEHOLDERS_DEFAULT` | 16 | Default registry constructor capacity |
+
+`DFTE_MAX_PLACEHOLDERS_DEFAULT` is only a constructor default; pass an explicit registry capacity where a device needs more. DeviceFramework runtime template parameters do not change DFTE’s compile-time object layout.
+
+Back to [documentation](README.md) · [project overview](../README.md).
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
new file mode 100644
index 0000000..5bb508e
--- /dev/null
+++ b/docs/DEVELOPMENT.md
@@ -0,0 +1,16 @@
+# Development and releases
+
+Released applications should use a public Git tag. While changing DFTE with a sibling library, select a local `symlink://` or `file://` dependency from an ignored PlatformIO override rather than changing tracked application dependencies.
+
+Before a release, update `library.json`, `CHANGELOG.md`, and the relevant guides, then run:
+
+```bash
+./scripts/check-docs.sh
+./scripts/test.sh compile --platform esp8266
+./scripts/test.sh compile --platform esp32
+./scripts/prepare-release.sh vMAJOR.MINOR.PATCH --tag
+```
+
+Push the branch and annotated tag. GitHub Actions repeats the board-free compile checks, validates the package, and creates a GitHub Release from that version’s changelog section. It does not publish to the PlatformIO Registry.
+
+Back to [documentation](README.md) · [project overview](../README.md).
diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md
new file mode 100644
index 0000000..cedd976
--- /dev/null
+++ b/docs/GETTING_STARTED.md
@@ -0,0 +1,42 @@
+# Getting started
+
+DFTE renders a named root template through a `TemplateContext`. Register templates and placeholder values once, then ask the renderer for fixed-size chunks until completion.
+
+```cpp
+#include
+#include
+
+static const char ROOT[] PROGMEM = R"DFTE(
+%TITLE%
Uptime: %UPTIME%
+)DFTE";
+
+PlaceholderRegistry registry;
+TemplateContext context;
+
+void setup() {
+ Serial.begin(115200);
+ registry.registerProgmemData(PSTR("%TITLE%"), PSTR("DFTE Quickstart"));
+ registry.registerRamData(PSTR("%UPTIME%"), []() -> const char* {
+ static char value[16];
+ snprintf(value, sizeof(value), "%lus", millis() / 1000);
+ return value;
+ });
+ registry.registerProgmemTemplate(PSTR("%ROOT%"), ROOT);
+ context.setRegistry(®istry);
+ TemplateRenderer::initializeContext(context, PSTR("%ROOT%"));
+}
+
+void loop() {
+ uint8_t chunk[128];
+ if (!TemplateRenderer::isComplete(context) && !TemplateRenderer::hasError(context)) {
+ const size_t written = TemplateRenderer::renderNextChunk(context, chunk, sizeof(chunk));
+ Serial.write(chunk, written);
+ }
+}
+```
+
+`registerProgmemData()` is for static flash data. `registerRamData()` takes a getter for a value that can change as chunks are rendered. `registerProgmemTemplate()` lets a placeholder expand to another template.
+
+For a buildable project, start with [HelloPlaceholder](../examples/HelloPlaceholder/). Next: [template language](TEMPLATE_LANGUAGE.md).
+
+Back to [documentation](README.md) · [project overview](../README.md).
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..fe6a54c
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,20 @@
+# DFTE documentation
+
+DFTE documentation is organised by the rendering problem being solved.
+
+| I want to… | Read |
+| --- | --- |
+| Render a first template | [Getting started](GETTING_STARTED.md) |
+| Use placeholders, templates, conditions, dynamic fragments, or iterators | [Template language](TEMPLATE_LANGUAGE.md) |
+| Stream a response through ESPAsyncWebServer | [Async web responses](ASYNC_WEB.md) |
+| Change buffer, stack, or placeholder limits safely | [Configuration](CONFIGURATION.md) |
+| Build the standalone examples | [Examples](../examples/README.md) |
+| Run checks or prepare a release | [Testing](TESTING.md) · [Development](DEVELOPMENT.md) |
+
+## Documentation rules
+
+- The template-language guide owns DFTE semantics and lifecycle contracts.
+- The configuration guide owns ABI-sensitive build flags.
+- The project README is an entry point and example chooser, not a full API manual.
+
+Back to the [project overview](../README.md).
diff --git a/docs/TEMPLATE_LANGUAGE.md b/docs/TEMPLATE_LANGUAGE.md
new file mode 100644
index 0000000..f3d8643
--- /dev/null
+++ b/docs/TEMPLATE_LANGUAGE.md
@@ -0,0 +1,30 @@
+# Template language and lifecycle
+
+Every token has the form `%NAME%`. DFTE resolves it from a `PlaceholderRegistry` according to its registered type.
+
+| Registered type | Purpose |
+| --- | --- |
+| `registerProgmemData()` | Static flash-resident text |
+| `registerRamData()` | Getter returning current `const char*` data |
+| `registerProgmemTemplate()` | Nested PROGMEM template |
+| `registerDynamicTemplate()` | Template fragment supplied at render time |
+| `registerConditional()` | Choose a true, false, or skipped delegate |
+| `registerIterator()` | Stream repeated item templates through an iterator handle |
+
+## Context lifecycle
+
+1. Populate a registry during setup.
+2. Attach it to a `TemplateContext`.
+3. Initialise the context with the root placeholder.
+4. Call `renderNextChunk()` until complete or error.
+5. Call `reset()` before reusing a completed context for another root.
+
+Do not treat `isComplete()` as a success result by itself; check `hasError()` as well.
+
+## Iterators
+
+An iterator opens a handle, produces `IteratorItemView` values, then closes the handle. Handles are closed when rendering finishes, resets, stalls, or fails. The `open`, `next`, and `close` callbacks must therefore tolerate early termination.
+
+Use [AsyncDashboardDemo](../examples/AsyncDashboardDemo/) for a complete iterator example. Use [NestedLayouts](../examples/NestedLayouts/) for partials and conditions.
+
+Back to [documentation](README.md) · [project overview](../README.md).
diff --git a/docs/TESTING.md b/docs/TESTING.md
new file mode 100644
index 0000000..53fe972
--- /dev/null
+++ b/docs/TESTING.md
@@ -0,0 +1,14 @@
+# Testing
+
+The two PlatformIO Unity commands compile the complete DFTE test suites without uploading or executing them, so they require no attached board.
+
+```bash
+./scripts/test.sh compile --platform esp8266
+./scripts/test.sh compile --platform esp32
+```
+
+The test environments include the library sources with `test_build_src = yes`. CI runs both target checks on the maintained branch and pull requests.
+
+The standalone examples are buildable PlatformIO projects; see [examples](../examples/README.md).
+
+Back to [documentation](README.md) · [project overview](../README.md).
diff --git a/examples/AsyncDashboardDemo/README.md b/examples/AsyncDashboardDemo/README.md
index b262bf0..6fea11f 100644
--- a/examples/AsyncDashboardDemo/README.md
+++ b/examples/AsyncDashboardDemo/README.md
@@ -17,6 +17,8 @@ pio run -d examples/AsyncDashboardDemo -e dashboard_esp32
## Run
+See the shared [examples guide](../README.md) and [async web guidance](../../docs/ASYNC_WEB.md).
+
1. Flash your target board (`pio run -d examples/AsyncDashboardDemo -e -t upload --upload-port `).
2. Open the serial monitor at 115200 baud to confirm the SoftAP credentials.
3. Connect to the advertised network and browse to `http://192.168.4.1/`. The captive portal should automatically redirect; if not, navigate manually.
diff --git a/examples/HelloPlaceholder/README.md b/examples/HelloPlaceholder/README.md
index ef7bb70..75f648f 100644
--- a/examples/HelloPlaceholder/README.md
+++ b/examples/HelloPlaceholder/README.md
@@ -13,5 +13,7 @@ pio run -d examples/HelloPlaceholder -e example_esp8266
pio run -d examples/HelloPlaceholder -e example_esp32
```
+See the shared [examples guide](../README.md) and [quickstart](../../docs/GETTING_STARTED.md).
+
Flash the firmware and open the serial monitor at 115200 baud to see the rendered text.
diff --git a/examples/NestedLayouts/README.md b/examples/NestedLayouts/README.md
index f8e5f82..00a0eeb 100644
--- a/examples/NestedLayouts/README.md
+++ b/examples/NestedLayouts/README.md
@@ -14,5 +14,7 @@ pio run -d examples/NestedLayouts -e example_esp8266
pio run -d examples/NestedLayouts -e example_esp32
```
+See the shared [examples guide](../README.md) and [template-language guide](../../docs/TEMPLATE_LANGUAGE.md).
+
Open the serial monitor at 115200 baud to inspect the rendered output.
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..da1efd9
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,22 @@
+# DFTE examples
+
+Every example is a standalone PlatformIO project. Build, upload, and monitor from its directory:
+
+```bash
+pio run -d examples/HelloPlaceholder -e example_esp8266
+pio run -d examples/HelloPlaceholder -e example_esp8266 -t upload
+pio run -d examples/HelloPlaceholder -e example_esp8266 -t monitor
+```
+
+Choose the ESP32 environment where provided.
+
+| Example | What it demonstrates |
+| --- | --- |
+| [HelloPlaceholder](HelloPlaceholder/) | Smallest registry/context/chunk flow over serial |
+| [NestedLayouts](NestedLayouts/) | Templates, partials, conditionals, and iterators without a web server |
+| [StreamingAsync](StreamingAsync/) | Request-scoped streaming HTTP response through a SoftAP portal |
+| [AsyncDashboardDemo](AsyncDashboardDemo/) | Dashboard telemetry, iterator rows, and captive-portal flow |
+
+The SoftAP examples print their network names and passwords to serial. They are demonstrations, not production provisioning implementations.
+
+Back to [DFTE documentation](../docs/README.md) · [project overview](../README.md).
diff --git a/examples/StreamingAsync/README.md b/examples/StreamingAsync/README.md
index cb1d378..ebcdcec 100644
--- a/examples/StreamingAsync/README.md
+++ b/examples/StreamingAsync/README.md
@@ -11,6 +11,8 @@ Small ESPAsyncWebServer demo that exposes a captive portal over a SoftAP and ren
```
pio run -d examples/StreamingAsync -e example_esp8266
+
+See the shared [examples guide](../README.md) and [async web guidance](../../docs/ASYNC_WEB.md).
pio run -d examples/StreamingAsync -e example_esp32
```
diff --git a/library.json b/library.json
index 084c1a0..ec5017a 100644
--- a/library.json
+++ b/library.json
@@ -34,5 +34,16 @@
"repository": {
"type": "git",
"url": "https://github.com/alexhopeoconnor/DFTE.git"
+ },
+ "export": {
+ "include": [
+ "include",
+ "src",
+ "examples",
+ "README.md",
+ "CHANGELOG.md",
+ "LICENSE",
+ "library.json"
+ ]
}
}
diff --git a/scripts/check-docs.sh b/scripts/check-docs.sh
new file mode 100755
index 0000000..4418599
--- /dev/null
+++ b/scripts/check-docs.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+required=(
+ README.md CHANGELOG.md
+ docs/README.md docs/GETTING_STARTED.md docs/TEMPLATE_LANGUAGE.md
+ docs/ASYNC_WEB.md docs/CONFIGURATION.md docs/TESTING.md docs/DEVELOPMENT.md
+ examples/README.md
+)
+
+for path in "${required[@]}"; do
+ [[ -f "$root/$path" ]] || { echo "Missing required documentation: $path" >&2; exit 1; }
+done
+
+while IFS= read -r -d '' markdown; do
+ while IFS= read -r target; do
+ [[ -z "$target" || "$target" == \#* || "$target" == http://* || "$target" == https://* || "$target" == mailto:* ]] && continue
+ target="${target%%#*}"
+ case "$target" in
+ /*) candidate="$root/${target#/}" ;;
+ *) candidate="$(dirname "$markdown")/$target" ;;
+ esac
+ [[ -e "$candidate" ]] || { echo "Broken relative link in ${markdown#$root/}: $target" >&2; exit 1; }
+ done < <(sed -nE 's/.*\]\(([^ )]+)( "[^"]*")?\).*/\1/p' "$markdown")
+done < <(find "$root" -path "$root/.git" -prune -o -path '*/.pio' -prune -o -name '*.md' -type f -print0)
+
+echo "Documentation links and required files passed"
diff --git a/scripts/prepare-release.sh b/scripts/prepare-release.sh
index e371f99..32977bb 100755
--- a/scripts/prepare-release.sh
+++ b/scripts/prepare-release.sh
@@ -27,6 +27,13 @@ if [[ -f "$root/library.properties" ]]; then
fi
fi
+if ! grep -q "^## ${version}$" "$root/CHANGELOG.md"; then
+ echo "CHANGELOG.md is missing a ## ${version} section" >&2
+ exit 1
+fi
+
+git -C "$root" diff --check
+
package_dir="$(mktemp -d)"
trap 'rm -rf "$package_dir"' EXIT
pio pkg pack "$root" --output "$package_dir/package.tar.gz" >/dev/null
diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh
new file mode 100755
index 0000000..1f35fe2
--- /dev/null
+++ b/scripts/release-notes.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ echo "Usage: $0 vMAJOR.MINOR.PATCH" >&2
+ exit 2
+}
+
+tag="${1:-}"
+[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage
+
+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+version="${tag#v}"
+changelog="$root/CHANGELOG.md"
+output="$(mktemp)"
+trap 'rm -f "$output"' EXIT
+
+awk -v version="$version" '
+ $0 == "## " version { capture = 1; next }
+ capture && /^## / { exit }
+ capture { print }
+' "$changelog" > "$output"
+
+if [[ ! -s "$output" ]]; then
+ echo "No release notes found for $tag in CHANGELOG.md" >&2
+ exit 1
+fi
+
+printf '%s\n\n' "# DeviceFrameworkTemplateEngine $tag"
+cat "$output"
diff --git a/scripts/test.sh b/scripts/test.sh
new file mode 100755
index 0000000..7e102d2
--- /dev/null
+++ b/scripts/test.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ echo "Usage: $0 compile --platform esp8266|esp32" >&2
+ exit 2
+}
+
+[[ "${1:-}" == "compile" && "${2:-}" == "--platform" && $# -eq 3 ]] || usage
+
+case "${3:-}" in
+ esp8266) environment="test_template_engine_8266" ;;
+ esp32) environment="test_template_engine_esp32" ;;
+ *) usage ;;
+esac
+
+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+pio test -d "$root" -e "$environment" --without-uploading --without-testing
+echo "DFTE compile check passed for ${3}"
diff --git a/src/DeviceFrameworkTemplateContext.cpp b/src/DeviceFrameworkTemplateContext.cpp
index fd89368..6482f21 100644
--- a/src/DeviceFrameworkTemplateContext.cpp
+++ b/src/DeviceFrameworkTemplateContext.cpp
@@ -154,7 +154,7 @@ RenderingContextType DeviceFrameworkTemplateContext::getCurrentContextType() con
}
bool DeviceFrameworkTemplateContext::isComplete() const {
- return state == TemplateRenderState::COMPLETE || state == TemplateRenderState::ERROR;
+ return state == TemplateRenderState::COMPLETE;
}
bool DeviceFrameworkTemplateContext::hasError() const {
diff --git a/test/test_template_engine/tests/test_template_context.cpp b/test/test_template_engine/tests/test_template_context.cpp
index f933ea8..550fe28 100644
--- a/test/test_template_engine/tests/test_template_context.cpp
+++ b/test/test_template_engine/tests/test_template_context.cpp
@@ -38,7 +38,7 @@ void test_template_context_initialization() {
ctx.state = TemplateRenderState::COMPLETE;
TEST_ASSERT_TRUE_MESSAGE(ctx.isComplete(), "Should be complete when state is COMPLETE");
ctx.state = TemplateRenderState::ERROR;
- TEST_ASSERT_TRUE_MESSAGE(ctx.isComplete(), "Should be complete when state is ERROR");
+ TEST_ASSERT_FALSE_MESSAGE(ctx.isComplete(), "An error must not report successful completion");
// Test hasError
ctx.state = TemplateRenderState::TEXT;
@@ -310,7 +310,7 @@ void test_template_context_state() {
TEST_ASSERT_TRUE_MESSAGE(ctx.isComplete(), "COMPLETE should be complete");
ctx.state = TemplateRenderState::ERROR;
- TEST_ASSERT_TRUE_MESSAGE(ctx.isComplete(), "ERROR should be complete");
+ TEST_ASSERT_FALSE_MESSAGE(ctx.isComplete(), "ERROR should not be complete");
// Test hasError for all states
ctx.state = TemplateRenderState::TEXT;