diff --git a/.gitattributes b/.gitattributes index 412eeda7..0368cf01 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,27 @@ # Auto detect text files and perform LF normalization * text=auto +# Template fixtures are byte-significant for golden comparisons: their raw newlines flow +# into rendered output. Pin them to LF on every platform so Windows checkouts don't smudge +# them to CRLF and break fixture-derived golden tests (goldens are read with \r\n -> \n). +*.heddle text eol=lf + +# Checked-in golden / fixture / snapshot data is byte-compared against LF engine output on BOTH +# ubuntu-latest and windows-latest CI. Some comparisons are verbatim (raw Assert.Equal, no \r\n +# normalization); rendered output is LF because the .heddle templates above are LF-pinned. With +# core.autocrlf=true a Windows checkout would smudge these to CRLF and diverge from the LF actual +# output — failing on Windows while Linux (LF everywhere) passes. Pin every such root to LF so the +# byte comparison holds identically on both platforms regardless of the developer's autocrlf setting. +# All files under these roots are text (verified: no binaries). New golden/fixture roots MUST be +# added here in the same change that introduces them — see docs/spec/common/testing-standards.md. +samples/**/golden/** text eol=lf +src/Heddle.Tests/TestTemplate/** text eol=lf +src/Heddle.Tests/Streaming/** text eol=lf +src/Heddle.Generator.Tests/Snapshots/** text eol=lf +src/Heddle.Generator.IntegrationTests/Fixtures/** text eol=lf +src/Heddle.LanguageServices.Tests/Corpus/** text eol=lf +src/Heddle.Demo.Wasm/contract-fixtures/** text eol=lf + # Custom for Visual Studio *.cs diff=csharp *.sln merge=union diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e15cce55..642ca429 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,5 +1,6 @@ -# Builds the VitePress documentation site from docs/*.md and deploys it to GitHub +# Builds the VitePress documentation site (incl. the WASM demo bundle) and deploys it to GitHub # Pages. One-time setup: repo Settings -> Pages -> Source = "GitHub Actions". +# PRs build + smoke-test the site (incl. the demo) but never deploy; only main deploys. name: Heddle Docs @@ -9,9 +10,23 @@ on: paths: - 'docs/**' - '.github/workflows/docs.yml' - # The live demo embeds the Ace editor bundle, built from these sources. - - 'src/Heddle.Language/js/**' - - 'src/Heddle.Language/build_ace.sh' + # The demo WASM bundle embeds the engine + facade (build_ace.sh and js/** are covered by the + # whole-project globs below, which supersede the old entries): + - 'src/Heddle/**' + - 'src/Heddle.Language/**' + - 'src/Heddle.LanguageServices/**' + - 'src/Heddle.Demo.Models/**' + - 'src/Heddle.Demo.Wasm/**' + pull_request: + branches: [ "main" ] + paths: # identical list, repeated verbatim — GitHub Actions + - 'docs/**' # path filters are per-trigger; keep the two lists + - '.github/workflows/docs.yml' # in sync when either changes + - 'src/Heddle/**' + - 'src/Heddle.Language/**' + - 'src/Heddle.LanguageServices/**' + - 'src/Heddle.Demo.Models/**' + - 'src/Heddle.Demo.Wasm/**' workflow_dispatch: permissions: @@ -35,13 +50,32 @@ jobs: - name: Setup Node uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 cache: npm cache-dependency-path: docs/package-lock.json + # ---- WS1 (D-D): regenerate the JS ANTLR grammar + propagate into the mode copy and + # fail the build on any drift against the checked-in files. This is the single drift + # guard: it regenerates js/*.js from the v2 .g4 (needs Java) and re-runs the + # deterministic propagation into js/src/mode/heddle/, then git diff --exit-code. + - name: Setup Java (ANTLR) + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '23' + - name: Regenerate + propagate JS grammar (drift guard) + working-directory: src/Heddle.Language + run: | + ANTLR_JAR=antlr-4.13.1-complete.jar + if [ ! -f "$ANTLR_JAR" ]; then + curl -fSL -o "$ANTLR_JAR" "https://www.antlr.org/download/$ANTLR_JAR" + fi + java -jar "$ANTLR_JAR" -Dlanguage=JavaScript HeddleLexer.g4 HeddleParser.g4 -o js -lib js -package Heddle.Language + node propagate_js.js + - name: Fail on generated-grammar drift + run: git diff --exit-code -- src/Heddle.Language/js # Build the custom Ace editor bundle (Ace + Heddle mode + ANTLR JS parser) and stage it - # into docs/public so VitePress copies it to the site root. Serves the demo at - # /Heddle/demo.html with the worker loaded same-origin from /Heddle/ace/. No Java needed - # (the JS parser under js/ is already generated; the script only assembles the bundle). + # into docs/public so VitePress copies it to the site root. The JS parser under js/ was + # just regenerated + propagated above; the script only assembles the bundle. - name: Build Ace editor bundle working-directory: src/Heddle.Language run: bash build_ace.sh @@ -49,21 +83,52 @@ jobs: run: | mkdir -p docs/public/ace cp -R src/Heddle.Language/ace_build/ace/build/src-noconflict/. docs/public/ace/ + # ---- Phase 9: the typed WASM demo bundle (module worker + _framework), Roslyn-free ---- + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + - name: Install wasm-tools workload + run: dotnet workload install wasm-tools + - name: Publish demo WASM host + run: dotnet publish src/Heddle.Demo.Wasm -c Release + - name: Stage demo bundle into docs/public + run: | + mkdir -p docs/public/demo + cp -R src/Heddle.Demo.Wasm/bin/Release/net10.0/publish/wwwroot/. docs/public/demo/ + - name: Demo bundle budget and purity gate + run: | + size=$(du -sb docs/public/demo | cut -f1) + echo "demo bundle: $size bytes" + test "$size" -le 12582912 || { echo "::error::demo bundle exceeds 12 MB budget"; exit 1; } + if find docs/public/demo -name 'Microsoft.CodeAnalysis*' | grep -q .; then + echo "::error::Roslyn assets found in the demo bundle (D4 regression)"; exit 1 + fi - name: Install dependencies working-directory: docs run: npm ci - name: Build site working-directory: docs run: npm run docs:build + # ---- Phase 9: the demo smoke suite (chromium only) against the built site ---- + - name: Install Playwright chromium + working-directory: docs + run: npx playwright install chromium --with-deps + - name: Demo smoke tests + working-directory: docs + run: npm run demo:smoke - name: Configure Pages + if: github.event_name != 'pull_request' uses: actions/configure-pages@v6 - name: Upload artifact + if: github.event_name != 'pull_request' uses: actions/upload-pages-artifact@v5 with: path: docs/.vitepress/dist deploy: needs: build + if: github.event_name != 'pull_request' runs-on: ubuntu-latest environment: name: github-pages diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 82b24230..10c72144 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -33,7 +33,7 @@ jobs: dotnet-version: | 6.0.x 8.0.x - 10.0.x + global-json-file: global.json - name: Restore dependencies run: dotnet restore - name: Build @@ -57,13 +57,15 @@ jobs: dotnet-version: | 6.0.x 8.0.x - 10.0.x + global-json-file: global.json - name: Restore dependencies run: dotnet restore - - name: Build - run: dotnet build --no-restore -c Release + # No `dotnet build --no-build` split here: Heddle.Tool / Heddle.LanguageServer are RID-specific + # .NET tools (PackAsTool + multiple RuntimeIdentifiers), so `dotnet pack` must publish each RID. + # A RID-less `dotnet build` never produces those per-RID outputs, so `--no-build` fails with + # MSB3030 (missing per-RID assemblies) / NETSDK1085. Let pack build+publish, like the release job. - name: Pack beta packages - run: dotnet pack -c Release --no-build -o ${{ runner.temp }}/pkg --version-suffix "-beta.${{ github.run_number }}" -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg + run: dotnet pack -c Release -o ${{ runner.temp }}/pkg --version-suffix "-beta.${{ github.run_number }}" - name: NuGet login (OIDC -> short-lived API key) uses: NuGet/login@v1 id: nuget-login @@ -89,14 +91,14 @@ jobs: dotnet-version: | 6.0.x 8.0.x - 10.0.x + global-json-file: global.json - name: Resolve version from tag id: ver run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" - name: Restore dependencies run: dotnet restore - name: Pack release packages - run: dotnet pack -c Release -p:Version=${{ steps.ver.outputs.version }} -o ${{ runner.temp }}/pkg -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg + run: dotnet pack -c Release -p:Version=${{ steps.ver.outputs.version }} -o ${{ runner.temp }}/pkg - name: NuGet login (OIDC -> short-lived API key) uses: NuGet/login@v1 id: nuget-login diff --git a/.github/workflows/lsp.yml b/.github/workflows/lsp.yml new file mode 100644 index 00000000..cea8c674 --- /dev/null +++ b/.github/workflows/lsp.yml @@ -0,0 +1,103 @@ +# Builds and tests the Heddle language services (facade + LSP server), packs the version-pinned +# per-RID dotnet tool, and packages the VS Code extension. Phase 6 (editor tooling / LSP). + +name: Heddle LSP + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + test: + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-dotnet@v5 + with: + # The multi-targeted test suite runs net6.0/net8.0/net10.0/net48; the runner image + # no longer ships the EOL .NET 6 (and may drop 8) runtime, so install them explicitly + # alongside the global.json SDK — otherwise the net6.0 test host aborts (NETCore.App 6 missing). + dotnet-version: | + 6.0.x + 8.0.x + global-json-file: global.json + - name: Build (Release) + run: dotnet build -c Release Heddle.sln + - name: Test engine suite + run: dotnet test src/Heddle.Tests/Heddle.Tests.csproj -c Release --no-build + - name: Test language services + run: dotnet test src/Heddle.LanguageServices.Tests/Heddle.LanguageServices.Tests.csproj -c Release --no-build + - uses: actions/setup-node@v6 + with: + node-version: 24 + - name: JS editor-artifact harness (highlight / beautify / completions) + working-directory: src/Heddle.Language + run: | + npm ci + npm test + + pack-tool: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + # Framework-dependent + R2R per-RID packs cross-pack from one runner (phase 6 D19). + # -p:EnableToolR2R=true makes PublishReadyToRun visible at the RID-less outer restore so + # the crossgen2 runtime packs for every RuntimeIdentifier are restored (else NETSDK1094). + - name: Pack heddle-lsp (manifest + per-RID + any) + run: dotnet pack src/Heddle.LanguageServer/Heddle.LanguageServer.csproj -c Release -o ./nupkg -p:EnableToolR2R=true + - name: Smoke — install and run --version + run: | + set -euo pipefail + dotnet tool install --global Heddle.LanguageServer --add-source ./nupkg --version 2.0.0 || true + export PATH="$PATH:$HOME/.dotnet/tools" + heddle-lsp --version + - uses: actions/upload-artifact@v7 + with: + name: heddle-lsp-nupkg + path: ./nupkg/*.nupkg + + vsix: + runs-on: ubuntu-latest + strategy: + matrix: + target: [win32-x64, win32-arm64, linux-x64, linux-arm64, alpine-x64, darwin-x64, darwin-arm64] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + - uses: actions/setup-node@v6 + with: + node-version: 24 + - name: Map VS Code target to .NET RID + id: rid + run: | + case "${{ matrix.target }}" in + win32-x64) echo "rid=win-x64" >> "$GITHUB_OUTPUT" ;; + win32-arm64) echo "rid=win-arm64" >> "$GITHUB_OUTPUT" ;; + linux-x64) echo "rid=linux-x64" >> "$GITHUB_OUTPUT" ;; + linux-arm64) echo "rid=linux-arm64" >> "$GITHUB_OUTPUT" ;; + alpine-x64) echo "rid=linux-musl-x64" >> "$GITHUB_OUTPUT" ;; + darwin-x64) echo "rid=osx-x64" >> "$GITHUB_OUTPUT" ;; + darwin-arm64) echo "rid=osx-arm64" >> "$GITHUB_OUTPUT" ;; + esac + - name: Publish server (framework-dependent, R2R) + run: dotnet publish src/Heddle.LanguageServer/Heddle.LanguageServer.csproj -c Release -r ${{ steps.rid.outputs.rid }} --self-contained false -o editors/vscode/server -p:EnableToolR2R=true + - name: Package VSIX + working-directory: editors/vscode + run: | + npm ci + npm run copy-grammar + npx @vscode/vsce package --target ${{ matrix.target }} + - uses: actions/upload-artifact@v7 + with: + name: heddle-vsix-${{ matrix.target }} + path: editors/vscode/*.vsix diff --git a/.github/workflows/npm.yml b/.github/workflows/npm.yml index 6ad59273..3c7400cb 100644 --- a/.github/workflows/npm.yml +++ b/.github/workflows/npm.yml @@ -29,13 +29,15 @@ jobs: steps: - uses: actions/checkout@v7 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 24 registry-url: https://registry.npmjs.org scope: '@multiarc' - - name: Ensure npm supports staged/trusted publishing - run: npm install -g npm@latest # staged publishing requires npm >= 11.15.0 + # Note: we deliberately do NOT upgrade npm here. The node-bundled npm ships its + # dependencies (including `sigstore`, used by the provenance path) intact; a global + # `npm install -g npm@latest` self-upgrade prunes those bundled deps and breaks + # provenance publishing with "Cannot find module 'sigstore'". - name: Build custom Ace bundle working-directory: src/Heddle.Language run: bash build_ace.sh diff --git a/.github/workflows/samples.yml b/.github/workflows/samples.yml new file mode 100644 index 00000000..cd5dca1d --- /dev/null +++ b/.github/workflows/samples.yml @@ -0,0 +1,50 @@ +# Integration demo gallery: every sample is a CI job with golden assertions. +# A red job here is an integration regression in the owning phase's feature. +name: Heddle Samples + +on: + push: + branches: [ "main" ] + paths: + - 'samples/**' + - 'src/**' + - '.github/workflows/samples.yml' + pull_request: + branches: [ "main" ] + paths: # identical list, repeated verbatim — path filters + - 'samples/**' # are per-trigger; keep both lists in sync + - 'src/**' + - '.github/workflows/samples.yml' + +permissions: + contents: read + +jobs: + sample: + strategy: + fail-fast: false # one broken sample must not mask the others + matrix: + sample: + - ssr-aspnetcore + - definition-library + - dynamic-models + - sandboxed-user-templates + - html-safe-output + - custom-extensions + - component-props-slots + - codegen-t4-successor + - precompiled-app + - streaming-ssr + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + - name: Comparer self-test + run: bash samples/tools/compare-golden-selftest.sh + - name: Run sample (capture mode) + run: dotnet run --project samples/${{ matrix.sample }} -c Release -- --capture out + - name: Compare against goldens + run: bash samples/tools/compare-golden.sh samples/${{ matrix.sample }} diff --git a/.gitignore b/.gitignore index f2c4efd8..34604d4e 100644 --- a/.gitignore +++ b/.gitignore @@ -115,12 +115,34 @@ project.lock.json # ANTLR tool jar, downloaded on demand by the generate_*.cmd scripts /src/Heddle.Language/antlr-4.13.1-complete.jar +# JS test harness dependencies (ace-code) - restored via `npm ci`/`npm install` +/src/Heddle.Language/node_modules + # VitePress documentation site /docs/node_modules /docs/.vitepress/dist /docs/.vitepress/cache + +# VS Code extension build artifacts +/editors/vscode/node_modules +/editors/vscode/dist +/editors/vscode/*.vsix +/editors/vscode/server # Ace editor bundle staged into the site by the docs workflow (generated) /docs/public/ace/ +# Phase 9 demo WASM bundle staged into the site by the docs workflow (generated, never committed) +/docs/public/demo/ +# Phase 9 sample gallery capture outputs (written by `dotnet run -- --capture out`; goldens are committed) +samples/**/out/ /.idea /src/Heddle.Language/gen /src/Heddle.Language/.antlr + +# Verify.SourceGenerators snapshot working files +*.received.* +# Phase 9 generator EmitCompilerGeneratedFiles output (build artifact) +samples/**/generated/ +# Playwright demo smoke artifacts +docs/test-results/ +docs/playwright-report/ +/docs/logs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bd73adc..beac3864 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,110 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +The **2.0** release is being formed on this branch and has **not** shipped yet (the latest released +tag is `v1.0.1`). It is a single breaking window: almost everything below is additive and the dynamic +engine's rendered bytes are unchanged, except for the items under **Changed (breaking)** and +**Removed**. + +### Added + +- **Native expression tier** — a sandbox-safe, Roslyn-free expression language in call parens + (`@if(Count > 0)`, `@(Price * Quantity)`, `@(Name ?? "anon")`, `@(cond ? a : b)`). Follows the C# + precedence table and compiles to `System.Linq.Expressions`; capability is limited to + properties/indexers/operators/literals plus host-whitelisted functions (`FunctionRegistry`, + `[ExportFunctions]`). New `TemplateOptions.ExpressionMode` (`MemberPathsOnly` / `Native` (default) / + `FullCSharp`); the inner-`@` form stays full C#. +- **Safe-by-default HTML output** — `OutputProfile.Html` HTML-encodes the unnamed `@(...)` carrier; + `@raw(...)` is the explicit opt-out and `@profile(html|text)` flips mid-document. The encoder is a + pluggable `TemplateOptions.Encoder` (`System.Text.Encodings.Web.TextEncoder`) — opt-in, with the + default (`null`) preserving the existing `WebUtility.HtmlEncode` bytes. +- **Context-encoding extensions** `@attr` / `@js` / `@url` — encode a value for the HTML-attribute, + JavaScript-string-literal, and URL-component contexts respectively. +- **Declarative branching** — `@elif` / `@else` (and `@ifnot`) as ordinary extensions that coordinate + with a preceding `@if` through a published local context. Public `Heddle.Attributes.BranchRole` + (`Opener`/`Continuation`/`Terminal`) + `[BranchRoleAttribute]` drive set classification; the + `Scope.Publish`/`TryRead` channel, `BranchState`, and `[ScopeChannel]` let custom extensions join a + set with the same semantics as the built-ins. Text between branch blocks is stripped with a warning. + Optional drift diagnostics (`HED3005` runtime, `HED7016` generator) flag a continuation/terminal + missing `[ScopeChannel]`. +- **Loop & directive ergonomics** — `@for(5)` / `@for(Count)` / `@for(range(...))` and `@list` loop + sugar (no hand-written C# loop models); `TrimDirectiveLines` to swallow whole-line directives' + trailing newline; a double-render warning when a default-output definition is also called by name. +- **Named props & slots** — typed named definition parameters with defaults + (`@card(Article, style: "wide", compact: true)`), prop reads in bodies, and parameterized `@out()` + slots, with abstract-definition composition and inheritance narrowing. Missing/unknown/mistyped + props are positioned compile errors at the call site. +- **Streaming & UTF-8 render surface** — `Generate(data, TextWriter)` and + `Generate(data, IBufferWriter)` sink overloads with a two-tier, surrogate-safe UTF-8 transcode + path (O(1) allocation regardless of output size). The string path stays bit-identical. New public + renderer surface (`IScopeRenderer` / `ISpanScopeRenderer` / `IUtf8ScopeRenderer` and implementations). +- **Render budgets** — `TemplateOptions.RenderBudget` (`MaxOutputChars` / `MaxRenderOps` / + `MaxRenderTime`) for running untrusted templates; a breach throws + `Heddle.Exceptions.TemplateRenderBudgetException` carrying `Kind`/`Limit`/`Observed`. Default is + unlimited with zero render-path cost, enforced at the renderer seam so counts are uniform across the + string/`TextWriter`/`IBufferWriter` sinks and identical on the dynamic and precompiled backends. +- **Build-time precompilation** — `Heddle.Generator`, a Roslyn incremental source generator that + compiles `.heddle` templates to `IProcessStrategy` classes at build time, byte-for-byte identical to + the dynamic engine and with no runtime Heddle dependency in the generated output. Covers the full + construct set; template errors surface as positioned build diagnostics (`HED70xx`) and anything not + yet emittable degrades safely to the dynamic path. Engine and generator are version-locked; + `PrecompiledMismatchPolicy` governs a mismatch; a `Heddle.CSharpTierEnabled` trim switch keeps + Roslyn out of a trimmed publish. +- **Stable diagnostic IDs** — `Heddle.Data.HeddleDiagnosticIds` (`HEDxxxx`), surfaced by both the + runtime compiler and the generator. +- **Editor tooling & LSP** — `Heddle.LanguageServices` (flag-gated, null-cost when off) and a + hand-rolled LSP 3.17 server (`Heddle.LanguageServer`): typed member completion inside `@(...)`, hover + types, diagnostics at template positions, and go-to-definition across `@<<` imports (imported-file + diagnostics re-anchored to the import site). Ships with a VS Code extension and a v2 Ace web grammar. +- **`heddle` CLI** — a T4-successor render tool (`heddle render