3 Commits
Author SHA1 Message Date
alex dbbb29484a Release DFTE v1.2.0 2026-09-04 17:40:27 +10:00
alex a09f9e331a docs: refine template engine guides and validation 2026-09-04 09:52:07 +10:00
alex ff33de6293 Improve release preparation tooling 2026-09-01 17:29:56 +10:00
15 changed files with 328 additions and 71 deletions
+2
View File
@@ -18,6 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: bash -n scripts/*.sh
- run: ./scripts/check-docs.sh
compile-tests:
runs-on: ubuntu-latest
@@ -32,3 +33,4 @@ jobs:
python-version: '3.11'
- run: python -m pip install --upgrade platformio==6.1.19
- run: ./scripts/test.sh compile --platform ${{ matrix.platform }}
- run: ./scripts/test.sh examples --platform ${{ matrix.platform }}
+2
View File
@@ -18,7 +18,9 @@ jobs:
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 examples --platform esp8266
- run: ./scripts/test.sh compile --platform esp32
- run: ./scripts/test.sh examples --platform esp32
- run: ./scripts/check-docs.sh
- run: ./scripts/prepare-release.sh "$GITHUB_REF_NAME"
- run: ./scripts/release-notes.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/release-notes.md"
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 1.2.0
- Add a borrowed async-response helper for fixed, caller-owned response slots. It avoids a per-request `shared_ptr` control allocation while releasing the slot safely on completion or disconnect.
- Add callback-enabled safe shared response helpers so dynamic contexts can return a bounded owner permit at the same time as their shared state is released.
## 1.1.0
- Replace layout-affecting compile-time storage overrides with per-context,
+21 -34
View File
@@ -1,25 +1,18 @@
# Device Framework Template Engine
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.
DFTE streams HTML and text from ESP8266 and ESP32 firmware without constructing a complete response in RAM. Store layouts in PROGMEM, resolve changing values through getters, and render fixed-size chunks directly to serial or an asynchronous HTTP response.
## Why use it
- **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.
## Try it
## Render a first template
```cpp
#include <TemplateEngine.h>
static const char PAGE[] PROGMEM = "<h1>%TITLE%</h1>";
Serial.begin(115200);
PlaceholderRegistry registry;
TemplateContext context;
void setup() {
Serial.begin(115200);
registry.registerProgmemData(PSTR("%TITLE%"), PSTR("Hello DFTE"));
registry.registerProgmemTemplate(PSTR("%PAGE%"), PAGE);
context.setRegistry(&registry);
@@ -34,37 +27,31 @@ void loop() {
}
```
## Featured examples
Build [Hello Placeholder](examples/HelloPlaceholder/) to see the rendered result over serial.
| Goal | Example |
## Choose an example
| Example | What you will build |
| --- | --- |
| 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/) |
| [Hello Placeholder](examples/HelloPlaceholder/) | the smallest registry, context, and serial-rendering flow |
| [Nested Layouts](examples/NestedLayouts/) | reusable partials, conditions, and iterator sections |
| [Streaming Async](examples/StreamingAsync/) | a streamed ESPAsyncWebServer response over a SoftAP |
| [Async Dashboard Demo](examples/AsyncDashboardDemo/) | a live dashboard with iterator rows and captive-portal access |
## Why DFTE
- **Bounded response memory:** render fixed-size chunks instead of allocating one large `String`.
- **Flash-resident layouts:** keep templates, CSS, and shared fragments in PROGMEM.
- **Live values:** resolve dynamic information only as the active response needs it.
- **Async-safe rendering:** each HTTP request owns its rendering context while sharing the prepared registry.
## Install
```ini
lib_deps =
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.1.0
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.2.0
```
PlatformIO checks out the Git ref after `#`; GitHub Release assets are unrelated. DFTE’s supported release targets are ESP8266 and ESP32.
PlatformIO checks out the Git ref after `#`; GitHub Release assets are unrelated. DFTE supports ESP8266 and ESP32 Arduino projects.
## Documentation
Read the [documentation index](docs/README.md) for template syntax, async web responses, per-context memory sizing, examples, tests, and releases.
## 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
```
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.
See the [changelog](CHANGELOG.md) and [licence](LICENSE).
See [getting started](docs/GETTING_STARTED.md), the [documentation index](docs/README.md), [examples](examples/README.md), [changelog](CHANGELOG.md), and [licence](LICENSE).
+48
View File
@@ -24,6 +24,54 @@ void sendTemplate(AsyncWebServerRequest* request, const char* root) {
The response retains the context while it streams. Releasing the request-owned `shared_ptr` on disconnect prevents state from leaking into later requests.
## Bounded fixed slots
Use `beginBorrowedChunkedResponse()` when the application already owns a small, fixed response pool. It does not allocate a `shared_ptr` control block for the request. The release callback must be idempotent because it can run once when rendering finishes and again if the connection later disconnects.
```cpp
struct ResponseSlot {
bool busy = false;
TemplateContext context;
};
ResponseSlot slots[2];
void releaseSlot(ResponseSlot& slot) {
slot.context.reset();
slot.busy = false;
}
void sendBoundedTemplate(AsyncWebServerRequest* request, const char* root) {
ResponseSlot* slot = nullptr;
for (auto& candidate : slots) {
if (!candidate.busy) {
slot = &candidate;
break;
}
}
if (slot == nullptr) {
request->send(503, "text/plain", "Busy");
return;
}
slot->busy = true;
slot->context.setRegistry(registry.get());
TemplateRenderer::initializeContext(slot->context, root);
request->send(TemplateEngineAsyncWeb::beginBorrowedChunkedResponse(
request, "text/html; charset=utf-8", slot,
[](ResponseSlot& state, uint8_t* out, size_t size, size_t) {
return TemplateEngineAsyncWeb::renderTemplateChunkWithRetries(
state.context, out, size, 128);
},
[](const ResponseSlot& state) {
return TemplateEngineAsyncWeb::isTemplateTerminal(state.context);
},
releaseSlot));
}
```
Use this form only while the slot itself has static or otherwise guaranteed lifetime. Use the `shared_ptr` form above for a request-owned dynamic context.
Use [StreamingAsync](../examples/StreamingAsync/) for a complete SoftAP/captive-portal project.
Back to [documentation](README.md) · [project overview](../README.md).
+3 -1
View File
@@ -2,9 +2,11 @@
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:
Start a release with `bump-version.sh`. It updates package metadata and canonical installation snippets, then creates the changelog section. Replace its generated TODO with the release summary and update any behavioural documentation before running:
```bash
./scripts/bump-version.sh vMAJOR.MINOR.PATCH
# Replace the generated CHANGELOG TODO with the release summary.
./scripts/check-docs.sh
./scripts/test.sh compile --platform esp8266
./scripts/test.sh compile --platform esp32
+1 -9
View File
@@ -1,20 +1,12 @@
# 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) |
| Build, flash, and observe a complete example | [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).
+9 -9
View File
@@ -1,22 +1,22 @@
# DFTE examples
Every example is a standalone PlatformIO project. Build, upload, and monitor from its directory:
Every example is a standalone PlatformIO project. Build, upload, and monitor from the repository root:
```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
pio device monitor -d examples/HelloPlaceholder -e example_esp8266
```
Choose the ESP32 environment where provided.
Choose the corresponding ESP32 environment where provided. The examples use the checked-out DFTE source, so they double as practical integration checks for this repository.
| Example | What it demonstrates |
| Example | Start here when you want to… |
| --- | --- |
| [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 |
| [HelloPlaceholder](HelloPlaceholder/) | understand the smallest registry/context/chunk flow over serial |
| [NestedLayouts](NestedLayouts/) | compose a page with partials, conditions, and repeating data |
| [StreamingAsync](StreamingAsync/) | serve one streamed page through an asynchronous web server |
| [AsyncDashboardDemo](AsyncDashboardDemo/) | inspect a richer browser-facing dashboard without buffering the response |
The SoftAP examples print their network names and passwords to serial. They are demonstrations, not production provisioning implementations.
The SoftAP examples print their network names and passwords to serial. They are self-contained browser demonstrations, not Wi-Fi provisioning implementations.
Back to [DFTE documentation](../docs/README.md) · [project overview](../README.md).
+13 -9
View File
@@ -1,20 +1,24 @@
# Streaming Async
Small ESPAsyncWebServer demo that exposes a captive portal over a SoftAP and renders a DFTE template directly to the HTTP response stream. It highlights:
This ESPAsyncWebServer example exposes a captive portal over a SoftAP and streams a DFTE template directly to each HTTP response. It demonstrates:
- Per-request `TemplateContext` ownership to avoid concurrent request clashes
- PROGMEM template with shared CSS/header/footer snippets
- Runtime data from RAM getters (uptime, connected station count)
- Captive portal DNS redirect so clients automatically receive the dashboard
- one request-scoped `TemplateContext` per response;
- a PROGMEM page with shared CSS/header/footer snippets;
- runtime getters for uptime and connected-station count;
- a captive-portal DNS redirect for browsers that support it.
## Build
```
```bash
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
```
Flash the board, connect to the SoftAP announced by the device (`DFTE-Portal-8266` or `DFTE-Portal-ESP32`, password `dfte-demo`), and your browser should automatically present the streamed dashboard. If it does not, navigate manually to `http://192.168.4.1/`.
## Run
1. Flash your target with `pio run -d examples/StreamingAsync -e <env> -t upload --upload-port <port>`.
2. Open the serial monitor at 115200 baud and confirm the SoftAP credentials.
3. Connect to `DFTE-Portal-8266` or `DFTE-Portal-ESP32` using password `dfte-demo`.
4. Browse to `http://192.168.4.1/` if the captive portal does not open automatically.
See the shared [examples guide](../README.md) and [async web guidance](../../docs/ASYNC_WEB.md).
+116
View File
@@ -85,6 +85,103 @@ AsyncWebServerResponse* beginSafeChunkedResponse(AsyncWebServerRequest* request,
});
}
// A bounded embedded server can own response state in a fixed slot instead of
// allocating a shared control block for every request. The caller guarantees
// that state remains valid until release() is called. release() must be safe
// to call more than once because a completed request can later disconnect.
template <typename StateT, typename FillFn, typename IsDoneFn, typename ReleaseFn>
AsyncWebServerResponse* beginBorrowedChunkedResponse(AsyncWebServerRequest* request,
const char* contentType,
StateT* state,
FillFn fill,
IsDoneFn isDone,
ReleaseFn release) {
request->onDisconnect([state, release]() mutable {
if (state != nullptr) {
release(*state);
}
});
return request->beginChunkedResponse(contentType,
[state, fill, isDone, release](uint8_t* buffer, size_t maxLen, size_t index) mutable -> size_t {
if (state == nullptr) {
return 0;
}
if (isDone(*state)) {
release(*state);
return 0;
}
if (maxLen == 0) {
return RESPONSE_TRY_AGAIN;
}
size_t written = fill(*state, buffer, maxLen, index);
if (written == RESPONSE_TRY_AGAIN) {
return RESPONSE_TRY_AGAIN;
}
if (written > 0) {
return written;
}
if (isDone(*state)) {
release(*state);
return 0;
}
yieldForChunkRetry();
return RESPONSE_TRY_AGAIN;
});
}
// Variant for dynamically allocated state when the owner also needs an
// idempotent completion notification (for example, returning a bounded web
// response permit). The original overload intentionally remains available for
// callers that only need shared ownership.
template <typename StateT, typename FillFn, typename IsDoneFn, typename ReleaseFn>
AsyncWebServerResponse* beginSafeChunkedResponse(AsyncWebServerRequest* request,
const char* contentType,
const std::shared_ptr<StateT>& sharedState,
FillFn fill,
IsDoneFn isDone,
ReleaseFn release) {
request->onDisconnect([state = sharedState, release]() mutable {
release();
state.reset();
});
return request->beginChunkedResponse(contentType,
[state = sharedState, fill, isDone, release](uint8_t* buffer, size_t maxLen, size_t index) mutable -> size_t {
if (!state) {
return 0;
}
if (maxLen == 0) {
return RESPONSE_TRY_AGAIN;
}
size_t written = fill(*state, buffer, maxLen, index);
if (written == RESPONSE_TRY_AGAIN) {
return RESPONSE_TRY_AGAIN;
}
if (written > 0) {
return written;
}
if (isDone(*state)) {
release();
state.reset();
return 0;
}
yieldForChunkRetry();
return RESPONSE_TRY_AGAIN;
});
}
template <typename StateT, typename FillFn, typename IsDoneFn>
AsyncWebServerResponse* beginSafeChunkedResponse(AsyncWebServerRequest* request,
const String& contentType,
@@ -128,6 +225,25 @@ AsyncWebServerResponse* beginSafeChunkedResponse(AsyncWebServerRequest* request,
});
}
template <typename ContextT, typename ReleaseFn>
AsyncWebServerResponse* beginSafeTemplateResponse(AsyncWebServerRequest* request,
const char* contentType,
const std::shared_ptr<ContextT>& sharedContext,
unsigned maxNoProgressRetries,
ReleaseFn release) {
return beginSafeChunkedResponse(
request,
contentType,
sharedContext,
[maxNoProgressRetries](ContextT& context, uint8_t* buffer, size_t maxLen, size_t /*index*/) -> size_t {
return renderTemplateChunkWithRetries(context, buffer, maxLen, maxNoProgressRetries);
},
[](const ContextT& context) -> bool {
return isTemplateTerminal(context);
},
release);
}
template <typename ContextT>
AsyncWebServerResponse* beginSafeTemplateResponse(AsyncWebServerRequest* request,
const char* contentType,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "DeviceFrameworkTemplateEngine",
"version": "1.1.0",
"version": "1.2.0",
"description": "Memory-efficient streaming template engine for ESP8266/ESP32 with chunked rendering support. Designed for embedded web interfaces with PROGMEM template support.",
"keywords": [
"template",
+46
View File
@@ -0,0 +1,46 @@
#!/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}"
repo_url="https://github.com/alexhopeoconnor/DFTE.git"
reference_files=(README.md)
current_version="$(sed -n 's/.*"version": "\([^"]*\)".*/\1/p' "$root/library.json" | head -n 1)"
[[ "$current_version" != "$version" ]] || {
echo "library.json already declares $version; choose a new version." >&2
exit 1
}
grep -q "^## $version$" "$root/CHANGELOG.md" && {
echo "CHANGELOG.md already has a $version section; choose a new version." >&2
exit 1
}
sed -i -E '0,/"version": "[0-9]+\.[0-9]+\.[0-9]+"/s//"version": "'"$version"'"/' "$root/library.json"
for file in "${reference_files[@]}"; do
sed -i -E "s|${repo_url}#v[0-9]+\.[0-9]+\.[0-9]+|${repo_url}#v${version}|g" "$root/$file"
done
temp_file="$(mktemp)"
trap 'rm -f "$temp_file"' EXIT
{
IFS= read -r changelog_heading < "$root/CHANGELOG.md"
[[ "$changelog_heading" == "# Changelog" ]] || {
echo "CHANGELOG.md must begin with # Changelog" >&2
exit 1
}
printf '%s\n\n## %s\n\n- TODO: Describe this release.\n' "$changelog_heading" "$version"
tail -n +2 "$root/CHANGELOG.md"
} > "$temp_file"
mv "$temp_file" "$root/CHANGELOG.md"
echo "Updated DFTE declarations and canonical install references to $tag."
echo "Replace the generated changelog TODO with the release summary, then run scripts/check-docs.sh and scripts/prepare-release.sh $tag."
+17
View File
@@ -25,4 +25,21 @@ while IFS= read -r -d '' markdown; do
done < <(sed -nE 's/.*\]\(([^ )]+)( "[^"]*")?\).*/\1/p' "$markdown")
done < <(find "$root" -path "$root/.git" -prune -o -path '*/.pio' -prune -o -name '*.md' -type f -print0)
while IFS= read -r example; do
for required in README.md platformio.ini; do
[[ -f "$example/$required" ]] || { echo "Incomplete example: ${example#$root/} is missing $required" >&2; exit 1; }
done
find "$example/src" -type f \( -name '*.ino' -o -name '*.cpp' \) -print -quit | grep -q . || {
echo "Incomplete example: ${example#$root/} has no source" >&2
exit 1
}
done < <(find "$root/examples" -mindepth 2 -maxdepth 2 -type f -name platformio.ini -printf '%h\n' | sort)
while IFS= read -r markdown; do
(( $(grep -Ec '^[[:space:]]*```' "$markdown") % 2 == 0 )) || {
echo "Unclosed code fence in ${markdown#$root/}" >&2
exit 1
}
done < <(find "$root" -path "$root/.git" -prune -o -path '*/.pio' -prune -o -name '*.md' -type f -print)
echo "Documentation links and required files passed"
+19 -1
View File
@@ -32,12 +32,30 @@ if ! grep -q "^## ${version}$" "$root/CHANGELOG.md"; then
exit 1
fi
if awk -v heading="## $version" '
$0 == heading { found = 1; next }
found && /^## / { exit }
found { print }
' "$root/CHANGELOG.md" | grep -Fq 'TODO: Describe this release.'; then
echo "CHANGELOG.md still has the generated TODO for $version" >&2
exit 1
fi
repo_url="https://github.com/alexhopeoconnor/DFTE.git"
validate_reference() {
local file="$1"
local reference_count
reference_count="$(grep -F "$repo_url#v" "$root/$file" | wc -l)"
[[ "$reference_count" -eq 1 ]] || { echo "$file must contain exactly one canonical release reference" >&2; exit 1; }
grep -Fq "$repo_url#$tag" "$root/$file" || { echo "$file does not reference $tag" >&2; exit 1; }
}
validate_reference README.md
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
echo "Validated PlatformIO package for $tag"
echo "Validated release metadata and PlatformIO package for $tag"
if [[ "${2:-}" == "--tag" ]]; then
git -C "$root" diff --quiet
+25 -7
View File
@@ -2,18 +2,36 @@
set -euo pipefail
usage() {
echo "Usage: $0 compile --platform esp8266|esp32" >&2
echo "Usage: $0 compile|examples --platform esp8266|esp32" >&2
exit 2
}
[[ "${1:-}" == "compile" && "${2:-}" == "--platform" && $# -eq 3 ]] || usage
[[ $# -eq 3 && ( "${1:-}" == "compile" || "${1:-}" == "examples" ) && "${2:-}" == "--platform" ]] || usage
case "${3:-}" in
esp8266) environment="test_template_engine_8266" ;;
esp32) environment="test_template_engine_esp32" ;;
esp8266|esp32) platform="$3" ;;
*) 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}"
if [[ "$1" == "compile" ]]; then
case "$platform" in
esp8266) test_environment="test_template_engine_8266" ;;
esp32) test_environment="test_template_engine_esp32" ;;
esac
pio test -d "$root" -e "$test_environment" --without-uploading --without-testing
echo "DFTE compile check passed for $platform"
exit 0
fi
suffix="$platform"
mapfile -t examples < <(find "$root/examples" -mindepth 2 -maxdepth 2 -type f -name platformio.ini -printf '%h\n' | sort)
if (( ${#examples[@]} == 0 )); then
echo "No example projects found" >&2
exit 1
fi
for example in "${examples[@]}"; do
env_name="example_$suffix"
[[ "$(basename "$example")" == "AsyncDashboardDemo" ]] && env_name="dashboard_$suffix"
pio run -d "$example" -e "$env_name" </dev/null
done
echo "DFTE examples compile check passed for $platform"