diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index f38edf0..fd55790 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -20,9 +20,7 @@ jobs: with: node-version: 20 - run: npm ci - - run: npm run lint - - run: npm run test - - run: npm run build:strict + - run: npm run verify - name: Commit dist if changed run: | git config user.name "github-actions[bot]" diff --git a/AGENTS.md b/AGENTS.md index 95ecb49..97fd8a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,13 +14,13 @@ Authoritative guide for AI/code agents contributing to this repository. ### Scripts (run from repo root) - **Install**: `npm ci` -- **Lint**: `npm run lint` -- **Lint (auto-fix)**: `npm run lint:fix` +- **Lint**: `npm run lint` and `npm run lint:fix` (autofix) - **Unit tests + coverage**: `npm test` -- **Performance tests**: `npm run test:perf` +- **Run individual test file(s)**: `npm run test:file 'path/to/test.js'` (supports glob patterns like `'test/security/*.test.js'`) - **Build bundle**: `npm run build` → outputs `dist/faintly.js` and prints gzipped size (warns if over limit) - **Build (strict)**: `npm run build:strict` → fails if gzipped size exceeds 5120 bytes - **Clean**: `npm run clean` +- **Verify all**: `npm run verify` → runs clean, build:strict, lint, and test in sequence (comprehensive check) ### Tests and coverage - Test runner: `@web/test-runner` with Mocha. @@ -30,6 +30,11 @@ Authoritative guide for AI/code agents contributing to this repository. - Coverage thresholds (enforced in CI): 100% for statements, branches, functions, and lines. - Coverage reports: written to `coverage/`. Excludes `test/fixtures/**`, `test/snapshots/**`, `test/test-utils.js`, and `node_modules/**`. - When adding features, add or update tests to maintain 100% coverage in the `unit` group. +- **Running individual test files**: + - Use `npm run test:file 'path/to/test.js'` to run a specific test file. + - Supports glob patterns: `npm run test:file 'test/security/*.test.js'` + - Note: This uses a separate config (`wtr-single.config.mjs`) because the main config's group-based file patterns take precedence over the `--files` flag. + - Do NOT use `npm test -- --files 'path/to/test.js'` as it will not work correctly with the group-based configuration. ### Linting and code style - ESLint config: `airbnb-base` via `.eslintrc.js` with `@babel/eslint-parser`. @@ -64,6 +69,8 @@ Authoritative guide for AI/code agents contributing to this repository. - `test/`: unit/perf tests, fixtures, snapshots, and utilities - `test/security/`: tests for security module - `coverage/`: coverage output when tests are run with coverage +- `web-test-runner.config.mjs`: main test runner config with group-based patterns +- `wtr-single.config.mjs`: minimal config for running individual test files (bypasses groups) ### Contribution checklist for agents 1. Install deps with `npm ci`. diff --git a/README.md b/README.md index 31d271d..220738e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ I've always liked the developer ergonomics (autocomplete, etc.) and separation o I've experimented with other existing libraries (ejs templates, etc.) but wanted something simple and purpose built. +## Migrating from HTL/Sightly? + +If you're coming from Adobe Experience Manager's HTL (HTML Template Language), check out **[HTL Migration Guide](./docs/HTL_MIGRATION.md)** for side-by-side comparisons. + ## Getting Started 1. Copy the `/dist/faintly.js` and `/dist/faintly.security.js` files to the scripts directory of your project @@ -73,102 +77,28 @@ When in a repeat loop, it will also include: ## Security -Faintly includes built-in security features to help protect against XSS (Cross-Site Scripting) attacks. By default, security is **enabled** and provides: - -* **Attribute sanitization** - Blocks dangerous attributes like event handlers (`onclick`, `onerror`, etc.) and `srcdoc` -* **URL scheme validation** - Restricts URLs in attributes like `href` and `src` to safe schemes (`http:`, `https:`, `mailto:`, `tel:`) -* **Same-origin enforcement** - Template includes are restricted to same-origin URLs only - -### Default Security - -When you call `renderBlock()` without a security context, default security is automatically applied: - -```javascript -await renderBlock(block); // Default security enabled -``` - -The default security module (`dist/faintly.security.js`) is dynamically loaded on first use. - -### Custom Security - -For more control, you can provide a custom security object with `shouldAllowAttribute` and `allowIncludePath` hooks: - -```javascript -await renderBlock(block, { - security: { - shouldAllowAttribute(attrName, value) { - // Return true to allow the attribute, false to block it - // Your custom logic here - return true; - }, - allowIncludePath(templatePath) { - // Return true to allow the template include, false to block it - // Your custom logic here - return true; - }, - }, -}); -``` - -You can also use the default security module and override specific configuration: - -```javascript -import createSecurity from './scripts/faintly.security.js'; - -await renderBlock(block, { - security: createSecurity({ - // Add 'data:' URLs to allowed schemes - allowedUrlSchemes: ['http:', 'https:', 'mailto:', 'tel:', 'data:'], - // Block additional attributes - blockedAttributes: ['srcdoc', 'sandbox'], - }), -}); -``` - -### Security Configuration Options - -The default security module accepts the following configuration: - -* `blockedAttributePatterns` (Array) - Regex patterns for blocked attribute names (default: `/^on/i` blocks all event handlers) -* `blockedAttributes` (Array) - Specific attribute names to block (default: `['srcdoc']`) -* `urlAttributes` (Array) - Attributes that contain URLs to validate (default: `['href', 'src', 'action', 'formaction', 'xlink:href']`) -* `allowedUrlSchemes` (Array) - Allowed URL schemes; relative URLs are always allowed (default: `['http:', 'https:', 'mailto:', 'tel:']`) - - -### Disabling Security (Unsafe Mode) - -You can disable security if needed. **THIS IS NOT RECOMMENDED** - - -> [!CAUTION] -> **THIS IS NOT RECOMMENDED** and bypasses all XSS protection. +Faintly includes built-in XSS protection that is **enabled by default**. ```javascript -await renderBlock(block, { - security: false, // or 'unsafe' -}); +await renderBlock(block); // Security automatically enabled ``` - -### Trust Boundaries - -It's important to understand what Faintly's security does and doesn't protect: - -**Protected:** -- ✅ Dangerous attributes (event handlers, `srcdoc`) -- ✅ Malicious URL schemes (`javascript:`, `data:` by default) +**What's protected:** +- ✅ Dangerous attributes (event handlers like `onclick`, `onerror`, etc.) +- ✅ Malicious URL schemes (`javascript:`, `data:`, `vbscript:`, `file:`) - ✅ Cross-origin template includes +- ✅ HTML strings treated as plain text (not parsed as HTML) -**Trusted (by design):** -- The rendering context you provide is fully trusted -- Templates fetched from your same-origin are trusted -- DOM Node objects provided in context are inserted directly +**What's NOT protected (by design):** +- ⚠️ **Context data** - The rendering context is fully trusted +- ⚠️ **Pre-built DOM elements** - Elements passed through context are inserted as-is +- ⚠️ **`utils:eval()` expressions** - JavaScript evaluation requires `unsafe-eval` CSP and trusts context data +- ⚠️ **Templates/HTML** - Templates are trusted. Never allow user input in templates, innerHTML, or setAttribute - expressions like `${...}` will be evaluated -> [!WARNING] -> **Be extremely careful when adding user-supplied data to the rendering context.** URL parameters, form inputs, cookies, and other user-controlled data should be validated and sanitized before adding to the context. The context is fully trusted, so untrusted data placed in it can bypass security protections. +> [!DANGER] +> **Never allow user input to become part of templates or HTML.** User input must ONLY go into the context. If users can control template content, they can inject `${utils:eval(...)}` to execute arbitrary code. -> [!TIP] -> Security works best in layers. Faintly's security helps prevent common XSS vectors, but you should also: validate and sanitize user input before adding it to context, use Content Security Policy headers, and follow secure coding practices. +For detailed information about the security model, configuration options, custom security hooks, and best practices, see **[Security Documentation](./docs/SECURITY.md)**. ## Directives @@ -197,8 +127,81 @@ Faintly supports the following directives. Faintly supports a simple expression syntax for resolving data from the rendering context. It supports only object dot-notation, but will call (optionally async) functions as well. This means that if you need to do something that can't be expressed in dot-notation, then you need to define a custom function for it, and add that function to the rendering context. -For `data-fly-include`, HTML text, and normal attributes, wrap your expression in `${}`. +**In `data-fly-*` directive attributes:** +- Both bare expressions and `${}` wrapped expressions are supported +- `data-fly-test="condition"` and `data-fly-test="${condition}"` both work + +**In `data-fly-include`, HTML text, and normal attributes:** +- You must wrap your expression in `${}` +- Example: `
`, `

