diff --git a/src/facade/_index.yaml b/src/facade/_index.yaml index a0088fb..88149d1 100644 --- a/src/facade/_index.yaml +++ b/src/facade/_index.yaml @@ -486,6 +486,42 @@ entries: path: /index.html func: index_handler + # Optional view-config resolution contract (bound by the application, if at all) + - name: resolver + kind: contract.definition + meta: + comment: Optional view-config resolution contract — override facade requirement values at request time + description: | + Optional contract for overriding facade requirement values (css_variables, + custom_css, app_title, app_icon, theme_mode, ...) at request time. An + application binds an implementation to drive the view config from runtime + state (settings, per-tenant branding). When no binding exists, the facade + config and CSS-vars handlers fall back to the static ns.requirement defaults + — detected structurally via :implementations() (an empty list), not by + inspecting the contract open error. Runs under the request's ambient actor. + methods: + - name: resolve + description: | + Return a map of facade requirement name -> override value (string). Values + use the same format as the static requirement default (JSON-encoded for the + JSON requirements such as css_variables). Omitted keys fall back to the + static default. + input_schemas: + - definition: | + { + "type": "object", + "description": "Optional resolution context (reserved; empty today)." + } + format: application/schema+json + output_schemas: + - definition: | + { + "type": "object", + "description": "Map of facade requirement name to override value string; omitted keys fall back to the static ns.requirement default.", + "additionalProperties": { "type": "string" } + } + format: application/schema+json + # Shared helpers: file:// resolution and CSS variable generation - name: theming_helpers kind: library.lua @@ -495,6 +531,8 @@ entries: modules: - fs - json + - registry + - contract # Config endpoint - name: config_handler diff --git a/src/facade/config_handler.lua b/src/facade/config_handler.lua index c523d05..444e6f5 100644 --- a/src/facade/config_handler.lua +++ b/src/facade/config_handler.lua @@ -1,31 +1,8 @@ local http = require("http") -local registry = require("registry") local json = require("json") local env = require("env") local theming = require("theming_helpers") -local NS = "wippy.facade:" - -local function get_req(name: string): string - local entry, _ = registry.get(NS .. name) - if entry and entry.data then - return entry.data.default or "" - end - return "" -end - -local function get_req_json_any(name: string): {[string]: any} - local raw = get_req(name) - if raw == "" then - return {} - end - local val, err = json.decode(raw) - if err then - return {} - end - return (val :: {[string]: any}) or {} -end - local function derive_ws_url(api_url: string): string if api_url == "" then return "" @@ -54,30 +31,50 @@ local function non_empty_array_or_nil(a: any): {any}? return a :: {any} end --- Build a theming scope from requirement name prefixes. --- file_sys is optional: when set, "file://path" values are resolved to file content. -local function build_theming_scope(css_req: string, vars_req: string, icon_sets_req: string?, file_sys: any): {[string]: any}? - local custom_css = non_empty_or_nil(theming.resolve_css(get_req(css_req), file_sys)) - local css_vars = non_empty_map_or_nil(theming.resolve_json(get_req(vars_req), file_sys) :: {[string]: any}) - -- icon_sets are always inline JSON blobs; file:// refs are not expected here - local icon_sets = icon_sets_req and non_empty_map_or_nil(get_req_json_any(icon_sets_req)) or nil - - if not custom_css and not css_vars and not icon_sets then - return nil - end - return { - customCSS = custom_css, - cssVariables = css_vars, - iconSets = icon_sets, - } -end - local function handler() local res = http.response() if not res then return nil, "no HTTP context" end + -- Optional view-config resolver (wippy.facade:resolver): when an application + -- binds one, its overrides replace matching requirement values below; with no + -- binding this is {} and every read falls back to the static ns.requirement. + local overrides = theming.resolve_overrides() + + local function get_req(name: string): string + return theming.requirement(name, overrides) + end + + local function get_req_json_any(name: string): {[string]: any} + local raw = get_req(name) + if raw == "" then + return {} + end + local val, err = json.decode(raw) + if err then + return {} + end + return (val :: {[string]: any}) or {} + end + + -- Build a theming scope from requirement name prefixes. file_sys is optional: + -- when set, "fs://path" values are resolved to file content. + local function build_theming_scope(css_req: string, vars_req: string, icon_sets_req: string?, file_sys: any): {[string]: any}? + local custom_css = non_empty_or_nil(theming.resolve_css(get_req(css_req), file_sys)) + local css_vars = non_empty_map_or_nil(theming.resolve_json(get_req(vars_req), file_sys) :: {[string]: any}) + -- icon_sets are always inline JSON blobs; fs:// refs are not expected here + local icon_sets = icon_sets_req and non_empty_map_or_nil(get_req_json_any(icon_sets_req)) or nil + if not custom_css and not css_vars and not icon_sets then + return nil + end + return { + customCSS = custom_css, + cssVariables = css_vars, + iconSets = icon_sets, + } + end + local facade_url = get_req("fe_facade_url") local entry_path = get_req("fe_entry_path") local fe_mode = get_req("fe_mode") diff --git a/src/facade/css_vars_handler.lua b/src/facade/css_vars_handler.lua index 712c82f..6582321 100644 --- a/src/facade/css_vars_handler.lua +++ b/src/facade/css_vars_handler.lua @@ -1,23 +1,20 @@ local http = require("http") -local registry = require("registry") local theming = require("theming_helpers") -local NS = "wippy.facade:" - -local function get_req(name: string): string - local entry, _ = registry.get(NS .. name) - if entry and entry.data then - return entry.data.default or "" - end - return "" -end - local function handler() local res = http.response() if not res then return nil, "no HTTP context" end + -- Optional view-config resolver (wippy.facade:resolver): overrides win over the + -- static requirement, so this CSS endpoint tones from the same source as + -- /facade/config. No binding => {} => static defaults. + local overrides = theming.resolve_overrides() + local function get_req(name: string): string + return theming.requirement(name, overrides) + end + local file_sys = theming.get_fs(get_req("content_fs")) local vars = theming.resolve_json(get_req("css_variables"), file_sys) diff --git a/src/facade/theming_helpers.lua b/src/facade/theming_helpers.lua index 59e6e8d..9f5fd57 100644 --- a/src/facade/theming_helpers.lua +++ b/src/facade/theming_helpers.lua @@ -1,8 +1,16 @@ local fs = require("fs") local json = require("json") +local registry = require("registry") +local contract = require("contract") local helpers = {} +local NS = "wippy.facade:" +-- Optional contract an application binds to override facade requirement values at +-- request time (see the `resolver` contract.definition). When unbound, open() fails +-- and every read falls back to the static ns.requirement default. +local RESOLVER_CONTRACT = "wippy.facade:resolver" + -- Get a named filesystem instance by registry entry ID. -- Returns nil (does not raise) when entry_id is empty or the named filesystem -- does not exist, so callers degrade gracefully when content_fs is unconfigured. @@ -149,4 +157,54 @@ function helpers.build_variables_css(vars: {[string]: any}): string return table.concat(css_parts, "\n") end +-- resolve_overrides: optionally consult a bound wippy.facade:resolver to override +-- facade requirement values at request time. Returns a name->value(string) map, or +-- {} when no resolver is bound (the common case) or resolution fails. The view must +-- always render, so any failure degrades to the static requirement defaults; an +-- absent resolver (no binding) is silent, a bound-but-failing one is logged. +function helpers.resolve_overrides(): {[string]: any} + -- Any failure degrades to {} (static defaults) so the load-bearing config/CSS + -- endpoints never break. open() alone suffices: it fails cleanly when unbound, + -- and unbound vs errored both fall back to static, so nothing gates the open. + -- Ambient actor/scope; pcall guards against unexpected throws. + local ok, result = pcall(function(): {[string]: any} + local c, get_err = contract.get(RESOLVER_CONTRACT) + if get_err or not c then + return {} + end + local instance, open_err = (c :: any):open() + if open_err or not instance then + return {} + end + local overrides, resolve_err = (instance :: any):resolve({}) + if resolve_err or type(overrides) ~= "table" then + return {} + end + return overrides :: {[string]: any} + end) + if not ok then + return {} + end + return result :: {[string]: any} +end + +-- requirement: the value for a facade requirement, preferring a resolver override +-- (a string) over the static registry default. Mirrors the handlers' get_req so a +-- resolver can replace any requirement (css_variables, custom_css, app_title, ...). +-- Override values use the same format as the static default (JSON-encoded for the +-- JSON requirements such as css_variables); omitted keys fall back to the default. +function helpers.requirement(name: string, overrides: {[string]: any}?): string + if overrides then + local ov = (overrides :: {[string]: any})[name] + if type(ov) == "string" then + return ov :: string + end + end + local entry, _ = registry.get(NS .. name) + if entry and entry.data then + return entry.data.default or "" + end + return "" +end + return helpers