@phis/ui 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/components/controls/phi-table-control.d.ts +6 -1
  2. package/components/controls/phi-table-control.js +16 -4
  3. package/components/layouts/layout-definitions.d.ts +2 -2
  4. package/components/layouts/layout-definitions.js +6 -1
  5. package/components/shell/use-phi-shell-sticky-top.d.ts +5 -0
  6. package/components/shell/use-phi-shell-sticky-top.js +78 -0
  7. package/components/widgets/helpers/semantic-html-translation.d.ts +3 -1
  8. package/components/widgets/helpers/semantic-html-translation.js +8 -3
  9. package/components/widgets/navigation-descriptor-resolver.server.js +6 -1
  10. package/components/widgets/shared/markdown-body-client.d.ts +2 -1
  11. package/components/widgets/shared/markdown-body-client.js +3 -1
  12. package/components/widgets/shared/markdown-render.js +23 -6
  13. package/helpers/text-source.d.ts +19 -0
  14. package/helpers/text-source.js +20 -0
  15. package/next/config.mjs +1 -1
  16. package/package.json +1 -1
  17. package/plugins/runtime-modules/auth/trees/phi-default-pub-confirm-page-tree.js +3 -0
  18. package/plugins/runtime-modules/auth/trees/phi-default-pub-login-page-tree.js +2 -0
  19. package/plugins/runtime-modules/auth/trees/phi-default-pub-registration-page-tree.js +3 -0
  20. package/plugins/runtime-modules/auth/trees/phi-default-pub-reset-password-page-tree.js +3 -0
  21. package/plugins/runtime-modules/builder/clients/widget-inspector.js +9 -0
  22. package/plugins/runtime-modules/builder/trees/phi-default-builder-area-preset-tree.js +4 -0
  23. package/plugins/runtime-modules/core/widgets/card/server.js +4 -2
  24. package/plugins/runtime-modules/core/widgets/description/config.d.ts +3 -0
  25. package/plugins/runtime-modules/core/widgets/description/config.js +2 -0
  26. package/plugins/runtime-modules/core/widgets/description/server.js +10 -12
  27. package/plugins/runtime-modules/core/widgets/entry-list/server.js +2 -2
  28. package/plugins/runtime-modules/core/widgets/html/server.js +1 -1
  29. package/plugins/runtime-modules/core/widgets/markdown/config.d.ts +3 -1
  30. package/plugins/runtime-modules/core/widgets/markdown/config.js +14 -0
  31. package/plugins/runtime-modules/core/widgets/markdown-toc/server.js +7 -2
  32. package/plugins/runtime-modules/core/widgets/quick-links/config.d.ts +3 -0
  33. package/plugins/runtime-modules/core/widgets/quick-links/config.js +2 -0
  34. package/plugins/runtime-modules/core/widgets/quick-links/plugin.js +4 -2
  35. package/plugins/runtime-modules/core/widgets/quick-links/server.d.ts +5 -1
  36. package/plugins/runtime-modules/core/widgets/quick-links/server.js +4 -2
  37. package/plugins/runtime-modules/core/widgets/simple-text/config.d.ts +3 -0
  38. package/plugins/runtime-modules/core/widgets/simple-text/config.js +2 -0
  39. package/plugins/runtime-modules/core/widgets/simple-text/plugin.js +1 -1
  40. package/plugins/runtime-modules/core/widgets/simple-text/server.d.ts +4 -1
  41. package/plugins/runtime-modules/core/widgets/simple-text/server.js +3 -3
  42. package/plugins/runtime-modules/core/widgets/table/client.js +1 -1
  43. package/plugins/runtime-modules/core/widgets/table/config.d.ts +1 -0
  44. package/plugins/runtime-modules/core/widgets/table/config.js +3 -0
  45. package/plugins/runtime-modules/phi-default-site-area-preset-tree.js +12 -4
  46. package/plugins/runtime-modules/public/trees/phi-default-pub-contact-page-tree.js +2 -0
  47. package/plugins/runtime-modules/public/trees/phi-default-pub-unsubscribe-page-tree.js +3 -0
  48. package/plugins/runtime-modules/public/trees/phi-default-pub-welcome-page-tree.js +2 -1
  49. package/plugins/runtime-modules/revisions/trees/phi-default-builder-revisions-page-tree.js +4 -0
  50. package/server-helpers/translate.d.ts +9 -0
  51. package/server-helpers/translate.js +14 -0
  52. package/styles/controls.css +41 -1
  53. package/styles/root.css +20 -19
  54. package/types/core-widget-placements.d.ts +8 -0
  55. package/types/table-widget.d.ts +2 -0