Hello ${user.name}

` + +**Escaping:** +- Use a leading backslash to prevent evaluation of an expression in text/attributes +- Example: `\${some.value}` will remain literal `${some.value}` + +### JavaScript Expression Evaluation with `utils:eval()` + +> [!CAUTION] +> **⚠️ This feature uses JavaScript's `Function` constructor (similar to `eval`)** +> +> - Requires Content Security Policy with `'unsafe-eval'` directive +> - **Has full access to context AND browser globals** (`window`, `document`, etc.) +> - An attacker with control over context data could craft expressions like `utils:eval(window.location='https://evil.com')` or `utils:eval(document.cookie)` +> - **Never put untrusted user input in the context** when using `utils:eval()` +> - If your CSP blocks `unsafe-eval`, this feature won't work +> +> Use `utils:eval()` thoughtfully. For complex logic, context functions are safer and more maintainable. + +When a bit more logic is required, you canuse `utils:eval()` to evaluate JavaScript expressions. Some examples: + +```html + +
More than 5
+
Active
+ + +
Admin or mod
+
Valid and active
+ + +
${utils:eval(showCount ? count : 'N/A')}
+
Status
+ + +
${utils:eval(items.join(', '))}
+
${utils:eval(name.substring(0, 10))}
+ + +
${utils:eval('Hello, ' + user.name)}
+ + +
${utils:eval(price * quantity)}
+ + +
Complex logic
+``` + +**What works WITHOUT `utils:eval()`:** + +```html + +
${user.name}
+
${user.profile.email}
+ + +
${items.length}
+ + +
${items.0}
+
${items.1}
+ + +
${user.getName}
+
${text.trim}
+``` -Escaping: use a leading backslash to prevent evaluation of an expression in text/attributes, e.g. `\${some.value}` will remain literal `${some.value}`. +**When to Use `utils:eval()` vs Context Functions:** -In all other `data-fly-*` attributes, just set the expression directly as the attribute value, no wrapping needed. +- **Use context functions** for complex logic, API calls, or data transformations +- **Use `utils:eval()`** for simple comparisons, formatting, or inline expressions +- Context functions are generally safer and more maintainable for complex operations diff --git a/dist/faintly.js b/dist/faintly.js index 0090cc6..3f7f080 100644 --- a/dist/faintly.js +++ b/dist/faintly.js @@ -28,10 +28,26 @@ async function resolveTemplate(context) { } // src/expressions.js +function evaluate(expr, context) { + const fn = new Function("ctx", `with(ctx) { return ${expr}; }`); + return fn(context); +} +function unwrapExpression(expression) { + const trimmed = expression.trim(); + if (trimmed.startsWith("${") && trimmed.endsWith("}")) { + return trimmed.slice(2, -1).trim(); + } + return expression; +} async function resolveExpression(expression, context) { + const trimmedExpression = expression.trim(); + if (trimmedExpression.startsWith("utils:eval(") && trimmedExpression.endsWith(")")) { + const expr = trimmedExpression.slice(11, -1); + return evaluate(expr, context); + } let resolved = context; let previousResolvedValue; - const parts = expression.split("."); + const parts = trimmedExpression.split("."); for (let i = 0; i < parts.length; i += 1) { if (typeof resolved === "undefined") break; const part = parts[i]; @@ -45,7 +61,7 @@ async function resolveExpression(expression, context) { return resolved; } async function resolveExpressions(str, context) { - const regexp = /(\\)?\${([a-z0-9\\.\s]+)}/gi; + const regexp = /(\\)?\${([^}]+)}/gi; const promises = []; str.replaceAll(regexp, (match, escapeChar, expression) => { const replacementPromise = escapeChar ? Promise.resolve(match.slice(1)) : resolveExpression(expression.trim(), context); @@ -70,7 +86,7 @@ async function processTextExpressions(node, context) { // src/directives.js async function processAttributesDirective(el, context) { if (!el.hasAttribute("data-fly-attributes")) return; - const attrsExpression = el.getAttribute("data-fly-attributes"); + const attrsExpression = unwrapExpression(el.getAttribute("data-fly-attributes")); const attrsData = await resolveExpression(attrsExpression, context); el.removeAttribute("data-fly-attributes"); if (attrsData) { @@ -105,7 +121,7 @@ async function processTest(el, context) { if (!testAttrName) return true; const nameParts = testAttrName.split("."); const contextName = nameParts[1] || ""; - const testExpression = el.getAttribute(testAttrName); + const testExpression = unwrapExpression(el.getAttribute(testAttrName)); const testData = await resolveExpression(testExpression, context); el.removeAttribute(testAttrName); const testResult = testAttrName.startsWith("data-fly-not") ? !testData : !!testData; @@ -117,7 +133,7 @@ async function processTest(el, context) { } async function processContent(el, context) { if (!el.hasAttribute("data-fly-content")) return false; - const contentExpression = el.getAttribute("data-fly-content"); + const contentExpression = unwrapExpression(el.getAttribute("data-fly-content")); const content = await resolveExpression(contentExpression, context); el.removeAttribute("data-fly-content"); if (content !== void 0) { @@ -139,7 +155,7 @@ async function processRepeat(el, context) { if (!repeatAttrName) return false; const nameParts = repeatAttrName.split("."); const contextName = nameParts[1] || "item"; - const repeatExpression = el.getAttribute(repeatAttrName); + const repeatExpression = unwrapExpression(el.getAttribute(repeatAttrName)); const arr = await resolveExpression(repeatExpression, context); if (!arr || Object.keys(arr).length === 0) { el.remove(); @@ -195,9 +211,9 @@ async function processInclude(el, context) { } async function resolveUnwrap(el, context) { if (!el.hasAttribute("data-fly-unwrap")) return; - const unwrapExpression = el.getAttribute("data-fly-unwrap"); - if (unwrapExpression) { - const unwrapVal = !!await resolveExpression(unwrapExpression, context); + const unwrapExpr = el.getAttribute("data-fly-unwrap"); + if (unwrapExpr) { + const unwrapVal = !!await resolveExpression(unwrapExpression(unwrapExpr), context); if (!unwrapVal) { el.removeAttribute("data-fly-unwrap"); } diff --git a/docs/HTL_MIGRATION.md b/docs/HTL_MIGRATION.md new file mode 100644 index 0000000..87f0d40 --- /dev/null +++ b/docs/HTL_MIGRATION.md @@ -0,0 +1,485 @@ +# Migrating from HTL/Sightly to Faintly + +This guide helps developers familiar with Adobe Experience Manager's HTL (HTML Template Language, formerly Sightly) transition to using Faintly for client-side templating in AEM Edge Delivery Services. + +## Table of Contents + +- [Quick Reference](#quick-reference) +- [Overview](#overview) +- [Key Differences](#key-differences) +- [Expression Syntax](#expression-syntax) +- [Backend Integration](#backend-integration) +- [Context Options](#context-options) +- [Common Patterns](#common-patterns) + +--- + +## Quick Reference + +### Syntax Cheat Sheet + +| Feature | HTL | Faintly | +|---------|-----|---------| +| **Text expression** | `${variable}` | `${variable}` | +| **Directive expression** | `${variable}` | `variable` or `${variable}` | +| **Comparison** | `${a > b}` (Java) | `utils:eval(a > b)` (JavaScript) | +| **Ternary** | `${condition ? 'yes' : 'no'}` | `${utils:eval(condition ? 'yes' : 'no')}` | +| **Logical NOT** | `${!condition}` | `data-fly-not="condition"` or `utils:eval(!condition)` | + +### Directive Mapping + +| HTL/Sightly | Faintly | +|-------------|---------| +| `data-sly-test` | `data-fly-test` | +| `data-sly-test` (negated) | `data-fly-not` | +| `data-sly-list` / `data-sly-repeat` | `data-fly-repeat` | +| `data-sly-use` | Functions in context object | +| `data-sly-text` | `data-fly-content` | +| `data-sly-attribute` | `data-fly-attributes` | +| `data-sly-unwrap` | `data-fly-unwrap` | +| `data-sly-template` | `