diff --git a/dist/build/build.js b/dist/build/build.js index 26762e2b25..6b18b465ae 100644 --- a/dist/build/build.js +++ b/dist/build/build.js @@ -2364,11 +2364,21 @@ ${urlInfo.url}`, const injectionSymbol = Symbol.for("jsenv_injection"); const INJECTIONS = { + /** + * Inject `Object.assign(window, { [key]: value })` at the top of the file + * (into a script for html, into the module itself for js) instead of + * replacing a placeholder: the value is read at runtime as a global. + */ global: (value) => { return { [injectionSymbol]: "global", value }; }, + /** + * Replace the placeholder when the file contains it, stay silent when it does not + * (without this a missing placeholder is reported as a warning). + */ optional: (value) => { - if (value && value[injectionSymbol] === "optional") { + if (value && value[injectionSymbol]) { + // a global injection is not a placeholder, it can't be missing from the file return value; } return { [injectionSymbol]: "optional", value }; @@ -2473,12 +2483,7 @@ return { magicSource.replace({ start, end, - replacement: - urlInfo.type === "js_classic" || - urlInfo.type === "js_module" || - urlInfo.type === "html" - ? JSON.stringify(value, null, " ") - : value, + replacement: asReplacement(value, urlInfo), }); index = content.indexOf(key, end); } @@ -2486,6 +2491,19 @@ return { return magicSource.toContentAndSourcemap(); }; +// In JS the placeholder stands for a value, so it must be substituted by a literal. +// Everywhere else (html attributes and text, css, ...) it stands for a piece of text +// and is substituted as-is, so it can be concatenated: href="__BACKEND_URL__/users/me" +const asReplacement = (value, urlInfo) => { + if (urlInfo.type === "js_classic" || urlInfo.type === "js_module") { + return JSON.stringify(value, null, " "); + } + if (typeof value === "string") { + return value; + } + return JSON.stringify(value, null, " "); +}; + const injectGlobals = (content, globals, urlInfo) => { if (urlInfo.type === "html") { return globalInjectorOnHtml(content, globals, urlInfo); @@ -6560,7 +6578,9 @@ const jsenvPluginVersionSearchParam = () => { * of them, the page switcher (cmd+K) opens one in the current tab. Whoever asks * gets the same answer. * - * Each page comes with what kind of page it is, read from where it sits and + * Each page comes with where its file is (so it can be opened in an editor as + * well as in the browser) and with what kind of page it is, read from where it + * sits and * what it is called — the two conventions this repo already follows: * - "experiment": something tried out, under a lab/ directory or named * *_experiment.html; @@ -6659,6 +6679,10 @@ const createHtmlPageLister = ({ rootDirectoryUrl }) => { ); return { url: `/${relativeUrl}`, + // Where the file actually is, so whoever wants to open it in an editor + // rather than in the browser has what GET /.internal/open_file/* asks + // for (a file url) without having to know the root directory. + fileUrl, kind: readKind(meta), // Relative to the root and without its trailing slash, which is how a // tree names its own nodes. @@ -7212,6 +7236,10 @@ const jsenvPluginFsRedirection = ({ } const { requestedUrl, rootDirectoryUrl, mainFilePath } = reference.ownerUrlInfo.context; + if (!requestedUrl) { + // the SPA fallback answers a request; during build there is none + return null; + } const closestHtmlRootFile = getClosestHtmlRootFile( requestedUrl, rootDirectoryUrl, @@ -7624,18 +7652,45 @@ const jsenvPluginInjections = (rawAssociations) => { { injectionsGetter: rawAssociations }, context.rootDirectoryUrl, ); - getInjections = (urlInfo) => { + const findInjectionsGetter = (urlInfo) => { const { injectionsGetter } = URL_META.applyAssociations({ url: asUrlWithoutSearch(urlInfo.url), associations: resolvedAssociations, }); - if (!injectionsGetter) { + if (injectionsGetter) { + return { injectionsGetter, isInherited: false }; + } + if (urlInfo.isInline) { + // content inlined into a file (a ` gets the JS literal, + * which is how a value is shared with every js file of the page. + * Use INJECTIONS.optional(value) for a placeholder that may be absent from the file + * and INJECTIONS.global(value) to inject `Object.assign(window, { ... })` instead of + * replacing a placeholder. * * @return {Promise} buildReturnValue * @return {Promise} [buildReturnValue.buildInlineContents] @@ -11939,6 +12025,17 @@ const build = async ({ { const unexpectedParamNames = Object.keys(rest); if (unexpectedParamNames.length > 0) { + const entryPointParamNames = unexpectedParamNames.filter((name) => + Object.hasOwn(entryPointDefaultParams, name), + ); + if (entryPointParamNames.length > 0) { + throw new TypeError( + `${entryPointParamNames.join(",")}: param(s) configured per entry point, move them into entryPoints, as in: +entryPoints: { + "./index.html": { ${entryPointParamNames.map((name) => `${name}: ...`).join(", ")} }, +}`, + ); + } throw new TypeError( `${unexpectedParamNames.join(",")}: there is no such param`, ); diff --git a/dist/js/page_switcher.js b/dist/js/page_switcher.js index 2abfa5ecf4..0801c13d40 100644 --- a/dist/js/page_switcher.js +++ b/dist/js/page_switcher.js @@ -2,6 +2,11 @@ * cmd+K / ctrl+K on any dev-served page: the .html files the server serves, as a * tree one walks, filter as you type, Enter to go there. * + * cmd+E / ctrl+E is the other half of the same question: instead of going to a + * page, open its file in the editor (the server does it, see + * GET /.internal/open_file/*). Pressed on the page itself it opens the page one + * is on; pressed inside the switcher it opens the row one is looking at. + * * A tree rather than a list of paths, because the paths are mostly the same * path: folding every directory that holds a single thing turns * "packages/frontend/navi/src/layout/demos/…" repeated forty times into one @@ -25,6 +30,10 @@ // another injected client. A function scope owes nothing to anybody. (() => { const PAGES_ENDPOINT = "/.internal/pages.json"; + // The server asks the OS to open a file in whatever editor is configured + // (VSCode here) — it takes a file url, which is why the page list carries one + // per page (see html_pages.js). + const OPEN_FILE_ENDPOINT = "/.internal/open_file/"; const STORAGE_KEY = "jsenv_page_switcher"; // Open across a reload: this is a dev tool on a page one is editing, and a hot // reload in the middle of looking for the next page should not close what one @@ -56,15 +65,20 @@ } }; - const isSwitcherKey = (event) => { - if (event.key !== "k" && event.key !== "K") { - return false; - } - // cmd on mac, ctrl elsewhere — the same split every editor makes. - return window.navigator.platform.toLowerCase().includes("mac") + // cmd on mac, ctrl elsewhere — the same split every editor makes. + const isCommandKey = (event) => + window.navigator.platform.toLowerCase().includes("mac") ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey; - }; + + const isSwitcherKey = (event) => + (event.key === "k" || event.key === "K") && isCommandKey(event); + // E for edit, next to K for the same reason the two belong together: K asks + // "which page", E asks "where does this page live". Outside the switcher it + // means the page one is on; inside it, the row one is looking at — so the + // same press reads the same way in both places. + const isEditorKey = (event) => + (event.key === "e" || event.key === "E") && isCommandKey(event); const STYLE_TEXT = /* css */ ` :host { @@ -230,6 +244,32 @@ } `; + const FLASH_STYLE_TEXT = /* css */ ` + :host { + position: fixed; + right: 16px; + bottom: 16px; + /* Above the switcher's own panel: it is the switcher that triggers it. */ + z-index: 2147483647; + display: block; + font-family: system-ui, sans-serif; + pointer-events: none; + } + .flash { + padding: 8px 14px; + color: light-dark(#0f172a, #e2e8f0); + font-size: 13px; + background: light-dark(white, #1e293b); + border-radius: 8px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); + color-scheme: light dark; + } + .flash[data-error] { + color: light-dark(#991b1b, #fecaca); + background: light-dark(#fee2e2, #7f1d1d); + } + `; + let pagesPromise = null; const loadPages = () => { // Once per page load: the list is a filesystem scan behind a short cache @@ -240,6 +280,54 @@ return pagesPromise; }; + // Opening a file in an editor happens in another application, on another + // screen sometimes: without a word here, a press that failed and a press that + // worked look exactly the same. Its own host and its own shadow root, so it + // can be shown whether or not the switcher is open. + let flashHost = null; + let flashBox = null; + let flashTimeout = null; + const flash = (message, isError) => { + if (!flashHost) { + flashHost = document.createElement("div"); + const shadow = flashHost.attachShadow({ mode: "open" }); + const style = document.createElement("style"); + style.textContent = FLASH_STYLE_TEXT; + flashBox = document.createElement("div"); + flashBox.className = "flash"; + shadow.append(style, flashBox); + } + flashBox.textContent = message; + flashBox.toggleAttribute("data-error", Boolean(isError)); + // Appended last every time, so it sits above the switcher's host when both + // are on the page and they share the same z-index. + document.body.append(flashHost); + window.clearTimeout(flashTimeout); + flashTimeout = window.setTimeout(() => flashHost.remove(), 2500); + }; + + const openInEditor = async (file) => { + if (!file || !file.fileUrl) { + flash("This page is not a file the server lists.", true); + return; + } + flash(`Opening ${file.name || file.url} in editor…`); + try { + const response = await fetch( + `${OPEN_FILE_ENDPOINT}${encodeURIComponent(file.fileUrl)}`, + ); + if (response.status === 404) { + // The route exists only when the server is willing to expose the + // machine it runs on (see start_server.js). + flash("This server does not open files in an editor.", true); + } else if (!response.ok) { + flash(`Editor said no (${response.status}).`, true); + } + } catch { + flash("Could not reach the dev server.", true); + } + }; + const readStoredState = () => { try { const stored = JSON.parse( @@ -396,7 +484,11 @@ panel.className = "panel"; const input = document.createElement("input"); input.type = "search"; - input.placeholder = "Go to page…"; + // The other key is written where one is already looking: a shortcut nobody + // is told about is a shortcut nobody presses. Both names, not the one this + // platform uses — the reader knows which of the two their keyboard has, and + // it keeps what the panel says the same everywhere. + input.placeholder = "Go to page… (cmd/ctrl+E to open in editor)"; input.setAttribute("aria-label", "Go to page"); const kindsRow = document.createElement("div"); kindsRow.className = "kinds"; @@ -664,6 +756,21 @@ toggleCollapsed(row.node.path); return; } + if (isEditorKey(event)) { + // Taken whatever the row is: let go of on a directory it would reach + // the page below and open the page one came from, which is not what a + // key pressed inside an open switcher can be asking for. + stop(); + const row = rows[currentIndex]; + if (!row || row.type !== "file") { + return; + } + // Done with the switcher: the answer to "where does this live" arrives + // in the editor, not here. + close(); + openInEditor(row.file); + return; + } if (isSwitcherKey(event)) { stop(); close(); @@ -708,15 +815,31 @@ // preventDefault, the key was theirs and nothing happens here. const listenSwitcherKey = () => { window.addEventListener("keydown", (event) => { - if (event.defaultPrevented || !isSwitcherKey(event)) { + if (event.defaultPrevented) { return; } - // Ours now: the browser has its own use for cmd+K (the address bar), which - // it must not get. - event.preventDefault(); - openSwitcher(); + if (isSwitcherKey(event)) { + // Ours now: the browser has its own use for cmd+K (the address bar), + // which it must not get. + event.preventDefault(); + openSwitcher(); + return; + } + if (isEditorKey(event)) { + event.preventDefault(); + openCurrentPageInEditor(); + } }); }; + // The page one is looking at, in the editor. The list is where the file url + // comes from, so a page the server does not list (an @fs url, something under + // node_modules) says so rather than opening the wrong thing. + const openCurrentPageInEditor = async () => { + const here = currentPageUrl(); + const pages = await loadPages(); + const page = pages.find((candidate) => candidate.url === here); + openInEditor(page && { ...page, name: here.split("/").pop() }); + }; const setup = () => { listenSwitcherKey(); if (wasOpen()) { diff --git a/dist/jsenv_core.js b/dist/jsenv_core.js index b07773fb58..abe86cd6db 100644 --- a/dist/jsenv_core.js +++ b/dist/jsenv_core.js @@ -3,11 +3,21 @@ import "@jsenv/sourcemap"; const injectionSymbol = Symbol.for("jsenv_injection"); const INJECTIONS = { + /** + * Inject `Object.assign(window, { [key]: value })` at the top of the file + * (into a script for html, into the module itself for js) instead of + * replacing a placeholder: the value is read at runtime as a global. + */ global: (value) => { return { [injectionSymbol]: "global", value }; }, + /** + * Replace the placeholder when the file contains it, stay silent when it does not + * (without this a missing placeholder is reported as a warning). + */ optional: (value) => { - if (value && value[injectionSymbol] === "optional") { + if (value && value[injectionSymbol]) { + // a global injection is not a placeholder, it can't be missing from the file return value; } return { [injectionSymbol]: "optional", value }; diff --git a/dist/start_dev_server/start_dev_server.js b/dist/start_dev_server/start_dev_server.js index 09484be60c..08bf527a8c 100644 --- a/dist/start_dev_server/start_dev_server.js +++ b/dist/start_dev_server/start_dev_server.js @@ -1321,7 +1321,9 @@ const jsenvPluginClientMonitoring = () => { /* * cmd+K (ctrl+K elsewhere) on any page the dev server serves opens a list of - * the .html files it serves, filter as you type, Enter to go there. + * the .html files it serves, filter as you type, Enter to go there. cmd+E + * (ctrl+E elsewhere) opens a page's file in the editor instead of going to it: + * the current page from anywhere, the selected row from inside the switcher. * * The list is the one the filesystem plugin already publishes for everyone * (GET /.internal/pages.json, see protocol_file/html_pages.js) — this only adds @@ -3327,7 +3329,9 @@ const FILE_AND_SERVER_URLS_CONVERTER = { * of them, the page switcher (cmd+K) opens one in the current tab. Whoever asks * gets the same answer. * - * Each page comes with what kind of page it is, read from where it sits and + * Each page comes with where its file is (so it can be opened in an editor as + * well as in the browser) and with what kind of page it is, read from where it + * sits and * what it is called — the two conventions this repo already follows: * - "experiment": something tried out, under a lab/ directory or named * *_experiment.html; @@ -3426,6 +3430,10 @@ const createHtmlPageLister = ({ rootDirectoryUrl }) => { ); return { url: `/${relativeUrl}`, + // Where the file actually is, so whoever wants to open it in an editor + // rather than in the browser has what GET /.internal/open_file/* asks + // for (a file url) without having to know the root directory. + fileUrl, kind: readKind(meta), // Relative to the root and without its trailing slash, which is how a // tree names its own nodes. @@ -4092,6 +4100,10 @@ const jsenvPluginFsRedirection = ({ } const { requestedUrl, rootDirectoryUrl, mainFilePath } = reference.ownerUrlInfo.context; + if (!requestedUrl) { + // the SPA fallback answers a request; during build there is none + return null; + } const closestHtmlRootFile = getClosestHtmlRootFile( requestedUrl, rootDirectoryUrl, @@ -4986,11 +4998,21 @@ const jsenvPluginDirectoryReferenceEffect = ( const injectionSymbol = Symbol.for("jsenv_injection"); const INJECTIONS = { + /** + * Inject `Object.assign(window, { [key]: value })` at the top of the file + * (into a script for html, into the module itself for js) instead of + * replacing a placeholder: the value is read at runtime as a global. + */ global: (value) => { return { [injectionSymbol]: "global", value }; }, + /** + * Replace the placeholder when the file contains it, stay silent when it does not + * (without this a missing placeholder is reported as a warning). + */ optional: (value) => { - if (value && value[injectionSymbol] === "optional") { + if (value && value[injectionSymbol]) { + // a global injection is not a placeholder, it can't be missing from the file return value; } return { [injectionSymbol]: "optional", value }; @@ -5095,12 +5117,7 @@ return { magicSource.replace({ start, end, - replacement: - urlInfo.type === "js_classic" || - urlInfo.type === "js_module" || - urlInfo.type === "html" - ? JSON.stringify(value, null, " ") - : value, + replacement: asReplacement(value, urlInfo), }); index = content.indexOf(key, end); } @@ -5108,6 +5125,19 @@ return { return magicSource.toContentAndSourcemap(); }; +// In JS the placeholder stands for a value, so it must be substituted by a literal. +// Everywhere else (html attributes and text, css, ...) it stands for a piece of text +// and is substituted as-is, so it can be concatenated: href="__BACKEND_URL__/users/me" +const asReplacement = (value, urlInfo) => { + if (urlInfo.type === "js_classic" || urlInfo.type === "js_module") { + return JSON.stringify(value, null, " "); + } + if (typeof value === "string") { + return value; + } + return JSON.stringify(value, null, " "); +}; + const injectGlobals = (content, globals, urlInfo) => { if (urlInfo.type === "html") { return globalInjectorOnHtml(content, globals, urlInfo); @@ -5181,18 +5211,45 @@ const jsenvPluginInjections = (rawAssociations) => { { injectionsGetter: rawAssociations }, context.rootDirectoryUrl, ); - getInjections = (urlInfo) => { + const findInjectionsGetter = (urlInfo) => { const { injectionsGetter } = URL_META.applyAssociations({ url: asUrlWithoutSearch(urlInfo.url), associations: resolvedAssociations, }); - if (!injectionsGetter) { + if (injectionsGetter) { + return { injectionsGetter, isInherited: false }; + } + if (urlInfo.isInline) { + // content inlined into a file (a ` shares the value with every js file of the page. See `INJECTIONS.optional` and `INJECTIONS.global`. * @param {object} [params.runtimeCompat] - Target runtimes; warns when dev code wouldn't survive the build. * @param {string} [params.sourcemaps="inline"] - Sourcemap mode. * @param {AbortSignal} [params.signal] - Abort to stop the server. diff --git a/package.json b/package.json index fa9012b163..abe041cc92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jsenv/core", - "version": "41.4.3", + "version": "41.4.4", "type": "module", "description": "Tool to develop, test and build js projects", "repository": { diff --git a/packages/frontend/navi/dist/jsenv_navi.js b/packages/frontend/navi/dist/jsenv_navi.js index 01a275b3bb..7c1f3b480b 100644 --- a/packages/frontend/navi/dist/jsenv_navi.js +++ b/packages/frontend/navi/dist/jsenv_navi.js @@ -2,19133 +2,14977 @@ * AI reading this file: read ../docs/AI_INSTRUCTIONS.md for context on * using @jsenv/navi as intended. */ -import { installImportMetaCssBuild, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, coarsePointerSignal } from "./jsenv_navi_side_effects.js"; -import { isValidElement, createContext, h, toChildArray, render, Fragment, cloneElement } from "preact"; -import { useErrorBoundary, useLayoutEffect, useEffect, useContext, useMemo, useRef, useState, useCallback, useId } from "preact/hooks"; -import { jsxs, jsx, Fragment as Fragment$1 } from "preact/jsx-runtime"; -import { signal, effect, computed, batch, useSignal } from "@preact/signals"; -import { createIterableWeakSet, createEventGroupLogger, normalizeStyle, mergeOneStyle, getPositionedParent, createPubSub, findEvent, dispatchInternalCustomEvent, mergeTwoStyles, normalizeStyles, createGroupTransitionController, getElementSignature, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, dispatchCustomEvent, createValueEffect, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, getKeyboardEventDefaultAction, chainEvent, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, activeElementSignal, initFocusGroup, elementIsFocusable, resolveOklchLightness, contrastColor, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, scrollIntoViewScoped, measureWidestChildRow, performTabNavigation, dragAfterThreshold, getScrollContainer, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, stringifyStyle as stringifyStyle$1 } from "@jsenv/dom"; +import { windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, installImportMetaCssBuild, coarsePointerSignal } from "./jsenv_navi_side_effects.js"; +import { createContext, isValidElement, h, Fragment, toChildArray, render, cloneElement } from "preact"; +import { useContext, useLayoutEffect, useRef, useEffect, useCallback, useState, useMemo, useId, useErrorBoundary } from "preact/hooks"; +import { jsx, jsxs, Fragment as Fragment$1 } from "preact/jsx-runtime"; +import { computed, signal, effect, batch, useSignal } from "@preact/signals"; +import { createPubSub, normalizeStyle, mergeOneStyle, getPositionedParent, findEvent, dispatchInternalCustomEvent, mergeTwoStyles, normalizeStyles, resolveCSSSize, measureLongestVisualLineWidth, hasCSSSizeUnit, resolveOklchLightness, contrastColor, createIterableWeakSet, dispatchCustomEvent, getElementSignature, createValueEffect, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, createEventGroupLogger, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, getKeyboardEventDefaultAction, chainEvent, activeElementSignal, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, findBefore, findAfter, initFocusGroup, elementIsFocusable, scrollIntoViewScoped, measureWidestChildRow, performTabNavigation, dragAfterThreshold, getScrollContainer, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, stringifyStyle as stringifyStyle$1 } from "@jsenv/dom"; export { contrastColor, startDragToReorder } from "@jsenv/dom"; -import { prefixFirstAndIndentRemainingLines } from "@jsenv/humanize"; import { createValidity, parseDuration, durationContainsNaN, compareTwoDurations, durationToSeconds, durationToISOString } from "@jsenv/validity"; export { compareTwoDurations, durationContainsNaN, durationToHours, durationToISOString, durationToMinutes, durationToNumber, durationToSeconds, durationToString, parseDuration } from "@jsenv/validity"; -import { Suspense, createPortal, forwardRef } from "preact/compat"; - -const actionPrivatePropertiesWeakMap = new WeakMap(); -const getActionPrivateProperties = (action) => { - const actionPrivateProperties = actionPrivatePropertiesWeakMap.get(action); - if (!actionPrivateProperties) { - throw new Error(`Cannot find action private properties for "${action}"`); - } - return actionPrivateProperties; -}; -const setActionPrivateProperties = (action, properties) => { - actionPrivatePropertiesWeakMap.set(action, properties); -}; +import { createPortal, Suspense, forwardRef } from "preact/compat"; +import { prefixFirstAndIndentRemainingLines } from "@jsenv/humanize"; -const IDLE = { id: "idle" }; -const RUNNING = { id: "running" }; -const ABORTED = { id: "aborted" }; -const FAILED = { id: "failed" }; -const COMPLETED = { id: "completed" }; +const NextResolverContext = createContext(null); +const useNextResolver = () => useContext(NextResolverContext); -const useActionStatus = (action) => { - if (!action) { - return { - params: undefined, - runningState: IDLE, - isPrerun: false, - idle: true, - loading: false, - aborted: false, - error: null, - completed: false, - data: undefined, - }; - } - const { - paramsSignal, - runningStateSignal, - isPrerunSignal, - errorSignal, - dataSignal, - } = action; - const params = paramsSignal.value; - const isPrerun = isPrerunSignal.value; - const runningState = runningStateSignal.value; - const idle = runningState === IDLE; - const aborted = runningState === ABORTED; - const error = errorSignal.value; - const loading = runningState === RUNNING; - const completed = runningState === COMPLETED; - const data = dataSignal.value; +/** + * Creates a renderComponent function that passes props through a chain of resolvers. + * Each resolver is a Preact component rendered in sequence (hooks are allowed). + * To pass through to the next resolver, call useNextResolver() and render the + * returned Next component with the desired props. + * To terminate the chain early (e.g. render a specialized component), render + * directly without calling Next. + * + * The last entry in the array is the final/target component — it receives null + * from useNextResolver() indicating it is terminal. + * + * Usage: + * const renderButton = createComponentResolver([ResolverA, ResolverB, ButtonTarget]); + * // Then inside a component render: + * renderButton(props) + * + * NextResolverContext exposes a stable Next component so resolvers can continue + * the chain via useNextResolver(). + * ResolverIndexContext tracks which resolver is next so that when a resolver + * re-renders and calls Next, the chain resumes from the correct position. + */ +const createComponentResolver = resolvers => { + const ResolverIndexContext = createContext(0); + const ChainRunner = props => { + const index = useContext(ResolverIndexContext); + if (index >= resolvers.length) { + return null; + } + const Resolver = resolvers[index]; + const isLast = index === resolvers.length - 1; + return jsx(ResolverIndexContext.Provider, { + value: index + 1, + children: isLast ? jsx(NextResolverContext.Provider, { + value: null, + children: jsx(Resolver, { + ...props + }) + }) : jsx(Resolver, { + ...props + }) + }); + }; - return { - params, - runningState, - isPrerun, - idle, - loading, - aborted, - error, - completed, - data, + // Stable component defined once per createComponentResolver call. + // Renders ChainRunner directly — no new providers — so ResolverIndexContext + // is inherited from the parent tree. When a resolver calls , the chain + // resumes from index+1 (already set by the Provider wrapping that resolver). + const NextComponent = props => jsx(ChainRunner, { + ...props + }); + const renderComponent = props => { + return jsx(NextResolverContext.Provider, { + value: NextComponent, + children: jsx(ResolverIndexContext.Provider, { + value: 0, + children: jsx(ChainRunner, { + ...props + }) + }) + }); }; + return renderComponent; }; -installImportMetaCssBuild(import.meta);const css$W = /* css */` - .action_error { - margin-top: 0; - margin-bottom: 20px; - padding: 20px; - background: #fdd; - border: 1px solid red; - } -`; -const renderIdleDefault = () => null; -const renderLoadingDefault = () => null; -const renderAbortedDefault = () => null; -const renderErrorDefault = error => { - let routeErrorText = error && error.message ? error.message : error; - return jsxs("p", { - className: "action_error", - children: ["An error occured: ", routeErrorText] - }); +/** + * Where an action asks "are you sure?", and where whatever knows how to ask it + * registers itself. + * + * Two directions meet here: the action execution path (use_execute_action.js) + * needs to ask the question, and what asks it (confirm_popup.jsx) is a popup + * built out of navi's own controls — which are themselves built out of the + * action execution path. Neither side can import the other, so the question + * travels through this leaf module instead. Same shape as registerNaviCommand. + * + * The question itself is attached to the control that asks it, not carried as + * a prop down the action pipeline: the control that requests an action is not + * always the one that runs it (a submit button hands the send to its form), so + * the request has an element to read the question off, and nothing else. + */ + + +let confirmImplementation = null; + +const registerConfirmImplementation = (implementation) => { + confirmImplementation = implementation; }; -const renderCompletedDefault = () => null; -const ActionRenderer = ({ - action, - children, - disabled -}) => { - import.meta.css = [css$W, "@jsenv/navi/src/action/action_renderer.jsx"]; - if (action === undefined) { - throw new Error("ActionRenderer requires an action to render, but none was provided."); - } - let renderBranches; - if (typeof children === "function") { - renderBranches = { - completed: children - }; - } else if (isValidElement(children)) { - renderBranches = { - always: () => children - }; - } else if (isPlainObject$1(children)) { - renderBranches = children; - } else { - renderBranches = { - completed: children - }; - } - const { - idle: renderIdle = renderIdleDefault, - loading: renderLoading = renderLoadingDefault, - aborted: renderAborted = renderAbortedDefault, - error: renderError = renderErrorDefault, - completed: renderCompleted, - always: renderAlways - } = renderBranches; - const { - idle, - loading, - aborted, - error, - completed, - data - } = useActionStatus(action); - const UIRenderedPromise = useUIRenderedPromise(action); - const [errorBoundary, resetErrorBoundary] = useErrorBoundary(); - // Mark this action as bound to UI components (has renderers) - // This tells the action system that errors should be caught and stored - // in the action's error state rather than bubbling up +const confirmParamsWeakMap = new WeakMap(); + +/** + * Attaches a confirmation to whatever `elementRef` points at, for as long as + * it is mounted. + * + * @param {import("preact/hooks").Ref} elementRef + * @param {object} params + * @param {string|import("preact").ComponentChildren} [params.message] - The + * question. Plain text, or JSX when it needs a link, an emphasis, a list. + * @param {import("preact").ComponentChildren} [params.content] - The whole + * popup body, replacing the default question + buttons. Answer from inside it + * with the `--navi-confirm` and `--navi-cancel` commands. + */ +const useConfirmParams = (elementRef, { message, content }) => { + // No dependency array: `content` is JSX, a fresh object on every render, so + // there is nothing stable to compare — and writing to a WeakMap costs less + // than deciding whether to. useLayoutEffect(() => { - if (action) { - const { - ui - } = getActionPrivateProperties(action); - ui.hasRenderers = true; + const element = elementRef.current; + if (!element) { + return undefined; } - }, [action]); - useLayoutEffect(() => { - resetErrorBoundary(); - }, [action, loading, idle, resetErrorBoundary]); - useLayoutEffect(() => { - UIRenderedPromise.resolve(); + if (message === undefined && content === undefined) { + confirmParamsWeakMap.delete(element); + return undefined; + } + confirmParamsWeakMap.set(element, { message, content }); return () => { - actionUIRenderedPromiseWeakMap.delete(action); + confirmParamsWeakMap.delete(element); }; - }, [action]); - if (disabled) { - return null; - } - // If renderAlways is provided, it wins and handles all rendering - if (renderAlways) { - return renderAlways({ - idle, - loading, - aborted, - completed, - error, - data - }); - } - if (idle) { - return renderIdle(action); + }); +}; + +const getConfirmParams = (element) => { + if (!element) { + return undefined; } - if (errorBoundary) { - return renderError(errorBoundary, "ui_error", action); + return confirmParamsWeakMap.get(element); +}; + +/** + * @param {object} params + * @param {string|import("preact").ComponentChildren} [params.message] + * @param {import("preact").ComponentChildren} [params.content] + * @param {Element} [params.anchor] - What asked, so the question can be shown + * next to it. + * @returns {Promise} + */ +const requestConfirmation = async ({ message, content, anchor }) => { + if (!confirmImplementation) { + // Reachable only when navi is used through its own submodules rather than + // its entry point (which registers the popup). An unanswerable question + // must not silently swallow the action the user asked for. + console.warn( + `no confirm implementation registered, the confirmation is considered given`, + ); + return true; } - if (aborted) { - return renderAborted(action); + return confirmImplementation({ message, content, anchor }); +}; + +/** + * Interpolates a template string, replacing [key] placeholders with values. + * Values can be strings or JSX elements (when allowJsx is true). + * Returns a plain string when all replacements are strings, or a Preact + * fragment when JSX values are present and allowJsx is enabled. + * + * `[]` was chosen as the placeholder delimiter (rather than `{}` or `{{}}`) + * because it does not conflict with JSX syntax, JavaScript template literals, + * or common punctuation in translated strings. + * + * Pass `allowJsx: true` to enable VNode replacements (used by ). + * Without it, all values are coerced to strings. + */ +const interpolateText = ( + template, + replacements, + { allowJsx = false } = {}, +) => { + if (!replacements || typeof template !== "string") { + return template; } - let renderCompletedSafe; - if (renderCompleted) { - renderCompletedSafe = renderCompleted; - } else { - const { - ui - } = getActionPrivateProperties(action); - if (ui.renderCompleted) { - renderCompletedSafe = ui.renderCompleted; - } else { - renderCompletedSafe = renderCompletedDefault; + const parts = template.split(/(\[[^\]]+\])/); + let hasVnode = false; + const resolved = []; + for (const part of parts) { + const match = part.match(/^\[([^\]]+)\]$/); + if (!match) { + resolved.push(part); + continue; } - } - if (loading) { - if (action.canDisplayOldData && data !== undefined) { - return renderCompletedSafe(data, action); + const key = match[1]; + let value = resolveValue(replacements, key, part); + if (typeof value === "function") { + value = value(); } - return renderLoading(action); + if (isValidElement(value)) { + if (allowJsx) { + hasVnode = true; + } else { + console.warn( + `interpolateText: VNode passed for placeholder [${match[1]}] but allowJsx is false — value coerced to string`, + ); + } + } + resolved.push(value); } - if (error) { - return renderError(error, "action_error", action); + if (!hasVnode) { + return resolved.join(""); } - return renderCompletedSafe(data, action); + // h(Fragment) instead of JSX (<>{resolved}) to keep this file as .js + return h(Fragment, null, resolved); }; -const defaultPromise = Promise.resolve(); -defaultPromise.resolve = () => {}; -const actionUIRenderedPromiseWeakMap = new WeakMap(); -const useUIRenderedPromise = action => { - if (!action) { - return defaultPromise; + +// Resolves a placeholder key against the replacements object. +// 1. Direct lookup: replacements["item.name"] +// 2. Dot-path lookup: replacements["item"]["name"] +// 3. Fallback: the original placeholder string (e.g. "[item.name]") +const resolveValue = (replacements, key, fallback) => { + if (key in replacements) { + return replacements[key]; } - const actionUIRenderedPromise = actionUIRenderedPromiseWeakMap.get(action); - if (actionUIRenderedPromise) { - return actionUIRenderedPromise; + const dotIndex = key.indexOf("."); + if (dotIndex !== -1) { + const head = key.slice(0, dotIndex); + const tail = key.slice(dotIndex + 1); + const parent = replacements[head]; + if (parent && typeof parent === "object") { + const nested = parent[tail]; + if (nested !== undefined) { + return nested; + } + } } - let resolve; - const promise = new Promise(res => { - resolve = res; - }); - promise.resolve = resolve; - actionUIRenderedPromiseWeakMap.set(action, promise); - return promise; + return fallback; }; -const isPlainObject$1 = obj => { - if (typeof obj !== "object" || obj === null) { - return false; + +const DEFAULT_LANG = "en"; + +/** + * The browser's own language preferences, most preferred first — read from + * `navigator.languages` (falling back to the single `navigator.language`, + * then to DEFAULT_LANG when neither is available, e.g. during SSR). Kept as + * its own signal, independent from what this app actually supports — see + * `supportedLanguagesSignal` below for the allow-list that filters it, and + * `languagesSignal` for the final, ready-to-use combination of the two (plus + * `preferredLanguageSignal`). + */ +const getRuntimeLanguages = () => { + if (typeof window === "undefined") { + return [DEFAULT_LANG]; } - let proto = obj; - while (Object.getPrototypeOf(proto) !== null) { - proto = Object.getPrototypeOf(proto); + const { navigator } = window; + if (typeof navigator === "undefined") { + return [DEFAULT_LANG]; } - return Object.getPrototypeOf(obj) === proto || Object.getPrototypeOf(obj) === null; + const { languages } = navigator; + if (Array.isArray(languages) && languages.length > 0) { + return languages; + } + const { language } = navigator; + if (typeof language === "string") { + return [language]; + } + return [DEFAULT_LANG]; }; -/* - * Deep structural equality for arbitrary JS values — what `===` can't do but this - * codebase constantly needs: memoization cache keys ({ id: 1 } equal to { id: 1 }), - * effect/memo dependency checks, signal/store change detection, and action - * parameter deduplication. +const runtimeLanguagesSignal = signal(getRuntimeLanguages()); + +if (typeof window !== "undefined") { + window.addEventListener("languagechange", () => { + runtimeLanguagesSignal.value = getRuntimeLanguages(); + }); +} + +/** + * The languages this app actually offers, e.g. `["en", "fr"]` — an allow-list + * `languagesSignal` below filters everything else against (runtime languages the + * browser reports, and `preferredLanguageSignal`'s own override), so a site + * that only supports English/French never ends up resolving to German just + * because that happens to be the browser's or the user's own preference. * - * Beyond recursive object/array comparison it covers the edge cases `===` gets - * "wrong" for equality purposes: NaN equals NaN, Date compared by time value, - * cycles don't loop (a seen-set guards circular refs), and same-type is required - * before descending. Cheap paths run first: reference equality, then the identity - * short-circuit below, then array length before element-by-element. + * `null` (the default) means no restriction at all: every language the + * browser/user prefers is allowed through, matching this module's previous, + * unrestricted behavior. + */ +const supportedLanguagesSignal = signal(null); + +/** + * @param {string[]|null} languages - e.g. `["en", "fr"]`. Pass `null`/`[]` + * to lift the restriction again (allow everything). + */ +const setSupportedLanguages = (languages) => { + supportedLanguagesSignal.value = + languages && languages.length ? languages : null; +}; + +/** + * A single language the user explicitly chose (e.g. via an in-app language + * picker), overriding whatever the browser itself reports — takes priority + * over `runtimeLanguagesSignal` in `languagesSignal` below, but is still subject + * to `supportedLanguagesSignal`'s own allow-list. * - * SYMBOL_IDENTITY. Two *different* object instances can be declared "conceptually - * the same" by sharing a SYMBOL_IDENTITY value; the comparison then treats them as - * equal with no deep walk. This is what lets a spread copy ({ ...params, extra }) - * still count as the same params as the original, and lets objects reconstructed - * across a serialization boundary be recognized as one entity — the cases where a - * content comparison would be too slow, or too strict to see them as equal. Use - * Symbol.for() so the marker is the same symbol across modules/contexts: + * Deliberately a single language, not an ordered list: reordering *among* + * several preferred languages is real complexity real users essentially + * never want — the practical need `languagesSignal` needs to serve is "let this + * one user pick their one preferred language instead of the browser's", + * nothing more. + */ +const preferredLanguageSignal = signal(null); + +/** + * @param {string|null} language - BCP 47 tag, e.g. "fr". Pass `null` to + * go back to following the browser's own language. + */ +const setPreferredLanguage = (language) => { + preferredLanguageSignal.value = language || null; +}; + +const getPrimarySubtag = (lang) => lang.split("-")[0]; + +const isLanguageSupported = (lang, supportedLanguages) => { + const primarySubtag = getPrimarySubtag(lang); + return supportedLanguages.some( + (supportedLanguage) => + getPrimarySubtag(supportedLanguage) === primarySubtag, + ); +}; + +/** + * The ordered, ready-to-use language preference list every navi + * component/util defaults to (naviI18n, formatNumber, the Time components, + * validation messages…), live on every read. Combines, in priority order: * - * const id = Symbol.for("params"); - * a[SYMBOL_IDENTITY] = id; - * b[SYMBOL_IDENTITY] = id; - * compareTwoJsValues(a, b); // true immediately, no property walk + * 1. `preferredLanguageSignal` (the user's own explicit pick, if any) + * 2. `runtimeLanguagesSignal` (the browser's own ordered preferences) + * + * then filters the result through `supportedLanguagesSignal` (if set) so + * only languages this app actually offers ever come out — e.g. a browser + * preferring `["de", "fr", "en"]` on a site that only supports `["en", + * "fr"]` resolves to `["fr", "en"]`, never touching German. If filtering + * would leave nothing at all (none of the browser's/user's preferences are + * supported), falls back to `supportedLanguagesSignal` itself so callers + * still get *something* usable rather than an empty array. + * + * Consumers that accept either a single lang or an ordered array (this + * package's own `matchBestLang`/`createI18n`, and native `Intl.NumberFormat`/ + * `Intl.DateTimeFormat`) can pass this straight through: anything not + * covered by the first entry falls through to the next, rather than + * jumping straight to an unrelated default like "en". */ +const languagesSignal = computed(() => { + const preferredLanguage = preferredLanguageSignal.value; + const runtimeLanguages = runtimeLanguagesSignal.value; + const supportedLanguages = supportedLanguagesSignal.value; -// Marks objects with a conceptual identity that transcends reference equality — -// see the file comment. Symbol.for keeps it one shared symbol across modules. -const SYMBOL_IDENTITY = Symbol.for("navi_object_identity"); + const orderedLanguages = preferredLanguage + ? [preferredLanguage, ...runtimeLanguages] + : runtimeLanguages; + const dedupedLanguages = [...new Set(orderedLanguages)]; + + if (!supportedLanguages) { + return dedupedLanguages; + } + const filteredLanguages = dedupedLanguages.filter((lang) => + isLanguageSupported(lang, supportedLanguages), + ); + return filteredLanguages.length > 0 ? filteredLanguages : supportedLanguages; +}); /** - * Deeply compares two values for structural equality. + * Creates a lightweight i18n instance for translating text in the current locale. * - * @param {any} rootA - First value. - * @param {any} rootB - Second value. * @param {object} [options] - * @param {(a: any, b: any, keyOrIndex: any, recurse: (a: any, b: any) => boolean) => boolean} [options.keyComparator] - * Custom comparator for object properties / array elements. Receives the internal - * `compare` as its last argument so it can defer to the default behavior. - * @param {boolean} [options.ignoreArrayOrder=false] - Compare arrays as multisets: - * equal when they contain the same elements regardless of order. - * @param {Iterable} [options.lightKeySet] - Object keys to compare first — - * cheaper or likelier-to-differ ones — to short-circuit before the remaining keys. - * @returns {boolean} true if the values are deeply equal. + * @param {string} [options.keyLang] + * When set, each key also serves as its own translation for `keyLang`. + * This allows writing keys directly in that language (typically English) so + * only other languages need to be registered: + * + * ```js + * const i18n = createI18n({ keyLang: "en" }); + * i18n.add("Hello [name]!", { fr: "Bonjour [name] !" }); + * i18n("Hello [name]!", { name: "Alice" }); // "Hello Alice!" (en — key is template) + * i18n("Hello [name]!", { name: "Alice" }); // "Bonjour Alice !" (fr) + * ``` + * + * Without `keyLang`, keys are opaque identifiers and all languages (including + * the fallback) must be registered explicitly: + * + * ```js + * const i18n = createI18n(); + * i18n.add("greeting", { en: "Hello [name]!", fr: "Bonjour [name] !" }); + * i18n("greeting", { name: "Alice" }); // "Hello Alice!" (en) + * ``` + * + * @param {string|string[]} [options.runtimeLang] + * The active language (BCP 47 tag or ordered array of tags) — named + * "runtime" rather than "system" because there is no actual access to the + * OS/user's system language from a browser, only `navigator.languages` (or + * an explicit override) at runtime. Defaults to `languagesSignal.value`, read + * fresh on every `format()`/`has()` call (not frozen at creation time) — + * so overriding the language app-wide via `setPreferredLanguage()`/ + * `setSupportedLanguages()` (see lang_signal.js) is picked up here too. + * Passing an explicit `runtimeLang` opts out of that and stays fixed for + * this instance's whole lifetime. + * + * --- + * + * ## Bulk registration + * + * **`i18n.add(key, { lang: "translation" })`** — one key, multiple languages. + * + * **`i18n.addAll({ key: { lang: "translation" }, ... })`** — multiple keys at once. + * + * **`i18n.addLangKeys(lang, { key: "translation", ... })`** — full language pack + * (useful when loading a JSON translation file). + * + * A regional variant (e.g. `"fr-CA"`) automatically inherits all keys from its + * parent (`"fr"`) that it does not explicitly override: + * ```js + * i18n.addLangKeys("fr", { hello: "Bonjour !" }); + * i18n.addLangKeys("fr-CA", { hello: "Allo !" }); // other "fr" keys inherited + * ``` + * + * --- + * + * @returns {Function & { add, addAll, addLangKeys, format, languageMap }} + * A callable function — `i18n(key, values?, { lang? })` — with the same + * signature as `i18n.format()`. `format` is kept as an alias. */ -const compareTwoJsValues = ( - rootA, - rootB, - { keyComparator, ignoreArrayOrder = false, lightKeySet } = {}, -) => { - const seenSet = new Set(); - const compare = (a, b) => { - if (a === b) { - return true; - } - const aIsIsTruthy = Boolean(a); - const bIsTruthy = Boolean(b); - if (aIsIsTruthy && !bIsTruthy) { - return false; - } - if (!aIsIsTruthy && !bIsTruthy) { - // null, undefined, 0, false, NaN - if (isNaN(a) && isNaN(b)) { - return true; - } - return a === b; +const createI18n = ({ keyLang, fallbackLang, runtimeLang } = {}) => { + const languageMap = new Map(); + // Bumped by addLangKeys — the only thing besides the active lang itself + // that could change what getActiveLang()/getResolvedFallbackLang() below + // resolve to, so it's what invalidates their own small caches. + let languageMapVersion = 0; + + // Explicit runtimeLang stays fixed for this instance's lifetime (matches + // the previous behavior exactly). Without one, re-read languagesSignal.value + // fresh on every call instead of freezing it here via languagesSignal.peek() + // once — that would silently ignore setPreferredLanguage()/ + // setSupportedLanguages() (see lang_signal.js) for the rest of this + // instance's life. + const hasExplicitRuntimeLang = runtimeLang !== undefined; + + // matchBestLang does real work (a Map lookup per candidate, a possible + // "fr-CA" → "fr" split-and-retry loop) — worth skipping on every single + // format()/has() call in the common case, since what it resolves to only + // ever changes when languageMap itself changes (addLangKeys) or, for the + // non-explicit case, when languagesSignal.value itself changes (preferred + // language, supported languages, or "languagechange" — see lang_signal.js, + // languagesSignal is a computed() so its reference is stable when none of its + // own dependencies actually changed) — comparing those two cheaply + // (===) is enough to know the cached result below is still valid. + let cachedActiveLang; + let cachedActiveLangRuntimeLang; + let cachedActiveLangVersion = -1; + const getActiveLang = () => { + const currentRuntimeLang = hasExplicitRuntimeLang + ? runtimeLang + : languagesSignal.value; + if ( + cachedActiveLangVersion === languageMapVersion && + cachedActiveLangRuntimeLang === currentRuntimeLang + ) { + return cachedActiveLang; } - const aType = typeof a; - const bType = typeof b; - if (aType !== bType) { - return false; + cachedActiveLang = matchBestLang(currentRuntimeLang, languageMap); + cachedActiveLangVersion = languageMapVersion; + cachedActiveLangRuntimeLang = currentRuntimeLang; + return cachedActiveLang; + }; + + // fallbackLang is a plain, never-reactive option set once at creation — + // its own resolution only ever needs recomputing when languageMap does. + let cachedResolvedFallbackLang; + let cachedResolvedFallbackLangVersion = -1; + const getResolvedFallbackLang = () => { + if (!fallbackLang) { + return null; } - const aIsPrimitive = - a === null || (aType !== "object" && aType !== "function"); - const bIsPrimitive = - b === null || (bType !== "object" && bType !== "function"); - if (aIsPrimitive !== bIsPrimitive) { - return false; + if (cachedResolvedFallbackLangVersion === languageMapVersion) { + return cachedResolvedFallbackLang; } - if (aIsPrimitive && bIsPrimitive) { - return a === b; + cachedResolvedFallbackLang = matchBestLang(fallbackLang, languageMap); + cachedResolvedFallbackLangVersion = languageMapVersion; + return cachedResolvedFallbackLang; + }; + + const addLangKeys = (lang, translations) => { + // Accumulate: merge with any existing translations for this lang + const existing = languageMap.get(lang); + if (existing) { + translations = { ...existing, ...translations }; } - if (seenSet.has(a)) { - return false; + // A regional variant inherits all keys not explicitly overridden + // e.g. "fr-CA" inherits from "fr" + const dashIndex = lang.indexOf("-"); + if (dashIndex !== -1) { + const parentLang = lang.slice(0, dashIndex); + const parentTranslations = languageMap.get(parentLang); + if (parentTranslations) { + translations = { ...parentTranslations, ...translations }; + } } - if (seenSet.has(b)) { - return false; + languageMap.set(lang, translations); + languageMapVersion++; + }; + + const add = (key, langTranslations) => { + if (keyLang && !(keyLang in langTranslations)) { + // Auto-register the key itself as the translation for keyLang + addLangKeys(keyLang, { [key]: key }); } - seenSet.add(a); - seenSet.add(b); - const aIsArray = Array.isArray(a); - const bIsArray = Array.isArray(b); - if (aIsArray !== bIsArray) { - return false; + for (const [lang, value] of Object.entries(langTranslations)) { + addLangKeys(lang, { [key]: value }); } - if (aIsArray) { - // compare arrays - if (a.length !== b.length) { - return false; - } - if (ignoreArrayOrder) { - // Unordered array comparison: each element in 'a' must have a match in 'b' - const usedIndices = new Set(); - for (let i = 0; i < a.length; i++) { - const aValue = a[i]; - let foundMatch = false; + }; - for (let j = 0; j < b.length; j++) { - if (usedIndices.has(j)) { - continue; // Already matched with another element - } - const bValue = b[j]; - if (compareAt(aValue, bValue, i)) { - foundMatch = true; - usedIndices.add(j); - break; - } - } - if (!foundMatch) { - return false; - } - } - return true; - } - // Ordered array comparison (original behavior) - let i = 0; - while (i < a.length) { - const aValue = a[i]; - const bValue = b[i]; - if (!compareAt(aValue, bValue, i)) { - return false; - } - i++; - } - return true; - } - // compare objects - const aIdentity = a[SYMBOL_IDENTITY]; - const bIdentity = b[SYMBOL_IDENTITY]; - if ( - aIdentity === bIdentity && - SYMBOL_IDENTITY in a && - SYMBOL_IDENTITY in b - ) { - return true; + const addAll = (keyMap) => { + for (const [key, langTranslations] of Object.entries(keyMap)) { + add(key, langTranslations); } - // Date objects must be compared by time value, not by enumerable keys (which are empty) - { - const aIsDate = a instanceof Date; - const bIsDate = b instanceof Date; - if (aIsDate !== bIsDate) { - return false; - } - if (aIsDate && bIsDate) { - const aTime = a.getTime(); - const bTime = b.getTime(); - if (aTime !== bTime) { - return false; - } + }; + + const _getTemplate = (key, lang) => { + // matchBestLang, not matchLang directly: lang can be an array (e.g. + // languagesSignal.value is always an ordered array — see lang_signal.js) and + // matchLang alone assumes a plain string, throwing + // on .split() otherwise. + const resolvedLang = lang ? matchBestLang(lang, languageMap) : null; + if (resolvedLang) { + const translations = languageMap.get(resolvedLang); + const translated = translations[key]; + if (translated !== undefined) { + return translated; } } - const aKeys = Object.keys(a); - const bKeys = Object.keys(b); - if (aKeys.length !== bKeys.length) { - return false; - } - if (lightKeySet) { - // compare light keys first, then remaining keys - // (optimization for cases where some keys are more likely to differ and/or faster to compare) - const keySet = new Set(aKeys); - for (const lightKey of lightKeySet) { - const aValue = a[lightKey]; - const bValue = b[lightKey]; - if (!compareAt(aValue, bValue, lightKey)) { - return false; - } - keySet.delete(lightKey); - } - for (const key of keySet) { - const aValue = a[key]; - const bValue = b[key]; - if (!compareAt(aValue, bValue, key)) { - return false; - } - } - } else { - for (const key of aKeys) { - const aValue = a[key]; - const bValue = b[key]; - if (!compareAt(aValue, bValue, key)) { - return false; - } + const resolvedFallbackLang = getResolvedFallbackLang(); + if (resolvedFallbackLang) { + const fallbackTranslations = languageMap.get(resolvedFallbackLang); + const fallbackTranslated = fallbackTranslations[key]; + if (fallbackTranslated !== undefined) { + return fallbackTranslated; } } - return true; + // No translation found — return key as-is (opaque fallback) + return key; }; - const compareAt = keyComparator - ? (a, b, keyOrArrayIndex) => keyComparator(a, b, keyOrArrayIndex, compare) - : compare; - - return compare(rootA, rootB); -}; -const debounceSignal = ( - signalToDebounce, - { delay = 300, deepCompare = true } = {}, -) => { - let timeoutId; - let latestValue = signalToDebounce.peek(); - const debouncedSignal = signal(latestValue); + const format = (key, values, { lang = getActiveLang() } = {}) => { + const template = _getTemplate(key, lang); + return interpolateText(template, values); + }; - effect(() => { - const value = signalToDebounce.value; - const debouncedValue = debouncedSignal.peek(); - if ( - deepCompare - ? compareTwoJsValues(value, debouncedValue) - : value === debouncedValue - ) { - return; + const has = (key, { lang = getActiveLang() } = {}) => { + const resolvedLang = lang ? matchBestLang(lang, languageMap) : null; + if (resolvedLang) { + const translations = languageMap.get(resolvedLang); + if (translations && key in translations) { + return true; + } } - clearTimeout(timeoutId); - latestValue = value; - timeoutId = setTimeout(() => { - debouncedSignal.value = latestValue; - }, delay); - }); - - debouncedSignal.flush = () => { - clearTimeout(timeoutId); - debouncedSignal.value = latestValue; + const resolvedFallbackLang = getResolvedFallbackLang(); + if (resolvedFallbackLang) { + const fallbackTranslations = languageMap.get(resolvedFallbackLang); + if (fallbackTranslations && key in fallbackTranslations) { + return true; + } + } + return false; }; - return debouncedSignal; -}; + // The i18n instance is itself a callable function + const i18n = (key, values, opts) => format(key, values, opts); + i18n.add = add; + i18n.addAll = addAll; + i18n.addLangKeys = addLangKeys; + i18n.has = has; + i18n.format = format; + i18n.languageMap = languageMap; -const isSignal = (value) => { - return getSignalType(value) !== null; + return i18n; }; -const BRAND_SYMBOL = Symbol.for("preact-signals"); -const getSignalType = (value) => { - if (!value || typeof value !== "object") { - return null; - } - - if (value.brand !== BRAND_SYMBOL) { - return null; +// Walk "fr-CA-variant" → "fr-CA" → "fr" until a registered lang is found +const matchLang = (lang, languageMap) => { + if (languageMap.has(lang)) { + return lang; } - - if (typeof value._fn === "function") { - return "computed"; + const parts = lang.split("-"); + while (parts.length > 1) { + parts.pop(); + const candidate = parts.join("-"); + if (languageMap.has(candidate)) { + return candidate; + } } - - return "signal"; + return null; }; -const MAX_ENTRIES = 5; - -const stringifyForDisplay = ( - value, - maxDepth = 2, - currentDepth = 0, - options = {}, -) => { - const { asFunctionArgs = false } = options; - const indent = " ".repeat(currentDepth); - const nextIndent = " ".repeat(currentDepth + 1); - - if (currentDepth >= maxDepth) { - return typeof value === "object" && value !== null - ? "[Object]" - : String(value); - } - - if (value === null) { - return "null"; - } - if (value === undefined) { - return "undefined"; - } - if (typeof value === "string") { - return `"${value}"`; - } - if (typeof value === "number" || typeof value === "boolean") { - return String(value); - } - if (typeof value === "function") { - return `[Function ${value.name || "anonymous"}]`; - } - if (value instanceof Date) { - return `Date(${value.toISOString()})`; +// lang can be a string or an ordered array of preference strings +const matchBestLang = (lang, languageMap) => { + if (!lang) { + return null; } - if (value instanceof RegExp) { - return value.toString(); + const candidates = Array.isArray(lang) ? lang : [lang]; + for (const candidate of candidates) { + const match = matchLang(candidate, languageMap); + if (match) { + return match; + } } + return null; +}; - if (Array.isArray(value)) { - const openBracket = asFunctionArgs ? "(" : "["; - const closeBracket = asFunctionArgs ? ")" : "]"; +/** + * The shared i18n instance for all @jsenv/navi components. + * + * Use `naviI18n.add(key, { lang: "translation" })` to register or override + * any text used by navi components. The active language is read from + * `languagesSignal` (see lang_signal.js — combines the browser's own + * `navigator.languages`, an optional `setPreferredLanguage()` user override, + * and an optional `setSupportedLanguages()` app-wide allow-list), live on + * every lookup. + * + * Built-in keys (can be overridden): + * - `"time.less_than_minute"` — e.g. "in less than a minute" + * - `"time.ongoing"` — e.g. "Ongoing" + * - `"time.tomorrow_at"` — e.g. "[day] at [time]" ([day] and [time] are placeholders) + * - `"time.midnight"` — e.g. "midnight" + * + * @example + * import { naviI18n } from "@jsenv/navi"; + * + * // Register unit translations for Quantity: + * naviI18n.add("minute", { en: "minute", fr: "minute" }); + * naviI18n.add("minute__plural", { en: "minutes", fr: "minutes" }); + * + * // Register multiple keys at once: + * naviI18n.addAll({ + * minute: { en: "minute", fr: "minute" }, + * minute__plural: { en: "minutes", fr: "minutes" }, + * }); + * + * // Override a built-in text: + * naviI18n.add("time.ongoing", { fr: "En cours…" }); + * + * // Load a full language pack at once: + * naviI18n.addLangKeys("fr", { minute: "minute", "minute__plural": "minutes" }); + */ +const naviI18n = createI18n(); - if (value.length === 0) return `${openBracket}${closeBracket}`; +naviI18n.addAll({ + "button.clear": { + en: "Clear", + fr: "Effacer", + }, + "button.reset": { + en: "Reset", + fr: "Réinitialiser", + }, + "button.send": { + en: "Send", + fr: "Envoyer", + }, + "button.open": { + en: "Open", + fr: "Ouvrir", + }, + "button.close": { + en: "Close", + fr: "Fermer", + }, + "button.cancel": { + en: "Cancel", + fr: "Annuler", + }, + "button.define": { + en: "Define", + fr: "Définir", + }, + "button.confirm": { + en: "Confirm", + fr: "Confirmer", + }, +}); - // Display arrays with only one element on a single line - if (value.length === 1) { - const item = stringifyForDisplay( - value[0], - maxDepth, - currentDepth + 1, - // Remove asFunctionArgs for nested calls - { ...options, asFunctionArgs: false }, - ); - return `${openBracket}${item}${closeBracket}`; - } - - if (value.length > MAX_ENTRIES) { - const preview = value - .slice(0, MAX_ENTRIES) - .map( - (v) => - `${nextIndent}${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`, - ); - return `${openBracket}\n${preview.join(",\n")},\n${nextIndent}...${value.length - MAX_ENTRIES} more\n${indent}${closeBracket}`; - } - - const items = value.map( - (v) => - `${nextIndent}${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`, - ); - return `${openBracket}\n${items.join(",\n")}\n${indent}${closeBracket}`; - } - - if (typeof value === "object") { - const signalType = getSignalType(value); - if (signalType) { - const signalValue = value.peek(); - const prefix = signalType === "computed" ? "computed" : "signal"; - return `${prefix}(${stringifyForDisplay(signalValue, maxDepth, currentDepth, { ...options, asFunctionArgs: false })})`; - } - - const entries = Object.entries(value); - if (entries.length === 0) return "{}"; - - // ✅ Inclure les clés avec valeurs undefined/null - const allEntries = []; - for (const [key, val] of entries) { - allEntries.push([key, val]); - } - - // Ajouter les clés avec undefined (que Object.entries omet) - const descriptor = Object.getOwnPropertyDescriptors(value); - for (const [key, desc] of Object.entries(descriptor)) { - if (desc.value === undefined && !entries.some(([k]) => k === key)) { - allEntries.push([key, undefined]); - } - } - - // Display objects with only one key on a single line - if (allEntries.length === 1) { - const [key, val] = allEntries[0]; - const valueStr = stringifyForDisplay(val, maxDepth, currentDepth + 1, { - ...options, - asFunctionArgs: false, - }); - return `{ ${key}: ${valueStr} }`; - } - - if (allEntries.length > MAX_ENTRIES) { - const preview = allEntries - .slice(0, MAX_ENTRIES) - .map( - ([k, v]) => - `${nextIndent}${k}: ${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`, - ); - return `{\n${preview.join(",\n")},\n${nextIndent}...${allEntries.length - MAX_ENTRIES} more\n${indent}}`; - } - - const pairs = allEntries.map( - ([k, v]) => - `${nextIndent}${k}: ${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`, - ); - return `{\n${pairs.join(",\n")}\n${indent}}`; - } - - return String(value); -}; - -/** - * jsenv/navi - createJsValueWeakMap - * - * Key/value cache with true ephemeron behavior and deep equality support. - * - * Features: - * - Mutual retention: key keeps value alive, value keeps key alive - * - Deep equality: different objects with same content are treated as identical keys - * - Automatic GC: entries are eligible for collection when unreferenced - * - Iteration support: can iterate over live entries for deep equality lookup - * - * Implementation: - * - Dual WeakMap (key->value, value->key) provides ephemeron behavior - * - WeakRef registry enables iteration without preventing GC - * - Primitives stored in Map (permanent retention - avoid for keys) - * - * Use case: Action caching where params (key) and action (value) should have - * synchronized lifetimes while allowing natural garbage collection. - */ - - -const createJsValueWeakMap = () => { - // Core ephemeron maps for mutual retention - const keyToValue = new WeakMap(); // key -> value - const valueToKey = new WeakMap(); // value -> key - - // Registry for iteration/deep equality (holds WeakRefs) - const keyRegistry = new Set(); // Set of WeakRef(key) - - // Primitive cache - const primitiveCache = new Map(); - - function cleanupKeyRegistry() { - for (const keyRef of keyRegistry) { - if (keyRef.deref() === undefined) { - keyRegistry.delete(keyRef); - } - } - } - - return { - *[Symbol.iterator]() { - cleanupKeyRegistry(); - for (const keyRef of keyRegistry) { - const key = keyRef.deref(); - if (key && keyToValue.has(key)) { - yield [key, keyToValue.get(key)]; - } - } - for (const [k, v] of primitiveCache) { - yield [k, v]; - } - }, - - get(key) { - const isObject = - key && (typeof key === "object" || typeof key === "function"); - if (isObject) { - // Fast path: exact key match - if (keyToValue.has(key)) { - return keyToValue.get(key); - } - - // Slow path: deep equality search - cleanupKeyRegistry(); - for (const keyRef of keyRegistry) { - const existingKey = keyRef.deref(); - if (existingKey && compareTwoJsValues(existingKey, key)) { - return keyToValue.get(existingKey); - } - } - return undefined; - } - return primitiveCache.get(key); - }, - - set(key, value) { - const isObject = - key && (typeof key === "object" || typeof key === "function"); - if (isObject) { - cleanupKeyRegistry(); - - // Remove existing deep-equal key - for (const keyRef of keyRegistry) { - const existingKey = keyRef.deref(); - if (existingKey && compareTwoJsValues(existingKey, key)) { - const existingValue = keyToValue.get(existingKey); - keyToValue.delete(existingKey); - valueToKey.delete(existingValue); - keyRegistry.delete(keyRef); - break; - } - } - - // Set ephemeron pair - keyToValue.set(key, value); - valueToKey.set(value, key); - keyRegistry.add(new WeakRef(key)); - } else { - primitiveCache.set(key, value); - } - }, - - delete(key) { - const isObject = - key && (typeof key === "object" || typeof key === "function"); - if (isObject) { - cleanupKeyRegistry(); - - // Try exact match first - if (keyToValue.has(key)) { - const value = keyToValue.get(key); - keyToValue.delete(key); - valueToKey.delete(value); - - // Remove from registry - for (const keyRef of keyRegistry) { - if (keyRef.deref() === key) { - keyRegistry.delete(keyRef); - break; - } - } - return true; - } - - // Try deep equality - for (const keyRef of keyRegistry) { - const existingKey = keyRef.deref(); - if (existingKey && compareTwoJsValues(existingKey, key)) { - const value = keyToValue.get(existingKey); - keyToValue.delete(existingKey); - valueToKey.delete(value); - keyRegistry.delete(keyRef); - return true; - } - } - return false; - } - return primitiveCache.delete(key); - }, - - getStats: () => { - cleanupKeyRegistry(); - const aliveKeys = Array.from(keyRegistry).filter((ref) => - ref.deref(), - ).length; - - return { - ephemeronPairs: { - total: keyRegistry.size, - alive: aliveKeys, - note: "True ephemeron: key ↔ value mutual retention via dual WeakMap", - }, - primitive: { - total: primitiveCache.size, - note: "Primitive keys never GC'd", - }, - }; - }, - }; -}; - -const MERGE_AS_PRIMITIVE_SYMBOL = Symbol("navi_merge_as_primitive"); - -const mergeTwoJsValues = (firstValue, secondValue) => { - const firstIsPrimitive = - firstValue === null || - typeof firstValue !== "object" || - MERGE_AS_PRIMITIVE_SYMBOL in firstValue; - - if (firstIsPrimitive) { - return secondValue; - } - const secondIsPrimitive = - secondValue === null || - typeof secondValue !== "object" || - MERGE_AS_PRIMITIVE_SYMBOL in secondValue; - if (secondIsPrimitive) { - return secondValue; - } - const objectMerge = {}; - const firstKeys = Object.keys(firstValue); - const secondKeys = Object.keys(secondValue); - let hasChanged = false; - - // First loop: check for keys in first object and recursively merge with second - for (const key of firstKeys) { - const firstValueForKey = firstValue[key]; - const secondHasKey = secondKeys.includes(key); +// Default built-in translations — apps can override any key via add() +naviI18n.addAll({ + "time.less_than_minute": { + en: "in less than a minute", + fr: "dans moins d'une minute", + de: "in weniger als einer Minute", + es: "en menos de un minuto", + it: "in meno di un minuto", + pt: "em menos de um minuto", + nl: "over minder dan een minuut", + }, + "time.ongoing": { + en: "Ongoing", + fr: "En cours", + de: "Laufend", + es: "En curso", + it: "In corso", + pt: "Em andamento", + nl: "Bezig", + }, + // [day] and [time] are replaced at runtime with the localized day/time strings + "time.tomorrow_at": { + en: "[day] at [time]", + fr: "[day] à [time]", + de: "[day] um [time]", + es: "[day] a las [time]", + it: "[day] alle [time]", + pt: "[day] às [time]", + nl: "[day] om [time]", + }, + // [duration] is replaced at runtime with the formatted duration string (e.g. "1h30", "45 min") + "time.in_duration": { + en: "in [duration]", + fr: "dans [duration]", + de: "in [duration]", + es: "en [duration]", + it: "tra [duration]", + pt: "em [duration]", + nl: "over [duration]", + }, + // Substituted in place of the "0 heure(s)" part of an Intl-generated + // duration string when