@@ -122,6 +122,11 @@ export type PhiTableControlProps<TRow extends Record<string, unknown>> = {
122
122
  bordered?: boolean;
123
123
  striped?: boolean;
124
124
  showHeader?: boolean;
125
+ /**
126
+ * The header row stays at the top while the rows scroll under it: under the Shell's Header on a page,
127
+ * at the top edge of a Modal's or Drawer's body. `styles/controls.css` places it.
128
+ */
129
+ stickyHeader?: boolean;
125
130
  footer?: PhiTableControlFooter;
126
131
  summary?: PhiTableControlSummary;
127
132
  size?: PhiControlSize;
@@ -141,4 +146,4 @@ export type PhiTableControlProps<TRow extends Record<string, unknown>> = {
141
146
  onExternalDrop?: (event: DragEvent<HTMLDivElement>) => void;
142
147
  };
143
148
  export declare function readPhiTableControlValue(row: Record<string, unknown>, path: string): unknown;
144
- export declare function PhiTableControl<TRow extends Record<string, unknown>>({ rows, rowIdentityPath, columns, columnOrder, sortingMode, sorts, onSortsChange, columnReordering, onColumnOrderChange, rowSelection, pagination, tree, rowReordering, editing, loading, bordered, striped, showHeader, footer, summary, size, emptyText, layout, rowStyle, onRowActivate, onExternalRowDragOver, onExternalRowDrop, onExternalDragOver, onExternalDrop, }: PhiTableControlProps<TRow>): import("react").JSX.Element;
149
+ export declare function PhiTableControl<TRow extends Record<string, unknown>>({ rows, rowIdentityPath, columns, columnOrder, sortingMode, sorts, onSortsChange, columnReordering, onColumnOrderChange, rowSelection, pagination, tree, rowReordering, editing, loading, bordered, striped, stickyHeader, showHeader, footer, summary, size, emptyText, layout, rowStyle, onRowActivate, onExternalRowDragOver, onExternalRowDrop, onExternalDragOver, onExternalDrop, }: PhiTableControlProps<TRow>): import("react").JSX.Element;
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { usePhiShellStickyTop } from "../shell/use-phi-shell-sticky-top";
3
4
  import { Button, Flex, Table } from "antd";
4
5
  import { DndContext, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors, } from "@dnd-kit/core";
5
6
  import { SortableContext, sortableKeyboardCoordinates, useSortable, } from "@dnd-kit/sortable";
@@ -354,7 +355,7 @@ function buildRowMove(rows, rowIdentityPath, parentRowIdentityPath, movedRowIden
354
355
  afterRowIdentity: placement === "after" ? targetRowIdentity : null,
355
356
  };
356
357
  }
