From 6a5b3aab9e5876b0c282d8796a6e8286eced8214 Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Thu, 27 Aug 2026 14:25:38 +1000 Subject: [PATCH] feat: prepare DFTE 1.0.2 --- .github/workflows/ci.yml | 14 +- .github/workflows/release.yml | 6 +- README.md | 343 ++---------------- docs/ASYNC_WEB.md | 29 ++ docs/CONFIGURATION.md | 25 ++ docs/DEVELOPMENT.md | 16 + docs/GETTING_STARTED.md | 42 +++ docs/README.md | 20 + docs/TEMPLATE_LANGUAGE.md | 30 ++ docs/TESTING.md | 14 + examples/AsyncDashboardDemo/README.md | 2 + examples/HelloPlaceholder/README.md | 2 + examples/NestedLayouts/README.md | 2 + examples/README.md | 22 ++ examples/StreamingAsync/README.md | 2 + library.json | 11 + scripts/check-docs.sh | 28 ++ scripts/prepare-release.sh | 7 + scripts/release-notes.sh | 30 ++ scripts/test.sh | 19 + src/DeviceFrameworkTemplateContext.cpp | 2 +- .../tests/test_template_context.cpp | 4 +- 22 files changed, 361 insertions(+), 309 deletions(-) create mode 100644 docs/ASYNC_WEB.md create mode 100644 docs/CONFIGURATION.md create mode 100644 docs/DEVELOPMENT.md create mode 100644 docs/GETTING_STARTED.md create mode 100644 docs/README.md create mode 100644 docs/TEMPLATE_LANGUAGE.md create mode 100644 docs/TESTING.md create mode 100644 examples/README.md create mode 100755 scripts/check-docs.sh create mode 100755 scripts/release-notes.sh create mode 100755 scripts/test.sh 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;