diff --git a/README.md b/README.md index a681599..0f88e2a 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ lives of PP developers easier: pp-dev is based on [Vite](https://vitejs.dev/). +πŸ“Έ **[Feature tour](./docs/features.md)** β€” screenshots of the dev panel, Request Inspector, and Variables Editor. + ## Installation ```bash @@ -472,6 +474,8 @@ For custom build configuration, create a `vite.config` file. See [Vite Configura pp-dev injects a floating dev panel into every served page. It shows the package name and version, the backend URL, the template mode and the App ID, and hosts the template **Sync** button. Since 1.0 the panel is fully repositionable and can be hidden. +![Dev panel settings popover](./docs/screenshots/03-panel-settings.png) + ### Position The panel can be anchored to any of the four screen corners (default: bottom-right). Three ways to move it: @@ -486,13 +490,17 @@ The minimize arrow, the panel's shadow, rounded corner and slide direction all m Toggle **Auto-hide** in the settings popover (or set `devPanel.autoHide: true`). The panel slides behind the nearest screen edge leaving a 4px accent strip; hovering the strip for ~300 ms slides it out, and it hides again ~500 ms after the pointer leaves. Keyboard focus inside the panel keeps it revealed. While auto-hide is active the minimize arrow acts as a **pin** button that returns the panel to normal mode. +### Theme + +The settings popover has an **Auto / Dark / Light** switcher. "Auto" follows the OS/browser preference; the other two force an override. The choice is shared (via the same `localStorage` key) with the standalone [Request Inspector](#request-inspector) and [Variables Editor](#variables-editor) pages, which also each have their own copy of the switcher for when they're opened directly without the panel present. + ### Hiding and restoring **Hide panel** in the settings popover (or `devPanel.hidden: true`) removes the panel from view entirely. To bring it back, open any page with `?pp-dev-panel=show` in the URL β€” the override persists across reloads. The symmetric `?pp-dev-panel=hide` hides it, which is handy for demos and clean screenshots. ### State persistence -Runtime choices are saved in the browser's `localStorage` (`pp-dev-info-position`, `pp-dev-info-auto-hide`, `pp-dev-info-hidden`) and take precedence over config values. **Reset to config defaults** in the settings popover clears all overrides. Storage is origin-scoped: overrides apply to every pp-dev app served on the same host and port, and a Metric Insights page that clears origin storage will reset them to config defaults. +Runtime choices are saved in the browser's `localStorage` (`pp-dev-info-position`, `pp-dev-info-auto-hide`, `pp-dev-info-hidden`, `pp-dev-info-theme`) and take precedence over config values. **Reset to config defaults** in the settings popover clears all overrides. Storage is origin-scoped: overrides apply to every pp-dev app served on the same host and port, and a Metric Insights page that clears origin storage will reset them to config defaults. ### Configuration @@ -509,6 +517,33 @@ export default defineConfig({ See the [`devPanel` option reference](#devpanel--dev-panel-appearance) for details. +### Page variables + +For pages with a template (`app.type: 'template'`), the settings popover has: + +- **Reload variables** β€” refetches the page's live variable values from MI right now (bypassing the usual request-level cache) and reloads the page, so `[VarName]` substitution reflects a just-saved value without restarting the dev server. +- **Open variables editor…** β€” opens the standalone [Variables Editor](#variables-editor) page. + +Pages without a template (`app.type: 'page'`) have no variables, so this group β€” and the button that opens the editor β€” is hidden. + +### Variables Editor + +A standalone page at `/@pp-dev/variables-editor`, alongside the [Request Inspector](#request-inspector), with two tabs: + +- **Schema** β€” view/edit the template's `__template_variables.json` (add/remove variables, change type, default, `additional_options`, etc.), with a raw-JSON escape hatch. + +![Variables Editor β€” Schema tab](./docs/screenshots/06-editor-schema.png) + +- **Values** β€” edit the page's live variable values in place, with type-aware widgets (searchable select for `static` options, a per-item form for `list`, …). A JSON mode (`View/edit raw JSON`) shows/accepts the same values as plain JSON β€” list-type values as native arrays, not double-escaped strings β€” with **Save to JSON file…** / **Import from JSON file…** buttons, and flags values that don't match a declared option, changed since the last load, or aren't in the schema. + +![Variables Editor β€” Values tab](./docs/screenshots/09-editor-values.png) + +Both the active tab and Values' JSON mode are reflected in the URL (`?tab=values&mode=json`), so a specific view can be bookmarked or shared. + +See the [feature tour](./docs/features.md#variables-editor) for more screenshots, including the advanced/raw-JSON views and the theme switcher. + +See [`TEMPLATE_VARIABLES.md`](./TEMPLATE_VARIABLES.md) β€” also shipped inside the published package β€” for the `__template_variables.json` schema this feature reads. + ## Request Inspector pp-dev includes a built-in request inspector that captures every proxied and locally-served HTTP request made during development. It is enabled by default. @@ -521,6 +556,12 @@ Open `http://localhost:3000/@pp-dev/inspector` (replace port as needed) in any b - Full request and response headers, with a **Copy** button per section - Request and response bodies rendered as text for JSON/HTML/CSS/plain-text content types, with **Copy** and **Save** buttons - Binary bodies (images, fonts, archives) show metadata only and offer a **Save** button + +![Request Inspector β€” request list](./docs/screenshots/04-inspector-list.png) + +Selecting a request opens the detail pane with headers and a syntax-highlighted body: + +![Request Inspector β€” request detail](./docs/screenshots/05-inspector-detail.png) - A **Clear** button in the top-right removes all stored entries ### Source badges diff --git a/TEMPLATE_VARIABLES.md b/TEMPLATE_VARIABLES.md new file mode 100644 index 0000000..6fe12d9 --- /dev/null +++ b/TEMPLATE_VARIABLES.md @@ -0,0 +1,125 @@ +# `__template_variables.json` schema (MI reference) + +This file is what Metric Insights (MI) writes/reads for a Portal Page **template**'s variables β€” its definitions (types, defaults, per-field config) live here, while the *live* values a page currently has are stored separately on the page itself (see below). pp-dev's standalone [Variables Editor](./README.md#variables-editor) page (`src/lib/variables-editor.ts`, backed by `src/lib/page-variables-diff.ts`) reads and writes this file's contents; the older backup/sync flow (`src/lib/dist.service.ts`, `TEMPLATE_VARIABLES_FILE_NAME`) only ever tracked it as an opaque blob (hash) for diffing, never parsing it. + +## File location + +- Filename: `__template_variables.json` +- In a Portal Page project, it normally lives at `public/__template_variables.json`. +- Produced by MI when exporting a template's assets; consumed by MI when template assets are synced back (e.g. via git-sync, or saving the template in MI's own editor). + +## Top-level shape + +```jsonc +{ + "tags": [ /* TemplateVariableTag[] β€” see below */ ], + "settings": { + "image_capture_timeout": 0, + "image_capture_on_event": "...", + "image_capture_css_selector": "..." + } +} +``` + +`tags` is the variable schema/definitions. `settings` is unrelated template-capture config β€” not a variable. + +## `TemplateVariableTag` fields + +| Field | Type | Notes | +|---|---|---| +| `name` | `string` | Unique variable name. This is the key that appears in the live page's `tags` (`{name, value}[]`, see below) and in `[VarName]` placeholders substituted by `pp.middleware.ts#buildPage()`. | +| `uid` | `string` | Stable md5 identifier, survives renames across syncs. | +| `tag_type` | `enum` | One of: `text`, `select`, `multiselect`, `file`, `list`, `color`, `boolean`. (`boolean` is a newer addition β€” older templates may not have it.) | +| `tag_source` | `enum` | One of: `static`, `page`, `element`, `folder`, `segment`, `dataset`, `dataset_data`, `announcement`, `group`, `category`, `custom_attribute`, `page_entity`. Describes where the value/options come from, not the value's shape. | +| `default_value` | `string` | Default value for the variable. For `tag_type: "list"`, this is a **JSON-encoded array** (string-of-JSON, not a nested array). | +| `additional_options` | `array \| object \| string` | Decoded JSON in the exported file (re-stringified on ingest). Shape depends on `tag_type` and, for `select`/`multiselect`, on `tag_source` β€” see the dedicated section below for the full per-source breakdown. Always absent/unused for `boolean`. | +| `description` | `string` | Markdown, rendered as such in MI's editor UI. | +| `use_hmtl_editor_ind` | `'Y' \| 'N'` | Whether the value is edited via a rich HTML editor. | +| `use_raw_html_ind` | `'Y' \| 'N'` | When `'Y'`, MI skips XSS-encoding the value on save (see caveat below). | +| `use_json_editor_ind` | `'Y' \| 'N'` | Whether the value is edited via a JSON editor. | +| `javascript_code` | `string \| null` | Optional JS snippet, with `[value]` substituted at render time. | +| `display_order` | `number` | Present explicitly in real exports (not just implied by array index) β€” matches the tag's position in `tags`. | +| `portal_page_template_tag_id` | `number` | MI's internal row id for this tag. Not needed to write a schema by hand β€” MI regenerates it. | +| `portal_page_template_id` | `number` | The owning template's id β€” same for every tag in one file. | +| `use_js_code_ind` | `'Y' \| 'N'` | Companion flag for `javascript_code` (whether it's active), seen alongside `use_hmtl_editor_ind`/`use_raw_html_ind`/`use_json_editor_ind` in real exports. | + +There is **no** `label` or `required` field, and no generic `options`/`enum` key β€” option lists live inside `additional_options`. + +`javascript_code`/`use_js_code_ind` are real, change-tracked columns, but MI's current "Create/Edit Variable" form has no field for either β€” there's no live path to set them through that UI today. Treat them as legacy/reserved: safe to read and preserve on a round-trip, but not something to expect a human to fill in via MI's own tooling. pp-dev's Variables Editor matches this and doesn't expose an editor for them either β€” it still reads/writes them untouched as part of each tag object. + +A real example covering every `tag_type` β€” including a dataset-driven `select` with non-enumerable `additional_options`, a flat-string `list`, and a column-defined `list` of objects (`ListItemFieldConfig[]`, see below) β€” lives at `tests/test-commonjs/public/__template_variables.json` in this repo. + +## Live page variable values vs. the schema + +The schema above (`__template_variables.json`) is **separate** from the live values stored on a Portal Page: + +- Live values: `Page.tags` β€” a JSON string of `{name, value}[]` β€” fetched/set via a dedicated endpoint, **not** `/api/page`: + - `GET /api/page_variable?page_id=` β†’ `{ tags: [{name, value}, ...] }` + - `PUT /api/page_variable?page_id=` with body `{ tags: "" }` + - `page_id` is the preferred identifier for both β€” pp-dev uses it exclusively (see `src/api/page-variable.ts`, `PageVariableAPI#getById`/`#updateById`). `internal_name` also works as a fallback (`?internal_name=` on either verb) but requires an extra page lookup to resolve first if you only have the numeric page id, so there's no reason to use it here. + - The route also accepts an `id`-style path/query param (`/api/page_variable/id/{id}`, `?id=...`) for **PUT only** β€” for **GET**, sending an `id` routes to a handler that's explicitly disabled, so **`id` must never be used for GET** β€” use `page_id` instead. + - MI resolves the page for this endpoint via `page_id` first, falling back to `internal_name` only when `page_id` is absent. +- MI's tag-saving logic does **not** validate a value against the variable's declared `tag_type`. It only special-cases `tag_type === 'list'` (JSON encode/decode the array + XSS-encode each element) vs. everything else (plain XSS-encode as a string), and skips encoding entirely when `use_raw_html_ind === 'Y'` or `use_hmtl_editor_ind === 'Y'`. So `select`, `multiselect`, `boolean`, `color`, and `file` values are all persisted as unvalidated strings by MI itself β€” there is no server-side type/enum enforcement to lean on. + +## `list` of objects β€” `additional_options` as `ListItemFieldConfig[]` + +A `list` variable isn't limited to flat strings. When `additional_options` is a non-empty array, MI's own page-variable editor treats each entry as a **column definition** and renders each list item as an object keyed by those column names β€” this is a real, first-class MI feature, not something pp-dev invented. + +```ts +interface ListItemFieldConfig { + name: string; // becomes the object key on each list item + type: 'textarea' | 'color' | 'select' | 'multi-select' | 'file'; + source?: string; // select/multi-select only β€” where THIS field's options come from + additional_options?: string; // not actually read for select/multi-select β€” see below + options?: string[]; // select/multi-select only β€” the option list itself +} +``` + +A bare string in the array is shorthand for `{ name: , type: 'textarea' }`. With `additional_options` empty (`""`), items fall back to being plain strings. `tests/test-commonjs/public/__template_variables.json` in this repo has both variants side by side: `variable-list` (flat strings) and `variable-list-objects` (column-defined β€” `additional_options: [{name:"id",type:"textarea"},{name:"label",type:"textarea"}]`, items like `{"id":"1","label":"First"}`). + +For a `select`/`multi-select` column, `source` (defaults to `'static'` if omitted) and `options` together follow the **exact same per-source rules** as the tag-level `tag_source`/`additional_options` pair described above β€” just fed by `options` (a plain `string[]`) instead of the tag's own `additional_options`. Concretely: with `source: 'static'` (or omitted), `options` **is** the list of choices; for any other `source`, the same live-loading/ignored-field rules apply, just scoped to this one column instead of the whole tag. MI's own list-item value editor never actually reads this column config's `additional_options` β€” only `options` β€” so leave it out rather than mirroring the tag-level shape here. + +pp-dev validates list items against this schema (`validateListItems` in `src/lib/page-variables-diff.ts`, best-effort/warning-only like everything else here): each item must be an object with every declared field present and no undeclared extra fields, `color` fields must match a hex pattern, and `select`/`multi-select` fields are checked against `options` (a plain `string[]`) when present. A flat list (empty `additional_options`) skips all of this β€” items are just left as whatever they are. + +## `list` values in the Variables Editor's Values-tab JSON mode + +On the wire (and in `default_value`), a `list` value is always a **JSON-encoded array packed into a string** β€” e.g. the string `["a","b"]`, not a nested array. Written naively into a JSON file, that means double-escaping: `"value": "[\"a\",\"b\"]"`. + +To avoid making a human write that by hand, the Variables Editor's Values tab β€” both its JSON mode (`View/edit raw JSON`, including the Save/Import-to-file buttons) and the file it writes β€” allows `value` to be a **native JSON array** for `list`-type entries β€” `"value": ["a","b"]` β€” and converts at the display/parse boundary only (`toExportableValueRows`/`fromExportableValueRows` in `src/lib/variables-editor.ts`): + +- **Display/export**: for any entry whose schema tag has `tag_type: "list"`, the stored string is `JSON.parse`'d back into a native array before it's shown in the JSON textarea or saved to a file, falling back to the raw string if it isn't valid JSON. +- **Parse/import**: the reverse β€” if `value` isn't a string, it's `JSON.stringify`'d immediately into MI's plain-string form before being sent anywhere. +- Everything in between β€” the `PUT /@api/variables/values` endpoint and `page-variables-diff.ts`'s export/validation logic β€” only ever sees plain strings; neither has any awareness of this convenience conversion. + +## What MI's own "Create/Edit Variable" form actually shows + +MI's schema-authoring UI is a hardcoded type switch, not generically schema-driven, so which fields it shows/asks for depends on `tag_type` (and, in one case, `tag_source`): + +- **Name**: must match `/^[A-Za-z0-9_\s-]+$/` (letters, digits, underscore, whitespace, hyphen) and be unique β€” both enforced client-side before MI will save it. +- **`tag_source` picker**: shown only for `tag_type: "select"`/`"multiselect"`. Every other type is created as `static` with no way to pick a different source through this form. +- **`use_hmtl_editor_ind` ("Use WYSIWYG Editor")**: shown only for `tag_type: "text"`. Checking it forces `use_raw_html_ind`/`use_json_editor_ind` off (mutually exclusive). +- **`use_raw_html_ind` ("Raw HTML")** / **`use_json_editor_ind` ("Use JSON Editor")**: shown for `tag_type: "text"` or `"list"`; for `"text"`, only while WYSIWYG isn't checked. Not offered for `select`/`multiselect`/`file`/`color`/`boolean`. +- **`default_value`**: shown only for `tag_type: "text"` or `"list"`. There's no way to set a default through this form for `select`/`multiselect`/`file`/`color`/`boolean`. +- **`additional_options`**: shown for `text`/`file`/`list`/`color`, and for `select`/`multiselect` only when `tag_source` is `static`/`segment`/`element`/`dataset_data` (the sources whose options aren't loaded live). Never shown for `boolean`. + +None of this is server-enforced (see below) β€” it's purely what the authoring form itself lets a human enter. A hand-written or MI-exported file can still legally contain combinations this form would never produce (e.g. a `default_value` on a `select` tag from an older export). + +## `additional_options` per `tag_type`/`tag_source` β€” what MI's options-loading endpoint actually reads + +The create-form's field visibility above is about what a human can *type into*; this is about what MI's backend actually *reads back out* when it needs to list a `select`/`multiselect` variable's options. The two don't always agree: + +- **`select`/`multiselect`, `tag_source: "static"`**: `additional_options` **is** the option list β€” an array where each entry is either a plain string, or an object with `id`/`text`: `[{"id":"1","text":"One"},"Two"]`. A plain string entry uses itself as both the stored value and the label. +- **`select`/`multiselect`, `tag_source: "dataset_data"`**: `{dataset_id, key_column, text_column}` β€” MI pulls id/label pairs live from that dataset, using `key_column` for the id and `text_column` for the label. +- **`select`/`multiselect`, `tag_source: "element"`**: optional β€” `{"type": "metric" | "multi-metric chart" | "internal report" | "external report" | "other external content"}` narrows the dashboard-element list to one type. Omit it to list every element. +- **`select`/`multiselect`, `tag_source: "segment"`**: the create form shows this field for `segment`, but MI's options-loading endpoint never actually reads it for this source β€” the segment list always comes back unfiltered regardless of what's in `additional_options`. A form/backend inconsistency, not a feature to rely on. +- **`select`/`multiselect`, any other `tag_source`** (`dataset`, `announcement`, `group`, `category`, `custom_attribute`, `page`, `page_entity`): ignored entirely β€” MI queries its own live data for that source instead. +- **`list`**: see the dedicated section below (`ListItemFieldConfig[]`). +- **`text`/`file`/`color`**: the create form accepts input here, but no runtime consumer of it was found β€” treat as unused/reserved. +- **`boolean`**: not shown in the create form at all, and unused. + +## Caveats for any pp-dev feature built on this + +- `boolean` values are the literal strings `'true'`/`'false'` (confirmed against MI's own page-variable value editor β€” it renders a two-option radio group with exactly those values). MI performs no coercion, so a hand-edited or legacy file could still contain something else (`'Y'`/`'N'`, `'1'`/`'0'`) and MI would persist it as-is without complaint. +- `multiselect` values are stored as a single comma-joined string (e.g. `"a,b,c"`), not JSON β€” unlike `list`, which is JSON-encoded (see above). +- `file` values are just a filename string, with no schema-declared shape. MI's own upload control restricts the actual upload to images (`jpg`/`jpeg`/`png`/`gif`/`svg`) β€” a hand-set value of a different extension isn't rejected by anything server-side, just unlikely to render as an image. There's no public API (only whole-bundle asset zip download/upload β€” see above) for listing or uploading a page's individual file assets, so pp-dev's Variables Editor only offers a manual path input for this type, no browse/upload button. +- Because MI performs no type/enum validation server-side, any client-side (pp-dev) validation against `tag_type`/`additional_options` is a **best-effort convenience check**, not something MI itself guarantees or requires. diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 0000000..dfa63ca --- /dev/null +++ b/docs/features.md @@ -0,0 +1,99 @@ +# pp-dev feature tour + +A visual walkthrough of everything pp-dev injects into a served Portal Page during development β€” +the dev panel, the Request Inspector, and the Variables Editor. See the root [README](../README.md) +for configuration details; this doc is the "what does it actually look like" companion. + +All screenshots below were taken against a real Metric Insights instance, with the panel's +**Auto** theme in most shots (it follows your OS/browser preference) unless noted otherwise. + +## Dev Panel + +Injected into every served page. Shows the package version, backend URL, template mode, and App +ID, and hosts the **Sync** button plus quick actions for page variables and dev tools. + +![Dev panel, expanded](./screenshots/01-panel-expanded.png) + +Minimizing (the arrow on the panel's edge) slides it down to a small peeking arrow in the +anchored corner β€” click it again to bring the panel back: + +![Dev panel, minimized to a peeking arrow](./screenshots/02-panel-minimized.png) + +The gear icon opens the settings popover: corner position, auto-hide, an **Auto / Dark / Light** +theme switcher, page-variable actions ("Reload variables", "Open variables editor…"), "Open +request inspector…", and hide/reset: + +![Dev panel settings popover](./screenshots/03-panel-settings.png) + +See the README's [Dev Panel](../README.md#dev-panel) section for the position/auto-hide/hide +behavior in detail, and [Page variables](../README.md#page-variables) for what the "Reload +variables" and "Open variables editor…" buttons do. + +## Request Inspector + +A standalone page (`/@pp-dev/inspector`) that captures every proxied and locally-served HTTP +request made during development β€” no config needed, enabled by default. + +![Request Inspector β€” request list](./screenshots/04-inspector-list.png) + +Each request in the list carries a color-coded left border and letter badge for where it was +served from β€” proxied to MI (purple `P`), served from the local proxy cache (amber `C`), or +served locally (grey `L`). Selecting one opens the detail pane: full request/response headers +(with a **Copy** button per section) and the body, syntax-highlighted for JSON: + +![Request Inspector β€” request detail with JSON body](./screenshots/05-inspector-detail.png) + +See the README's [Request Inspector](../README.md#request-inspector) section for the REST API +(`/@api/requests`) and configuration options (`maxMemory`, `captureLimit`). + +## Variables Editor + +A standalone page (`/@pp-dev/variables-editor`) for editing a template's variable schema and a +page's live variable values, without hand-editing JSON or going through Metric Insights' own +editor. + +### Schema tab + +Edit `__template_variables.json` directly β€” add/remove variables, change type, default value, +description, and the editor flags MI's own form exposes per type. Expanding a row's **βš™** +reveals the advanced fields (`uid`, `tag_source`, `additional_options`, editor-flag checkboxes): + +![Variables Editor β€” Schema tab, advanced row expanded](./screenshots/07-editor-schema-advanced.png) + +The **?** next to `additional_options` opens a modal explaining exactly what's expected for that +row's current type/source β€” reverse-engineered from MI's own create-variable form and its +options-loading endpoint (see [`TEMPLATE_VARIABLES.md`](../TEMPLATE_VARIABLES.md) for the written +version): + +![Variables Editor β€” additional_options help modal](./screenshots/08-editor-schema-help-modal.png) + +### Values tab + +Edit the page's live variable values in place, with a widget per type: a searchable combobox for +`select`/`multiselect` with static options, a per-item form for `list` (flat or column-defined +objects), a checkbox for `boolean`, a color picker, and so on: + +![Variables Editor β€” Values tab, table view](./screenshots/09-editor-values.png) + +**View/edit raw JSON** switches to a JSON view of the same data β€” list-type values shown as +native arrays instead of double-escaped strings β€” with **Save to JSON file…** / **Import from +JSON file…** buttons and inline warnings for values that don't match a declared option, changed +since the tab was last loaded, or aren't in the schema: + +![Variables Editor β€” Values tab, JSON mode](./screenshots/10-editor-values-json.png) + +### Theme switcher + +The dev panel's settings popover (see [Dev Panel](#dev-panel) above) is where you'd normally set +this, but the Inspector and Variables Editor also have their own **Auto / Dark / Light** switcher +in the toolbar for when either is opened directly. "Auto" follows the OS/browser preference; the +other two set an explicit override, persisted in `localStorage` and shared across all three +surfaces β€” pick a theme in the panel and it carries over to the standalone pages, and vice versa: + + + + +
Light themeDark theme
+ +Both the active tab and the Values tab's JSON mode are reflected in the URL +(`?tab=values&mode=json`), so a specific view can be bookmarked or shared. diff --git a/docs/pp-dev-1.0-plan.md b/docs/pp-dev-1.0-plan.md deleted file mode 100644 index ac08fd6..0000000 --- a/docs/pp-dev-1.0-plan.md +++ /dev/null @@ -1,179 +0,0 @@ -# pp-dev v1.0.0 β€” Implementation Plan - -> **Status:** Approved, ready for implementation -> **Branch:** pp-3449 -> **Related:** [PP-3449](https://linear.app/metricinsights/issue/PP-3449/pp-dev-v10), [PP-3440](https://linear.app/metricinsights/issue/PP-3440/polish-pp-dev-ui-to-fit-with-core-mi-ui) -> **Config spec:** [pp-dev-config-1.0-canvas.md](./pp-dev-config-1.0-canvas.md) -> **Design decisions:** [pp-dev-config-1.0-design-notes.md](./pp-dev-config-1.0-design-notes.md) - ---- - -## 1. New configuration schema - -Full replacement of flat `VitePPDevOptions` β†’ grouped `PPDevConfig`: - -```ts -{ - mi: { url, token, mode, include, apiVersion } - app: { id, type, name } - proxy: { cache, cacheTtl, tls: { allowSelfSigned } } - build: { outDir, zip, versionFile, imageOptimisations } - sync: { backupsDir } -} -``` - -### Defaults - -| Field | Default | -| --- | --- | -| `mi.mode` | `'standalone'` | -| `mi.apiVersion` | `7` | -| `app.type` | `'template'` | -| `app.name` | resolved from `package.json#name` | - -### Validation rules - -| # | Condition | Action | -| --- | --- | --- | -| 1 | `mi.include` set + `mi.mode !== 'standalone'` | warn β†’ error | -| 2 | `mi.url` missing + (`mi.mode === 'embedding'` OR `app.type === 'template'`) | error | -| 3 | `mi.url` missing + `mi.mode === 'standalone'` + `app.type === 'page'` | warning | -| 4 | `app.type === 'template'` without `app.id` | error | -| 5 | `app.type === 'page'` + `mi.mode === 'standalone'` without `app.id` | error | -| 6 | `app.name` missing and no `package.json#name` | error | - -### Files to change - -- [ ] `src/plugin.ts` β€” new `PPDevConfig` type, new normalization (maps to internal vars) -- [ ] `src/config.ts` β€” new types, remove `PPWatchConfig` + watch-loader -- [ ] `src/constants.ts` β€” remove `PP_WATCH_CONFIG_NAMES` -- [ ] `src/cli.ts` β€” remove watch-config logic -- [ ] `src/index.ts` β€” update exports -- [ ] `src/lib/dev-panel.ts` β€” `portalPageId` β†’ `appId` -- [ ] `src/client/index.html` β€” label "Portal page ID:" β†’ "App ID:" -- [ ] `tests/unit/config/` β€” update tests - -### Removed in 1.0 - -| Removed | Replacement | -| --- | --- | -| `backendBaseURL` | `mi.url` | -| `personalAccessToken` | `mi.token` | -| `miHudLess` | `mi.mode` | -| `integrateMiTopBar` | `mi.include` | -| `v7Features` | `mi.apiVersion` | -| `appId` / `portalPageId` | `app.id` | -| `templateName` (required) | `app.name` (auto from `package.json#name`) | -| `templateLess` | `app.type` | -| `enableProxyCache` | `proxy.cache` | -| `proxyCacheTTL` | `proxy.cacheTtl` | -| `disableSSLValidation` | `proxy.tls.allowSelfSigned` | -| `distZip` | `build.zip` | -| `versionPlugin` | `build.versionFile` | -| `imageOptimizer` | `build.imageOptimisations` | -| `outDir` | `build.outDir` | -| `syncBackupsDir` | `sync.backupsDir` | -| `pp-watch.config.*` / `.pp-watch.config.*` | Not supported β€” use `pp-dev.config.*` | -| `PPWatchConfig` type | β€” | - ---- - -## 2. `defineConfig()` helper - -- [ ] Add to `src/helpers.ts` -- [ ] Export from `src/index.ts` - -```ts -import { defineConfig } from '@metricinsights/pp-dev'; - -export default defineConfig({ - mi: { url: 'https://mi.company.com', mode: 'standalone' }, - app: { id: 937 }, -}); -``` - ---- - -## 3. Codemod / migration script - -Automatic migration of `pp-dev.config.*` from 0.x β†’ 1.0 format. - -- [ ] Implement as a CLI command: `pp-dev migrate` (or standalone script) -- [ ] Transform flat options to grouped structure - -### Mapping - -| 0.x | 1.0 | -| --- | --- | -| `backendBaseURL` | `mi.url` | -| `personalAccessToken` | `mi.token` | -| `miHudLess: true` | `mi.mode: 'standalone'` | -| `miHudLess: false` | `mi.mode: 'embedding'` | -| `integrateMiTopBar: true` | `mi.mode: 'standalone'`, `mi.include: 'top-bar'` | -| `integrateMiTopBar: { addSharedComponentsScripts: true, addRootElement: false }` | `mi.include: 'shared-components'` | -| `v7Features: true` | `mi.apiVersion: 7` | -| `v7Features: false` | `mi.apiVersion: 6` | -| `appId` / `portalPageId` | `app.id` | -| `templateName` | `app.name` (or omit if matches `package.json#name`) | -| `templateLess: true` | `app.type: 'page'` | -| `templateLess: false` | `app.type: 'template'` | -| `enableProxyCache` | `proxy.cache` | -| `proxyCacheTTL` | `proxy.cacheTtl` | -| `disableSSLValidation: true` | `proxy.tls.allowSelfSigned: true` | -| `distZip` | `build.zip` | -| `versionPlugin` | `build.versionFile` | -| `imageOptimizer` | `build.imageOptimisations` | -| `outDir` | `build.outDir` | -| `syncBackupsDir` | `sync.backupsDir` | - ---- - -## 4. UI redesign (PP-3440) - -### Color palette (`src/client/assets/css/client.scss`) - -| CSS variable | Current | New | -| --- | --- | --- | -| `--pp-dev-info-color-primary` | `#007bff` | `#075B7E` | -| `--pp-dev-info-color-success` | `#28a745` | `#077E45` | -| `--pp-dev-info-color-danger` | `#dc3545` | `#AC2B2B` | -| `--pp-dev-info-color-warning` | `#ffc107` | `#FFB000` | -| `--pp-dev-info-color-secondary` | `#6c757d` | `rgba(34,34,34,0.64)` | - -Add: `font-family: 'Inter', sans-serif` to panel `*` reset. - -### Toast (popup) - -- [ ] Remove colored `background-color` from `.pp-dev-info__popup-title` -- [ ] Replace with `border: 2px solid ` on the popup wrapper -- [ ] `border-radius: 8px` β†’ `2px` -- [ ] `max-width: 300px` β†’ `280px` -- [ ] Title row: icon + text + close button in one row (no separate colored header block) - -### Modal (confirm dialog) - -- [ ] `min(500px, …)` β†’ `408px` fixed width -- [ ] `border-radius: 10px` β†’ `3px` -- [ ] `box-shadow` β†’ `0px 8px 32px 0px rgba(34,34,34,0.4)` -- [ ] Footer buttons: full-width, column stack (not row with `justify-end`) - -### Buttons (toggle + sync) - -- [ ] Size: `24px` β†’ `28px` -- [ ] Add `border: 1px solid #075B7E`, `border-radius: 3px`, `padding: 6px` - -### Panel container - -- [ ] `border-radius: 8px 0 0 0` β†’ `4px 0 0 0` -- [ ] `box-shadow: 0 -2px 4px rgba(0,0,0,0.1)` β†’ `-2px -2px 8px 0px rgba(34,34,34,0.08)` -- [ ] Add `border-bottom: 1px solid rgba(34,34,34,0.08)` - ---- - -## 5. Release prep - -- [ ] `CHANGELOG.md` β€” full breaking changes list + migration guide -- [ ] Bump version to `1.0.0` in `package.json` -- [ ] `npm run reinstall:all` -- [ ] `npm run test` -- [ ] `npm run audit:all` diff --git a/docs/pp-dev-config-1.0-canvas.md b/docs/pp-dev-config-1.0-canvas.md deleted file mode 100644 index f0469fe..0000000 --- a/docs/pp-dev-config-1.0-canvas.md +++ /dev/null @@ -1,335 +0,0 @@ -# pp-dev 1.0.0 β€” Configuration Schema (DRAFT) - -> **Status:** Design proposal for team review -> **Target:** `@metricinsights/pp-dev` v1.0.0 -> **Breaking changes:** yes β€” flat 0.x options replaced by grouped config -> **Design notes:** [pp-dev-config-1.0-design-notes.md](./pp-dev-config-1.0-design-notes.md) - ---- - -## Goals - -- Remove deprecated options (`portalPageId`, `templateLess`, `miHudLess`, `integrateMiTopBar`, …) -- Remove legacy pre-release config (`pp-watch.config.*`, `.pp-watch.config.*`) -- Use positive, domain-driven naming -- Group options by responsibility: `mi`, `app`, `proxy`, `build`, `sync` -- Make invalid combinations impossible via validation - ---- - -## Example config - -```ts -// pp-dev.config.ts -import type { PPDevConfig } from '@metricinsights/pp-dev'; - -export default { - mi: { - url: 'https://mi.company.com', - token: process.env.MI_ACCESS_TOKEN, - mode: 'standalone', - include: 'top-bar', - }, - app: { - id: 937, - type: 'template', - }, - proxy: { - cache: true, - cacheTtl: 600_000, - tls: { allowSelfSigned: false }, - }, - build: { - outDir: 'dist', - zip: true, - versionFile: true, - imageOptimisations: true, - }, - sync: { - backupsDir: 'backups', - }, -} satisfies PPDevConfig; -``` - -`app.name` is omitted β€” resolved from `package.json#name`. - ---- - -## Config sources - -| Source | Location | -| --- | --- | -| Config file | `pp-dev.config.{js,cjs,ts,json}` | -| package.json | `"pp-dev": { ... }` | - -> `pp-watch.config.*` and `.pp-watch.config.*` are **not supported** in 1.0 (legacy pre-release). - ---- - -# `mi` β€” MI instance & page embedding - -## `mi.mode` - -How the local page integrates into Metric Insights. - -| Value | Behavior | Replaces | -| --- | --- | --- | -| `standalone` | Full control over HTML. MI does not inject wrapper or scripts. Recommended for React/SPA. | `miHudLess: true` | -| `embedding` | Page content embedded inside MI backend HTML wrapper (inside ``). Not recommended for React. | `miHudLess: false` | - -**Default:** `standalone` - -## `mi.include` - -Optional MI shared resources bundled into the build. -**Only valid when `mi.mode` is `standalone`.** - -| Value | Behavior | Replaces | -| --- | --- | --- | -| *(omitted)* | Nothing from MI core added | `integrateMiTopBar: false` | -| `shared-components` | Injects `/auth/info.js`, `/js/main.js`, `/css/main.css`. No `#mi-react-root`. | `integrateMiTopBar: { addSharedComponentsScripts: true, addRootElement: false }` | -| `top-bar` | `shared-components` + `
` in `` | `integrateMiTopBar: true` | - -> `top-bar` always implies `shared-components`. Cannot exist without them. - -### Validation - -| Condition | Action | -| --- | --- | -| `include` is set + `mode !== 'standalone'` | **warn β†’ error** | - -## Other `mi` fields - -| Field | Type | Default | Description | -| --- | --- | --- | --- | -| `url` | `string` | `process.env.MI_BACKEND_URL` | MI instance base URL. Replaces `backendBaseURL`. | -| `token` | `string` | `process.env.MI_ACCESS_TOKEN` | Personal Access Token. Replaces `personalAccessToken`. | -| `apiVersion` | `6 \| 7` | `7` | API/routing version. Replaces `v7Features`. **TBD:** drop `6` in 1.0? | - ---- - -# `app` β€” application identity & type - -## `app.type` - -| Value | Dev path (v7) | Data loading | Replaces | -| --- | --- | --- | --- | -| `page` | `/p/` | Generic page template from MI | `templateLess: true` | -| `template` | `/pl/` | Template variables for `app.id` (always) | `templateLess: false` | - -**Default:** `template` - -> Template mode always loads variables β€” no separate `variables` flag. - -## Other `app` fields - -| Field | Type | Default | Description | -| --- | --- | --- | --- | -| `id` | `number` | β€” | Portal page / app ID on MI. Replaces `appId`. | -| `name` | `string` | `package.json#name` | Internal asset name for URLs and ZIP. Replaces `templateName`. | - -### When is `app.id` required? - -| `app.type` | `mi.mode` | `app.id` | -| --- | --- | --- | -| `template` | any | **Required** | -| `page` | `standalone` | **Required** | -| `page` | `embedding` | Optional | - ---- - -# `proxy` β€” dev-server proxy - -| Field | Type | Default | Replaces | -| --- | --- | --- | --- | -| `cache` | `boolean` | `true` | `enableProxyCache` | -| `cacheTtl` | `number` (ms) | `600_000` | `proxyCacheTTL` | -| `tls.allowSelfSigned` | `boolean` | `false` | `disableSSLValidation: true` | - ---- - -# `build` β€” build output & post-processing - -| Field | Type | Default | Replaces | -| --- | --- | --- | --- | -| `outDir` | `string` | `'dist'` | `outDir` | -| `zip` | `boolean \| object` | `true` | `distZip` | -| `versionFile` | `boolean \| object` | `true` | `versionPlugin` | -| `imageOptimisations` | `boolean \| object` | `true` | `imageOptimizer` | - -### `build.zip` object - -| Field | Default | -| --- | --- | -| `fileName` | `'[name].zip'` | -| `outDir` | `'dist-zip'` | -| `inDir` | value of `build.outDir` | - -### `build.versionFile` object - -| Field | Default | -| --- | --- | -| `enabled` | `true` | -| `fileNameTemplate` | `'VERSION-v{packageversion}-{currentDate}.json'` | - ---- - -# `sync` β€” asset sync / backups - -| Field | Type | Default | Replaces | -| --- | --- | --- | --- | -| `backupsDir` | `string` | `'backups'` | `syncBackupsDir` | - ---- - -# Matrix: `mi.mode` Γ— `mi.include` - -| `mi.mode` | `mi.include` | Result | -| --- | --- | --- | -| `embedding` | β€” | MI backend wrapper | -| `embedding` | any value | **warn β†’ error** (`include` requires `standalone`) | -| `standalone` | β€” | Full HTML control, no MI assets | -| `standalone` | `shared-components` | + MI scripts & styles | -| `standalone` | `top-bar` | + scripts/styles + `#mi-react-root` | - ---- - -# Matrix: `app.type` Γ— `mi.mode` - -| `app.type` | `mi.mode` | Dev path (api v7) | `app.id` | -| --- | --- | --- | --- | -| `page` | `standalone` | `/p/` | required | -| `page` | `embedding` | `/p/` | optional | -| `template` | `standalone` | `/pl/` | required | -| `template` | `embedding` | `/pl/` | required | - ---- - -# Migration map (0.x β†’ 1.0) - -| 0.x | 1.0 | -| --- | --- | -| `backendBaseURL` | `mi.url` | -| `personalAccessToken` | `mi.token` | -| `miHudLess: true` | `mi.mode: 'standalone'` | -| `miHudLess: false` | `mi.mode: 'embedding'` | -| `integrateMiTopBar: true` | `mi.mode: 'standalone'`, `mi.include: 'top-bar'` | -| `integrateMiTopBar: { addSharedComponentsScripts: true }` | `mi.include: 'shared-components'` | -| `v7Features: true/false` | `mi.apiVersion: 7/6` | -| `appId` / `portalPageId` | `app.id` | -| `templateName` | `app.name` (auto from package.json) | -| `templateLess: true/false` | `app.type: 'page'/'template'` | -| `enableProxyCache` | `proxy.cache` | -| `proxyCacheTTL` | `proxy.cacheTtl` | -| `disableSSLValidation: true` | `proxy.tls.allowSelfSigned: true` | -| `distZip` | `build.zip` | -| `versionPlugin` | `build.versionFile` | -| `imageOptimizer` | `build.imageOptimisations` | -| `outDir` | `build.outDir` | -| `syncBackupsDir` | `sync.backupsDir` | -| `pp-watch.config.*` | Not supported β€” use `pp-dev.config.*` | - ---- - -# TypeScript interfaces (reference) - -```ts -export interface PPDevConfig { - mi?: MiConfig; - app?: AppConfig; - proxy?: ProxyConfig; - build?: BuildConfig; - sync?: SyncConfig; -} - -export type MiMode = 'standalone' | 'embedding'; -export type MiInclude = 'shared-components' | 'top-bar'; -export type AppType = 'page' | 'template'; - -export interface MiConfig { - url?: string; - token?: string; - mode?: MiMode; // default: 'embedding' - include?: MiInclude; // only when mode === 'standalone' - apiVersion?: 6 | 7; // default: 7 β€” TBD: remove 6? -} - -export interface AppConfig { - id?: number; - name?: string; // default: package.json#name - type?: AppType; // default: 'template' -} - -export interface ProxyConfig { - cache?: boolean; - cacheTtl?: number; - tls?: { allowSelfSigned?: boolean }; -} - -export interface BuildConfig { - outDir?: string; - zip?: boolean | { fileName?: string; outDir?: string; inDir?: string }; - versionFile?: boolean | { enabled?: boolean; fileNameTemplate?: string }; - imageOptimisations?: boolean | Record; -} - -export interface SyncConfig { - backupsDir?: string; -} -``` - ---- - -# Common scenarios - -### React app (recommended) - -```ts -mi: { mode: 'standalone', include: 'top-bar' } -app: { id: 937, type: 'template' } -``` - -### Standalone page, no MI chrome - -```ts -mi: { mode: 'standalone' } -app: { id: 937, type: 'page' } -``` - -### Legacy / non-React - -```ts -mi: { mode: 'embedding' } -app: { id: 937, type: 'template' } -``` - -### Invalid - -```ts -mi: { mode: 'embedding', include: 'top-bar' } // Error -``` - ---- - -# Open questions for discussion - -- [x] `mi.apiVersion` β€” keep `6 | 7`, default `7` -- [x] Default `mi.mode` β€” `standalone` -- [x] Missing `mi.url` β€” **warning** when `mode=standalone` + `app.type=page`; **error** otherwise (`embedding` always needs backend; `template` always needs backend for variables) -- [x] `include` with `embedding` β€” warn β†’ error (log warning, then throw) -- [x] Top-level `outDir` β€” removed; only `build.outDir` -- [x] Provide codemod / migration script for 0.x configs β€” yes - ---- - -# Removed in 1.0 - -| Removed | Replacement | -| --- | --- | -| `pp-watch.config.*` / `.pp-watch.config.*` (pre-release legacy) | Not supported β€” use `pp-dev.config.*` only | -| `portalPageId` | `app.id` | -| `templateLess` | `app.type` | -| `miHudLess` | `mi.mode` | -| `integrateMiTopBar` | `mi.include` | -| `v7Features` | `mi.apiVersion` (or removed) | -| `templateName` (required) | `app.name` (auto-resolved) | diff --git a/docs/pp-dev-config-1.0-design-notes.md b/docs/pp-dev-config-1.0-design-notes.md deleted file mode 100644 index a207e10..0000000 --- a/docs/pp-dev-config-1.0-design-notes.md +++ /dev/null @@ -1,223 +0,0 @@ -# pp-dev 1.0.0 β€” Design notes (chat summary) - -> **Created:** 2025-06-05 -> **Purpose:** Context for resuming config redesign after team discussion -> **Full spec for Slack/review:** [pp-dev-config-1.0-canvas.md](./pp-dev-config-1.0-canvas.md) - ---- - -## Goal - -Prepare `@metricinsights/pp-dev` **v1.0.0** with: - -- Removal of deprecated 0.x options -- Renamed, grouped configuration -- Clearer domain model (MI embedding, app type, proxy, build) - -This is a **breaking change**. - ---- - -## Evolution of decisions (why things changed) - -### 1. Flat options β†’ grouped config - -0.x `VitePPDevOptions` is a flat list mixing unrelated concerns. 1.0 groups into: - -| Block | Responsibility | -| --- | --- | -| `mi` | MI instance URL, auth, how page embeds into MI | -| `app` | App identity and type on MI | -| `proxy` | Dev-server proxy to MI backend | -| `build` | Build output and post-processing | -| `sync` | Asset backup dir for CLI sync | - -### 2. No separate `template` block - -Initial idea had `template: { mode, variables }`. **Rejected** because: - -- Variables exist **only** in template mode β€” not a separate axis -- `templateLess` is really **app type**, not β€œtemplate settings” -- There is no valid state β€œtemplate without variables” - -**Decision:** `app.type: 'page' | 'template'` only. No `variables` flag. - -| `app.type` | Replaces | Behavior | -| --- | --- | --- | -| `'page'` | `templateLess: true` | Custom app, path `/p/`, `getPageTemplate()` | -| `'template'` | `templateLess: false` | Template page, path `/pl/` or `/pt/`, always loads variables via `getPageVariables(appId)` | - -### 3. No `shell` / no single `embedding` enum - -`shell` was rejected β€” options describe **how the page embeds in MI**, not a generic β€œshell”. - -First unified enum `embedding: 'embedded' | 'standalone' | 'shared-components' | 'top-bar'` was split further: - -**Decision:** two fields under `mi`: - -- `mi.mode` β€” primary integration mode -- `mi.include` β€” optional MI assets (only for `standalone`) - -### 4. `mi.mode` + `mi.include` - -#### `mi.mode` - -| Value | Replaces | Meaning | -| --- | --- | --- | -| `'standalone'` | `miHudLess: true` | Full HTML control; MI adds nothing. Recommended for React/SPA | -| `'embedding'` | `miHudLess: false` | Code injected inside MI backend HTML wrapper in ``. Not recommended for React | - -**Default (proposed):** `'embedding'` (matches current 0.x default) - -#### `mi.include` (only when `mode === 'standalone'`) - -| Value | Replaces | Meaning | -| --- | --- | --- | -| *(omitted)* | no `integrateMiTopBar` | Nothing from MI core | -| `'shared-components'` | `integrateMiTopBar: { addSharedComponentsScripts: true, addRootElement: false }` | `/auth/info.js`, `/js/main.js`, `/css/main.css` β€” no `#mi-react-root` | -| `'top-bar'` | `integrateMiTopBar: true` | shared-components **plus** `
` | - -**Rules:** - -- `top-bar` **always implies** `shared-components` β€” cannot exist alone -- If `mode === 'embedding'` and `include` is set β†’ **configuration error** (not warn+ignore, unless team decides otherwise during migration) - -### 5. `app` fields - -| Field | Replaces | Notes | -| --- | --- | --- | -| `app.id` | `appId` (drop `portalPageId`) | Required for `type: 'template'` always; for `type: 'page'` when `mi.mode: 'standalone'` | -| `app.name` | `templateName` | Auto from `package.json#name` when omitted β€” no longer required in config | -| `app.type` | `templateLess` | `'page' \| 'template'` | - -### 6. Other renames - -| 0.x | 1.0 | -| --- | --- | -| `backendBaseURL` | `mi.url` | -| `personalAccessToken` | `mi.token` | -| `v7Features` | `mi.apiVersion: 6 \| 7` (or remove `6` entirely) | -| `enableProxyCache` | `proxy.cache` | -| `proxyCacheTTL` | `proxy.cacheTtl` | -| `disableSSLValidation: true` | `proxy.tls.allowSelfSigned: true` | -| `distZip` | `build.zip` | -| `versionPlugin` | `build.versionFile` | -| `imageOptimizer` | `build.imageOptimisations` | -| `outDir` | `build.outDir` | -| `syncBackupsDir` | `sync.backupsDir` | - -### 7. `pp-watch` configs β€” not supported in 1.0 - -Legacy pre-public config files (`pp-watch.config.*`, `.pp-watch.config.*`) will be **removed**. - -Only `pp-dev.config.*` and `package.json#pp-dev` remain. - ---- - -## Proposed final shape (quick reference) - -```ts -export default { - mi: { - url: 'https://mi.company.com', - token: process.env.MI_ACCESS_TOKEN, - mode: 'standalone', - include: 'top-bar', - apiVersion: 7, - }, - app: { - id: 937, - type: 'template', - }, - proxy: { - cache: true, - cacheTtl: 600_000, - tls: { allowSelfSigned: false }, - }, - build: { - outDir: 'dist', - zip: true, - versionFile: true, - imageOptimisations: true, - }, - sync: { - backupsDir: 'backups', - }, -}; -``` - ---- - -## Validation rules (agreed) - -1. `mi.include` set + `mi.mode !== 'standalone'` β†’ **warn β†’ error** (log warning, then throw) -2. `mi.url` missing + (`mi.mode === 'embedding'` OR `app.type === 'template'`) β†’ **error** -3. `mi.url` missing + `mi.mode === 'standalone'` + `app.type === 'page'` β†’ **warning** (works without backend) -4. `app.type === 'template'` without `app.id` β†’ **error** -5. `app.type === 'page'` + `mi.mode === 'standalone'` without `app.id` β†’ **error** -6. `app.name` missing and no `package.json#name` β†’ **error** - ---- - -## Open questions (for team discussion) - -Track answers here after the review: - -| # | Question | Decision | -| --- | --- | --- | -| 1 | `mi.apiVersion` β€” keep `6 \| 7` or only `7`? | βœ… Keep both; default `7` | -| 2 | Default `mi.mode` β€” `embedding` or `standalone`? | βœ… `standalone` | -| 3 | Missing `mi.url` β€” warning or error? | _TBD_ | -| 4 | `include` + `embedding` β€” strict error only, or warn during migration? | βœ… warn β†’ error (log warning, then throw) | -| 5 | Top-level `outDir` for Vite compat, or only `build.outDir`? | βœ… Only `build.outDir` | -| 6 | Provide codemod / migration script for 0.x configs? | βœ… Yes | - ---- - -## Implementation notes (when resuming work) - -### Internal normalization (minimal runtime change) - -Map new config to existing internals during transition: - -```ts -const miHudLess = mi.mode === 'standalone'; -const integrateMiTopBar = - mi.include === 'top-bar' - ? true - : mi.include === 'shared-components' - ? { addSharedComponentsScripts: true, addRootElement: false } - : false; -const templateLess = app.type === 'page'; -``` - -### Files likely to touch - -- `src/plugin.ts` β€” `VitePPDevOptions`, validation, normalization -- `src/config.ts` β€” `PPDevConfig`, remove `PPWatchConfig` + watch loader -- `src/constants.ts` β€” remove `PP_WATCH_CONFIG_NAMES` -- `src/cli.ts` β€” config loading, remove watch names -- `src/index.ts` β€” exports -- `pp-dev.d.ts` β€” module declarations -- `README.md`, `CHANGELOG.md` -- Tests under `tests/unit/config/` - -### Artifacts from this chat - -| File | Contents | -| --- | --- | -| [pp-dev-config-1.0-canvas.md](./pp-dev-config-1.0-canvas.md) | Full schema, tables, migration map β€” ready for Slack Canvas | -| [pp-dev-config-1.0-design-notes.md](./pp-dev-config-1.0-design-notes.md) | This summary | - ---- - -## Removed in 1.0 (checklist) - -- [ ] `portalPageId` -- [ ] `templateLess` -- [ ] `miHudLess` -- [ ] `integrateMiTopBar` -- [ ] `v7Features` (or replace with `mi.apiVersion`) -- [ ] Required `templateName` in config -- [ ] `pp-watch.config.*` / `.pp-watch.config.*` support -- [ ] `PPWatchConfig` type and exports diff --git a/docs/screenshots/01-panel-expanded.png b/docs/screenshots/01-panel-expanded.png new file mode 100644 index 0000000..5130aee Binary files /dev/null and b/docs/screenshots/01-panel-expanded.png differ diff --git a/docs/screenshots/02-panel-minimized.png b/docs/screenshots/02-panel-minimized.png new file mode 100644 index 0000000..3126263 Binary files /dev/null and b/docs/screenshots/02-panel-minimized.png differ diff --git a/docs/screenshots/03-panel-settings.png b/docs/screenshots/03-panel-settings.png new file mode 100644 index 0000000..d007469 Binary files /dev/null and b/docs/screenshots/03-panel-settings.png differ diff --git a/docs/screenshots/04-inspector-list.png b/docs/screenshots/04-inspector-list.png new file mode 100644 index 0000000..bbe825c Binary files /dev/null and b/docs/screenshots/04-inspector-list.png differ diff --git a/docs/screenshots/05-inspector-detail.png b/docs/screenshots/05-inspector-detail.png new file mode 100644 index 0000000..d29ccb2 Binary files /dev/null and b/docs/screenshots/05-inspector-detail.png differ diff --git a/docs/screenshots/06-editor-schema.png b/docs/screenshots/06-editor-schema.png new file mode 100644 index 0000000..43329c5 Binary files /dev/null and b/docs/screenshots/06-editor-schema.png differ diff --git a/docs/screenshots/07-editor-schema-advanced.png b/docs/screenshots/07-editor-schema-advanced.png new file mode 100644 index 0000000..82f28b8 Binary files /dev/null and b/docs/screenshots/07-editor-schema-advanced.png differ diff --git a/docs/screenshots/08-editor-schema-help-modal.png b/docs/screenshots/08-editor-schema-help-modal.png new file mode 100644 index 0000000..b983a00 Binary files /dev/null and b/docs/screenshots/08-editor-schema-help-modal.png differ diff --git a/docs/screenshots/09-editor-values.png b/docs/screenshots/09-editor-values.png new file mode 100644 index 0000000..390badf Binary files /dev/null and b/docs/screenshots/09-editor-values.png differ diff --git a/docs/screenshots/10-editor-values-json.png b/docs/screenshots/10-editor-values-json.png new file mode 100644 index 0000000..d2e5d9e Binary files /dev/null and b/docs/screenshots/10-editor-values-json.png differ diff --git a/docs/screenshots/11-editor-theme-light.png b/docs/screenshots/11-editor-theme-light.png new file mode 100644 index 0000000..5ab2751 Binary files /dev/null and b/docs/screenshots/11-editor-theme-light.png differ diff --git a/docs/screenshots/12-editor-theme-dark.png b/docs/screenshots/12-editor-theme-dark.png new file mode 100644 index 0000000..7f666fb Binary files /dev/null and b/docs/screenshots/12-editor-theme-dark.png differ diff --git a/package-lock.json b/package-lock.json index a52a543..6bbb527 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,7 +71,7 @@ "tslib": "^2.8.1", "tsx": "^4.23.0", "typescript-eslint": "^8.63.0", - "undici": "^7.28.0", + "undici": "^7.29.0", "vitest": "^4.1.10", "yargs": "^18.0.0" }, @@ -4889,9 +4889,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -8043,9 +8043,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" @@ -8396,9 +8396,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz", + "integrity": "sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==", "dev": true, "funding": [ { @@ -8415,7 +8415,7 @@ "argparse": "^2.0.1" }, "bin": { - "js-yaml": "bin/js-yaml.js" + "js-yaml": "bin/js-yaml.mjs" } }, "node_modules/jsdom": { @@ -10443,7 +10443,7 @@ } }, "node_modules/npm/node_modules/brace-expansion": { - "version": "5.0.8", + "version": "5.0.9", "dev": true, "inBundle": true, "license": "MIT", @@ -10688,7 +10688,7 @@ } }, "node_modules/npm/node_modules/ip-address": { - "version": "10.2.0", + "version": "10.4.0", "dev": true, "inBundle": true, "license": "MIT", @@ -11565,7 +11565,7 @@ } }, "node_modules/npm/node_modules/tar": { - "version": "7.5.19", + "version": "7.5.22", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", @@ -11661,12 +11661,12 @@ } }, "node_modules/npm/node_modules/undici": { - "version": "6.27.0", + "version": "7.29.0", "dev": true, "inBundle": true, "license": "MIT", "engines": { - "node": ">=18.17" + "node": ">=20.18.1" } }, "node_modules/npm/node_modules/util-deprecate": { @@ -14943,9 +14943,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/package.json b/package.json index 0d24c95..0a08044 100644 --- a/package.json +++ b/package.json @@ -75,10 +75,11 @@ }, "overrides": { "@xmldom/xmldom": ">=0.8.12", - "brace-expansion": "5.0.8", + "brace-expansion": ">=5.0.9", "chokidar": "^5.0.0", "file-type": "^22.0.1", - "ip-address": "^10.2.0", + "ip-address": ">=10.4.0", + "js-yaml": ">=4.3.1", "minimatch": "10.2.5", "next@*": { "postcss": "8.5.23" @@ -88,7 +89,7 @@ "serialize-javascript": ">=7.0.5", "sharp": "^0.35.3", "tar": "^7.5.22", - "undici": "^7.28.0", + "undici": "^7.29.0", "vite@*": { "postcss": "8.5.23" } @@ -161,7 +162,7 @@ "tslib": "^2.8.1", "tsx": "^4.23.0", "typescript-eslint": "^8.63.0", - "undici": "^7.28.0", + "undici": "^7.29.0", "vitest": "^4.1.10", "yargs": "^18.0.0" }, @@ -174,7 +175,8 @@ "LICENSE.md", "pp-dev.d.ts", "assets", - "CHANGELOG.md" + "CHANGELOG.md", + "TEMPLATE_VARIABLES.md" ], "directories": { "test": "test" diff --git a/scripts/patch-npm-bundled-vulnerabilities.mjs b/scripts/patch-npm-bundled-vulnerabilities.mjs index 8ce4c7e..6fa1ac2 100644 --- a/scripts/patch-npm-bundled-vulnerabilities.mjs +++ b/scripts/patch-npm-bundled-vulnerabilities.mjs @@ -3,27 +3,68 @@ * replace those copies, so `npm audit` still flags known-fixed versions that exist * hoisted at the project root. Sync patched trees into npm's bundle after install. */ -import { cpSync, existsSync, rmSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { cpSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const npmRoot = join(root, 'node_modules', 'npm'); +const lockfilePath = join(root, 'package-lock.json'); function replaceDir(src, dest) { if (!existsSync(src) || !existsSync(dirname(dest))) { - return; + return null; } rmSync(dest, { recursive: true, force: true }); cpSync(src, dest, { recursive: true }); + + return dest; } +const patchedDirs = []; + if (existsSync(npmRoot)) { - replaceDir(join(root, 'node_modules', 'brace-expansion'), join(npmRoot, 'node_modules', 'brace-expansion')); - replaceDir(join(root, 'node_modules', 'ip-address'), join(npmRoot, 'node_modules', 'ip-address')); - replaceDir(join(root, 'node_modules', 'tar'), join(npmRoot, 'node_modules', 'tar')); - replaceDir( - join(root, 'node_modules', 'picomatch'), - join(npmRoot, 'node_modules', 'tinyglobby', 'node_modules', 'picomatch'), + patchedDirs.push(replaceDir(join(root, 'node_modules', 'brace-expansion'), join(npmRoot, 'node_modules', 'brace-expansion'))); + patchedDirs.push(replaceDir(join(root, 'node_modules', 'ip-address'), join(npmRoot, 'node_modules', 'ip-address'))); + patchedDirs.push(replaceDir(join(root, 'node_modules', 'tar'), join(npmRoot, 'node_modules', 'tar'))); + patchedDirs.push(replaceDir(join(root, 'node_modules', 'undici'), join(npmRoot, 'node_modules', 'undici'))); + patchedDirs.push( + replaceDir(join(root, 'node_modules', 'picomatch'), join(npmRoot, 'node_modules', 'tinyglobby', 'node_modules', 'picomatch')), ); } + +// `npm audit`/`npm ls` read package-lock.json's recorded "version" fields, not the files on +// disk β€” without this, the directory copies above are invisible to that tooling and the +// advisories keep reappearing even though the vulnerable code has already been replaced. +if (existsSync(lockfilePath)) { + const original = readFileSync(lockfilePath, 'utf-8'); + const eol = original.includes('\r\n') ? '\r\n' : '\n'; + const lockfile = JSON.parse(original); + let changed = false; + + for (const dest of patchedDirs) { + if (!dest) { + continue; + } + + const key = relative(root, dest).split('\\').join('/'); + const pkgJsonPath = join(dest, 'package.json'); + + if (!existsSync(pkgJsonPath) || !lockfile.packages?.[key]) { + continue; + } + + const realVersion = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')).version; + + if (lockfile.packages[key].version !== realVersion) { + lockfile.packages[key].version = realVersion; + changed = true; + } + } + + if (changed) { + const serialized = (JSON.stringify(lockfile, null, 2) + '\n').split('\n').join(eol); + + writeFileSync(lockfilePath, serialized); + } +} diff --git a/src/api/index.ts b/src/api/index.ts index c2250ef..8d5f8cb 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -2,4 +2,5 @@ export * from './assets.js'; export * from './assets-v7.js'; export * from './page.js'; export * from './page-template.js'; +export * from './page-variable.js'; export * from './unavailable-json-api.js'; diff --git a/src/api/page-variable.ts b/src/api/page-variable.ts new file mode 100644 index 0000000..97635ea --- /dev/null +++ b/src/api/page-variable.ts @@ -0,0 +1,78 @@ +import { BaseAPI } from './base.js'; +import { Headers } from './constants.js'; + +export interface PageVariableTagEntry { + name: string; + value: string; +} + +function isPageVariableTagEntry(value: unknown): value is PageVariableTagEntry { + return ( + typeof value === 'object' && + value !== null && + typeof (value as PageVariableTagEntry).name === 'string' && + typeof (value as PageVariableTagEntry).value === 'string' + ); +} + +/** `tags` may come back as a JSON string or an already-parsed array depending on backend version. */ +export function normalizePageVariableTags(tags: PageVariableTagEntry[] | string): PageVariableTagEntry[] { + if (Array.isArray(tags)) { + return tags.filter(isPageVariableTagEntry); + } + + try { + const parsed = JSON.parse(tags) as unknown; + + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.filter(isPageVariableTagEntry); + } catch { + return []; + } +} + +export class PageVariableAPI extends BaseAPI { + /** + * GET `/api/page_variable?page_id={pageId}`. + * MI resolves the page via `PortalPage::loadById()` when `page_id` is a positive integer + * (falls back to `internal_name` otherwise) β€” using `page_id` skips that fallback and the + * extra page lookup a caller would otherwise need to resolve `internal_name` first. + */ + async getById(pageId: number, headers?: Headers): Promise { + const data = ( + await this.axios.get<{ tags: PageVariableTagEntry[] | string }>('/api/page_variable', { + withCredentials: true, + headers: Object.assign({}, headers, { + accept: 'application/json', + 'content-type': 'application/json', + }), + params: { + page_id: pageId, + }, + }) + ).data; + + return normalizePageVariableTags(data?.tags ?? []); + } + + /** PUT `/api/page_variable?page_id={pageId}` */ + async updateById(pageId: number, tags: PageVariableTagEntry[], headers?: Headers): Promise { + await this.axios.put( + '/api/page_variable', + { tags: JSON.stringify(tags) }, + { + withCredentials: true, + headers: Object.assign({}, headers, { + accept: 'application/json', + 'content-type': 'application/json', + }), + params: { + page_id: pageId, + }, + }, + ); + } +} diff --git a/src/cli.ts b/src/cli.ts index d78fdf4..c1d1eb2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -39,6 +39,7 @@ import { normalizePPDevConfig, validatePPDevConfig } from './plugin.js'; import { RequestStore } from './lib/request-store.js'; import { createRequestCaptureMiddleware } from './lib/request-capture.middleware.js'; import { registerInspectorRoutes, INSPECTOR_PATH } from './lib/request-inspector.js'; +import { registerVariablesEditorRoutes, VARIABLES_EDITOR_PATH } from './lib/variables-editor.js'; import { resolveBuildCliOverrides, applyDistZipOverride, @@ -1091,8 +1092,12 @@ cli // 5. Internal Server middleware (API endpoints + inspector UI) - essential for all routes const internalServerMiddleware = internalServer; const internalServerWrapper = (req: any, res: any, next: () => void) => { - // Check if this is an internal pp-dev request (API or inspector UI) - if (req.url?.startsWith('/@api/') || req.url?.startsWith(INSPECTOR_PATH)) { + // Check if this is an internal pp-dev request (API or inspector/editor UI) + if ( + req.url?.startsWith('/@api/') || + req.url?.startsWith(INSPECTOR_PATH) || + req.url?.startsWith(VARIABLES_EDITOR_PATH) + ) { const mockNext = () => {}; internalServerMiddleware(req, res, mockNext); @@ -1199,6 +1204,7 @@ cli } as unknown as ViteDevServer; new ClientService(clientServiceServer, { distService, miAPI: mi }); + registerVariablesEditorRoutes(internalServer, { distService, miAPI: mi }); // Route HTTP upgrades: pp-dev WS to our server, everything else (Next.js HMR) // to Next's own upgrade handler when available. diff --git a/src/client/assets/css/client.scss b/src/client/assets/css/client.scss index c3ec51a..e6889a2 100644 --- a/src/client/assets/css/client.scss +++ b/src/client/assets/css/client.scss @@ -41,18 +41,49 @@ $pp-dev-corners: ( ), ); +// Light/dark color maps, shared by the default (light) rules, the prefers-color-scheme:dark +// media query (Auto), and the explicit data-pp-dev-theme override (Dark/Light picked in the +// panel's settings popover β€” see src/client/theme.ts). One place to edit either palette. +$pp-dev-light-colors: ( + primary: #075b7e, + secondary: rgba(34, 34, 34, 0.64), + success: #077e45, + info: #17a2b8, + warning: #ffb000, + danger: #ac2b2b, + light: #f8f9fa, + dark: #343a40, + "white": #ffffff, + "black": #000000, +); + +$pp-dev-dark-colors: ( + primary: #2a8db5, + secondary: rgba(220, 220, 220, 0.64), + success: #0fad5e, + info: #2dccff, + warning: #ffb302, + danger: #e05252, + light: #343a40, + dark: #f8f9fa, + "white": #000000, + "black": #ffffff, +); + +@mixin pp-dev-theme-colors($colors) { + @each $name, $value in $colors { + --pp-dev-info-color-#{$name}: #{$value}; + } +} + .pp-dev-info-namespace { - // Define theme colors - --pp-dev-info-color-primary: #075b7e; - --pp-dev-info-color-secondary: rgba(34, 34, 34, 0.64); - --pp-dev-info-color-success: #077e45; - --pp-dev-info-color-info: #17a2b8; - --pp-dev-info-color-warning: #ffb000; - --pp-dev-info-color-danger: #ac2b2b; - --pp-dev-info-color-light: #f8f9fa; - --pp-dev-info-color-dark: #343a40; - --pp-dev-info-color-white: #ffffff; - --pp-dev-info-color-black: #000000; + // Define theme colors β€” light by default; overridden for Auto (media query, below) or an + // explicit Dark/Light pick (html[data-pp-dev-theme], below β€” always wins on specificity). + @include pp-dev-theme-colors($pp-dev-light-colors); + + html[data-pp-dev-theme='light'] & { + @include pp-dev-theme-colors($pp-dev-light-colors); + } .pp-dev-info__popup { position: fixed; @@ -332,6 +363,10 @@ $pp-dev-corners: ( width: 1px; background-color: rgba(34, 34, 34, 0.24); + html[data-pp-dev-theme='dark'] & { + background-color: rgba(255, 255, 255, 0.2); + } + @media screen and (prefers-color-scheme: dark) { background-color: rgba(255, 255, 255, 0.2); } @@ -587,6 +622,37 @@ $pp-dev-corners: ( } } + .pp-dev-info__theme-grid { + display: flex; + gap: 4px; + } + + .pp-dev-info__theme-btn { + padding: 3px 8px; + border: 1px solid rgba(34, 34, 34, 0.25); + border-radius: 3px; + cursor: pointer; + background: transparent; + font-size: 11px; + line-height: 16px; + color: var(--pp-dev-info-color-secondary); + transition: + border-color 0.2s ease, + color 0.2s ease, + background-color 0.2s ease; + + &:hover { + border-color: var(--pp-dev-info-color-primary); + color: var(--pp-dev-info-color-primary); + } + + &.active { + border-color: var(--pp-dev-info-color-primary); + background-color: var(--pp-dev-info-color-primary); + color: var(--pp-dev-info-color-white); + } + } + .pp-dev-info__settings-hide-btn { width: 100%; padding: 6px 10px; @@ -602,6 +668,40 @@ $pp-dev-corners: ( } } + .pp-dev-info__settings-group { + display: flex; + flex-direction: column; + gap: 6px; + padding-top: 10px; + border-top: 1px solid rgba(34, 34, 34, 0.12); + } + + .pp-dev-info__vars-buttons { + display: flex; + flex-direction: column; + gap: 6px; + } + + .pp-dev-info__vars-btn { + width: 100%; + padding: 6px 10px; + border-radius: 4px; + cursor: pointer; + font-weight: 600; + text-align: center; + color: var(--pp-dev-info-color-primary); + background-color: rgba(7, 91, 126, 0.1); + border: 1px solid rgba(7, 91, 126, 0.4); + transition: + background-color 0.2s ease, + border-color 0.2s ease; + + &:hover { + background-color: rgba(7, 91, 126, 0.18); + border-color: rgba(7, 91, 126, 0.6); + } + } + .pp-dev-info__settings-hint { font-size: 10px; line-height: 14px; @@ -825,6 +925,10 @@ $pp-dev-corners: ( height: 16px; background-color: rgba(34, 34, 34, 0.24); + html[data-pp-dev-theme='dark'] & { + background-color: rgba(255, 255, 255, 0.2); + } + @media screen and (prefers-color-scheme: dark) { background-color: rgba(255, 255, 255, 0.2); } @@ -881,20 +985,52 @@ $pp-dev-corners: ( } } - // Dark mode support with css variables + // Explicit Dark pick (html[data-pp-dev-theme='dark'], set via the settings popover's Theme + // row β€” see src/client/theme.ts) β€” always wins over both the light default above and the + // Auto media query below, on selector specificity alone. + html[data-pp-dev-theme='dark'] & { + @include pp-dev-theme-colors($pp-dev-dark-colors); + } + + html[data-pp-dev-theme='dark'] & { + // rgba() literals below don't follow the --pp-dev-info-color-primary reassignment + // above, so the button fill/border are re-tinted here to match the dark-mode primary. + .pp-dev-info__vars-btn { + background-color: rgba(42, 141, 181, 0.14); + border-color: rgba(42, 141, 181, 0.45); + + &:hover { + background-color: rgba(42, 141, 181, 0.24); + border-color: rgba(42, 141, 181, 0.65); + } + } + + .pp-dev-info__settings-group { + border-top-color: rgba(255, 255, 255, 0.15); + } + } + + // Auto (OS/browser preference) β€” same values, only takes effect when no explicit + // html[data-pp-dev-theme] override is set (those always outrank a plain :root/& selector). @media screen and (prefers-color-scheme: dark) { & { - // Dark theme colors - --pp-dev-info-color-primary: #2a8db5; - --pp-dev-info-color-secondary: rgba(220, 220, 220, 0.64); - --pp-dev-info-color-success: #0fad5e; - --pp-dev-info-color-info: #2dccff; - --pp-dev-info-color-warning: #ffb302; - --pp-dev-info-color-danger: #e05252; - --pp-dev-info-color-light: #343a40; - --pp-dev-info-color-dark: #f8f9fa; - --pp-dev-info-color-white: #000000; - --pp-dev-info-color-black: #ffffff; + @include pp-dev-theme-colors($pp-dev-dark-colors); + } + + // rgba() literals below don't follow the --pp-dev-info-color-primary reassignment + // above, so the button fill/border are re-tinted here to match the dark-mode primary. + .pp-dev-info__vars-btn { + background-color: rgba(42, 141, 181, 0.14); + border-color: rgba(42, 141, 181, 0.45); + + &:hover { + background-color: rgba(42, 141, 181, 0.24); + border-color: rgba(42, 141, 181, 0.65); + } + } + + .pp-dev-info__settings-group { + border-top-color: rgba(255, 255, 255, 0.15); } } } diff --git a/src/client/index.html b/src/client/index.html index e5e77e3..00b81ab 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -4,6 +4,7 @@ data-position="{%= devPanelPosition %}" data-auto-hide="{%= devPanelAutoHide %}" data-hidden="{%= devPanelHidden %}" + data-template-less="{%= templateLess %}" >
void) | null = null; function updatePopupPositions() { const popups = document.querySelectorAll('.pp-dev-info-namespace:not(.pp-dev-info)'); @@ -163,7 +171,7 @@ function animatePopup($popup: HTMLDivElement, type: 'enter' | 'exit') { }); } -function infoPopup(opts: InfoPopupOptions) { +function infoPopup(opts: InfoPopupOptions): { close: () => void } { const $popup = createPopupElement(opts); const $closeButton = $popup.querySelector('.pp-dev-info__popup-title-close'); @@ -228,6 +236,8 @@ function infoPopup(opts: InfoPopupOptions) { requestAnimationFrame(scheduleDismiss); } + + return { close: removePopup }; } function closeAllConfirmModals() { @@ -340,6 +350,9 @@ if ($infoPanel) { initPanelSettings($infoPanel, panelController, { onOpenChange: (open) => autoHide.keepPeeked(open), + onOpenVariablesEditorClick: () => window.open(window.location.origin + '/@pp-dev/variables-editor', '_blank'), + onOpenInspectorClick: () => window.open(window.location.origin + '/@pp-dev/inspector', '_blank'), + onReloadVariablesClick: () => startReloadVariablesFlow?.(), }); panelController.onChange((state) => { @@ -492,4 +505,39 @@ if (hot) { hot.send('template:sync', {}); }); } + + hot.on('page-variables:reload:response', (payload: PageVariablesReloadResponsePayload) => { + if ('error' in payload) { + infoPopup({ + title: 'Reload variables error', + content: payload.error, + type: 'danger', + }); + + return; + } + + if (payload.skipped) { + infoPopup({ + title: 'Nothing to reload', + content: 'This page has no template variables to refresh.', + type: 'warning', + }); + + return; + } + + infoPopup({ + title: 'Variables reloaded', + content: `Refetched ${payload.count} variable(s) from MI. Reloading the page…`, + type: 'success', + duration: 1200, + }); + + setTimeout(() => window.location.reload(), 600); + }); + + startReloadVariablesFlow = () => { + hot.send('page-variables:reload', {}); + }; } diff --git a/src/client/panel-settings.ts b/src/client/panel-settings.ts index e551eb3..6215e29 100644 --- a/src/client/panel-settings.ts +++ b/src/client/panel-settings.ts @@ -1,5 +1,6 @@ import type { Corner, PanelStateController } from './panel-state.js'; import { CORNERS } from './panel-state.js'; +import { getStoredTheme, setTheme, type ThemeChoice } from './theme.js'; const CORNER_TITLES: Record = { 'top-left': 'Top left', @@ -8,7 +9,10 @@ const CORNER_TITLES: Record = { 'bottom-right': 'Bottom right', }; -function buildPopover(controller: PanelStateController): HTMLDivElement { +const THEME_CHOICES: ThemeChoice[] = ['auto', 'dark', 'light']; +const THEME_LABELS: Record = { auto: 'Auto', dark: 'Dark', light: 'Light' }; + +function buildPopover(controller: PanelStateController, showPageVariablesGroup: boolean): HTMLDivElement { const state = controller.getState(); const $popover = document.createElement('div'); @@ -20,6 +24,24 @@ function buildPopover(controller: PanelStateController): HTMLDivElement { return ``; }).join(''); + const currentTheme = getStoredTheme(); + const themeButtons = THEME_CHOICES.map((choice) => { + const active = choice === currentTheme ? ' active' : ''; + + return ``; + }).join(''); + + const pageVariablesRow = showPageVariablesGroup + ? ` +
+ Page variables +
+ + +
+
` + : ''; + $popover.innerHTML = `
Panel settings
@@ -35,6 +57,17 @@ function buildPopover(controller: PanelStateController): HTMLDivElement { ${state.autoHide ? 'checked' : ''} />
+
+ Theme +
${themeButtons}
+
+ ${pageVariablesRow} +
+ Dev tools +
+ +
+
Restore with ?pp-dev-panel=show in the URL
Reset to config defaults @@ -62,6 +95,9 @@ function syncPopover($popover: HTMLDivElement, controller: PanelStateController) export interface PanelSettingsHooks { onOpenChange?: (open: boolean) => void; + onOpenVariablesEditorClick?: () => void; + onOpenInspectorClick?: () => void; + onReloadVariablesClick?: () => void; } /** Settings popover: corner picker, auto-hide toggle, hide button, reset. */ @@ -105,7 +141,9 @@ export function initPanelSettings( }; const open = () => { - $popover = buildPopover(controller); + const showPageVariablesGroup = $panel.dataset.templateLess !== 'true'; + + $popover = buildPopover(controller, showPageVariablesGroup); $popover.querySelectorAll('.pp-dev-info__corner-btn').forEach(($cornerBtn) => { $cornerBtn.addEventListener('click', (ev) => { @@ -118,12 +156,44 @@ export function initPanelSettings( controller.setAutoHide((ev.target as HTMLInputElement).checked); }); + $popover.querySelectorAll('.pp-dev-info__theme-btn').forEach(($themeBtn) => { + $themeBtn.addEventListener('click', (ev) => { + ev.preventDefault(); + setTheme($themeBtn.dataset.themeChoice as ThemeChoice); + + $popover!.querySelectorAll('.pp-dev-info__theme-btn').forEach(($btn) => { + const active = $btn === $themeBtn; + + $btn.classList.toggle('active', active); + $btn.setAttribute('aria-pressed', String(active)); + }); + }); + }); + $popover.querySelector('.pp-dev-info__settings-hide-btn')?.addEventListener('click', (ev) => { ev.preventDefault(); close(); controller.setHidden(true); }); + $popover.querySelector('.pp-dev-info__open-editor-btn')?.addEventListener('click', (ev) => { + ev.preventDefault(); + close(); + hooks?.onOpenVariablesEditorClick?.(); + }); + + $popover.querySelector('.pp-dev-info__reload-vars-btn')?.addEventListener('click', (ev) => { + ev.preventDefault(); + close(); + hooks?.onReloadVariablesClick?.(); + }); + + $popover.querySelector('.pp-dev-info__open-inspector-btn')?.addEventListener('click', (ev) => { + ev.preventDefault(); + close(); + hooks?.onOpenInspectorClick?.(); + }); + const $reset = $popover.querySelector('.pp-dev-info__settings-reset'); $reset?.addEventListener('click', (ev) => { diff --git a/src/client/storage.ts b/src/client/storage.ts index 400cdf2..32d07b3 100644 --- a/src/client/storage.ts +++ b/src/client/storage.ts @@ -6,6 +6,8 @@ export const STORAGE_KEYS = { position: 'pp-dev-info-position', autoHide: 'pp-dev-info-auto-hide', hidden: 'pp-dev-info-hidden', + /** Shared with the standalone Inspector/Variables Editor pages β€” same key, same values. */ + theme: 'pp-dev-info-theme', } as const; export function checkLocalStorage() { diff --git a/src/client/theme.ts b/src/client/theme.ts new file mode 100644 index 0000000..5bd444a --- /dev/null +++ b/src/client/theme.ts @@ -0,0 +1,33 @@ +// Theme override (Auto/Dark/Light), shared across the dev panel and the standalone +// Inspector/Variables Editor pages via the same localStorage key (see STORAGE_KEYS.theme) and +// the same `data-pp-dev-theme` attribute convention (prefixed to avoid colliding with a host +// Portal Page's own theme attribute, since the panel is injected into pages pp-dev doesn't own). +import { STORAGE_KEYS, getStorageItem, setStorageItem } from './storage.js'; + +export type ThemeChoice = 'auto' | 'dark' | 'light'; + +const THEME_ATTR = 'data-pp-dev-theme'; + +export function getStoredTheme(): ThemeChoice { + const value = getStorageItem(STORAGE_KEYS.theme); + + return value === 'dark' || value === 'light' ? value : 'auto'; +} + +export function applyTheme(theme: ThemeChoice): void { + if (theme === 'dark' || theme === 'light') { + document.documentElement.setAttribute(THEME_ATTR, theme); + } else { + document.documentElement.removeAttribute(THEME_ATTR); + } +} + +export function setTheme(theme: ThemeChoice): void { + setStorageItem(STORAGE_KEYS.theme, theme); + applyTheme(theme); +} + +/** Call once on page load to apply whatever was last chosen (defaults to 'auto', a no-op). */ +export function initTheme(): void { + applyTheme(getStoredTheme()); +} diff --git a/src/lib/client.service.ts b/src/lib/client.service.ts index a4e4321..5ca8585 100644 --- a/src/lib/client.service.ts +++ b/src/lib/client.service.ts @@ -52,6 +52,7 @@ export class ClientService { this.eventMap.set('info-data:request', this.onInfoDataRequest.bind(this)); this.eventMap.set('template:sync', this.onTemplateSync.bind(this)); this.eventMap.set('template:sync:action-response', this.onTemplateSyncActionResponse.bind(this)); + this.eventMap.set('page-variables:reload', this.onPageVariablesReload.bind(this)); this.logger = createLogger(); @@ -480,4 +481,44 @@ export class ClientService { return; } } + + /** + * Force-refetches live page variables right now (bypassing `load-pp-data.middleware.ts`'s + * page-data cache), so `[VarName]` substitution reflects a just-saved value without waiting + * for the cache to expire or restarting the dev server. The client reloads the browser page + * on success so the refreshed substitution is actually visible. + */ + async onPageVariablesReload(_data: unknown, client: WebSocketClient) { + const { miAPI } = this.opts; + + if (!miAPI) { + client.send('page-variables:reload:response', { + error: 'MiAPI is not defined', + }); + + this.logger.error(colors.red('MiAPI is not defined')); + + return; + } + + try { + const reloaded = await miAPI.reloadPageVariables(); + + client.send('page-variables:reload:response', { + ok: true, + count: reloaded ? reloaded.length : 0, + skipped: reloaded === null, + }); + + this.logger.info(colors.green('Page variables reloaded')); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Reload variables failed'; + + this.logger.error(colors.red(`Reload variables failed: ${message}`)); + + client.send('page-variables:reload:response', { + error: message, + }); + } + } } diff --git a/src/lib/dist.service.ts b/src/lib/dist.service.ts index 03e269e..5a3bab0 100644 --- a/src/lib/dist.service.ts +++ b/src/lib/dist.service.ts @@ -573,6 +573,13 @@ export class DistService { return crypto.createHash('sha256').update(fileData).digest('hex'); } + /** Raw content of `public/__template_variables.json`, or `null` if missing/unreadable. */ + async readPublicTemplateVariablesFile(): Promise { + const templateVariablesPath = path.resolve(process.cwd(), 'public', TEMPLATE_VARIABLES_FILE_NAME); + + return await fs.readFile(templateVariablesPath).catch(() => null); + } + async saveTemplateVariablesFile(content: Buffer): Promise { const templateVariablesPath = path.resolve(process.cwd(), 'public', TEMPLATE_VARIABLES_FILE_NAME); diff --git a/src/lib/page-variables-diff.ts b/src/lib/page-variables-diff.ts new file mode 100644 index 0000000..e35adec --- /dev/null +++ b/src/lib/page-variables-diff.ts @@ -0,0 +1,306 @@ +import { PageVariableTagEntry } from '../api/index.js'; + +export type PageVariableEntry = PageVariableTagEntry; + +export type TemplateVariableTagType = 'text' | 'select' | 'multiselect' | 'file' | 'list' | 'color' | 'boolean'; + +/** + * One entry of `public/__template_variables.json`'s `tags[]`. + * Schema reverse-engineered from MI's backend β€” see TEMPLATE_VARIABLES.md at the repo root. + */ +export interface TemplateVariableTag { + name: string; + uid?: string; + tag_type?: TemplateVariableTagType | string; + tag_source?: string; + default_value?: string; + additional_options?: unknown; + description?: string; + use_hmtl_editor_ind?: 'Y' | 'N'; + use_raw_html_ind?: 'Y' | 'N'; + use_json_editor_ind?: 'Y' | 'N'; + javascript_code?: string; +} + +export interface TemplateVariablesSchema { + tags: TemplateVariableTag[]; + settings?: Record; +} + +export interface PageVariableValidationIssue { + name: string; + severity: 'warning' | 'error'; + message: string; +} + +/** + * A `list` tag's `additional_options`, when non-empty, is an array of these β€” each defines one + * field/column of a list-of-objects item. A bare string entry is shorthand for + * `{ name: , type: 'textarea' }`. Confirmed against MI's own page-variable editor + * β€” see TEMPLATE_VARIABLES.md. + */ +export interface ListItemFieldConfig { + name: string; + type?: 'textarea' | 'color' | 'select' | 'multi-select' | 'file' | string; + /** `select`/`multi-select` only. Defaults to `'static'`. Same per-source rules as the tag-level + * `tag_source`/`additional_options` pair (see TEMPLATE_VARIABLES.md) β€” just fed by `options` + * below instead of the tag's own `additional_options`. */ + source?: string; + /** Not read by MI's own list-item value editor for `select`/`multi-select` β€” `options` (below) + * is what's actually used. Kept only because real exports carry the key; safe to ignore. */ + additional_options?: string; + /** `select`/`multi-select` only β€” the option list itself when `source` is `'static'` (the + * default). For other `source` values, same rules as the tag-level `additional_options` + * apply (e.g. `dataset_data` still expects a dataset/column config, not this array). */ + options?: string[]; +} + +// MI's own page-variable UI writes literal 'true'/'false' strings; the rest are accepted +// leniently for legacy/hand-edited data, since MI itself performs no validation on save. +const BOOLEAN_ALLOWED_VALUES = ['true', 'false', 'Y', 'N', '1', '0']; +const COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; + +function isOptionsWithEnumerableList( + additionalOptions: unknown, +): additionalOptions is { id?: string; text?: string }[] { + return Array.isArray(additionalOptions); +} + +function optionMatches(option: { id?: string; text?: string }, token: string): boolean { + return option.id === token || option.text === token; +} + +/** `null` when `additional_options` doesn't declare per-item fields (flat list of primitives). */ +function normalizeListItemFields(additionalOptions: unknown): ListItemFieldConfig[] | null { + if (!Array.isArray(additionalOptions) || additionalOptions.length === 0) { + return null; + } + + const fields = additionalOptions + .map((entry): ListItemFieldConfig | null => { + if (typeof entry === 'string') { + return { name: entry, type: 'textarea' }; + } + + if (entry && typeof entry === 'object' && typeof (entry as { name?: unknown }).name === 'string') { + return entry as ListItemFieldConfig; + } + + return null; + }) + .filter((field): field is ListItemFieldConfig => field !== null); + + return fields.length ? fields : null; +} + +/** Best-effort check of one field's value against its declared `ListItemFieldConfig.type`. */ +function validateListItemFieldValue( + tagName: string, + itemLabel: string, + field: ListItemFieldConfig, + value: unknown, +): PageVariableValidationIssue[] { + if (typeof value !== 'string') { + return [ + { + name: tagName, + severity: 'warning', + message: `${itemLabel}: field "${field.name}" should be a string, got ${typeof value}.`, + }, + ]; + } + + if (!value) { + return []; + } + + switch (field.type) { + case 'color': + return COLOR_PATTERN.test(value) + ? [] + : [ + { + name: tagName, + severity: 'warning', + message: `${itemLabel}: field "${field.name}" value "${value}" is not a recognized color (expected e.g. "#075b7e").`, + }, + ]; + + case 'select': + return field.options?.length && !field.options.includes(value) + ? [ + { + name: tagName, + severity: 'warning', + message: `${itemLabel}: field "${field.name}" value "${value}" is not one of the declared options (${field.options.join(', ')}).`, + }, + ] + : []; + + case 'multi-select': { + if (!field.options?.length) { + return []; + } + + const unmatched = value.split(',').map((v) => v.trim()).filter((token) => !field.options!.includes(token)); + + return unmatched.length + ? [ + { + name: tagName, + severity: 'warning', + message: `${itemLabel}: field "${field.name}" value "${unmatched.join(', ')}" is not among the declared options (${field.options.join(', ')}).`, + }, + ] + : []; + } + + default: + return []; + } +} + +/** Best-effort validation of a `list` value's items against `additional_options` (`ListItemFieldConfig[]`). */ +function validateListItems(tag: TemplateVariableTag, items: unknown[]): PageVariableValidationIssue[] { + const fields = normalizeListItemFields(tag.additional_options); + + if (!fields) { + return []; + } + + const fieldNames = new Set(fields.map((field) => field.name)); + const issues: PageVariableValidationIssue[] = []; + + items.forEach((item, index) => { + const itemLabel = `"${tag.name}" item #${index + 1}`; + + if (typeof item !== 'object' || item === null || Array.isArray(item)) { + issues.push({ + name: tag.name, + severity: 'warning', + message: `${itemLabel} should be an object with field(s): ${fields.map((field) => field.name).join(', ')}.`, + }); + + return; + } + + const itemRecord = item as Record; + + for (const field of fields) { + if (!(field.name in itemRecord)) { + issues.push({ + name: tag.name, + severity: 'warning', + message: `${itemLabel} is missing field "${field.name}".`, + }); + + continue; + } + + issues.push(...validateListItemFieldValue(tag.name, itemLabel, field, itemRecord[field.name])); + } + + const extraKeys = Object.keys(itemRecord).filter((key) => !fieldNames.has(key)); + + if (extraKeys.length) { + issues.push({ + name: tag.name, + severity: 'warning', + message: `${itemLabel} has unexpected field(s) not declared in additional_options: ${extraKeys.join(', ')}.`, + }); + } + }); + + return issues; +} + +/** + * Best-effort, non-blocking validation of a candidate value against its schema tag. + * MI's own backend performs no such validation server-side (see TEMPLATE_VARIABLES.md + * caveats), so every issue here is a `warning`, never an `error`. + */ +export function validateValueAgainstTag(tag: TemplateVariableTag, value: string): PageVariableValidationIssue[] { + const issues: PageVariableValidationIssue[] = []; + + switch (tag.tag_type) { + case 'boolean': { + if (!BOOLEAN_ALLOWED_VALUES.includes(value)) { + issues.push({ + name: tag.name, + severity: 'warning', + message: `Value "${value}" is not a recognized boolean encoding (expected one of ${BOOLEAN_ALLOWED_VALUES.join(', ')}).`, + }); + } + + break; + } + + case 'list': { + try { + const parsed = JSON.parse(value); + + if (!Array.isArray(parsed)) { + issues.push({ + name: tag.name, + severity: 'warning', + message: `Value for list variable "${tag.name}" is valid JSON but not an array.`, + }); + } else { + issues.push(...validateListItems(tag, parsed)); + } + } catch { + issues.push({ + name: tag.name, + severity: 'warning', + message: `Value for list variable "${tag.name}" is not valid JSON.`, + }); + } + + break; + } + + case 'select': + case 'multiselect': { + if (isOptionsWithEnumerableList(tag.additional_options)) { + const options = tag.additional_options; + const tokens = tag.tag_type === 'multiselect' ? value.split(',').map((v) => v.trim()) : [value]; + const unmatched = tokens.filter((token) => !options.some((option) => optionMatches(option, token))); + + if (unmatched.length) { + issues.push({ + name: tag.name, + severity: 'warning', + message: `Value "${unmatched.join(', ')}" does not match any declared option for "${tag.name}".`, + }); + } + } + + break; + } + + default: + break; + } + + return issues; +} + +/** + * Everything currently known about the page's variables β€” live values, backfilled with + * schema defaults for anything not yet set on the page. `schema === null` degrades to the + * live values as-is. + */ +export function buildPageVariablesExport( + schema: TemplateVariablesSchema | null, + live: PageVariableEntry[], +): PageVariableEntry[] { + const schemaMap = new Map((schema?.tags ?? []).map((tag) => [tag.name, tag])); + const liveMap = new Map(live.map((entry) => [entry.name, entry.value])); + const knownNames = new Set([...schemaMap.keys(), ...liveMap.keys()]); + + return Array.from(knownNames).map((name) => ({ + name, + value: liveMap.get(name) ?? schemaMap.get(name)?.default_value ?? '', + })); +} + diff --git a/src/lib/pp.middleware.ts b/src/lib/pp.middleware.ts index 81ac1aa..e8a18f9 100644 --- a/src/lib/pp.middleware.ts +++ b/src/lib/pp.middleware.ts @@ -2,7 +2,7 @@ import axios, { Axios } from 'axios'; import http from 'node:http'; import https from 'node:https'; import { JSDOM } from 'jsdom'; -import { AssetsAPI, PageAPI, AssetsV7API, PageTemplateAPI } from '../api/index.js'; +import { AssetsAPI, PageAPI, AssetsV7API, PageTemplateAPI, PageVariableAPI, PageVariableTagEntry } from '../api/index.js'; import { isUnavailableJsonApiError } from '../api/unavailable-json-api.js'; import { createLogger } from './logger.js'; import { colors, getTokenErrorInfo, logTokenError } from './helpers/index.js'; @@ -52,6 +52,7 @@ export class MiAPI { private assetsApi: AssetsAPI; private pageApi: PageAPI; private pageTemplateApi: PageTemplateAPI; + private pageVariableApi: PageVariableAPI; private logger: Logger; @@ -118,6 +119,7 @@ export class MiAPI { this.assetsApi = new (!v7Features ? AssetsAPI : AssetsV7API)(this.#axios); this.pageApi = new PageAPI(this.#axios); this.pageTemplateApi = new PageTemplateAPI(this.#axios); + this.pageVariableApi = new PageVariableAPI(this.#axios); this.logger = createLogger(); } @@ -389,6 +391,74 @@ export class MiAPI { }); } + /** + * Get the page's live variable values via the dedicated `/api/page_variable` endpoint, + * looked up by numeric page id (`page_id`) rather than `internal_name` β€” this skips the + * extra page lookup that resolving `internal_name` would otherwise require. + * Independent from `getPageVariables()`/`#pageVars` (used for `[VarName]` HTML substitution). + * + * @param headers + */ + async getLivePageVariables(headers: Headers = this.#headers): Promise { + const pageId = this.appId!; + const start = Date.now(); + + this.logger.info(colors.cyan(`[page-variables] Fetching live variables for page ID ${pageId} (page_id lookup)`)); + + const tags = await this.pageVariableApi.getById(pageId, this.#clearHeaders(headers)); + + this.logger.info( + colors.green(`[page-variables] Fetched ${tags.length} live variable(s) for page ID ${pageId} in ${Date.now() - start}ms`), + ); + + return tags; + } + + /** + * Write page variable values back to MI via the dedicated `/api/page_variable` endpoint, + * looked up by numeric page id (`page_id`) rather than `internal_name`. + * + * @param tags + * @param headers + */ + async applyPageVariables(tags: PageVariableTagEntry[], headers: Headers = this.#headers): Promise { + const pageId = this.appId!; + const start = Date.now(); + + this.logger.info( + colors.cyan(`[page-variables] Applying ${tags.length} variable(s) to page ID ${pageId} (page_id lookup)`), + ); + + await this.pageVariableApi.updateById(pageId, tags, this.#clearHeaders(headers)); + + this.logger.info( + colors.green(`[page-variables] Applied variables to page ID ${pageId} in ${Date.now() - start}ms`), + ); + } + + /** Whether this page has no associated template β€” no `__template_variables.json`, no page variables. */ + get isTemplateLess(): boolean { + return !!this.templateLess; + } + + /** + * Force-refetch the page's live variable values (and title) right now, bypassing whatever + * request-level cache normally gates re-fetching (see `load-pp-data.middleware.ts`'s 3-minute + * page-data cache). Used by the dev panel's "Reload variables" button so `[VarName]` + * substitution in `buildPage()` reflects a just-saved value without waiting for that cache to + * expire or restarting the dev server. No-op (returns `null`) for templateLess pages or before + * `appId` is known (nothing to reload yet). + * + * @param headers + */ + async reloadPageVariables(headers: Headers = this.#headers): Promise<{ name: string; value: string }[] | null> { + if (this.templateLess || typeof this.appId === 'undefined') { + return null; + } + + return this.getPageVariables(this.appId, headers); + } + /** * Get page info * diff --git a/src/lib/request-inspector.ts b/src/lib/request-inspector.ts index 8304ea5..6cf3b70 100644 --- a/src/lib/request-inspector.ts +++ b/src/lib/request-inspector.ts @@ -132,16 +132,49 @@ function getInspectorHtml(captureLimit: number): string { Request Inspector β€” pp-dev + + + +
+
+ 🧩 Variables Editor + ${templateLess ? '' : `
+ + +
`} + ${templateLess ? '' : ``} +
+ ${templateLess ? '' : ` + `} +
+ + + +
+
+
${ + templateLess + ? '
This page has no associated template β€” there\'s no __template_variables.json and no page variables to edit here.
' + : '' + }
+
+ + +`; +} diff --git a/src/plugin.ts b/src/plugin.ts index 9a32a93..2cfbd02 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -10,10 +10,11 @@ import { initPPRedirect } from './lib/pp-redirect.middleware.js'; import { initLoadPPData } from './lib/load-pp-data.middleware.js'; import type { ViteImageOptimizer } from 'vite-plugin-image-optimizer'; import { createInternalServer } from './lib/internal.middleware.js'; -import { getTokenErrorInfo } from './lib/helpers/index.js'; +import { colors, getTokenErrorInfo } from './lib/helpers/index.js'; import { RequestStore } from './lib/request-store.js'; import { createRequestCaptureMiddleware } from './lib/request-capture.middleware.js'; import { registerInspectorRoutes, INSPECTOR_PATH } from './lib/request-inspector.js'; +import { registerVariablesEditorRoutes } from './lib/variables-editor.js'; // ─── Public config types ────────────────────────────────────────────────────── @@ -402,6 +403,9 @@ function vitePPDev(options: NormalizedVitePPDevOptions): Plugin { } if (backendBaseURL) { + server.config.logger.info(colors.blue(`🌐 Backend URL: ${backendBaseURL}`)); + server.config.logger.info(colors.blue(`πŸ†” Custom App ID: ${appId}`)); + const baseUrlHost = new URL(backendBaseURL).host; const miConfig: MiAPIConfig = { @@ -605,8 +609,6 @@ function vitePPDev(options: NormalizedVitePPDevOptions): Plugin { } }); - server.middlewares.use(internalServer); - const distService = distZip !== false ? new DistService( @@ -629,6 +631,14 @@ function vitePPDev(options: NormalizedVitePPDevOptions): Plugin { : undefined; new ClientService(server, { distService, miAPI: mi }); + registerVariablesEditorRoutes(internalServer, { distService, miAPI: mi }); + + // IMPORTANT: mount `internalServer` only after all `.get`/`.post`/`.put`/... routes are + // registered on it. Vite's own middleware-mounting (`server.middlewares.use`) sets a + // `.route` string property on whatever's mounted (to track its mount path) β€” colliding + // with and overwriting Express's own `app.route` *method*. Any route registered on this + // app *after* it's been mounted crashes with "this.route is not a function". + server.middlewares.use(internalServer); return () => { server.middlewares.use( diff --git a/tests/test-commonjs/package-lock.json b/tests/test-commonjs/package-lock.json index ad2beed..53e2c05 100644 --- a/tests/test-commonjs/package-lock.json +++ b/tests/test-commonjs/package-lock.json @@ -1277,9 +1277,9 @@ } }, "node_modules/@metricinsights/pp-dev": { - "version": "1.0.0-beta.2", + "version": "1.1.1-beta.1", "resolved": "file:../../metricinsights-pp-dev-latest.tgz", - "integrity": "sha512-8Q0XNx0fFa+ar0+5v967se9UbyTmsYSjanVkimAlLVogtCd3xM31MSkic7wRDB+Asy2WrMrYUPIcJrGvzE3IqA==", + "integrity": "sha512-4/h5Vt1u5UIK2BIqOGqF5FNBKgOdMRDxpQBwCVg72AshGqs+qOT78yFthxMGOqSLI1fv8XAMZP/k19XoaVujgA==", "hasInstallScript": true, "license": "ISC", "dependencies": { @@ -2787,9 +2787,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -3104,6 +3104,15 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cheerio/node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -4523,9 +4532,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" @@ -4773,6 +4782,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/jsdom/node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/jsdom/node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", @@ -7134,15 +7152,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/undici": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", - "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, "node_modules/undici-types": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", diff --git a/tests/test-commonjs/package.json b/tests/test-commonjs/package.json index 09cf09d..cf63358 100644 --- a/tests/test-commonjs/package.json +++ b/tests/test-commonjs/package.json @@ -21,7 +21,7 @@ }, "overrides": { "@xmldom/xmldom": ">=0.8.12", - "brace-expansion": "5.0.8", + "brace-expansion": ">=5.0.9", "esbuild": ">=0.28.1", "file-type": "^22.0.0", "flatted": ">=3.4.2", @@ -33,7 +33,8 @@ "picomatch": "4.0.4", "postcss": ">=8.5.23", "serialize-javascript": ">=7.0.5", - "undici": ">=7.28.0", + "ip-address": ">=10.4.0", + "undici": ">=7.29.0 <8.0.0", "http-proxy-middleware": "^3.0.7", "vite@*": { "postcss": "8.5.23" diff --git a/tests/test-commonjs/public/__template_variables.json b/tests/test-commonjs/public/__template_variables.json index 4d0f9f3..8f39085 100644 --- a/tests/test-commonjs/public/__template_variables.json +++ b/tests/test-commonjs/public/__template_variables.json @@ -16,6 +16,180 @@ "description": "Value for test", "use_js_code_ind": "N", "use_raw_html_ind": "N" + }, + { + "portal_page_template_tag_id": 2342, + "portal_page_template_id": 313, + "name": "variable-select", + "uid": "a1a1a1a1b2b2b2b2c3c3c3c3d4d4d4d4", + "default_value": "opt1", + "additional_options": [ + { + "id": "opt1", + "text": "Option One" + }, + { + "id": "opt2", + "text": "Option Two" + }, + { + "id": "opt3", + "text": "Option Three" + } + ], + "tag_type": "select", + "tag_source": "static", + "javascript_code": null, + "display_order": 1, + "use_hmtl_editor_ind": "N", + "use_json_editor_ind": "N", + "description": "Single-select value for test", + "use_js_code_ind": "N", + "use_raw_html_ind": "N" + }, + { + "portal_page_template_tag_id": 2343, + "portal_page_template_id": 313, + "name": "variable-multiselect", + "uid": "b2b2b2b2c3c3c3c3d4d4d4d4e5e5e5e5", + "default_value": "tag1,tag2", + "additional_options": [ + { + "id": "tag1", + "text": "Tag One" + }, + { + "id": "tag2", + "text": "Tag Two" + }, + { + "id": "tag3", + "text": "Tag Three" + } + ], + "tag_type": "multiselect", + "tag_source": "static", + "javascript_code": null, + "display_order": 2, + "use_hmtl_editor_ind": "N", + "use_json_editor_ind": "N", + "description": "Multi-select value for test", + "use_js_code_ind": "N", + "use_raw_html_ind": "N" + }, + { + "portal_page_template_tag_id": 2344, + "portal_page_template_id": 313, + "name": "variable-list", + "uid": "c3c3c3c3d4d4d4d4e5e5e5e5f6f6f6f6", + "default_value": "[\"Item One\",\"Item Two\"]", + "additional_options": "", + "tag_type": "list", + "tag_source": "static", + "javascript_code": null, + "display_order": 3, + "use_hmtl_editor_ind": "N", + "use_json_editor_ind": "N", + "description": "List (JSON array) value for test", + "use_js_code_ind": "N", + "use_raw_html_ind": "N" + }, + { + "portal_page_template_tag_id": 2349, + "portal_page_template_id": 313, + "name": "variable-list-objects", + "uid": "b3b3b3b3c4c4c4c4d5d5d5d5e6e6e6e6", + "default_value": "[{\"id\":\"1\",\"label\":\"First\"},{\"id\":\"2\",\"label\":\"Second\"}]", + "additional_options": [ + { + "name": "id", + "type": "textarea" + }, + { + "name": "label", + "type": "textarea" + } + ], + "tag_type": "list", + "tag_source": "static", + "javascript_code": null, + "display_order": 8, + "use_hmtl_editor_ind": "N", + "use_json_editor_ind": "N", + "description": "List of objects for test β€” additional_options defines the per-item fields (id, label); each list item is an object keyed by those field names", + "use_js_code_ind": "N", + "use_raw_html_ind": "N" + }, + { + "portal_page_template_tag_id": 2345, + "portal_page_template_id": 313, + "name": "variable-boolean", + "uid": "d4d4d4d4e5e5e5e5f6f6f6f6a1a1a1a1", + "default_value": "true", + "additional_options": "", + "tag_type": "boolean", + "tag_source": "static", + "javascript_code": null, + "display_order": 4, + "use_hmtl_editor_ind": "N", + "use_json_editor_ind": "N", + "description": "Boolean value for test", + "use_js_code_ind": "N", + "use_raw_html_ind": "N" + }, + { + "portal_page_template_tag_id": 2346, + "portal_page_template_id": 313, + "name": "variable-color", + "uid": "e5e5e5e5f6f6f6f6a1a1a1a1b2b2b2b2", + "default_value": "#075b7e", + "additional_options": "", + "tag_type": "color", + "tag_source": "static", + "javascript_code": null, + "display_order": 5, + "use_hmtl_editor_ind": "N", + "use_json_editor_ind": "N", + "description": "Color value for test", + "use_js_code_ind": "N", + "use_raw_html_ind": "N" + }, + { + "portal_page_template_tag_id": 2347, + "portal_page_template_id": 313, + "name": "variable-file", + "uid": "f6f6f6f6a1a1a1a1b2b2b2b2c3c3c3c3", + "default_value": "", + "additional_options": "", + "tag_type": "file", + "tag_source": "static", + "javascript_code": null, + "display_order": 6, + "use_hmtl_editor_ind": "N", + "use_json_editor_ind": "N", + "description": "File value for test", + "use_js_code_ind": "N", + "use_raw_html_ind": "N" + }, + { + "portal_page_template_tag_id": 2348, + "portal_page_template_id": 313, + "name": "variable-select-dataset", + "uid": "a2a2a2a2b3b3b3b3c4c4c4c4d5d5d5d5", + "default_value": "", + "additional_options": { + "source": "dataset", + "name": "segments" + }, + "tag_type": "select", + "tag_source": "dataset", + "javascript_code": null, + "display_order": 7, + "use_hmtl_editor_ind": "N", + "use_json_editor_ind": "N", + "description": "Dataset-driven select for test (no enumerable options β€” pp-dev should skip enum validation)", + "use_js_code_ind": "N", + "use_raw_html_ind": "N" } ], "settings": { @@ -23,4 +197,4 @@ "image_capture_on_event": "none", "image_capture_css_selector": "none" } -} +} \ No newline at end of file diff --git a/tests/test-nextjs-cjs/package-lock.json b/tests/test-nextjs-cjs/package-lock.json index 8df6853..1496f24 100644 --- a/tests/test-nextjs-cjs/package-lock.json +++ b/tests/test-nextjs-cjs/package-lock.json @@ -1821,9 +1821,9 @@ } }, "node_modules/@metricinsights/pp-dev": { - "version": "1.0.0-beta.2", + "version": "1.1.1-beta.1", "resolved": "file:../../metricinsights-pp-dev-latest.tgz", - "integrity": "sha512-8Q0XNx0fFa+ar0+5v967se9UbyTmsYSjanVkimAlLVogtCd3xM31MSkic7wRDB+Asy2WrMrYUPIcJrGvzE3IqA==", + "integrity": "sha512-4/h5Vt1u5UIK2BIqOGqF5FNBKgOdMRDxpQBwCVg72AshGqs+qOT78yFthxMGOqSLI1fv8XAMZP/k19XoaVujgA==", "hasInstallScript": true, "license": "ISC", "dependencies": { @@ -4599,9 +4599,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -5008,6 +5008,15 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cheerio/node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -7530,9 +7539,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" @@ -8060,9 +8069,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz", + "integrity": "sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==", "dev": true, "funding": [ { @@ -8079,7 +8088,7 @@ "argparse": "^2.0.1" }, "bin": { - "js-yaml": "bin/js-yaml.js" + "js-yaml": "bin/js-yaml.mjs" } }, "node_modules/jsdom": { @@ -8189,6 +8198,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/jsdom/node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/jsdom/node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", @@ -11714,15 +11732,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/undici": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", - "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", diff --git a/tests/test-nextjs-cjs/package.json b/tests/test-nextjs-cjs/package.json index d9f0fba..8272543 100644 --- a/tests/test-nextjs-cjs/package.json +++ b/tests/test-nextjs-cjs/package.json @@ -30,7 +30,7 @@ }, "overrides": { "@xmldom/xmldom": ">=0.8.12", - "brace-expansion": "5.0.8", + "brace-expansion": ">=5.0.9", "esbuild": ">=0.28.1", "file-type": "^22.0.0", "flatted": ">=3.4.2", @@ -45,7 +45,9 @@ "picomatch": "4.0.4", "serialize-javascript": ">=7.0.5", "sharp": "^0.35.3", - "undici": ">=7.28.0", + "ip-address": ">=10.4.0", + "js-yaml": ">=4.3.1", + "undici": ">=7.29.0 <8.0.0", "http-proxy-middleware": "^3.0.7", "vite": "^8.0.16", "vite@*": { diff --git a/tests/test-nextjs/package-lock.json b/tests/test-nextjs/package-lock.json index d3a8b4d..c162ff6 100644 --- a/tests/test-nextjs/package-lock.json +++ b/tests/test-nextjs/package-lock.json @@ -1915,9 +1915,9 @@ } }, "node_modules/@metricinsights/pp-dev": { - "version": "1.1.0-beta.1", + "version": "1.1.1-beta.1", "resolved": "file:../../metricinsights-pp-dev-latest.tgz", - "integrity": "sha512-t40WSPwpLr8j0dWa0J+Zbhbwh9EASqqJXxJdIN1vCvo3U4BtsUbxgueVQOZYYyABPM4mFBcmVl4Ej5sMymVy+A==", + "integrity": "sha512-4/h5Vt1u5UIK2BIqOGqF5FNBKgOdMRDxpQBwCVg72AshGqs+qOT78yFthxMGOqSLI1fv8XAMZP/k19XoaVujgA==", "hasInstallScript": true, "license": "ISC", "dependencies": { @@ -4803,9 +4803,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -5087,6 +5087,15 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cheerio/node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -7578,9 +7587,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" @@ -8126,9 +8135,9 @@ "dev": true }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz", + "integrity": "sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==", "dev": true, "funding": [ { @@ -8145,7 +8154,7 @@ "argparse": "^2.0.1" }, "bin": { - "js-yaml": "bin/js-yaml.js" + "js-yaml": "bin/js-yaml.mjs" } }, "node_modules/jsdom": { @@ -8221,6 +8230,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/jsdom/node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/jsdom/node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", @@ -11857,15 +11875,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/undici": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", - "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", diff --git a/tests/test-nextjs/package.json b/tests/test-nextjs/package.json index 505c867..27732cf 100644 --- a/tests/test-nextjs/package.json +++ b/tests/test-nextjs/package.json @@ -58,7 +58,7 @@ }, "overrides": { "@xmldom/xmldom": ">=0.8.12", - "brace-expansion": "5.0.8", + "brace-expansion": ">=5.0.9", "esbuild": ">=0.28.1", "file-type": "^22.0.1", "flatted": ">=3.4.2", @@ -73,7 +73,9 @@ "picomatch": "4.0.4", "serialize-javascript": ">=7.0.5", "sharp": "^0.35.3", - "undici": ">=7.28.0", + "ip-address": ">=10.4.0", + "js-yaml": ">=4.3.1", + "undici": ">=7.29.0 <8.0.0", "vite": "^8.0.16", "vite@*": { "postcss": "8.5.23" diff --git a/tests/unit/api/page-variable.spec.ts b/tests/unit/api/page-variable.spec.ts new file mode 100644 index 0000000..ed1d772 --- /dev/null +++ b/tests/unit/api/page-variable.spec.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi } from 'vitest'; +import { PageVariableAPI, normalizePageVariableTags } from '../../../src/api/page-variable.js'; + +function makeAxios(overrides: { get?: any; put?: any } = {}) { + return { + get: overrides.get ?? vi.fn(), + put: overrides.put ?? vi.fn(), + } as any; +} + +describe('normalizePageVariableTags', () => { + it('passes through a well-formed array', () => { + const tags = [{ name: 'a', value: '1' }]; + + expect(normalizePageVariableTags(tags)).toEqual(tags); + }); + + it('filters out malformed entries from an array', () => { + const tags = [{ name: 'a', value: '1' }, { name: 'b' }, { value: '2' }, 'not-an-object'] as any; + + expect(normalizePageVariableTags(tags)).toEqual([{ name: 'a', value: '1' }]); + }); + + it('parses a JSON-stringified array', () => { + expect(normalizePageVariableTags(JSON.stringify([{ name: 'a', value: '1' }]))).toEqual([ + { name: 'a', value: '1' }, + ]); + }); + + it('returns an empty array for invalid JSON or a non-array payload', () => { + expect(normalizePageVariableTags('not json')).toEqual([]); + expect(normalizePageVariableTags(JSON.stringify({ not: 'an array' }))).toEqual([]); + }); +}); + +describe('PageVariableAPI', () => { + it('GETs /api/page_variable with page_id and normalizes the response', async () => { + const get = vi.fn().mockResolvedValue({ data: { tags: [{ name: 'title', value: 'Hello' }] } }); + const api = new PageVariableAPI(makeAxios({ get })); + + const result = await api.getById(937); + + expect(get).toHaveBeenCalledWith('/api/page_variable', expect.objectContaining({ params: { page_id: 937 } })); + expect(result).toEqual([{ name: 'title', value: 'Hello' }]); + }); + + it('normalizes a stringified tags response from GET', async () => { + const get = vi.fn().mockResolvedValue({ data: { tags: JSON.stringify([{ name: 'title', value: 'Hello' }]) } }); + const api = new PageVariableAPI(makeAxios({ get })); + + const result = await api.getById(937); + + expect(result).toEqual([{ name: 'title', value: 'Hello' }]); + }); + + it('PUTs /api/page_variable with page_id param and JSON-stringified tags body', async () => { + const put = vi.fn().mockResolvedValue({ data: {} }); + const api = new PageVariableAPI(makeAxios({ put })); + + await api.updateById(937, [{ name: 'title', value: 'Hello' }]); + + expect(put).toHaveBeenCalledWith( + '/api/page_variable', + { tags: JSON.stringify([{ name: 'title', value: 'Hello' }]) }, + expect.objectContaining({ params: { page_id: 937 } }), + ); + }); +}); diff --git a/tests/unit/client/panel-settings.spec.ts b/tests/unit/client/panel-settings.spec.ts new file mode 100644 index 0000000..7cbb9e5 --- /dev/null +++ b/tests/unit/client/panel-settings.spec.ts @@ -0,0 +1,125 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach } from 'vitest'; +import { createPanelStateController } from '../../../src/client/panel-state.js'; +import { initPanelSettings } from '../../../src/client/panel-settings.js'; + +function makePanel(templateLess: string): HTMLElement { + const $panel = document.createElement('div'); + + $panel.className = 'pp-dev-info-namespace pp-dev-info'; + $panel.dataset.position = 'bottom-right'; + $panel.dataset.autoHide = 'false'; + $panel.dataset.hidden = 'false'; + $panel.dataset.templateLess = templateLess; + + const $settingsBtn = document.createElement('button'); + + $settingsBtn.className = 'pp-dev-info__settings-btn'; + $panel.appendChild($settingsBtn); + + document.body.appendChild($panel); + + return $panel; +} + +beforeEach(() => { + localStorage.clear(); + document.body.innerHTML = ''; +}); + +describe('initPanelSettings β€” "Reload variables" button visibility', () => { + it('renders the button when the page is not templateLess', () => { + const $panel = makePanel('false'); + const controller = createPanelStateController($panel); + + initPanelSettings($panel, controller); + $panel.querySelector('.pp-dev-info__settings-btn')!.click(); + + expect($panel.querySelector('.pp-dev-info__reload-vars-btn')).not.toBeNull(); + }); + + it('omits the button entirely when the page is templateLess', () => { + const $panel = makePanel('true'); + const controller = createPanelStateController($panel); + + initPanelSettings($panel, controller); + $panel.querySelector('.pp-dev-info__settings-btn')!.click(); + + expect($panel.querySelector('.pp-dev-info__reload-vars-btn')).toBeNull(); + }); + + it('invokes onReloadVariablesClick and closes the popover when clicked', () => { + const $panel = makePanel('false'); + const controller = createPanelStateController($panel); + let clicked = false; + + initPanelSettings($panel, controller, { onReloadVariablesClick: () => (clicked = true) }); + $panel.querySelector('.pp-dev-info__settings-btn')!.click(); + $panel.querySelector('.pp-dev-info__reload-vars-btn')!.click(); + + expect(clicked).toBe(true); + expect($panel.querySelector('.pp-dev-info__settings')).toBeNull(); + }); +}); + +describe('initPanelSettings β€” "Open variables editor" button visibility', () => { + it('renders the button when the page is not templateLess', () => { + const $panel = makePanel('false'); + const controller = createPanelStateController($panel); + + initPanelSettings($panel, controller); + $panel.querySelector('.pp-dev-info__settings-btn')!.click(); + + expect($panel.querySelector('.pp-dev-info__open-editor-btn')).not.toBeNull(); + }); + + it('omits the button entirely when the page is templateLess', () => { + const $panel = makePanel('true'); + const controller = createPanelStateController($panel); + + initPanelSettings($panel, controller); + $panel.querySelector('.pp-dev-info__settings-btn')!.click(); + + expect($panel.querySelector('.pp-dev-info__open-editor-btn')).toBeNull(); + }); + + it('invokes onOpenVariablesEditorClick and closes the popover when clicked', () => { + const $panel = makePanel('false'); + const controller = createPanelStateController($panel); + let clicked = false; + + initPanelSettings($panel, controller, { onOpenVariablesEditorClick: () => (clicked = true) }); + $panel.querySelector('.pp-dev-info__settings-btn')!.click(); + $panel.querySelector('.pp-dev-info__open-editor-btn')!.click(); + + expect(clicked).toBe(true); + expect($panel.querySelector('.pp-dev-info__settings')).toBeNull(); + }); +}); + +describe('initPanelSettings β€” "Open request inspector" button (always present)', () => { + it('renders regardless of templateLess', () => { + for (const templateLess of ['true', 'false']) { + const $panel = makePanel(templateLess); + const controller = createPanelStateController($panel); + + initPanelSettings($panel, controller); + $panel.querySelector('.pp-dev-info__settings-btn')!.click(); + + expect($panel.querySelector('.pp-dev-info__open-inspector-btn')).not.toBeNull(); + } + }); + + it('invokes onOpenInspectorClick and closes the popover when clicked', () => { + const $panel = makePanel('true'); + const controller = createPanelStateController($panel); + let clicked = false; + + initPanelSettings($panel, controller, { onOpenInspectorClick: () => (clicked = true) }); + $panel.querySelector('.pp-dev-info__settings-btn')!.click(); + $panel.querySelector('.pp-dev-info__open-inspector-btn')!.click(); + + expect(clicked).toBe(true); + expect($panel.querySelector('.pp-dev-info__settings')).toBeNull(); + }); +}); diff --git a/tests/unit/lib/client.service.spec.ts b/tests/unit/lib/client.service.spec.ts index ee46b42..9efd3c8 100644 --- a/tests/unit/lib/client.service.spec.ts +++ b/tests/unit/lib/client.service.spec.ts @@ -1,6 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { ViteDevServer, WebSocketClient } from 'vite'; import { ClientService } from '../../../src/lib/client.service.js'; +import type { DistService } from '../../../src/lib/dist.service.js'; +import type { MiAPI } from '../../../src/lib/pp.middleware.js'; /** * Regression tests for the WebSocket "broadcast to all clients" bug. @@ -64,3 +66,90 @@ describe('ClientService β€” targeted WebSocket responses', () => { expect(server.ws.send).not.toHaveBeenCalled(); }); }); + +describe('ClientService β€” page-variables:reload', () => { + const handlers = new Map void>(); + let server: ViteDevServer; + + function makeClient(): WebSocketClient { + return { send: vi.fn() } as unknown as WebSocketClient; + } + + beforeEach(() => { + handlers.clear(); + + server = { + ws: { + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + handlers.set(event, handler); + }), + send: vi.fn(), + }, + config: { clientInjectionPlugin: { v7Features: false } }, + } as unknown as ViteDevServer; + }); + + it('errors when miAPI is not configured', async () => { + new ClientService(server); + + const client = makeClient(); + + await handlers.get('page-variables:reload')!({}, client); + + expect(client.send).toHaveBeenCalledWith('page-variables:reload:response', { + error: 'MiAPI is not defined', + }); + }); + + it('reports the refetched count on success', async () => { + const miAPI = { + reloadPageVariables: vi.fn().mockResolvedValue([{ name: 'title', value: 'Hello' }]), + } as unknown as MiAPI; + + new ClientService(server, { miAPI }); + + const client = makeClient(); + + await handlers.get('page-variables:reload')!({}, client); + + expect(client.send).toHaveBeenCalledWith('page-variables:reload:response', { + ok: true, + count: 1, + skipped: false, + }); + }); + + it('reports skipped:true for a templateLess page (reloadPageVariables resolves null)', async () => { + const miAPI = { + reloadPageVariables: vi.fn().mockResolvedValue(null), + } as unknown as MiAPI; + + new ClientService(server, { miAPI }); + + const client = makeClient(); + + await handlers.get('page-variables:reload')!({}, client); + + expect(client.send).toHaveBeenCalledWith('page-variables:reload:response', { + ok: true, + count: 0, + skipped: true, + }); + }); + + it('sends an error response when reloadPageVariables throws', async () => { + const miAPI = { + reloadPageVariables: vi.fn().mockRejectedValue(new Error('boom')), + } as unknown as MiAPI; + + new ClientService(server, { miAPI }); + + const client = makeClient(); + + await handlers.get('page-variables:reload')!({}, client); + + expect(client.send).toHaveBeenCalledWith('page-variables:reload:response', { + error: 'boom', + }); + }); +}); diff --git a/tests/unit/lib/dist.service.template-variables.spec.ts b/tests/unit/lib/dist.service.template-variables.spec.ts new file mode 100644 index 0000000..4fc9760 --- /dev/null +++ b/tests/unit/lib/dist.service.template-variables.spec.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +// Only `fs.promises.readFile` is intercepted (and only for the __template_variables.json +// path); everything else falls through to the real implementation so DistService's own +// meta-file bookkeeping (constructor's `syncMeta()`) keeps working against real fs, matching +// the partial-mock pattern used in dist.service.next.spec.ts. +const actualReadFile = vi.hoisted(() => ({ fn: null as unknown as (...args: any[]) => Promise })); + +vi.mock('fs', async (orig) => { + const actual = await orig(); + + actualReadFile.fn = actual.promises.readFile.bind(actual.promises); + + return { + ...actual, + promises: { + ...actual.promises, + readFile: vi.fn((...args: any[]) => actualReadFile.fn(...args)), + }, + }; +}); + +const { DistService, TEMPLATE_VARIABLES_FILE_NAME } = await import('../../../src/lib/dist.service.js'); +const fsModule = await import('fs'); + +function passthroughOtherPaths(filePath: unknown, ...rest: any[]) { + return actualReadFile.fn(filePath, ...rest); +} + +describe('DistService#readPublicTemplateVariablesFile', () => { + afterEach(() => { + vi.mocked(fsModule.promises.readFile).mockImplementation(passthroughOtherPaths); + }); + + it('returns null when public/__template_variables.json is missing', async () => { + vi.mocked(fsModule.promises.readFile).mockImplementation((filePath: unknown, ...rest: any[]) => { + if (String(filePath).endsWith(TEMPLATE_VARIABLES_FILE_NAME)) { + return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + } + + return passthroughOtherPaths(filePath, ...rest); + }); + + const result = await new DistService('test-app').readPublicTemplateVariablesFile(); + + expect(result).toBeNull(); + }); + + it('returns the raw file content when present', async () => { + const content = Buffer.from(JSON.stringify({ tags: [{ name: 'greeting', default_value: 'hello' }] })); + + vi.mocked(fsModule.promises.readFile).mockImplementation((filePath: unknown, ...rest: any[]) => { + if (String(filePath).endsWith(TEMPLATE_VARIABLES_FILE_NAME)) { + return Promise.resolve(content); + } + + return passthroughOtherPaths(filePath, ...rest); + }); + + const result = await new DistService('test-app').readPublicTemplateVariablesFile(); + + expect(result).toEqual(content); + }); +}); diff --git a/tests/unit/lib/page-variables-diff.spec.ts b/tests/unit/lib/page-variables-diff.spec.ts new file mode 100644 index 0000000..3d53985 --- /dev/null +++ b/tests/unit/lib/page-variables-diff.spec.ts @@ -0,0 +1,197 @@ +import { describe, it, expect } from 'vitest'; +import { + buildPageVariablesExport, + validateValueAgainstTag, + type TemplateVariablesSchema, + type TemplateVariableTag, +} from '../../../src/lib/page-variables-diff.js'; + +describe('validateValueAgainstTag', () => { + it('is callable directly and returns warnings for a bad value', () => { + const issues = validateValueAgainstTag({ name: 'flag', tag_type: 'boolean' }, 'maybe'); + + expect(issues).toContainEqual(expect.objectContaining({ name: 'flag', severity: 'warning' })); + }); + + it('returns no issues for a good value', () => { + expect(validateValueAgainstTag({ name: 'flag', tag_type: 'boolean' }, 'Y')).toEqual([]); + }); + + it('warns when a list value is not valid JSON', () => { + const issues = validateValueAgainstTag({ name: 'items', tag_type: 'list' }, 'not-json'); + + expect(issues).toContainEqual(expect.objectContaining({ name: 'items' })); + }); + + it('accepts a valid JSON array for a list value', () => { + expect(validateValueAgainstTag({ name: 'items', tag_type: 'list' }, '["a","b"]')).toEqual([]); + }); + + describe('list-of-objects (additional_options as ListItemFieldConfig[])', () => { + const rowsTag: TemplateVariableTag = { + name: 'rows', + tag_type: 'list', + additional_options: [ + { name: 'id', type: 'textarea' }, + { name: 'shade', type: 'color' }, + { name: 'kind', type: 'select', options: ['a', 'b'] }, + { name: 'tags', type: 'multi-select', options: ['x', 'y', 'z'] }, + 'plain-string-field', + ], + }; + + it('accepts well-formed items with no warnings', () => { + const value = JSON.stringify([ + { id: '1', shade: '#075b7e', kind: 'a', tags: 'x,y', 'plain-string-field': 'hi' }, + ]); + + expect(validateValueAgainstTag(rowsTag, value)).toEqual([]); + }); + + it('warns when an item is not an object', () => { + const value = JSON.stringify(['just-a-string']); + + expect(validateValueAgainstTag(rowsTag, value)).toContainEqual( + expect.objectContaining({ name: 'rows', message: expect.stringContaining('should be an object') }), + ); + }); + + it('warns about a missing declared field', () => { + const value = JSON.stringify([{ id: '1' }]); + + expect(validateValueAgainstTag(rowsTag, value)).toContainEqual( + expect.objectContaining({ message: expect.stringContaining('missing field "shade"') }), + ); + }); + + it('warns about an unexpected field not declared in additional_options', () => { + const value = JSON.stringify([ + { id: '1', shade: '#000', kind: 'a', tags: 'x', 'plain-string-field': 'hi', extra: 'nope' }, + ]); + + expect(validateValueAgainstTag(rowsTag, value)).toContainEqual( + expect.objectContaining({ message: expect.stringContaining('unexpected field(s)') }), + ); + }); + + it('warns on an invalid color field value', () => { + const value = JSON.stringify([ + { id: '1', shade: 'not-a-color', kind: 'a', tags: 'x', 'plain-string-field': 'hi' }, + ]); + + expect(validateValueAgainstTag(rowsTag, value)).toContainEqual( + expect.objectContaining({ message: expect.stringContaining('field "shade"') }), + ); + }); + + it('warns on a select field value outside the declared options', () => { + const value = JSON.stringify([ + { id: '1', shade: '#000', kind: 'nope', tags: 'x', 'plain-string-field': 'hi' }, + ]); + + expect(validateValueAgainstTag(rowsTag, value)).toContainEqual( + expect.objectContaining({ message: expect.stringContaining('field "kind"') }), + ); + }); + + it('warns on a multi-select field with a token outside the declared options', () => { + const value = JSON.stringify([ + { id: '1', shade: '#000', kind: 'a', tags: 'x,nope', 'plain-string-field': 'hi' }, + ]); + + expect(validateValueAgainstTag(rowsTag, value)).toContainEqual( + expect.objectContaining({ message: expect.stringContaining('field "tags"') }), + ); + }); + + it('warns when a field value is not a string', () => { + const value = JSON.stringify([{ id: 1, shade: '#000', kind: 'a', tags: 'x', 'plain-string-field': 'hi' }]); + + expect(validateValueAgainstTag(rowsTag, value)).toContainEqual( + expect.objectContaining({ message: expect.stringContaining('should be a string') }), + ); + }); + + it('skips per-item validation for a flat list (empty additional_options)', () => { + const flatTag: TemplateVariableTag = { name: 'items', tag_type: 'list', additional_options: '' }; + + expect(validateValueAgainstTag(flatTag, '["a","b"]')).toEqual([]); + }); + }); + + it('warns when a select value does not match a declared option id/text', () => { + const tag: TemplateVariableTag = { + name: 'color', + tag_type: 'select', + additional_options: [{ id: '1', text: 'Red' }], + }; + + expect(validateValueAgainstTag(tag, 'Blue')).toContainEqual(expect.objectContaining({ name: 'color' })); + }); + + it('accepts a select value matching a declared option id or text', () => { + const tag: TemplateVariableTag = { + name: 'color', + tag_type: 'select', + additional_options: [{ id: '1', text: 'Red' }], + }; + + expect(validateValueAgainstTag(tag, '1')).toEqual([]); + expect(validateValueAgainstTag(tag, 'Red')).toEqual([]); + }); + + it('checks every comma-separated token for a multiselect value', () => { + const tag: TemplateVariableTag = { + name: 'colors', + tag_type: 'multiselect', + additional_options: [{ id: '1', text: 'Red' }], + }; + + expect(validateValueAgainstTag(tag, '1, Red')).toEqual([]); + expect(validateValueAgainstTag(tag, '1, Blue')).toContainEqual(expect.objectContaining({ name: 'colors' })); + }); + + it('skips the enum check for dataset-driven additional_options ({source,name} shape)', () => { + const tag: TemplateVariableTag = { + name: 'segment', + tag_type: 'select', + additional_options: { source: 'dataset', name: 'segments' }, + }; + + expect(validateValueAgainstTag(tag, 'anything')).toEqual([]); + }); +}); + +describe('buildPageVariablesExport', () => { + it('uses the live value when the variable is already set on the page', () => { + const schema: TemplateVariablesSchema = { tags: [{ name: 'title', default_value: 'Default title' }] }; + + expect(buildPageVariablesExport(schema, [{ name: 'title', value: 'Live title' }])).toEqual([ + { name: 'title', value: 'Live title' }, + ]); + }); + + it('backfills the schema default when the variable is not set on the page', () => { + const schema: TemplateVariablesSchema = { tags: [{ name: 'greeting', default_value: 'hello' }] }; + + expect(buildPageVariablesExport(schema, [])).toEqual([{ name: 'greeting', value: 'hello' }]); + }); + + it('exports an empty string when neither a live value nor a schema default exists', () => { + const schema: TemplateVariablesSchema = { tags: [{ name: 'no_default' }] }; + + expect(buildPageVariablesExport(schema, [])).toEqual([{ name: 'no_default', value: '' }]); + }); + + it('includes live-only variables absent from the schema', () => { + expect(buildPageVariablesExport({ tags: [] }, [{ name: 'legacy', value: 'x' }])).toEqual([ + { name: 'legacy', value: 'x' }, + ]); + }); + + it('degraded mode (schema === null) exports the live values as-is', () => { + expect(buildPageVariablesExport(null, [{ name: 'title', value: 'Live' }])).toEqual([ + { name: 'title', value: 'Live' }, + ]); + }); +}); diff --git a/tests/unit/lib/variables-editor.spec.ts b/tests/unit/lib/variables-editor.spec.ts new file mode 100644 index 0000000..f520d83 --- /dev/null +++ b/tests/unit/lib/variables-editor.spec.ts @@ -0,0 +1,345 @@ +import { describe, it, expect, vi } from 'vitest'; +import { registerVariablesEditorRoutes, VARIABLES_EDITOR_PATH } from '../../../src/lib/variables-editor.js'; +import type { DistService } from '../../../src/lib/dist.service.js'; +import type { MiAPI } from '../../../src/lib/pp.middleware.js'; + +function makeApp() { + const handlers = new Map any>(); + + const app = { + handlers, + get(path: string, handler: (req: any, res: any) => any) { + handlers.set(`GET ${path}`, handler); + }, + put(path: string, handler: (req: any, res: any) => any) { + handlers.set(`PUT ${path}`, handler); + }, + }; + + return app as unknown as import('express').Application & { handlers: typeof handlers }; +} + +function makeRes() { + const res: any = { + statusCode: 200, + body: undefined, + headers: {} as Record, + status(code: number) { + res.statusCode = code; + + return res; + }, + json(payload: unknown) { + res.body = payload; + + return res; + }, + setHeader(name: string, value: string) { + res.headers[name] = value; + }, + end(payload?: unknown) { + res.body = payload; + }, + }; + + return res; +} + +// `registerVariablesEditorRoutes` ties its handlers to the FIRST `app` it's ever called with +// for the lifetime of the process (Express routes can't be unregistered) β€” every subsequent +// call, even against a different `app`, only updates the module-level `current` deps ref that +// those already-registered handlers read from. So every test below shares one `app` and gets +// fresh behavior purely by re-registering with new `deps` before invoking a handler. +const sharedApp = makeApp(); + +function register(deps: { distService?: DistService; miAPI: MiAPI }) { + registerVariablesEditorRoutes(sharedApp, deps); + + return sharedApp; +} + +describe('registerVariablesEditorRoutes', () => { + it('never re-registers routes on a different app instance (routesInstalled guard)', () => { + register({ miAPI: {} as unknown as MiAPI }); + + const otherApp = makeApp(); + + registerVariablesEditorRoutes(otherApp, { miAPI: {} as unknown as MiAPI }); + + expect(sharedApp.handlers.size).toBeGreaterThan(0); + expect(otherApp.handlers.size).toBe(0); + }); + + describe('GET /@api/variables/schema', () => { + it('errors when distService is not defined', async () => { + const app = register({ miAPI: {} as unknown as MiAPI }); + const res = makeRes(); + + await app.handlers.get('GET /@api/variables/schema')!({}, res); + + expect(res.statusCode).toBe(503); + expect(res.body).toEqual({ error: 'Dist service or MiAPI is not defined' }); + }); + + it('reports exists:false when the file is missing', async () => { + const distService = { + readPublicTemplateVariablesFile: vi.fn().mockResolvedValue(null), + } as unknown as DistService; + const app = register({ distService, miAPI: {} as unknown as MiAPI }); + const res = makeRes(); + + await app.handlers.get('GET /@api/variables/schema')!({}, res); + + expect(res.body).toEqual({ exists: false, schema: null, raw: null }); + }); + + it('returns the parsed schema plus raw text for a valid file', async () => { + const raw = JSON.stringify({ tags: [{ name: 'foo' }] }); + const distService = { + readPublicTemplateVariablesFile: vi.fn().mockResolvedValue(Buffer.from(raw)), + } as unknown as DistService; + const app = register({ distService, miAPI: {} as unknown as MiAPI }); + const res = makeRes(); + + await app.handlers.get('GET /@api/variables/schema')!({}, res); + + expect(res.body).toEqual({ exists: true, schema: { tags: [{ name: 'foo' }] }, raw }); + }); + + it('reports a parseError for a corrupt file, still returning raw text', async () => { + const distService = { + readPublicTemplateVariablesFile: vi.fn().mockResolvedValue(Buffer.from('not json')), + } as unknown as DistService; + const app = register({ distService, miAPI: {} as unknown as MiAPI }); + const res = makeRes(); + + await app.handlers.get('GET /@api/variables/schema')!({}, res); + + expect(res.body.exists).toBe(true); + expect(res.body.schema).toBeNull(); + expect(res.body.raw).toBe('not json'); + expect(res.body.parseError).toBeTruthy(); + }); + }); + + describe('PUT /@api/variables/schema', () => { + function setup() { + const saveTemplateVariablesFile = vi.fn().mockResolvedValue('public/__template_variables.json'); + const distService = { + readPublicTemplateVariablesFile: vi.fn(), + saveTemplateVariablesFile, + } as unknown as DistService; + const app = register({ distService, miAPI: {} as unknown as MiAPI }); + + return { app, saveTemplateVariablesFile }; + } + + it('400s on malformed JSON', async () => { + const { app } = setup(); + const res = makeRes(); + + await app.handlers.get('PUT /@api/variables/schema')!({ body: { raw: 'not json' } }, res); + + expect(res.statusCode).toBe(400); + }); + + it('400s when tags is not an array', async () => { + const { app } = setup(); + const res = makeRes(); + + await app.handlers.get('PUT /@api/variables/schema')!( + { body: { raw: JSON.stringify({ tags: 'nope' }) } }, + res, + ); + + expect(res.statusCode).toBe(400); + }); + + it('400s when a tag is missing a non-empty name', async () => { + const { app } = setup(); + const res = makeRes(); + + await app.handlers.get('PUT /@api/variables/schema')!( + { body: { raw: JSON.stringify({ tags: [{ name: '' }] }) } }, + res, + ); + + expect(res.statusCode).toBe(400); + }); + + it('saves and returns warnings for duplicate names / unknown tag_type, without blocking', async () => { + const { app, saveTemplateVariablesFile } = setup(); + const raw = JSON.stringify({ + tags: [ + { name: 'dup', tag_type: 'text' }, + { name: 'dup', tag_type: 'not-a-real-type' }, + ], + }); + + const res = makeRes(); + + await app.handlers.get('PUT /@api/variables/schema')!({ body: { raw } }, res); + + expect(res.statusCode).toBe(200); + expect(res.body.ok).toBe(true); + expect(res.body.warnings.length).toBeGreaterThanOrEqual(2); + expect(saveTemplateVariablesFile).toHaveBeenCalledWith(Buffer.from(raw, 'utf-8')); + }); + + it('warns (without blocking) on a name MI\'s own editor would reject, but accepts a valid one', async () => { + const { app } = setup(); + const raw = JSON.stringify({ + tags: [ + { name: 'has a valid_name-here' }, + { name: 'has$special!chars' }, + ], + }); + + const res = makeRes(); + + await app.handlers.get('PUT /@api/variables/schema')!({ body: { raw } }, res); + + expect(res.statusCode).toBe(200); + expect(res.body.ok).toBe(true); + expect(res.body.warnings).toEqual([ + expect.stringContaining('"has$special!chars" contains a character'), + ]); + }); + }); + + describe('GET /@api/variables/values', () => { + it('combines schema defaults with live values via buildPageVariablesExport', async () => { + const distService = { + readPublicTemplateVariablesFile: vi.fn().mockResolvedValue( + Buffer.from(JSON.stringify({ tags: [{ name: 'greeting', default_value: 'hello' }] })), + ), + } as unknown as DistService; + const miAPI = { + getLivePageVariables: vi.fn().mockResolvedValue([{ name: 'title', value: 'Live Title' }]), + } as unknown as MiAPI; + const app = register({ distService, miAPI }); + const res = makeRes(); + + await app.handlers.get('GET /@api/variables/values')!({}, res); + + expect(res.body.live).toEqual([{ name: 'title', value: 'Live Title' }]); + expect(res.body.combined).toEqual( + expect.arrayContaining([ + { name: 'greeting', value: 'hello' }, + { name: 'title', value: 'Live Title' }, + ]), + ); + }); + + it('502s when fetching live variables fails', async () => { + const distService = { + readPublicTemplateVariablesFile: vi.fn().mockResolvedValue(null), + } as unknown as DistService; + const miAPI = { + getLivePageVariables: vi.fn().mockRejectedValue(new Error('boom')), + } as unknown as MiAPI; + const app = register({ distService, miAPI }); + const res = makeRes(); + + await app.handlers.get('GET /@api/variables/values')!({}, res); + + expect(res.statusCode).toBe(502); + }); + }); + + describe('PUT /@api/variables/values', () => { + it('400s on a malformed body', async () => { + const app = register({ miAPI: { applyPageVariables: vi.fn() } as unknown as MiAPI }); + const res = makeRes(); + + await app.handlers.get('PUT /@api/variables/values')!({ body: { tags: [{ name: 'x' }] } }, res); + + expect(res.statusCode).toBe(400); + }); + + it('saves and returns non-blocking warnings from validateValueAgainstTag', async () => { + const distService = { + readPublicTemplateVariablesFile: vi.fn().mockResolvedValue( + Buffer.from(JSON.stringify({ tags: [{ name: 'flag', tag_type: 'boolean' }] })), + ), + } as unknown as DistService; + const applyPageVariables = vi.fn().mockResolvedValue(undefined); + const app = register({ distService, miAPI: { applyPageVariables } as unknown as MiAPI }); + const res = makeRes(); + + await app.handlers.get('PUT /@api/variables/values')!( + { body: { tags: [{ name: 'flag', value: 'not-a-bool' }] } }, + res, + ); + + expect(res.statusCode).toBe(200); + expect(res.body.ok).toBe(true); + expect(res.body.warnings).toContainEqual(expect.objectContaining({ name: 'flag', severity: 'warning' })); + expect(applyPageVariables).toHaveBeenCalledWith([{ name: 'flag', value: 'not-a-bool' }]); + }); + + it('502s when applyPageVariables throws', async () => { + const distService = { + readPublicTemplateVariablesFile: vi.fn().mockResolvedValue(null), + } as unknown as DistService; + const miAPI = { + applyPageVariables: vi.fn().mockRejectedValue(new Error('boom')), + } as unknown as MiAPI; + const app = register({ distService, miAPI }); + const res = makeRes(); + + await app.handlers.get('PUT /@api/variables/values')!( + { body: { tags: [{ name: 'x', value: 'y' }] } }, + res, + ); + + expect(res.statusCode).toBe(502); + }); + }); + + describe('GET /@api/variables/new-uid', () => { + it('returns a 32-char lowercase hex md5-shaped uid', async () => { + const app = register({ miAPI: {} as unknown as MiAPI }); + const res = makeRes(); + + await app.handlers.get('GET /@api/variables/new-uid')!({}, res); + + expect(res.body.uid).toMatch(/^[0-9a-f]{32}$/); + }); + + it('returns a fresh uid on every call', async () => { + const app = register({ miAPI: {} as unknown as MiAPI }); + const res1 = makeRes(); + const res2 = makeRes(); + + await app.handlers.get('GET /@api/variables/new-uid')!({}, res1); + await app.handlers.get('GET /@api/variables/new-uid')!({}, res2); + + expect(res1.body.uid).not.toBe(res2.body.uid); + }); + }); + + describe(`GET ${VARIABLES_EDITOR_PATH}`, () => { + it('serves an HTML page', async () => { + const app = register({ miAPI: {} as unknown as MiAPI }); + const res = makeRes(); + + app.handlers.get(`GET ${VARIABLES_EDITOR_PATH}`)!({}, res); + + expect(res.headers['Content-Type']).toContain('text/html'); + expect(typeof res.body).toBe('string'); + expect(res.body).toContain('Variables Editor'); + }); + + it('shows an info message with no tabs/Save when the page is templateLess', async () => { + const app = register({ miAPI: { isTemplateLess: true } as unknown as MiAPI }); + const res = makeRes(); + + app.handlers.get(`GET ${VARIABLES_EDITOR_PATH}`)!({}, res); + + expect(res.body).toContain('no associated template'); + expect(res.body).not.toContain('id="tab-schema"'); + expect(res.body).not.toContain('id="save-btn"'); + }); + }); +});