357
- export function PhiTableControl({ rows, rowIdentityPath, columns, columnOrder, sortingMode, sorts, onSortsChange, columnReordering = false, onColumnOrderChange, rowSelection, pagination = false, tree, rowReordering, editing, loading = false, bordered, striped = false, showHeader, footer, summary, size, emptyText, layout, rowStyle, onRowActivate, onExternalRowDragOver, onExternalRowDrop, onExternalDragOver, onExternalDrop, }) {
358
+ export function PhiTableControl({ rows, rowIdentityPath, columns, columnOrder, sortingMode, sorts, onSortsChange, columnReordering = false, onColumnOrderChange, rowSelection, pagination = false, tree, rowReordering, editing, loading = false, bordered, striped = false, stickyHeader = false, showHeader, footer, summary, size, emptyText, layout, rowStyle, onRowActivate, onExternalRowDragOver, onExternalRowDrop, onExternalDragOver, onExternalDrop, }) {
358
359
  const { token } = usePhiConfig();
359
360
  const [editingRowIdentityState, setEditingRowIdentity] = useState(null);
360
361
  const [rowEditPatch, setRowEditPatch] = useState({});
@@ -988,6 +989,8 @@ export function PhiTableControl({ rows, rowIdentityPath, columns, columnOrder, s
988
989
  const hasClosingBlock = !loading &&
989
990
  (Boolean(footer) || (summary != null && summary.placement !== "sticky-top"));
990
991
  const frameRef = useRef(null);
992
+ // Where the Shell's Header ends, which is where a sticky header row stops (`styles/controls.css`).
993
+ usePhiShellStickyTop({ active: stickyHeader && showHeader !== false, elementRef: frameRef });
991
994
  const [headerBox, setHeaderBox] = useState(null);
992
995
  useEffect(() => {
993
996
  const frame = frameRef.current;
@@ -1007,11 +1010,15 @@ export function PhiTableControl({ rows, rowIdentityPath, columns, columnOrder, s
1007
1010
  observer.observe(frame);
1008
1011
  return () => observer.disconnect();
1009
1012
  }, [showHeader, loading]);
1010
- const table = (_jsx(Table, { className: ["phi-table-control", hasClosingBlock ? "phi-table-control--closing-block" : null]
1013
+ const table = (_jsx(Table, { className: [
1014
+ "phi-table-control",
1015
+ hasClosingBlock ? "phi-table-control--closing-block" : null,
1016
+ stickyHeader && showHeader !== false ? "phi-table-control--sticky-header" : null,
1017
+ ]
1011
1018
  .filter(Boolean)
1012
1019
  .join(" "), style: { "--phi-table-striped-row-background": token.colorFillAlter }, rowKey: (row) => loading
1013
1020
  ? `__phi_table_loading_${String(row[PHI_TABLE_LOADING_ROW])}`
1014
- : String(readIdentity(row, rowIdentityPath)), columns: renderedColumns, dataSource: loading ? loadingRows : dataSource, bordered: bordered, showHeader: showHeader, tableLayout: layout.mode, size: size, rowClassName: (_row, index) => striped && index % 2 === 1 ? "phi-table-control__row--striped" : "", rowSelection: loading ? undefined : antdRowSelection, components: rowReordering?.enabled && !loading ? { body: { row: PhiTableSortableRow } } : undefined, scroll: layout.overflowX === "auto" ? { x: true } : undefined, pagination: pagination === false ? false : {
1021
+ : String(readIdentity(row, rowIdentityPath)), columns: renderedColumns, dataSource: loading ? loadingRows : dataSource, bordered: bordered, showHeader: showHeader, tableLayout: layout.mode, size: size, rowClassName: (_row, index) => striped && index % 2 === 1 ? "phi-table-control__row--striped" : "", rowSelection: loading ? undefined : antdRowSelection, components: rowReordering?.enabled && !loading ? { body: { row: PhiTableSortableRow } } : undefined, scroll: layout.overflowX === "auto" ? { x: true } : undefined, sticky: stickyHeader && showHeader !== false ? true : undefined, pagination: pagination === false ? false : {
1015
1022
  ...{ [PHI_VISUAL_ANCHOR_PART_ATTRIBUTE]: "pagination" },
1016
1023
  current: pagination.page,
1017
1024
  pageSize: pagination.pageSize,
@@ -1097,7 +1104,12 @@ export function PhiTableControl({ rows, rowIdentityPath, columns, columnOrder, s
1097
1104
  insertionGapIndex,
1098
1105
  insertionGapHeight: activeDragRowHeight,
1099
1106
  }, children: table }) }) })) : table;
1100
- const constrainedTable = (_jsxs("div", { ref: frameRef, style: { position: "relative", width: "100%", maxWidth: "100%", minWidth: 0, overflow: "hidden" }, children: [renderedTable, headerBox ? (_jsx("div", { "aria-hidden": true, [PHI_VISUAL_ANCHOR_PART_ATTRIBUTE]: "header", style: { position: "absolute", left: 0, right: 0, top: headerBox.top, height: headerBox.height, pointerEvents: "none" } })) : null] }));
1107
+ /*
1108
+ * Clipped, not hidden: `hidden` makes the frame a scroll container, and a sticky header row then sticks
1109
+ * to the frame's own top -- pushed down inside the Table by the Shell's Header height -- instead of to
1110
+ * the page. `clip` cuts off the same overflow and leaves the page the one that scrolls.
1111
+ */
1112
+ const constrainedTable = (_jsxs("div", { ref: frameRef, style: { position: "relative", width: "100%", maxWidth: "100%", minWidth: 0, overflow: "clip" }, children: [renderedTable, headerBox ? (_jsx("div", { "aria-hidden": true, [PHI_VISUAL_ANCHOR_PART_ATTRIBUTE]: "header", style: { position: "absolute", left: 0, right: 0, top: headerBox.top, height: headerBox.height, pointerEvents: "none" } })) : null] }));
1101
1113
  return onExternalDragOver || onExternalDrop ? (_jsx("div", { onDragOver: (event) => {
1102
1114
  if (event.defaultPrevented) {
1103
1115
  setExternalAppendActive(false);
@@ -483,7 +483,7 @@ export declare const PHI_GRID_LAYOUT_DEFINITION: {
483
483
  kind: "layout";
484
484
  pluginKey: string;
485
485
  typeKey: string;
486
- slotSizePolicy: "fill";
486
+ slotSizePolicy: "fill-inline";
487
487
  title: string;
488
488
  description: string;
489
489
  category: "structure";
@@ -1176,7 +1176,7 @@ export declare const PHI_CORE_LAYOUT_DEFINITIONS: readonly [{
1176
1176
  kind: "layout";
1177
1177
  pluginKey: string;
1178
1178
  typeKey: string;
1179
- slotSizePolicy: "fill";
1179
+ slotSizePolicy: "fill-inline";
1180
1180
  title: string;
1181
1181
  description: string;
1182
1182
  category: "structure";
@@ -415,7 +415,12 @@ export const PHI_GRID_LAYOUT_DEFINITION = {
415
415
  kind: "layout",
416
416
  pluginKey: resolvePhiCmsLayoutPluginKey("grid"),
417
417
  typeKey: "grid",
418
- slotSizePolicy: "fill",
418
+ /*
419
+ * As wide as its slot, as tall as its rows. A Grid is content in flow: filling the block axis as well
420
+ * let a vertical stack share its height out among the Grids in it, and each one, held to its share,
421
+ * spilled its rows over the next section.
422
+ */
423
+ slotSizePolicy: "fill-inline",
419
424
  title: "Grid",
420
425
  description: "Slots in rows, as many to a row as the Grid's own width allows.",
421
426
  category: "structure",
@@ -0,0 +1,5 @@
1
+ import { type RefObject } from "react";
2
+ export declare function usePhiShellStickyTop({ active, elementRef, }: {
3
+ active: boolean;
4
+ elementRef: RefObject<HTMLElement | null>;
5
+ }): void;
@@ -0,0 +1,78 @@
1
+ "use client";
2
+ import { useEffect } from "react";
3
+ /**
4
+ * How far down the viewport the Shell's Header holds right now, published on the Shell.
5
+ *
6
+ * Something that sticks to the top of the page -- a Table's header row -- has to stick under the Header
7
+ * rather than behind it, and where the Header ends is not a constant: each band sticks or scrolls by its
8
+ * own config, and a revealing `header_top` slides away while the visitor reads down and comes back when
9
+ * they scroll up. So the edge is measured, not configured: the lowest bottom of every Header band that is
10
+ * stuck at this moment, written as `--phi-shell-sticky-top` on the Shell element. Unset, a reader falls
11
+ * back to `0px`, which is right for a Shell without a sticky Header.
12
+ *
13
+ * Run by whatever sticks under it -- every sticky Table does -- so a page without one loads nothing for
14
+ * it. Each runner measures all the bands and they all write the same value. Not inside an Overlay: a
15
+ * Modal's or Drawer's body scrolls on its own, and its top edge is the line.
16
+ */
17
+ const PHI_SHELL_STICKY_TOP_PROPERTY = "--phi-shell-sticky-top";
18
+ const HEADER_BANDS = '[data-phi-region-key^="header_"]:not([hidden])';
19
+ function measurePhiShellStickyTop(shell) {
20
+ let bottom = 0;
21
+ for (const band of shell.querySelectorAll(HEADER_BANDS)) {
22
+ const style = window.getComputedStyle(band);
23
+ if (style.position !== "sticky" && style.position !== "fixed") {
24
+ continue;
25
+ }
26
+ const rect = band.getBoundingClientRect();
27
+ const stuckAt = Number.parseFloat(style.top) || 0;
28
+ // Below its sticking line it is still in flow; above the viewport it has slid away.
29
+ if (rect.height === 0 || rect.top > stuckAt + 1 || rect.bottom <= 0) {
30
+ continue;
31
+ }
32
+ bottom = Math.max(bottom, rect.bottom);
33
+ }
34
+ return `${Math.round(bottom)}px`;
35
+ }
36
+ const OVERLAY_BODIES = "[data-phi-modal-body-content], [data-phi-drawer-body-content]";
37
+ export function usePhiShellStickyTop({ active, elementRef, }) {
38
+ useEffect(() => {
39
+ const element = elementRef.current;
40
+ const shell = active && element && !element.closest(OVERLAY_BODIES)
41
+ ? element.closest("[data-phi-shell-layout]")
42
+ : null;
43
+ if (!element || !shell) {
44
+ return undefined;
45
+ }
46
+ let frame = 0;
47
+ const publish = () => {
48
+ frame = 0;
49
+ const next = measurePhiShellStickyTop(shell);
50
+ if (shell.style.getPropertyValue(PHI_SHELL_STICKY_TOP_PROPERTY) !== next) {
51
+ shell.style.setProperty(PHI_SHELL_STICKY_TOP_PROPERTY, next);
52
+ }
53
+ };
54
+ const schedule = () => {
55
+ if (frame === 0) {
56
+ frame = window.requestAnimationFrame(publish);
57
+ }
58
+ };
59
+ publish();
60
+ window.addEventListener("scroll", schedule, { passive: true });
61
+ window.addEventListener("resize", schedule);
62
+ // A revealing band moves by a transition; its end is where it settled.
63
+ shell.addEventListener("transitionend", schedule);
64
+ const observer = new ResizeObserver(schedule);
65
+ for (const band of shell.querySelectorAll(HEADER_BANDS)) {
66
+ observer.observe(band);
67
+ }
68
+ return () => {
69
+ if (frame !== 0) {
70
+ window.cancelAnimationFrame(frame);
71
+ }
72
+ window.removeEventListener("scroll", schedule);
73
+ window.removeEventListener("resize", schedule);
74
+ shell.removeEventListener("transitionend", schedule);
75
+ observer.disconnect();
76
+ };
77
+ }, [active, elementRef]);
78
+ }
@@ -1,2 +1,4 @@
1
1
  import "server-only";
2
- export declare function translateSemanticHtml(sourceHtml: string, sourceLocale?: string): Promise<string>;
2
+ export declare function translateSemanticHtml(sourceHtml: string, sourceLocale?: string,
3
+ /** The render's locale. Without it the request's is asked, and a static render has no request. */
4
+ locale?: string): Promise<string>;
@@ -1,7 +1,7 @@
1
1
  import "server-only";
2
2
  import renderHtml from "dom-serializer";
3
3
  import { parseDocument } from "htmlparser2";
4
- import { trBulk } from "../../../server-helpers/translate";
4
+ import { trBulk, trBulkForLocale } from "../../../server-helpers/translate";
5
5
  import { sanitizePhiHtmlWidgetMarkup } from "./html-content";
6
6
  const SEMANTIC_HTML_BLOCKS = new Set([
7
7
  "p",
@@ -49,7 +49,9 @@ function renderNodes(nodes) {
49
49
  function hasVisibleText(html) {
50
50
  return html.replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " ").trim().length > 0;
51
51
  }
52
- export async function translateSemanticHtml(sourceHtml, sourceLocale) {
52
+ export async function translateSemanticHtml(sourceHtml, sourceLocale,
53
+ /** The render's locale. Without it the request's is asked, and a static render has no request. */
54
+ locale) {
53
55
  const sanitized = sanitizePhiHtmlWidgetMarkup(sourceHtml);
54
56
  if (!sanitized) {
55
57
  return "";
@@ -64,7 +66,10 @@ export async function translateSemanticHtml(sourceHtml, sourceLocale) {
64
66
  if (units.length === 0) {
65
67
  return sanitized;
66
68
  }
67
- const translated = await trBulk(units.map((unit) => unit.html), 0, "html", sourceLocale);
69
+ const msgs = units.map((unit) => unit.html);
70
+ const translated = locale
71
+ ? await trBulkForLocale(locale, msgs, 0, "html", sourceLocale)
72
+ : await trBulk(msgs, 0, "html", sourceLocale);
68
73
  units.forEach((unit, index) => {
69
74
  const fragment = parseDocument(translated[index] ?? unit.html);
70
75
  unit.node.children = fragment.children ?? [];
@@ -127,6 +127,11 @@ export async function resolvePhiDescriptorNavigationItems(runtime, navKey, overl
127
127
  /*
128
128
  * One batch per source language. A Module's label names its language and is translated from it; an
129
129
  * operator's names none and is read in the Site's source language, which the server supplies.
130
+ *
131
+ * The batch also decides the store. A Module's label is the platform's text, the same on every Site,
132
+ * so it is translated once in the global store, which only the operator edits. A Site that wants
133
+ * other words renames the entry, the label loses its `sourceLocale`, and from then on it is the
134
+ * Site's own text in the Site's store. The Builder's surfaces carry Module labels only.
130
135
  */
131
136
  await Promise.all([...collectNavigationLabels(surface.items)].map(async ([sourceLocale, group]) => {
132
137
  const labels = [...group];
@@ -136,7 +141,7 @@ export async function resolvePhiDescriptorNavigationItems(runtime, navKey, overl
136
141
  locale: runtime.locale.current,
137
142
  ...(sourceLocale ? { sourceLocale } : {}),
138
143
  };
139
- const translator = runtime.area === "builder"
144
+ const translator = sourceLocale || runtime.area === "builder"
140
145
  ? createGlobalTranslator(options)
141
146
  : createSiteTranslator({ ...options, siteKey: rt.siteKey });
142
147
  /*
@@ -1,6 +1,6 @@
1
1
  import type { PhiClientBlockBaseProps, PhiNoLabels } from "../../../types";
2
2
  import type { PhiCodeToken } from "./markdown-code-tokens";
3
- import type { PhiMarkdownTextAlign } from "../../../types/core-widget-placements";
3
+ import type { PhiMarkdownImageFloat, PhiMarkdownTextAlign } from "../../../types/core-widget-placements";
4
4
  import { type PhiSpacingScaleKey } from "../config/spacing-options";
5
5
  import type { PhiMarkdownTocHeading } from "../../../types/markdown";
6
6
  import { type PhiBulletListStyle } from "./bullet-list-vocabulary";
@@ -75,6 +75,7 @@ export type PhiMarkdownWidgetClientProps = PhiClientBlockBaseProps<PhiNoLabels,
75
75
  tocKey?: string;
76
76
  headings?: PhiMarkdownTocHeading[];
77
77
  textAlign?: PhiMarkdownTextAlign;
78
+ imageFloat?: PhiMarkdownImageFloat;
78
79
  textBlockSpacingBefore?: PhiSpacingScaleKey;
79
80
  textBlockSpacingAfter?: PhiSpacingScaleKey;
80
81
  headingBlockSpacingBefore?: PhiSpacingScaleKey;
@@ -162,7 +162,9 @@ export function PhiMarkdownWidgetClient({ config }) {
162
162
  marginBlockStart: resolvePhiSpacingScaleValue(config?.headingBlockSpacingBefore ?? "none"),
163
163
  marginBlockEnd: resolvePhiSpacingScaleValue(config?.headingBlockSpacingAfter ?? "sm"),
164
164
  };
165
- return (_jsx("div", { style: { display: "grid", gap: 0, width: "100%", textAlign: config?.textAlign }, children: renderBlocks(blocks, token, paragraphBlockStyle, headingBlockStyle, {
165
+ return (_jsx("div", { className: config?.imageFloat ? `phi-markdown-body phi-markdown-body--float-${config.imageFloat}` : undefined, style: config?.imageFloat
166
+ ? { display: "flow-root", width: "100%", textAlign: config.textAlign, containerType: "inline-size" }
167
+ : { display: "grid", gap: 0, width: "100%", textAlign: config?.textAlign }, children: renderBlocks(blocks, token, paragraphBlockStyle, headingBlockStyle, {
166
168
  style: resolvePhiBulletListStyle(config?.list),
167
169
  depth: 1,
168
170
  }) }));
@@ -2,7 +2,7 @@ import { cache } from "react";
2
2
  import { unified } from "unified";
3
3
  import remarkParse from "remark-parse";
4
4
  import remarkGfm from "remark-gfm";
5
- import { trBulk } from "../../../server-helpers/translate";
5
+ import { trBulk, trBulkForLocale } from "../../../server-helpers/translate";
6
6
  import { resolvePhiCodeLanguage, tokenizePhiCode } from "./markdown-code-tokens";
7
7
  import { readPhiInternalReference } from "../../../types/references";
8
8
  import { resolvePhiWidgetInternalReferences } from "../helpers/internal-reference-resolver.server";
@@ -56,6 +56,13 @@ function normalizePotentialPhiScheme(value) {
56
56
  * accepted let `javascript:` and `data:` through here while the inline path refused them.
57
57
  */
58
58
  const PHI_EXTERNAL_DOCUMENT_URL_SCHEMES = /^(?:https?:|mailto:|tel:)$/iu;
59
+ /*
60
+ * A picture written into the text itself may also be a data URL of an image type -- the one way a Module
61
+ * brings a drawing with its copy, since it cannot put a file into the Site's `public/`. An `<img>` runs
62
+ * nothing it loads, an SVG's scripts included, so the type list is what keeps this a picture. Links
63
+ * stay on the scheme list above, and a fetched document gets no data URLs at all.
64
+ */
65
+ const PHI_INLINE_IMAGE_DATA_URL = /^data:image\/(?:png|jpeg|gif|webp|svg\+xml)[;,]/iu;
59
66
  function resolveExternalDocumentUrl(value, sourceUrl) {
60
67
  const normalized = value.trim();
61
68
  if (!normalized || normalizePotentialPhiScheme(normalized).startsWith("phis:"))
@@ -119,7 +126,7 @@ async function projectInternalMarkdownNodes(tree, runtime) {
119
126
  const src = resolved.assetUrls.get(reference.assetId);
120
127
  return src ? [{ ...node, url: src, children }] : [];
121
128
  }
122
- if (!/^https?:/iu.test(node.url ?? ""))
129
+ if (!/^https?:/iu.test(node.url ?? "") && !PHI_INLINE_IMAGE_DATA_URL.test(node.url ?? ""))
123
130
  return [];
124
131
  return [{ ...node, children }];
125
132
  }
@@ -294,11 +301,21 @@ function parseTranslatedMarkdownInlines(translatedHtml, metadata) {
294
301
  appendText(translatedHtml.slice(cursor));
295
302
  return stack.length === 1 ? stack[0]?.children ?? [] : null;
296
303
  }
297
- async function buildMarkdownInlineMap(tree, translate, sourceLocale) {
304
+ /*
305
+ * The render's own locale where there is one. A static render has no request to ask: `trBulk` reads the
306
+ * locale from the request headers, finds none, and answers in the Site's default -- which left every
307
+ * static Public page's body in its source language under a translated header.
308
+ */
309
+ function translateUnits(locale, msgs, format, sourceLocale) {
310
+ return locale
311
+ ? trBulkForLocale(locale, msgs, 0, format, sourceLocale)
312
+ : trBulk(msgs, 0, format, sourceLocale);
313
+ }
314
+ async function buildMarkdownInlineMap(tree, translate, sourceLocale, locale) {
298
315
  const units = [];
299
316
  collectMarkdownTranslationUnits(tree, units);
300
317
  const translated = translate && units.length > 0
301
- ? await trBulk(units.map((unit) => unit.html), 0, "html", sourceLocale)
318
+ ? await translateUnits(locale, units.map((unit) => unit.html), "html", sourceLocale)
302
319
  : units.map((unit) => unit.html);
303
320
  /*
304
321
  * Image titles are prose and translate as plain text, each on its own: they are separate sentences
@@ -307,7 +324,7 @@ async function buildMarkdownInlineMap(tree, translate, sourceLocale) {
307
324
  */
308
325
  const titleSlots = units.flatMap((unit) => unit.metadata.titles.flatMap((title, index) => title ? [{ metadata: unit.metadata, index, title }] : []));
309
326
  if (translate && titleSlots.length > 0) {
310
- const translatedTitles = await trBulk(titleSlots.map((slot) => slot.title), 0, "text", sourceLocale);
327
+ const translatedTitles = await translateUnits(locale, titleSlots.map((slot) => slot.title), "text", sourceLocale);
311
328
  titleSlots.forEach((slot, index) => {
312
329
  slot.metadata.titles[slot.index] = translatedTitles[index] || slot.title;
313
330
  });
@@ -508,7 +525,7 @@ async function resolveMarkdownRenderDataFromSource(markdown, translate, headingI
508
525
  headingIdPrefix,
509
526
  headingOffset,
510
527
  };
511
- const inlineMap = await buildMarkdownInlineMap(tree, translate, sourceLocale);
528
+ const inlineMap = await buildMarkdownInlineMap(tree, translate, sourceLocale, runtime?.locale.current);
512
529
  const blocks = mapBlockNodes(tree.children, context, inlineMap);
513
530
  return { blocks, headings: context.headings };
514
531
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Whose words a text Widget holds, which decides the store that translates them.
3
+ *
4
+ * A Preset's text is the platform's: the same sentence on every installation, translated once in the
5
+ * global store, which only the operator edits. Text an author wrote on a Site is the Site's and goes to
6
+ * the Site's store. The Preset says so by writing `textSource: "preset"` beside its words; the Builder
7
+ * drops the marker the moment an author changes one of the Widget's texts, and from then on all of them
8
+ * are the Site's.
9
+ *
10
+ * A marker rather than a translation done by the Preset itself: adopting a Preset stores the tree it
11
+ * built, so a Preset that translated its words would freeze one locale into the Site's copy.
12
+ */
13
+ export declare const PHI_TEXT_SOURCE_PRESET: "preset";
14
+ export type PhiTextSource = typeof PHI_TEXT_SOURCE_PRESET;
15
+ export declare function readPhiTextSource(value: unknown): PhiTextSource | undefined;
16
+ /** Whether a Builder edit of this field ends the Preset's ownership of the Widget's words. */
17
+ export declare function isPhiAuthoredTextField(field: {
18
+ type?: string;
19
+ }): boolean;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Whose words a text Widget holds, which decides the store that translates them.
3
+ *
4
+ * A Preset's text is the platform's: the same sentence on every installation, translated once in the
5
+ * global store, which only the operator edits. Text an author wrote on a Site is the Site's and goes to
6
+ * the Site's store. The Preset says so by writing `textSource: "preset"` beside its words; the Builder
7
+ * drops the marker the moment an author changes one of the Widget's texts, and from then on all of them
8
+ * are the Site's.
9
+ *
10
+ * A marker rather than a translation done by the Preset itself: adopting a Preset stores the tree it
11
+ * built, so a Preset that translated its words would freeze one locale into the Site's copy.
12
+ */
13
+ export const PHI_TEXT_SOURCE_PRESET = "preset";
14
+ export function readPhiTextSource(value) {
15
+ return value === PHI_TEXT_SOURCE_PRESET ? value : undefined;
16
+ }
17
+ /** Whether a Builder edit of this field ends the Preset's ownership of the Widget's words. */
18
+ export function isPhiAuthoredTextField(field) {
19
+ return field.type === "string";
20
+ }
package/next/config.mjs CHANGED
@@ -57,7 +57,7 @@ function customRenders(antd, h, Fragment) {
57
57
  Form: (Form) => h(Form, null, h(Form.Item, { label: "Label" }, h(antd.Input))),
58
58
  Dropdown: (Dropdown) => h(Dropdown, { menu: { items: [] } }, h("div")),
59
59
  Menu: (Menu) => h(Menu, { items: [] }),
60
- QRCode: (QRCode) => h(QRCode, { value: "https://phisys.com" }),
60
+ QRCode: (QRCode) => h(QRCode, { value: "https://phis.dev" }),
61
61
  Tree: (Tree) => h(Tree, { treeData: [] }),
62
62
  Tag: (Tag) => h(Fragment, null, h(Tag, { color: "blue" }, "Tag"), h(Tag, { color: "success" }, "Tag")),
63
63
  Badge: (Badge) => h(Fragment, null, h(Badge), h(Badge.Ribbon)),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phis/ui",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "license": "Apache-2.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -1,3 +1,4 @@
1
+ import { PHI_TEXT_SOURCE_PRESET } from "../../../../helpers/text-source";
1
2
  import { createPhiPresetCmsInstanceIdMap } from "../../../../types/cms-instance-id";
2
3
  import { PHI_SPACE } from "../../../../theme/antd-css-var-contract";
3
4
  import { PHI_AUTH_RUNTIME_MODULE_ID } from "../ids";
@@ -97,6 +98,7 @@ export async function buildPhiDefaultPubConfirmPageTree({ page, presetKey, }) {
97
98
  sortOrder: 0,
98
99
  label: "pub confirmation description widget",
99
100
  config: {
101
+ textSource: PHI_TEXT_SOURCE_PRESET,
100
102
  eyebrow: "Confirm Email",
101
103
  title: "Activate your account",
102
104
  description: "Review your registration details and confirm your email address to activate access.",
@@ -160,6 +162,7 @@ export async function buildPhiDefaultPubConfirmPageTree({ page, presetKey, }) {
160
162
  slotIndex: 2,
161
163
  label: "pub confirmation missing token",
162
164
  config: {
165
+ textSource: PHI_TEXT_SOURCE_PRESET,
163
166
  text: "No confirmation token was provided. Open the confirmation link from your email.",
164
167
  tone: "secondary",
165
168
  visibleWhen: withoutToken,
@@ -1,3 +1,4 @@
1
+ import { PHI_TEXT_SOURCE_PRESET } from "../../../../helpers/text-source";
1
2
  import { createPhiPresetCmsInstanceIdMap } from "../../../../types/cms-instance-id";
2
3
  import { PHI_SPACE } from "../../../../theme/antd-css-var-contract";
3
4
  import { PHI_LAYOUT } from "../../../../theme/phi-tokens";
@@ -98,6 +99,7 @@ export async function buildPhiDefaultPubLoginPageTree({ page, presetKey, runtime
98
99
  sortOrder: 0,
99
100
  label: "pub login description widget",
100
101
  config: {
102
+ textSource: PHI_TEXT_SOURCE_PRESET,
101
103
  eyebrow: "Login",
102
104
  title: "Sign in to your account",
103
105
  description: "Access your customer workspace, orders, and account details.",
@@ -1,3 +1,4 @@
1
+ import { PHI_TEXT_SOURCE_PRESET } from "../../../../helpers/text-source";
1
2
  import { createPhiPresetCmsInstanceIdMap } from "../../../../types/cms-instance-id";
2
3
  import { PHI_SPACE } from "../../../../theme/antd-css-var-contract";
3
4
  import { PHI_AUTH_RUNTIME_MODULE_ID } from "../ids";
@@ -68,6 +69,7 @@ export async function buildPhiDefaultPubRegistrationPageTree({ page, presetKey,
68
69
  sortOrder: 0,
69
70
  label: "pub registration description widget",
70
71
  config: {
72
+ textSource: PHI_TEXT_SOURCE_PRESET,
71
73
  eyebrow: "Register",
72
74
  title: "Create account",
73
75
  description: "Create your account to access your personalized workspace.",
@@ -113,6 +115,7 @@ export async function buildPhiDefaultPubRegistrationPageTree({ page, presetKey,
113
115
  slotIndex: 1,
114
116
  label: "pub registration notice",
115
117
  config: {
118
+ textSource: PHI_TEXT_SOURCE_PRESET,
116
119
  text: "We only use your details to create and support your account.",
117
120
  tone: "secondary",
118
121
  },
@@ -1,3 +1,4 @@
1
+ import { PHI_TEXT_SOURCE_PRESET } from "../../../../helpers/text-source";
1
2
  import { createPhiPresetCmsInstanceIdMap } from "../../../../types/cms-instance-id";
2
3
  import { PHI_SPACE } from "../../../../theme/antd-css-var-contract";
3
4
  import { PHI_AUTH_RUNTIME_MODULE_ID } from "../ids";
@@ -89,6 +90,7 @@ export async function buildPhiDefaultPubResetPasswordPageTree({ page, presetKey,
89
90
  sortOrder: 0,
90
91
  label: "pub reset password description widget",
91
92
  config: {
93
+ textSource: PHI_TEXT_SOURCE_PRESET,
92
94
  eyebrow: "Reset password",
93
95
  title: "Choose a new password",
94
96
  description: "Use the reset link from your email to choose a new secure password.",
@@ -113,6 +115,7 @@ export async function buildPhiDefaultPubResetPasswordPageTree({ page, presetKey,
113
115
  slotIndex: 0,
114
116
  label: "pub reset password intro",
115
117
  config: {
118
+ textSource: PHI_TEXT_SOURCE_PRESET,
116
119
  text: "If the account exists, a reset email is on its way. Open the link in that email to choose a new password.",
117
120
  tone: "secondary",
118
121
  visibleWhen: withoutToken,
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { isPhiAuthoredTextField, readPhiTextSource } from "../../../../helpers/text-source";
3
4
  import { useEffect } from "react";
4
5
  import { PhiGeometryControl } from "../../../../components/controls/phi-geometry-control";
5
6
  import { PhiSurfaceControl } from "../../../../components/controls/phi-surface-control";
@@ -81,6 +82,14 @@ export function PhiDeveloperBuilderWidgetInspectorWidgetClient({ section, builde
81
82
  videoProviderDescriptors,
82
83
  onChange: (next) => {
83
84
  const patch = buildPhiInspectorConfigPathPatch(currentWidgetSettingsConfigRecord, next);
85
+ /*
86
+ * An author writing into a Preset's text makes the Widget's words the Site's: from here on the
87
+ * Site's store translates them, not the global one (`helpers/text-source.ts`).
88
+ */
89
+ if (readPhiTextSource(currentWidgetSettingsConfigRecord.textSource) != null
90
+ && isPhiAuthoredTextField(field)) {
91
+ patch.textSource = undefined;
92
+ }
84
93
  onConfigChange?.(patch);
85
94
  },
86
95
  });
@@ -1663,6 +1663,8 @@ async function buildPhiDefaultBuilderPagePresetTemplateTree({ page, runtime, pre
1663
1663
  }),
1664
1664
  nodes.widget({
1665
1665
  typeKey: "simple-text",
1666
+ /* Label set text, translated already; a second pass would hand the translator a non-source text. */
1667
+ flags: PhiCmsFlags.NoTranslate,
1666
1668
  id: PHI_BUILDER_MODULE_USAGE_WIDGET_IDS.moduleUsageIntro,
1667
1669
  parentLayoutNodeId: PHI_BUILDER_MODULE_USAGE_LAYOUT_IDS.moduleUsageBody,
1668
1670
  slotIndex: PHI_CMS_DEFAULT_SLOT_INDEX,
@@ -1737,6 +1739,8 @@ async function buildPhiDefaultBuilderPagePresetTemplateTree({ page, runtime, pre
1737
1739
  }),
1738
1740
  nodes.widget({
1739
1741
  typeKey: "simple-text",
1742
+ /* Label set text, translated already; a second pass would hand the translator a non-source text. */
1743
+ flags: PhiCmsFlags.NoTranslate,
1740
1744
  id: PHI_BUILDER_PUBLIC_ROUTES_WIDGET_IDS.publicRoutesIntro,
1741
1745
  parentLayoutNodeId: PHI_BUILDER_PUBLIC_ROUTES_LAYOUT_IDS.publicRoutesBody,
1742
1746
  slotIndex: PHI_CMS_DEFAULT_SLOT_INDEX,
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { trBulk } from "../../../../../server-helpers/translate";
2
+ import { trBulk, trBulkForLocale } from "../../../../../server-helpers/translate";
3
3
  import { isPhiMediaSvgContentType } from "../../../../../constants/media";
4
4
  import { resolvePhiImagePresentation } from "../../../../../components/media/image-presentation";
5
5
  import { resolvePhiPublicAssetReference } from "../../../../../components/widgets/helpers/internal-reference-resolver.server";
@@ -23,7 +23,9 @@ export async function PhiCardWidget({ labels, config, runtime, links, translate,
23
23
  ["actionLabel", actionEnabled ? ownActionLabel : undefined],
24
24
  ];
25
25
  const translatedTexts = translate
26
- ? await trBulk(textEntries.map(([, value]) => value ?? ""))
26
+ ? await (runtime?.locale.current
27
+ ? trBulkForLocale(runtime.locale.current, textEntries.map(([, value]) => value ?? ""))
28
+ : trBulk(textEntries.map(([, value]) => value ?? "")))
27
29
  : textEntries.map(([, value]) => value ?? "");
28
30
  const translatedByKey = new Map(textEntries.map(([key], index) => [key, translatedTexts[index] || undefined]));
29
31
  /*
@@ -1,3 +1,4 @@
1
+ import { type PhiTextSource } from "../../../../../helpers/text-source";
1
2
  import { type PhiCmsWidgetConfigBase } from "../../../../../components/widgets/config/parser-primitives";
2
3
  import { type PhiTextTone } from "../../../../../components/widgets/config/text-tone";
3
4
  import { type PhiBulletListEntry, type PhiBulletListStyle } from "../../../../../components/widgets/shared/bullet-list-vocabulary";
@@ -13,6 +14,8 @@ export type PhiCmsDescriptionWidgetConfig = PhiCmsWidgetConfigBase & {
13
14
  footer?: string;
14
15
  /** The tone of the description text; absent is the default secondary text. */
15
16
  tone?: PhiTextTone;
17
+ /** `"preset"` while the words are a Preset's and translated globally (`helpers/text-source.ts`). */
18
+ textSource?: PhiTextSource;
16
19
  };
17
20
  declare function parsePhiCmsDescriptionWidgetConfig(config: Record<string, unknown>): PhiCmsDescriptionWidgetConfig;
18
21
  export declare const PHI_DESCRIPTION_WIDGET_DEFINITION: {
@@ -1,4 +1,5 @@
1
1
  import { resolvePhiCmsWidgetPluginKey } from "../../../../../constants/cms-widget-types";
2
+ import { readPhiTextSource } from "../../../../../helpers/text-source";
2
3
  import { PhiCmsWidgetType } from "../../../../../constants/cms-widget-types";
3
4
  import { readRenderableBlockConfig, readString } from "../../../../../components/widgets/config/parser-primitives";
4
5
  import { PHI_TEXT_TONE_FIELD_OPTIONS, readPhiTextTone, } from "../../../../../components/widgets/config/text-tone";
@@ -15,6 +16,7 @@ function parsePhiCmsDescriptionWidgetConfig(config) {
15
16
  asideList: readPhiBulletListStyle(config.asideList),
16
17
  footer: readString(config.footer),
17
18
  tone: readPhiTextTone(config.tone),
19
+ textSource: readPhiTextSource(config.textSource),
18
20
  };
19
21
  }
20
22
  export const PHI_DESCRIPTION_WIDGET_DEFINITION = {