export type FilterFn = (value: unknown, ...args: unknown[]) => unknown; export type TestFn = (value: unknown) => boolean; /** A minimal request shape a {% live %} data provider receives. */ export interface LiveRequest { headers?: Record; params?: Record; } /** A {% live %} data provider — re-runs with the live request each refresh. */ export type LiveProvider = (req: LiveRequest) => Record; /** Result of respondLive — a pure {status, body} descriptor a route applies. */ export interface LiveResponse { status: number; body: string; } /** WebSocket broadcaster hook wired by @tina4/core so pushLive can broadcast. */ export type LiveBroadcaster = (wsPath: string | null, name: string, envelope: string) => void; /** * Cache for parsed filter chains: expr string -> [variable, filters]. * Exported (like TEMPLATE_CACHE_MAX) so the ADR-0004 bound has something for * a test to inspect directly — module-level state has no instance to read * off, unlike `compiled`/`compiledStrings`/`fragmentCache`. */ export declare const filterChainCache: Map; /** Cache for parsed dotted/bracket paths: expr string -> [parts, fromBracket]. Exported for the same reason as filterChainCache. */ export declare const pathParseCache: Map; /** * Hard cap on the template caches — `compiled` and `compiledStrings` * (ADR-0004, parity with PHP/Python/Ruby TEMPLATE_CACHE_MAX). * * An entry here is a whole token list, so the cap sits well below what a * per-expression memo would justify. 256 is far above any real application's * template count, so a normal app never evicts. The cap exists for the * workload that genuinely grows without limit for the life of a worker: * `renderString` keys on md5(source), so an app that builds template strings * dynamically adds an entry per distinct string. */ export declare const TEMPLATE_CACHE_MAX = 256; /** * Hard cap on every per-expression memo cache — `filterChainCache`, * `pathParseCache`, and `expressionFormCache` (ADR-0004, parity with PHP's MEMO_CACHE_MAX and the * Python master's `@lru_cache(maxsize=1024)` on the equivalent module-level * parsers). Deliberately higher than TEMPLATE_CACHE_MAX: one entry here is a * small parsed-path array, orders of magnitude smaller than a token list. * * Also reused for `fragmentCache` (the `{% cache %}` tag's runtime store): * TEMPLATE_CACHE_MAX, not this one — a rendered fragment is a whole HTML * string, the same order of magnitude as a compiled template. */ export declare const MEMO_CACHE_MAX = 1024; /** Cached expression dispatcher branch; exported only for cache-bound verification. */ export declare const expressionFormCache: Map; /** * Set the session ID used by formToken() / form_token() for CSRF session binding. */ export declare function setFormTokenSessionId(sessionId: string): void; export declare class Frond { private static classFilters; private static classGlobals; private static classTests; private static liveFragments; private static liveSources; private static liveWsPaths; private static liveBroadcaster; /** * Register a custom filter at the class level — available to every * future ``new Frond()`` instance. Callable as ``Frond.addFilter()`` * (static) or ``frond.addFilter()`` (instance). See instance method * below for the dual-call semantics. */ static addFilter(name: string, fn: FilterFn): void; /** * Register a global variable available in all templates of every * future instance. Callable as ``Frond.addGlobal()`` (static) or * ``frond.addGlobal()`` (instance). */ static addGlobal(name: string, value: unknown): void; /** * Register a custom test (``{% if x is positive %}``) at the class * level. Callable as ``Frond.addTest()`` (static) or * ``frond.addTest()`` (instance). */ static addTest(name: string, fn: TestFn): void; /** * Clear the class-level globals/filters/tests registries. * Useful in test fixtures to prevent leaking state between tests. * Does NOT affect built-in filters or globals — only user-registered * ones via Frond.addFilter / addGlobal / addTest. */ static clearRegistry(): void; private templateDir; private filters; private globals; private tests; private _sandbox; private _allowedFilters; private _allowedTags; private _allowedVars; private fragmentCache; private _autoEscape; private readonly blockHandlers; /** * Token pre-compilation cache for file templates. * * `cachedAt` is captured so the TINA4_TEMPLATE_CACHE_TTL env var can * force re-compilation after N seconds even in production. TTL of 0 * means "no time-based invalidation" — entries live forever. */ private compiled; /** Token pre-compilation cache for string templates */ private compiledStrings; /** * Bound reference to `applyFilters`, stashed into the render context as * `__frond_apply_filters__` so the module-level `evalExpr` can resolve a * filter pipe using THIS instance's registered filters. Bound once. (#171) */ private readonly _applyFiltersBound; getTemplateDir(): string; constructor(templateDir?: string); sandbox(filters?: string[], tags?: string[], vars?: string[]): Frond; unsandbox(): Frond; /** * Register a custom filter on this instance only. Use the static method * for process-global registration. tina4: ADR-0052. */ addFilter(name: string, fn: FilterFn): void; /** * Register a global variable on this instance only. */ addGlobal(name: string, value: unknown): void; /** * Register a custom test on this instance only. */ addTest(name: string, fn: TestFn): void; /** * Read the cache TTL in seconds. `TINA4_TEMPLATE_CACHE_TTL=0` (the * default) keeps the existing "cache forever in prod" behaviour — any * positive value invalidates compiled tokens after N seconds, useful * when running long-lived servers behind a slow file sync where mtime * isn't a reliable freshness signal. */ private cacheTtlSeconds; render(template: string, data?: Record): string; renderString(source: string, data?: Record): string; /** Clear all compiled template caches. */ clearCache(): void; /** Render a debug dump of a value as HTML — parity with PHP/Ruby/Python. * Gated on TINA4_DEBUG=true. Returns empty string in production. */ renderDump(value: unknown): string; /** * Load a template's source, CONFINED under the templates directory. * * Every path-taking tag ({% include %}, {% extends %}, {% import %}, * {% from ... import %}) funnels through this one loader, so this single guard * confines them all (TAG-DEC-01): a name that is absolute, climbs out with a * `..` up-level segment, or resolves through a symlink to a location OUTSIDE * the templates root is REFUSED -- the outside file is never read. Template * -side analogue of the static-asset confinement (feature 41 / ADR-0050). */ private load; /** Execute pre-tokenized template against context. */ private executeCached; /** Execute with both source and pre-tokenized tokens available. */ private executeWithSource; private execute; private extractBlocks; /** * Depth-aware block substitution against `source` (typically the * fully-resolved root template). * * A single regex `.replace()` pass (the flat `pattern` this replaces in * renderWithBlocks) pairs an OUTER block's open tag with the FIRST * `{% endblock %}` found -- which, when the outer block wraps a NESTED * `{% block %}`, is the nested block's own close tag, not the outer's. * That silently truncates the outer block's captured content and drops * everything after the inner endblock (the root-nested-block * content-loss bug). This scans with an open/close depth counter * instead (mirroring extractBlocks), so an outer block always captures * its FULL body, nested child blocks included. * * The content chosen for each block -- the child override in `blocks` * if present, else the block's own default body -- is then recursively * substituted against the SAME `blocks` map before being tokenized and * rendered, so a block nested inside another block resolves correctly * regardless of which template in the inheritance chain declared the * nesting (the root, an intermediate, however many levels deep). * * `{{ parent() }}` / `{{ super() }}` inside a block still render that * block's OWN default content at this level (lazy, on first call). */ private substituteBlocks; private renderWithBlocks; private dispatchBlock; private renderTextToken; private renderVarToken; private renderBlockToken; private renderTokens; /** * May this filter RUN under the current sandbox? * * The escaping decision has to ask this rather than read the filter name out of * the source. Node carries safety as a FLAG rather than as a value-level marker * (Python and Ruby return a SafeString, PHP prepends a RAW_MARKER -- all three * produced only by actually running the filter), so here the name alone was * enough to suppress auto-escaping even when the filter was denied and skipped. */ private filterPermitted; /** * May this tag run under the current sandbox? * * One gate for every tag, so the allow-list governs the whole tag vocabulary * instead of the four names that happened to be checked individually. */ private tagPermitted; private applyFilterValue; /** * Consume a denied tag WITHOUT running it, returning the index past its body. * * Advancing a single token past a body-owning tag would leave the body's tokens * to render at the TOP level, leaking exactly the content the sandbox denied. */ private skipDeniedTag; private skipBlock; /** * Apply a parsed filter chain to an already-evaluated value. This is the * instance-aware filter engine used by `evalExpr` (via the * `__frond_apply_filters__` hook in the render context) so filters resolve * with this Frond's registered/custom filters at ANY nesting depth — inside * concat operands, ternary branches, and parenthesised sub-expressions — not * only at the top-level {{ }} output. Mirrors the filter loop in * `evalVarRaw`: `first`/`last` tail-paths, registered `this.filters`, and the * trailing-comparison form (`length != 1`). Auto-escaping stays the caller's * concern (`evalVarInner`). (#171) */ private applyFilters; private evalVar; private evalVarRaw; /** * Apply the no-argument filters that are common enough to avoid generic * dispatch. Keeping this table separate from evalVarInner makes the * expression pipeline easier to audit without changing filter order. */ private applyFastFilter; private applyRenderedFilter; private variablePermitted; private resolveConcatenation; private applyRenderedFilters; private evalVarInner; private pushIfBranch; private collectIfBranches; private handleIf; private collectForTokens; private forItems; private handleFor; private handleSet; private handleInclude; private handleMacro; /** * Parse a macro parameter list into [name, default] pairs. * * Handles: name, name="default", name='default'. Splitting on "," alone left a * defaulted parameter literally NAMED `greeting='Hello'`, so the body's * {{ greeting }} matched nothing (rendered empty) AND the caller's positional * argument was stored under that junk key and lost. Mirrors the Python master's * _parse_macro_params. The default is null when none is declared. */ static parseMacroParams(rawParams: string): Array<[string, string | null]>; /** * {% import "file" as alias %} -- load EVERY macro in a file under one namespace. * * The alias is bound as a plain object of macro functions, so {{ alias.greet(x) }} * resolves through the engine's existing dotted-call path and each macro keeps the * same argument binding, default handling and SafeString output as any other macro. * A namespace object (not a class) is deliberate: a function stored as a class * attribute binds as a method and would inject the namespace as the first argument, * which is exactly the argument-shift bug the Python master carried (fixed there * with types.SimpleNamespace). Both import forms must render identically. */ private handleImportAs; private handleFromImport; private collectMacroDefinitions; private createMacro; /** * Collect the body tokens of a {% %}...{% end %} block, * starting from the token after the opening tag (start + 1). Nested same-tag * blocks are kept in the body and balanced by depth; the matching closing tag is * consumed but NOT included. Returns [bodyTokens, indexAfterClosingTag]. * * canNest guards the open-tag count: handleSetBlock passes it so the inline * {% set x = 1 %} form (which has no {% endset %}) never opens a nested block — * only the block form {% set x %} nests. Omitted, every openTag occurrence nests. */ private collectBlockBody; private handleCache; /** * Handle {% live "name" poll N | sse | ws "path" [src "url"] %}...{% endlive %}. * * Server-rendered live region. The body renders once for first paint, is * registered under so GET /__frond/live/ (or a liveSource * provider) can re-render it, and is wrapped in a marker element that * frond.js wires to the chosen transport (poll / sse / ws). Mirrors the * Python master's _handle_live and PHP/Ruby handleLive. */ private handleLive; private parseLiveOptions; private collectLiveBody; private liveAttributes; /** * Re-render a registered {% live %} fragment by name with fresh data. * Returns the rendered HTML, or null if no fragment is registered under that * name yet (its page has not rendered). GET /__frond/live/ calls this * after resolving the provider data. */ static renderLive(name: string, data?: Record): string | null; /** Register a data provider for a {% live %} block. Invoked with the live * request on every refresh so auth re-applies. Mirrors Python's @live_source. */ static liveSource(name: string, fn: LiveProvider): void; /** The provider registered for a live block, or null. */ static getLiveSource(name: string): LiveProvider | null; /** Whether a live fragment has been registered (its page rendered). */ static hasLiveFragment(name: string): boolean; /** The ws path a live block declared (data-ws), or null. */ static getLiveWsPath(name: string): string | null; /** * Resolve GET /__frond/live/{name}: run the provider with the live request * (auth re-applies), re-render the fragment, and return a pure {status, body} * descriptor the route handler applies to the response. 404 for an unknown * name / unrendered fragment. Mirrors Python's live_endpoint / PHP respondLive. */ static respondLive(req: LiveRequest, name: string): LiveResponse; /** Wire the WebSocket broadcaster used by pushLive. Called once by @tina4/core * at server boot (frond is a zero-dep leaf and cannot import core). */ static setLiveBroadcaster(fn: LiveBroadcaster | null): void; /** * Re-render the '' live fragment and push it to connected clients. * Broadcasts a {type,name,html} envelope over WebSocket to the block's * declared data-ws path (else a room named ). Returns the rendered * HTML, or null if the fragment is not registered. Mirrors Python push_live * / PHP pushLive. The broadcast is best-effort — a missing/failed broadcaster * never throws into the caller. */ static pushLive(name: string, data?: Record): string | null; /** * {% set name %}...{% endset %} -- render the body and bind it. * * Emits nothing itself. The captured value is a SafeString because it is * template output that has already been escaped on the way in; re-escaping it * at {{ name }} would double-encode every entity. Twig and Jinja2 both mark the * capture safe. Returns the index just past {% endset %}. */ private handleSetBlock; private handleSpaceless; private handleAutoescape; }