admin: parsed JSONC settings editor (takes over #7666, closes #7603) (#7709)

* admin: parsed JSONC settings editor with form view (#7603, #7666)

Takes over #7666 / closes #7603. Squashed rebase of 32 commits onto
current develop (which has since absorbed admin design rework #7716
and admin i18n fixes #7736 — granular history preserved on
takeover/7666-admin-settings-editor before this squash, see PR
description for the original commit log).

Highlights:

- New parsed JSONC settings editor under
  admin/src/components/settings/ — FormView, ModeToggle,
  ParseErrorBanner, JsoncNode dispatcher, leaf widgets (string,
  number, bool, null, env pill), and pure helpers (comments,
  envPill, jsoncEdit, labels, templateComments).
- ${VAR:default} env placeholders render as editable inline inputs
  that round-trip through the raw textarea (env-pill spec asserts
  this; docker-template spec protects against form-view degradation
  on env-heavy configs).
- Schema-driven help text sourced from settings.json.template,
  inlined at build time via vite (drops the runtime fs.allow
  widening that earlier iterations needed).
- ModeToggle switches between FormView and raw textarea on
  /admin/settings; parse errors surface in a non-blocking banner.
- jsonc-parser dep added; pure helpers wrap modify() for stable
  edits that preserve key order and trailing comments (stops at
  end-of-line so trailing-comment trains don't bleed into the next
  property).
- i18n keys added for form mode, parse error, env pill,
  default_label, and input aria.
- Playwright specs cover form view, env pill, parse error banner,
  raw round-trip, and form-mode regressions called out in #7666
  review (stable React keys from AST offsets, save-toast on server
  ack only, NumberInput draft sync, parse-error flash during
  initial load, .settings CSS conflict resolution, focus retention
  via rAF, IconButton type defaulting to 'button').

Co-authored-by: Ayushi Gupta <ayushigupta36881@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(admin): stabilise React keys to prevent focus loss in settings editor

Switch React keys in JsoncNode and FormView from byte offsets to stable
JSON paths (`getNodePath(...).join('.')`). Byte offsets shift on every
keystroke because the edit changes the surrounding character count,
which forces React to remount inputs and lose focus mid-typing.

- Object children key on the property path.
- Array elements key on their JSON path index.
- Add a Playwright regression test pinning focus stability for array
  element edits.

Co-authored-by: John McLear <john@mclear.co.uk>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(admin): stop trailing /* */ comments from bleeding into next key's label (#7740)

In the parsed settings form view, each key's row was rendering its label
as the previous keys' source lines concatenated together. Root cause:
findLeading() in admin/src/components/settings/comments.ts treated any
line ending in `*/` as a comment continuation, so a JSON line like
  "altF9": true, /* focus on the File Menu and/or editbar */
was absorbed into the next sibling's leading comment block, and then
each subsequent key picked up an even longer accumulation.

- Tighten findLeading's isComment check to only match structural comment
  lines (`//`, `/*`, or a `*`-prefixed continuation/close), so JSON code
  with a trailing block comment no longer matches.
- Surface leading and trailing comments separately from the template
  map. Leaf rows with only a trailing same-line comment now render the
  humanized key as the row label and the comment as the help text below
  the control, matching settings.json.template's convention (and #7740's
  recommendation that "helper text should be below").
- Add unit tests pinning the regression and the JSDoc/`//` leading
  styles, plus a Playwright spec that asserts altC's row carries a
  clean label and the "focus on the Chat window" help text.

Closes #7740.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Ayushi Gupta <ayushigupta36881@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-05-15 10:41:37 +01:00 committed by GitHub
parent 7d537f3deb
commit ab60ed33c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 3554 additions and 128 deletions

View file

@ -11,12 +11,13 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"build-copy": "pnpm gen:api && tsc && vite build --outDir ../src/templates/admin --emptyOutDir", "build-copy": "pnpm gen:api && tsc && vite build --outDir ../src/templates/admin --emptyOutDir",
"preview": "vite preview", "preview": "vite preview",
"test": "pnpm gen:api && tsx --test src/api/__tests__/client.test.ts" "test": "pnpm gen:api && tsx --test 'src/**/__tests__/*.test.ts'"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@tanstack/react-query": "^5.100.10", "@tanstack/react-query": "^5.100.10",
"@tanstack/react-query-devtools": "^5.100.10", "@tanstack/react-query-devtools": "^5.100.10",
"jsonc-parser": "^3.3.1",
"openapi-fetch": "^0.17.0", "openapi-fetch": "^0.17.0",
"openapi-react-query": "^0.5.4" "openapi-react-query": "^0.5.4"
}, },

View file

@ -0,0 +1,290 @@
/* Raw textarea (kept dark to signal "this is code") */
textarea.settings {
font-family: "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
font-size: 14px;
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
width: 100%;
height: 500px;
padding: 15px;
background-color: #1e1e1e;
color: #d4d4d4;
line-height: 1.5;
border: 1px solid #333;
resize: vertical;
}
textarea.settings:focus {
outline: 2px solid #007acc;
outline-offset: -1px;
}
.settings-button-bar {
display: flex;
flex-shrink: 0;
gap: 10px;
margin-top: 15px;
}
.settings-links {
display: flex;
gap: 20px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ddd;
}
/* --- mode toggle --- */
.settings-mode-toggle {
display: inline-flex;
flex-shrink: 0;
border: 1px solid #ccc;
border-radius: 6px;
overflow: hidden;
margin-bottom: 16px;
background: #fff;
}
.settings-mode-toggle button {
padding: 6px 14px;
border: 0;
background: transparent;
color: #555;
font: inherit;
cursor: pointer;
}
.settings-mode-toggle button.active {
background: var(--etherpad-color, #0f775b);
color: #fff;
}
/* --- form (light, two-column) --- */
.settings-form {
font-family: inherit;
font-size: 14px;
color: #333;
}
.settings-section {
background: #fff;
border: 1px solid #ddd;
border-radius: 6px;
margin-bottom: 18px;
overflow: hidden;
}
.settings-section-header {
padding: 14px 18px;
border-bottom: 1px solid #eee;
background: #fafafa;
}
.settings-section-header h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #222;
letter-spacing: 0.01em;
text-transform: uppercase;
}
.settings-section-header p {
margin: 4px 0 0;
font-size: 13px;
color: #666;
white-space: pre-wrap;
}
.settings-section-body {
padding: 4px 0;
}
/* Two-column row: label | control, with help below spanning column 2.
* Single-column on narrow widths. */
.settings-row {
display: grid;
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
gap: 6px 18px;
padding: 10px 18px;
align-items: center;
border-top: 1px solid #f4f4f4;
}
.settings-row:first-child {
border-top: 0;
}
.settings-row-label {
font-weight: 600;
color: #333;
word-break: break-word;
}
.settings-row-control {
min-width: 0;
}
.settings-row-help {
grid-column: 2;
margin: 2px 0 0;
font-size: 12.5px;
color: #666;
white-space: pre-wrap;
line-height: 1.4;
}
@media (max-width: 600px) {
.settings-row {
grid-template-columns: 1fr;
}
.settings-row-help {
grid-column: 1;
}
}
/* --- nested subsections (objects/arrays inside a section) --- */
.settings-subsection {
grid-column: 1 / -1;
margin: 8px 18px;
border-left: 3px solid #e2e2e2;
padding-left: 14px;
}
.settings-subsection-header {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 0;
}
.settings-subsection-title {
font-weight: 600;
color: #444;
font-size: 13.5px;
text-transform: uppercase;
letter-spacing: 0.01em;
}
.settings-subsection-help {
color: #777;
font-size: 12.5px;
white-space: pre-wrap;
}
.settings-subsection-body .settings-row {
padding-left: 0;
padding-right: 0;
}
/* --- leaf widgets (light) --- */
.settings-widget-string,
.settings-widget-number {
width: 100%;
background: #fff;
color: #222;
border: 1px solid #ccc;
border-radius: 4px;
padding: 6px 10px;
font-family: inherit;
font-size: inherit;
}
.settings-widget-string:focus,
.settings-widget-number:focus {
outline: none;
border-color: var(--etherpad-color, #0f775b);
box-shadow: 0 0 0 3px rgba(15, 119, 91, 0.15);
}
.settings-widget-number.invalid {
border-color: #ce5050;
}
.settings-widget-null {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
background: #f0f0f0;
color: #888;
font-style: italic;
font-size: 12.5px;
}
.settings-widget-env {
display: inline-flex;
align-items: center;
gap: 6px;
background: #f4f8ff;
color: #335;
border: 1px dashed #88a;
border-radius: 12px;
padding: 2px 10px;
font-size: 13px;
}
.settings-widget-env-icon {
font-style: normal;
color: #557;
}
.settings-widget-env-name {
font-family: "Fira Code", monospace;
font-weight: 600;
}
.settings-widget-env-default-label {
color: #557;
font-size: 12px;
text-transform: lowercase;
}
.settings-widget-env-default-input {
background: white;
border: 1px solid #ccd;
border-radius: 6px;
padding: 1px 6px;
font-family: "Fira Code", monospace;
font-size: 13px;
color: #804;
min-width: 80px;
max-width: 240px;
width: auto;
}
.settings-widget-env-default-input:focus {
outline: 2px solid var(--accent, #2b8a3e);
outline-offset: 1px;
}
/* Radix switch (boolean) */
.settings-widget-boolean {
appearance: none;
width: 36px;
height: 20px;
border-radius: 999px;
background: #ccc;
border: 0;
position: relative;
cursor: pointer;
transition: background 120ms ease;
padding: 0;
}
.settings-widget-boolean[data-state="checked"] {
background: var(--etherpad-color, #0f775b);
}
.settings-widget-boolean-thumb {
display: block;
width: 16px;
height: 16px;
background: #fff;
border-radius: 50%;
transform: translateX(2px);
transition: transform 120ms ease;
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
.settings-widget-boolean[data-state="checked"] .settings-widget-boolean-thumb {
transform: translateX(18px);
}
/* --- parse error --- */
.settings-parse-error {
border: 1px solid #d99;
background: #fff5f5;
color: #842;
padding: 14px 18px;
border-radius: 6px;
}
.settings-parse-error-detail {
margin: 8px 0;
white-space: pre-wrap;
font-family: "Fira Code", monospace;
font-size: 12.5px;
}
.settings-parse-error button {
margin-top: 4px;
background: var(--etherpad-color, #0f775b);
color: #fff;
border: 0;
padding: 6px 14px;
border-radius: 4px;
cursor: pointer;
font: inherit;
}

View file

@ -63,7 +63,15 @@ export const App = () => {
useStore.getState().setShowLoading(false); useStore.getState().setShowLoading(false);
}); });
settingSocket.on('saveprogress', (status) => console.log(status)) settingSocket.on('saveprogress', (status: string, payload?: {message?: string}) => {
const {setToastState} = useStore.getState();
if (status === 'saved') {
setToastState({open: true, title: t('admin_settings.toast.saved'), success: true});
} else {
const detail = payload?.message ?? '';
setToastState({open: true, title: t('admin_settings.toast.save_failed') + (detail ? ` (${detail})` : ''), success: false});
}
})
return () => { return () => {
settingSocket.disconnect(); settingSocket.disconnect();

View file

@ -1,17 +1,14 @@
import {FC, JSX, ReactElement} from "react"; import {ButtonHTMLAttributes, FC, JSX, ReactElement} from "react";
export type IconButtonProps = { export type IconButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title' | 'onClick'> & {
style?: React.CSSProperties,
icon: JSX.Element, icon: JSX.Element,
title: string|ReactElement, title: string|ReactElement,
onClick: ()=>void, onClick: ()=>void,
className?: string,
disabled?: boolean
} }
export const IconButton:FC<IconButtonProps> = ({icon,className,onClick,title, disabled, style})=>{ export const IconButton: FC<IconButtonProps> = ({icon, className, onClick, title, type = 'button', ...rest}) => (
return <button style={style} onClick={onClick} className={"icon-button "+ className} disabled={disabled}> <button {...rest} type={type} onClick={onClick} className={"icon-button " + (className ?? "")}>
{icon} {icon}
<span>{title}</span> <span>{title}</span>
</button> </button>
} );

View file

@ -0,0 +1,144 @@
import { parseTree, getNodePath, type JSONPath, type Node, type ParseError } from 'jsonc-parser';
import { useStore } from '../../store/store';
import { useTranslation } from 'react-i18next';
import { editJsonc } from './jsoncEdit';
import { JsoncNode } from './JsoncNode';
import { ParseErrorBanner } from './ParseErrorBanner';
import { extractAdjacentComments } from './comments';
import { lookupTemplateComment } from './templateComments';
import { labelAndHelp } from './labels';
type Props = {
onSwitchToRaw: () => void;
};
// Parser-error token labels are kept in English — they are technical tokens
// matching the jsonc-parser error enum, not user-facing prose.
const ParseErrorMessage: Record<number, string> = {
1: 'Invalid symbol',
2: 'Invalid number format',
3: 'Property name expected',
4: 'Value expected',
5: 'Colon expected',
6: 'Comma expected',
7: 'Closing brace expected',
8: 'Closing bracket expected',
9: 'End of file expected',
16: 'Unexpected end of comment',
17: 'Unexpected end of string',
18: 'Unexpected end of number',
19: 'Invalid unicode',
20: 'Invalid escape character',
21: 'Invalid character',
};
const formatErrors = (errors: ParseError[]): string =>
errors.length === 0
? ''
: errors.map(e => `offset ${e.offset}: ${ParseErrorMessage[e.error] ?? 'parse error'}`).join('\n');
const Section = ({ title, description, children }: {
title: string;
description?: string;
children: React.ReactNode;
}) => (
<section className="settings-section">
<header className="settings-section-header">
<h2>{title}</h2>
{description && <p>{description}</p>}
</header>
<div className="settings-section-body">{children}</div>
</section>
);
const propertyKey = (prop: Node): string =>
prop.type === 'property' && prop.children?.[0]?.type === 'string'
? String(prop.children[0].value)
: '';
const propertyComment = (prop: Node, text: string, key: string): string | null => {
const valueNode = prop.children?.[1];
if (!valueNode) return null;
const live = extractAdjacentComments(text, prop.offset, valueNode.offset, valueNode.length);
if (live.leading) return live.leading;
// Section headings prefer the leading block comment; only fall back to the
// trailing comment if no leading documentation exists at all.
const tmpl = lookupTemplateComment([key]);
if (!tmpl) return null;
return tmpl.leading || tmpl.trailing || null;
};
export const FormView = ({ onSwitchToRaw }: Props) => {
const { t } = useTranslation();
const rawText = useStore(s => s.settings);
// While settings haven't loaded yet, show an empty busy placeholder so we
// don't flash a parse-error banner for the undefined→'' empty-string case.
if (rawText === undefined) {
return <div className="settings-form" data-testid="settings-form-view" aria-busy="true" />;
}
const text = rawText;
const errors: ParseError[] = [];
const tree = parseTree(text, errors, { allowTrailingComma: true });
// Always read the latest text from the store instead of closing over the
// render-time snapshot, so rapid sequential edits don't clobber each other.
const onEdit = (path: JSONPath, value: unknown) => {
const current = useStore.getState().settings ?? '';
useStore.getState().setSettings(editJsonc(current, path, value));
};
if (!tree || errors.length > 0 || tree.type !== 'object') {
return <ParseErrorBanner message={formatErrors(errors)} onSwitchToRaw={onSwitchToRaw} />;
}
const generalProps: Node[] = [];
const sectionProps: Node[] = [];
for (const prop of tree.children ?? []) {
if (prop.type !== 'property' || !prop.children?.[1]) continue;
const valueType = prop.children[1].type;
if (valueType === 'object' || valueType === 'array') sectionProps.push(prop);
else generalProps.push(prop);
}
return (
<div className="settings-form" data-testid="settings-form-view">
{generalProps.length > 0 && (
<Section title={t('admin_settings.section.general')}>
{generalProps.map((prop) => {
const propPath = getNodePath(prop);
const propKey = propPath.join('.');
return (
<JsoncNode
key={propKey}
node={prop.children![1]}
property={prop}
text={text}
onEdit={onEdit}
/>
);
})}
</Section>
)}
{sectionProps.map((prop) => {
const key = propertyKey(prop);
const { label, help } = labelAndHelp(propertyComment(prop, text, key), key);
const propPath = getNodePath(prop);
const sectionKey = propPath.join('.');
return (
<Section key={sectionKey} title={label} description={help}>
<JsoncNode
node={prop.children![1]}
property={prop}
text={text}
onEdit={onEdit}
suppressOwnHeader
/>
</Section>
);
})}
</div>
);
};

View file

@ -0,0 +1,173 @@
import type { JSONPath, Node } from 'jsonc-parser';
import { getNodePath } from 'jsonc-parser';
import { extractAdjacentComments } from './comments';
import { matchEnvPlaceholder } from './envPill';
import { lookupTemplateComment } from './templateComments';
import { humanize, labelAndHelp } from './labels';
import { StringInput } from './widgets/StringInput';
import { NumberInput } from './widgets/NumberInput';
import { BooleanToggle } from './widgets/BooleanToggle';
import { NullChip } from './widgets/NullChip';
import { EnvPill } from './widgets/EnvPill';
type Props = {
/** The value node (not the property node). */
node: Node;
/** The property node, when this value is the value-side of `"key": value`. */
property?: Node;
text: string;
onEdit: (path: JSONPath, value: unknown) => void;
/**
* When true, this group's own label/header is suppressed because a
* containing Section already rendered it. The group's children still
* render. Used for top-level object/array sections in FormView.
*/
suppressOwnHeader?: boolean;
};
const propertyKey = (property: Node | undefined): string => {
if (!property || property.type !== 'property') return '';
const k = property.children?.[0];
return k?.type === 'string' ? String(k.value) : '';
};
const renderLeaf = (
node: Node,
path: JSONPath,
text: string,
onEdit: (path: JSONPath, value: unknown) => void,
) => {
if (node.type === 'string') {
const raw = text.slice(node.offset, node.offset + node.length);
const env = matchEnvPlaceholder(raw);
if (env) {
return (
<EnvPill
placeholder={env}
path={path}
onChange={(d) => onEdit(path, `\${${env.variable}:${d}}`)}
/>
);
}
return (
<StringInput
value={String(node.value)}
path={path}
onChange={v => onEdit(path, v)}
/>
);
}
if (node.type === 'number') {
return (
<NumberInput
value={Number(node.value)}
path={path}
onChange={v => onEdit(path, v)}
/>
);
}
if (node.type === 'boolean') {
return (
<BooleanToggle
value={Boolean(node.value)}
path={path}
onChange={v => onEdit(path, v)}
/>
);
}
if (node.type === 'null') {
return <NullChip path={path} />;
}
return null;
};
export const JsoncNode = ({ node, property, text, onEdit, suppressOwnHeader }: Props) => {
const path = getNodePath(node);
const key = propertyKey(property);
const anchor = property ?? node;
const fileComments = extractAdjacentComments(text, anchor.offset, node.offset, node.length);
const tmpl = property ? lookupTemplateComment(path) : null;
const leading = fileComments.leading || tmpl?.leading || '';
const trailing = fileComments.trailing || tmpl?.trailing || '';
// Leading block comments (e.g. /* Description … */ above a key) carry the
// descriptive label — use labelAndHelp's first-sentence split.
// Trailing same-line comments (e.g. "altF9": true, /* focus on … */) are
// brief per-key annotations: the key itself reads as the label, the comment
// belongs in the help slot below the control. See #7740.
let label: string;
let help: string;
if (leading) {
const r = labelAndHelp(leading, key);
label = r.label;
help = [r.help, trailing].filter(Boolean).join(' ');
} else if (trailing) {
label = humanize(key);
help = trailing;
} else {
label = humanize(key);
help = '';
}
const rowId = `settings-row-${path.join('.') || 'root'}`;
const helpId = help ? `${rowId}-help` : undefined;
// ---- Object / array groups ----
if (node.type === 'object' || node.type === 'array') {
const children = (node.children ?? []).map((child) => {
// For object: child is a property node, drill into its value node.
// For array: child is a value node directly.
if (node.type === 'object') {
const valueNode = child.children?.[1];
if (!valueNode) return null;
const propPath = getNodePath(child);
const propKey = propPath.join('.');
return (
<JsoncNode
key={propKey}
node={valueNode}
property={child}
text={text}
onEdit={onEdit}
/>
);
}
// Array element: use stable JSON path as key.
const childPath = getNodePath(child);
return <JsoncNode key={childPath.join('.')} node={child} text={text} onEdit={onEdit} />;
});
if (suppressOwnHeader || !property) {
// Render children flat — the containing Section provides the label.
return <>{children}</>;
}
// Nested group within a section: render as a sub-section with its own
// heading, indented under its parent.
return (
<div className="settings-subsection" data-testid={`group-${path.join('.')}`}>
<div className="settings-subsection-header">
<span className="settings-subsection-title">{label}</span>
{help && <span className="settings-subsection-help">{help}</span>}
</div>
<div className="settings-subsection-body">{children}</div>
</div>
);
}
// ---- Leaf row ----
return (
<div className="settings-row" id={rowId}>
<label className="settings-row-label" htmlFor={`field-${path.join('.')}`}>
{label}
</label>
<div className="settings-row-control">
{renderLeaf(node, path, text, onEdit)}
</div>
{help && (
<p className="settings-row-help" id={helpId}>{help}</p>
)}
</div>
);
};

View file

@ -0,0 +1,36 @@
import { Trans, useTranslation } from 'react-i18next';
export type Mode = 'form' | 'raw';
type Props = {
mode: Mode;
onChange: (mode: Mode) => void;
};
export const ModeToggle = ({ mode, onChange }: Props) => {
const { t } = useTranslation();
return (
<div className="settings-mode-toggle" role="tablist" aria-label={t('admin_settings.mode.aria_label')}>
<button
type="button"
role="tab"
aria-selected={mode === 'form'}
data-testid="mode-toggle-form"
className={mode === 'form' ? 'active' : ''}
onClick={() => onChange('form')}
>
<Trans i18nKey="admin_settings.mode.form" />
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'raw'}
data-testid="mode-toggle-raw"
className={mode === 'raw' ? 'active' : ''}
onClick={() => onChange('raw')}
>
<Trans i18nKey="admin_settings.mode.raw" />
</button>
</div>
);
};

View file

@ -0,0 +1,16 @@
import { Trans } from 'react-i18next';
type Props = {
message: string;
onSwitchToRaw: () => void;
};
export const ParseErrorBanner = ({ message, onSwitchToRaw }: Props) => (
<div className="settings-parse-error" role="alert" data-testid="parse-error-banner">
<strong><Trans i18nKey="admin_settings.parse_error.title" /></strong>
<pre className="settings-parse-error-detail">{message}</pre>
<button type="button" onClick={onSwitchToRaw} data-testid="parse-error-switch-raw">
<Trans i18nKey="admin_settings.parse_error.cta" />
</button>
</div>
);

View file

@ -0,0 +1,93 @@
// admin/src/components/settings/__tests__/comments.test.ts
//
// Regression coverage for https://github.com/ether/etherpad/issues/7740.
// A previous version of findLeading treated any line ending in `*/` as a
// comment continuation; a JSON line like
// "altF9": true, /* focus on the File Menu and/or editbar */
// then leaked into the next sibling's "leading comment", which the form
// view rendered as the row label.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { extractAdjacentComments } from '../comments.ts';
import { humanize, labelAndHelp } from '../labels.ts';
const padShortcutText = `{
"padShortcutEnabled" : {
"altF9": true, /* focus on the File Menu and/or editbar */
"altC": true, /* focus on the Chat window */
"cmdShift2": true, /* shows a gritter popup showing a line author */
"delete": true,
"return": true,
"esc": true, /* in mozilla versions 14-19 avoid reconnecting pad */
"cmdS": true /* save a revision */
}
}`;
const offsetsFor = (text: string, key: string) => {
const keyOffset = text.indexOf(`"${key}"`);
const valOffset = text.indexOf('true', keyOffset);
return { keyOffset, valOffset, valLength: 4 };
};
test('does not absorb prior JSON line with trailing comment as leading', () => {
const { keyOffset, valOffset, valLength } = offsetsFor(padShortcutText, 'altC');
const { leading, trailing } =
extractAdjacentComments(padShortcutText, keyOffset, valOffset, valLength);
assert.equal(leading, '');
assert.equal(trailing, 'focus on the Chat window');
});
test('does not accumulate multiple prior trailing-comment lines', () => {
const { keyOffset, valOffset, valLength } = offsetsFor(padShortcutText, 'cmdShift2');
const { leading } =
extractAdjacentComments(padShortcutText, keyOffset, valOffset, valLength);
assert.equal(leading, '');
});
test('leading is empty when prior line is plain code (no trailing comment)', () => {
const { keyOffset, valOffset, valLength } = offsetsFor(padShortcutText, 'return');
const { leading } =
extractAdjacentComments(padShortcutText, keyOffset, valOffset, valLength);
assert.equal(leading, '');
});
test('still recognises JSDoc-style leading block comments', () => {
const text = `{
/*
* Pad Shortcut Keys
*/
"padShortcutEnabled" : {}
}`;
const keyOffset = text.indexOf('"padShortcutEnabled"');
const valOffset = text.indexOf('{}');
const { leading, trailing } = extractAdjacentComments(text, keyOffset, valOffset, 2);
assert.equal(leading, 'Pad Shortcut Keys');
assert.equal(trailing, '');
});
test('still recognises single-line // leading comments', () => {
const text = `{
// Whether to enable the thing.
"thing": true
}`;
const keyOffset = text.indexOf('"thing"');
const valOffset = text.indexOf('true');
const { leading } = extractAdjacentComments(text, keyOffset, valOffset, 4);
assert.equal(leading, 'Whether to enable the thing.');
});
test('humanize spaces camelCase and capitalises only the first word', () => {
assert.equal(humanize('requireAuthentication'), 'Require authentication');
assert.equal(humanize('altF9'), 'Alt f9');
});
test('labelAndHelp splits a leading block at the first sentence boundary', () => {
const { label, help } = labelAndHelp(
'Name your instance! Optional context follows.',
'title',
);
assert.equal(label, 'Name your instance!');
assert.equal(help, 'Optional context follows.');
});

View file

@ -0,0 +1,95 @@
// admin/src/components/settings/comments.ts
//
// Given the source text and a property's `keyOffset` (jsonc-parser's
// Node.offset for the property node), extract:
// - `leading`: the contiguous run of `/* */` or `//` comments
// immediately above the key. At most one blank line is allowed
// between the comment block and the key.
// - `trailing`: a single `// ...` or `/* ... */` on the same line
// as the value, after any trailing comma.
export type AdjacentComments = {
leading: string;
trailing: string;
};
const LINE_BREAK = /\r?\n/;
const stripCommentMarkers = (raw: string): string => {
// raw is a concatenation of comment tokens separated by newlines.
// Drop /* */ and // markers and trim each line.
return raw
.split(LINE_BREAK)
.map(line => line
.replace(/^\s*\/\*+/, '')
.replace(/\*+\/\s*$/, '')
.replace(/^\s*\*\s?/, '')
.replace(/^\s*\/\/\s?/, '')
.trim())
.filter(line => line.length > 0)
.join(' ');
};
const findLeading = (text: string, keyOffset: number): string => {
// Walk backwards from keyOffset to the start of the line containing it.
const lineStart = text.lastIndexOf('\n', keyOffset - 1) + 1;
let cursor = lineStart;
let blankLineSeen = false;
const collected: string[] = [];
while (cursor > 0) {
// Look at the previous line.
const prevLineEnd = cursor - 1; // the '\n' before our cursor's line
const prevLineStart = text.lastIndexOf('\n', prevLineEnd - 1) + 1;
const line = text.slice(prevLineStart, prevLineEnd);
const trimmed = line.trim();
if (trimmed === '') {
if (blankLineSeen) break;
blankLineSeen = true;
cursor = prevLineStart;
continue;
}
// A JSON line with a trailing `/* … */` comment (e.g.
// "altF9": true, /* focus on the File Menu and/or editbar */
// ) ends with `*/` but is NOT a comment continuation. Only treat a
// previous line as part of the leading comment block if it structurally
// opens (`/*`), continues (`*` — JSDoc style, covers ` */` close), or
// is a single-line `//` comment. This matches the comment styles used
// in settings.json.template; #7740.
const isComment =
trimmed.startsWith('//') ||
trimmed.startsWith('/*') ||
trimmed.startsWith('*');
if (!isComment) break;
collected.unshift(line);
cursor = prevLineStart;
}
return stripCommentMarkers(collected.join('\n'));
};
const findTrailing = (text: string, valueOffset: number, valueLength: number): string => {
// Trailing comments only exist on the same line as the value. If there's
// no newline after the value the file has no line structure (e.g. minified
// settings.json) and `//` inside any later string literal would otherwise
// be matched as a comment.
const lineEnd = text.indexOf('\n', valueOffset + valueLength);
if (lineEnd === -1) return '';
const slice = text.slice(valueOffset + valueLength, lineEnd);
const m = /,?\s*(\/\/.*|\/\*.*?\*\/)\s*$/.exec(slice);
return m ? stripCommentMarkers(m[1]) : '';
};
export const extractAdjacentComments = (
text: string,
keyOffset: number,
valueOffset: number,
valueLength: number,
): AdjacentComments => ({
leading: findLeading(text, keyOffset),
trailing: findTrailing(text, valueOffset, valueLength),
});

View file

@ -0,0 +1,21 @@
// admin/src/components/settings/envPill.ts
//
// Detect `"${VAR}"` and `"${VAR:default}"` placeholders inside the raw
// slice of a string node. The slice INCLUDES the surrounding quotes,
// because jsonc-parser exposes node.offset/length over the whole literal.
export type EnvPlaceholder = {
variable: string;
defaultValue: string | null;
};
const RE = /^"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::([^}]*))?\}"$/;
export const matchEnvPlaceholder = (rawSlice: string): EnvPlaceholder | null => {
const m = RE.exec(rawSlice);
if (!m) return null;
return {
variable: m[1],
defaultValue: m[2] ?? null,
};
};

View file

@ -0,0 +1,11 @@
// admin/src/components/settings/jsoncEdit.ts
import { applyEdits, modify, type JSONPath } from 'jsonc-parser';
const FORMATTING = {
formattingOptions: { tabSize: 2, insertSpaces: true, eol: '\n' as const },
};
export const editJsonc = (text: string, path: JSONPath, value: unknown): string => {
const edits = modify(text, path, value, FORMATTING);
return edits.length === 0 ? text : applyEdits(text, edits);
};

View file

@ -0,0 +1,46 @@
// Pretty-label derivation. The first sentence of a key's documentation
// comment is its label; the rest stays in the help-text slot. When no
// comment exists, fall back to a humanized key name (camelCase → "Camel
// case").
const SENTENCE_END = /[.!?](\s|$)/;
export const humanize = (key: string): string => {
if (!key) return key;
// Split camelCase / PascalCase / snake_case / kebab-case
const words = key
.replace(/[_-]+/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.toLowerCase()
.trim()
.split(/\s+/);
if (words.length === 0) return key;
return words[0].charAt(0).toUpperCase() + words[0].slice(1) +
(words.length > 1 ? ' ' + words.slice(1).join(' ') : '');
};
const splitFirstSentence = (text: string): { head: string; rest: string } => {
const trimmed = text.trim();
const m = SENTENCE_END.exec(trimmed);
if (!m) return { head: trimmed, rest: '' };
const cut = m.index + 1; // include the punctuation
return {
head: trimmed.slice(0, cut).trim(),
rest: trimmed.slice(cut).trim(),
};
};
export const labelAndHelp = (
comment: string | null | undefined,
key: string,
): { label: string; help: string } => {
if (!comment || !comment.trim()) {
return { label: humanize(key), help: '' };
}
const { head, rest } = splitFirstSentence(comment);
return {
label: head || humanize(key),
help: rest,
};
};

View file

@ -0,0 +1,50 @@
// Build a fallback path → comment map from `settings.json.template`. The live
// settings.json is per-developer and often lacks comments; the template is the
// authoritative source of per-key documentation.
import { parseTree, type JSONPath, type Node } from 'jsonc-parser';
import { extractAdjacentComments, type AdjacentComments } from './comments';
// Injected by Vite at build time from settings.json.template (see vite.config.ts).
// Inlining at config time avoids widening the dev server's filesystem allowlist
// to the repo root, which would expose settings.json/credentials.json over the
// dev server.
declare const __SETTINGS_TEMPLATE__: string;
const templateText: string = __SETTINGS_TEMPLATE__;
const pathKey = (path: JSONPath): string => path.map(String).join('.');
const buildMap = (text: string): Map<string, AdjacentComments> => {
const map = new Map<string, AdjacentComments>();
const tree = parseTree(text, [], { allowTrailingComma: true });
if (!tree) return map;
const walk = (node: Node, path: JSONPath) => {
if (node.type === 'object') {
for (const prop of node.children ?? []) {
if (prop.type !== 'property' || !prop.children || prop.children.length < 2) continue;
const keyNode = prop.children[0];
const valueNode = prop.children[1];
if (keyNode.type !== 'string') continue;
const childPath = [...path, String(keyNode.value)];
const adjacent = extractAdjacentComments(
text, prop.offset, valueNode.offset, valueNode.length,
);
if (adjacent.leading || adjacent.trailing) {
map.set(pathKey(childPath), adjacent);
}
walk(valueNode, childPath);
}
} else if (node.type === 'array') {
(node.children ?? []).forEach((child, i) => walk(child, [...path, i]));
}
};
walk(tree, []);
return map;
};
const templateMap = buildMap(templateText);
export const lookupTemplateComment = (path: JSONPath): AdjacentComments | null =>
templateMap.get(pathKey(path)) ?? null;

View file

@ -0,0 +1,20 @@
import * as Switch from '@radix-ui/react-switch';
import type { JSONPath } from 'jsonc-parser';
type Props = {
value: boolean;
path: JSONPath;
onChange: (next: boolean) => void;
};
export const BooleanToggle = ({ value, path, onChange }: Props) => (
<Switch.Root
checked={value}
onCheckedChange={onChange}
id={`field-${path.join('.')}`}
className="settings-widget settings-widget-boolean"
data-testid={`field-${path.join('.')}`}
>
<Switch.Thumb className="settings-widget-boolean-thumb" />
</Switch.Root>
);

View file

@ -0,0 +1,57 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { JSONPath } from 'jsonc-parser';
import type { EnvPlaceholder } from '../envPill';
type Props = {
placeholder: EnvPlaceholder;
path: JSONPath;
onChange: (newDefault: string) => void;
};
const sanitize = (s: string) => s.replace(/[}]/g, '');
export const EnvPill = ({ placeholder, path, onChange }: Props) => {
const { t } = useTranslation();
const initial = placeholder.defaultValue ?? '';
const [draft, setDraft] = useState(initial);
const focused = useRef(false);
// Sync local draft from upstream (server canonicalisation, raw-mode edit)
// only while the input isn't focused so we don't trample mid-typing.
useEffect(() => {
if (!focused.current) setDraft(initial);
}, [initial]);
const id = `field-${path.join('.')}`;
const testid = `env-${path.join('.')}`;
return (
<span
className="settings-widget settings-widget-env"
title={t('admin_settings.env_pill.tooltip', { variable: placeholder.variable })}
>
<span className="settings-widget-env-icon" aria-hidden></span>
<span className="settings-widget-env-name">{placeholder.variable}</span>
<span className="settings-widget-env-default-label" aria-hidden>
{t('admin_settings.env_pill.default_label')}
</span>
<input
id={id}
data-testid={testid}
className="settings-widget-env-default-input"
type="text"
value={draft}
spellCheck={false}
aria-label={t('admin_settings.env_pill.input_aria', { variable: placeholder.variable })}
onFocus={() => { focused.current = true; }}
onBlur={() => { focused.current = false; }}
onChange={e => {
const v = sanitize(e.target.value);
setDraft(v);
onChange(v);
}}
/>
</span>
);
};

View file

@ -0,0 +1,10 @@
import type { JSONPath } from 'jsonc-parser';
type Props = { path: JSONPath };
export const NullChip = ({ path }: Props) => (
<span
className="settings-widget settings-widget-null"
data-testid={`field-${path.join('.')}`}
>null</span>
);

View file

@ -0,0 +1,48 @@
import { useEffect, useRef, useState } from 'react';
import type { JSONPath } from 'jsonc-parser';
type Props = {
value: number;
path: JSONPath;
onChange: (next: number) => void;
};
export const NumberInput = ({ value, path, onChange }: Props) => {
const [draft, setDraft] = useState(String(value));
const [invalid, setInvalid] = useState(false);
const focusedRef = useRef(false);
// Sync draft when the prop value changes (e.g. after a server round-trip
// canonicalises the number) — but only when the input is not focused so we
// don't stomp on the user while they are typing.
useEffect(() => {
if (!focusedRef.current) {
setDraft(String(value));
setInvalid(false);
}
}, [value]);
return (
<input
type="text"
inputMode="decimal"
id={`field-${path.join('.')}`}
className={'settings-widget settings-widget-number' + (invalid ? ' invalid' : '')}
data-testid={`field-${path.join('.')}`}
value={draft}
onFocus={() => { focusedRef.current = true; }}
onBlur={() => { focusedRef.current = false; }}
onChange={e => {
const next = e.target.value;
setDraft(next);
const parsed = Number(next);
if (next.trim() !== '' && Number.isFinite(parsed)) {
setInvalid(false);
onChange(parsed);
} else {
setInvalid(true);
}
}}
/>
);
};

View file

@ -0,0 +1,19 @@
import type { JSONPath } from 'jsonc-parser';
type Props = {
value: string;
path: JSONPath;
onChange: (next: string) => void;
};
export const StringInput = ({ value, path, onChange }: Props) => (
<input
type="text"
id={`field-${path.join('.')}`}
className="settings-widget settings-widget-string"
data-testid={`field-${path.join('.')}`}
value={value}
spellCheck={false}
onChange={e => onChange(e.target.value)}
/>
);

View file

@ -272,16 +272,9 @@ td, th {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 20px;
height: 100%; min-height: 100%;
} }
.settings {
flex-grow: max(1, 1);
outline: none;
width: 100%;
resize: none;
font-family: monospace;
}
#response { #response {
display: inline; display: inline;

View file

@ -1,51 +1,123 @@
import {useStore} from "../store/store.ts"; import React, { useState } from 'react';
import {isJSONClean, cleanComments} from "../utils/utils.ts"; import { useStore } from '../store/store';
import {Trans, useTranslation} from "react-i18next"; import { isJSONClean, cleanComments } from '../utils/utils';
import {IconButton} from "../components/IconButton.tsx"; import { Trans, useTranslation } from 'react-i18next';
import {RotateCw, Save} from "lucide-react"; import { IconButton } from '../components/IconButton';
import { RotateCw, Save, AlignLeft, ShieldCheck } from 'lucide-react';
import { FormView } from '../components/settings/FormView';
import { ModeToggle, type Mode } from '../components/settings/ModeToggle';
export const SettingsPage = ()=>{ const TAB_INDENT = ' ';
const settingsSocket = useStore(state=>state.settingsSocket)
const settings = cleanComments(useStore(state=>state.settings))
const {t} = useTranslation()
return <div className="settings-page"> export const SettingsPage = () => {
<h1><Trans i18nKey="admin_settings.current"/></h1> const { t } = useTranslation();
<textarea value={settings} className="settings" onChange={v => { const settingsSocket = useStore(state => state.settingsSocket);
useStore.getState().setSettings(v.target.value) const settings = useStore(state => state.settings) ?? '';
}}/>
<div className="settings-button-bar"> const [mode, setMode] = useState<Mode>('form');
<IconButton className="settingsButton" icon={<Save/>} const [exposeExperimental] = useState(false);
title={<Trans i18nKey="admin_settings.current_save.value"/>} onClick={() => {
if (isJSONClean(settings!)) { // Tab in textarea inserts two spaces instead of moving focus; rAF restores caret position after React re-renders.
// JSON is clean so emit it to the server const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
settingsSocket!.emit('saveSettings', settings!); if (e.key !== 'Tab') return;
useStore.getState().setToastState({ e.preventDefault();
open: true, const target = e.currentTarget;
title: t('admin_settings.saved_success'), const { selectionStart, selectionEnd, value } = target;
success: true const next = value.substring(0, selectionStart) + TAB_INDENT + value.substring(selectionEnd);
}) useStore.getState().setSettings(next);
} else { requestAnimationFrame(() => {
useStore.getState().setToastState({ target.selectionStart = target.selectionEnd = selectionStart + TAB_INDENT.length;
open: true, });
title: t('admin_settings.save_error'), };
success: false
}) const showToast = (titleKey: string, success: boolean) => {
} useStore.getState().setToastState({ open: true, title: t(titleKey), success });
}}/> };
<IconButton className="settingsButton" icon={<RotateCw/>}
title={<Trans i18nKey="admin_settings.current_restart.value"/>} onClick={() => { const testJSON = () => {
settingsSocket!.emit('restartServer'); if (isJSONClean(settings)) showToast('admin_settings.toast.validation_ok', true);
}}/> else showToast('admin_settings.toast.validation_failed', false);
};
const prettifyJSON = () => {
try {
const obj = JSON.parse(cleanComments(settings) ?? '');
if (window.confirm(t('admin_settings.prettify_confirm'))) {
useStore.getState().setSettings(JSON.stringify(obj, null, 2));
}
} catch {
showToast('admin_settings.toast.prettify_failed', false);
}
};
const handleSave = () => {
if (!isJSONClean(settings)) return showToast('admin_settings.toast.json_invalid', false);
if (!settingsSocket?.connected) return showToast('admin_settings.toast.disconnected', false);
// Toast is shown by the saveprogress socket listener in App.tsx on server ack.
settingsSocket.emit('saveSettings', settings);
};
return (
<div className="settings-page">
<h1><Trans i18nKey="admin_settings.current" /></h1>
<ModeToggle mode={mode} onChange={setMode} />
{mode === 'form'
? <FormView onSwitchToRaw={() => setMode('raw')} />
: (
<textarea
value={settings}
className="settings"
data-testid="settings-raw-textarea"
spellCheck={false}
onKeyDown={handleKeyDown}
onChange={v => useStore.getState().setSettings(v.target.value)}
/>
)
}
<div className="settings-button-bar">
<IconButton
className="settingsButton"
data-testid="save-settings-button"
icon={<Save />}
title={<Trans i18nKey="admin_settings.current_save.value" />}
onClick={handleSave}
/>
<IconButton
className="settingsButton"
data-testid="test-settings-button"
icon={<ShieldCheck />}
title={<Trans i18nKey="admin_settings.current_test.value" />}
onClick={testJSON}
/>
{exposeExperimental && (
<IconButton
className="settingsButton"
data-testid="prettify-settings-button"
icon={<AlignLeft />}
title={<Trans i18nKey="admin_settings.current_prettify.value" />}
onClick={prettifyJSON}
/>
)}
<IconButton
className="settingsButton"
data-testid="restart-etherpad-button"
icon={<RotateCw />}
title={<Trans i18nKey="admin_settings.current_restart.value" />}
onClick={() => settingsSocket?.emit('restartServer')}
/>
</div>
<div className="settings-links">
<a rel="noopener noreferrer" target="_blank" href="https://github.com/ether/etherpad/wiki/Example-Production-Settings.JSON">
<Trans i18nKey="admin_settings.current_example-prod" />
</a>
<a rel="noopener noreferrer" target="_blank" href="https://github.com/ether/etherpad/wiki/Example-Development-Settings.JSON">
<Trans i18nKey="admin_settings.current_example-devel" />
</a>
</div>
</div> </div>
<div className="separator"/> );
<div className="settings-button-bar"> };
<a rel="noopener noreferrer" target="_blank"
href="https://github.com/ether/etherpad-lite/wiki/Example-Production-Settings.JSON"><Trans
i18nKey="admin_settings.current_example-prod"/></a>
<a rel="noopener noreferrer" target="_blank"
href="https://github.com/ether/etherpad-lite/wiki/Example-Development-Settings.JSON"><Trans
i18nKey="admin_settings.current_example-devel"/></a>
</div>
</div>
}

View file

@ -1,5 +1,17 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
// Inline `settings.json.template` at config time so the bundle has the
// per-key documentation without expanding the dev server's filesystem
// allowlist (which would otherwise serve every file in the repo root,
// including settings.json and credentials.json, to anything that can
// reach the dev server).
const settingsTemplate = readFileSync(
resolve(__dirname, '..', 'settings.json.template'),
'utf8',
)
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
@ -11,6 +23,9 @@ export default defineConfig({
}), }),
], ],
base: '/admin', base: '/admin',
define: {
__SETTINGS_TEMPLATE__: JSON.stringify(settingsTemplate),
},
build: { build: {
outDir: '../src/templates/admin', outDir: '../src/templates/admin',
emptyOutDir: true, emptyOutDir: true,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,237 @@
# Admin /settings parsed view — design
Date: 2026-05-09
Status: draft, awaiting review
Related: closes #7603, supersedes parts of #7666 / #7709
## Problem
`/admin/settings` renders `settings.json` as a single textarea. Admins
asked (#7603) for a parsed view that surfaces each key with the JSONC
comment that documents it, so the file stops looking like an opaque blob.
The current PR (#7709) only restyles the textarea and adds Validate /
Prettify / Restart buttons. The actual "parsed per-key with inline
comments" feature is still missing.
## Goals
- Render `settings.json` as a tree of typed widgets, one per key.
- Show each key's leading `/* */` or `//` comment as inline help text.
- Round-trip back to `settings.json` preserving comments, key order,
whitespace, and `${ENV:default}` placeholders byte-identically for
keys the admin did not edit.
- Keep a raw textarea fallback for power users and structural edits.
## Non-goals (deferred)
- Adding or removing keys from the form view.
- Editing `${ENV:default}` placeholders inline. They render as
read-only pills; structural changes go through raw mode.
- Curated, schema-driven help text. Comments in `settings.json` are
the help text; we don't ship a parallel schema.
- Search / filter / collapse-all controls.
## Constraints
- `settings.json` is JSONC: `/* */` and `//` comments, trailing commas
tolerated by the loader.
- Values can contain `${VAR:default}` placeholders that the server
resolves at boot. The form must not rewrite or normalise these.
- Server already ships the raw file text down `settingsSocket` and
accepts a full-text `saveSettings` event back. We don't change the
server contract.
## Architecture
All parsing and editing run in the browser. The server-side path is
unchanged.
```
load server reads settings.json --raw text--> settingsSocket --> store.settings
edit widget --modify(text, path, value)--> new text --> store.settings
save store.settings --emit('saveSettings', text)--> server writes file
```
The store keeps a single source of truth: the file text. The AST is
re-parsed from the text on each render of the form view; we don't keep
a separate model and try to keep them in sync.
### Library
Add `jsonc-parser` (MS, MIT) to `admin/`. We use four entry points:
- `parseTree(text)` → AST `Node` with `{ type, offset, length, children, value }`.
- `getNodePath(node)``(string|number)[]` JSON pointer-ish path.
- `modify(text, path, value, options)` → array of `Edit { offset, length, content }`.
- `applyEdits(text, edits)` → new text.
Comments are recoverable from the source `text` using `node.offset`;
`parseTree` skips them but their byte ranges are deterministic.
## Components
```
SettingsPage
├── ModeToggle Form | Raw (segmented control)
├── FormView visible when mode === 'form'
│ ├── ParseErrorBanner shown when parseTree fails
│ └── JsoncNode (recursive) one per AST node
│ ├── CommentLabel leading /* */ or // text
│ ├── KeyLabel
│ ├── ValueWidget dispatches on node.type
│ │ ├── StringInput
│ │ ├── NumberInput
│ │ ├── BooleanToggle
│ │ ├── NullChip
│ │ ├── EnvPill read-only, when raw matches ${VAR:default}
│ │ ├── ObjectGroup collapsible, recurses
│ │ └── ArrayGroup collapsible, recurses
│ └── TrailingCommentBadge `// inline` after the value
├── RawView visible when mode === 'raw'
│ └── <textarea className="settings"> (the existing editor)
└── ButtonBar
├── Save
├── Validate dry-run JSON parse, toast result
└── Restart unchanged, keeps data-testid
```
`exposeExperimental` (Prettify) stays gated off, as in #7709.
### Comment binding
For each property node `"key": value`:
- "Leading comment" = the longest run of `/* */` and `//` lines whose
byte range ends at the line break immediately before `node.offset`,
with at most one blank line allowed between the comment block and
the key. Rendered as the help text under `KeyLabel`.
- "Trailing comment" = a single `//` or `/* */` on the same line as
the value, after the trailing comma if any. Rendered as a small
badge with tooltip.
- Comments not adjacent to a key (file header, orphan blocks) render
in raw mode only.
### Env placeholder detection
A string node whose raw text slice (`text.slice(offset, offset+length)`)
matches `/^"\$\{[^}]+\}"$/` renders as `EnvPill` instead of
`StringInput`. The pill shows the variable name and (if present) the
default after `:`. It is read-only; tooltip explains "edit in raw mode".
## Data flow
1. `settingsSocket` emits the raw file text. Store sets
`state.settings = text` (this already happens today).
2. `FormView` calls `parseTree(state.settings)`. On any thrown
`SyntaxError` it renders `ParseErrorBanner` and a "Switch to raw to
fix" button instead of the tree.
3. Each leaf widget receives `(node, path)` and an `onChange(value)`
callback. `onChange` runs:
```ts
const edits = modify(state.settings, path, value, {
formattingOptions: { tabSize: 2, insertSpaces: true }
});
useStore.getState().setSettings(applyEdits(state.settings, edits));
```
4. Re-render uses the new text. Successive edits stack naturally.
5. Save: `isJSONClean(text)``socket.emit('saveSettings', text)`.
6. Mode toggle does not touch `state.settings`; both views share it.
### Why no AST round-trip
We tried (in design) the alternative "parse → model → reserialize" and
rejected it: even with `jsonc-parser`'s `Edit` API at the bottom, an
intermediate model loses information about whitespace runs and
comment whitespace prefixes. Patching the original text with
`modify()` is the only path that gives byte-identical output for
untouched regions, which is the explicit requirement (admins watch
`settings.json` in git).
## Save semantics
- Form mode: each widget edit produces a `modify()` patch against the
current text. Untouched bytes — including comments, ordering,
trailing commas, env placeholders — are preserved.
- Raw mode: textarea writes the whole text back wholesale. New keys
and structural reshuffles only happen here.
- The save button itself is mode-agnostic: it sends `state.settings`
as it stands. The existing `isJSONClean` validation gates it.
- Toggling Form → Raw is always safe: text is unchanged.
- Toggling Raw → Form may surface a parse error; banner explains and
offers to switch back.
## Error handling
| Failure | UX |
| ---------------------------------------- | ----------------------------------------------------------------- |
| Server hasn't sent settings yet | Spinner (existing behavior preserved) |
| Socket disconnected during save | Failure toast `admin_settings.toast.disconnected` |
| Invalid JSON at save time | Failure toast `admin_settings.toast.json_invalid`, save blocked |
| Invalid JSON when toggling Raw → Form | `ParseErrorBanner` with line/col, "Switch back to Raw" button |
| `modify()` returns no edits (no-op) | Treat as successful; widget value reflects current text |
| Number widget non-finite/non-numeric | Field-level inline error; file text is *not* updated until input parses; save uses last-valid value |
## i18n
All new strings go through `react-i18next`. New keys:
```
admin_settings.mode.form "Form"
admin_settings.mode.raw "Raw"
admin_settings.parse_error.title "Cannot parse settings.json"
admin_settings.parse_error.cta "Switch to raw to edit"
admin_settings.env_pill.tooltip "Environment variable. Edit in raw mode."
admin_settings.add_key.disabled "Add new keys in raw mode"
```
Plus the toast keys already added in #7709.
## Accessibility
- Mode toggle is a `role=tablist` with arrow-key navigation.
- Each form group has an `aria-labelledby` pointing at its key label.
- `EnvPill` is `role=note` with `aria-label="environment variable …"`.
- Collapsible groups use `<details>`/`<summary>` so keyboard and
screen-reader behavior is native.
## Test plan (Playwright, `admin-spec/adminsettings.spec.ts`)
Add specs:
- `form view renders comment as help text` — assert that a `dbType`
row's `aria-describedby` resolves to text containing the leading
comment in `settings.json`.
- `editing string preserves comments` — change `title` via the form
input, save, reload; assert raw text contains both the new title and
the original comment block above it.
- `boolean toggle round-trips` — toggle `requireAuthentication`, save,
reload; assert raw text shows `true`/`false` literal and surrounding
comments unchanged.
- `env pill is read-only` — locate the SSO `issuer` row, assert pill
is rendered, assert no `<input>` accepts text in form mode.
- `raw toggle round-trip is lossless` — Form → Raw → Form returns
identical bytes when nothing was edited.
- `invalid raw JSON shows banner on toggle to form` — paste broken
JSON in raw, toggle to form, assert `ParseErrorBanner`, assert
"Switch to raw" button works.
- Existing `comments preserved after save round-trip`,
`validate button toasts`, `restart works` specs continue to pass
through the new helper testids.
## Out of scope (future PRs)
- Add-key / delete-key form UI.
- Env-var inline editing widget.
- Schema-driven help text overlay.
- Sectioning / search / filter.
## Rollout
Single PR replacing the current `SettingsPage.tsx` from #7709. The
takeover branch (`takeover/7666-admin-settings-editor` on
johnmclear/etherpad-lite) gets new commits on top; #7709 stays open
and gets force-updated. No server-side migration.

355
pnpm-lock.yaml generated
View file

@ -52,6 +52,9 @@ importers:
'@tanstack/react-query-devtools': '@tanstack/react-query-devtools':
specifier: ^5.100.10 specifier: ^5.100.10
version: 5.100.10(@tanstack/react-query@5.100.10(react@19.2.6))(react@19.2.6) version: 5.100.10(@tanstack/react-query@5.100.10(react@19.2.6))(react@19.2.6)
jsonc-parser:
specifier: ^3.3.1
version: 3.3.1
openapi-fetch: openapi-fetch:
specifier: ^0.17.0 specifier: ^0.17.0
version: 0.17.0 version: 0.17.0
@ -1400,6 +1403,9 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@oxc-project/types@0.128.0':
resolution: {integrity: sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==}
'@oxc-project/types@0.129.0': '@oxc-project/types@0.129.0':
resolution: {integrity: sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==} resolution: {integrity: sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==}
@ -1708,30 +1714,60 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [android] os: [android]
'@rolldown/binding-android-arm64@1.0.0-rc.18':
resolution: {integrity: sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/binding-darwin-arm64@1.0.0': '@rolldown/binding-darwin-arm64@1.0.0':
resolution: {integrity: sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==} resolution: {integrity: sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@rolldown/binding-darwin-arm64@1.0.0-rc.18':
resolution: {integrity: sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/binding-darwin-x64@1.0.0': '@rolldown/binding-darwin-x64@1.0.0':
resolution: {integrity: sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==} resolution: {integrity: sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@rolldown/binding-darwin-x64@1.0.0-rc.18':
resolution: {integrity: sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/binding-freebsd-x64@1.0.0': '@rolldown/binding-freebsd-x64@1.0.0':
resolution: {integrity: sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==} resolution: {integrity: sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [freebsd] os: [freebsd]
'@rolldown/binding-freebsd-x64@1.0.0-rc.18':
resolution: {integrity: sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/binding-linux-arm-gnueabihf@1.0.0': '@rolldown/binding-linux-arm-gnueabihf@1.0.0':
resolution: {integrity: sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==} resolution: {integrity: sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18':
resolution: {integrity: sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/binding-linux-arm64-gnu@1.0.0': '@rolldown/binding-linux-arm64-gnu@1.0.0':
resolution: {integrity: sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==} resolution: {integrity: sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -1739,6 +1775,13 @@ packages:
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18':
resolution: {integrity: sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0': '@rolldown/binding-linux-arm64-musl@1.0.0':
resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==} resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -1746,6 +1789,13 @@ packages:
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.18':
resolution: {integrity: sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0': '@rolldown/binding-linux-ppc64-gnu@1.0.0':
resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==} resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -1753,6 +1803,13 @@ packages:
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18':
resolution: {integrity: sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0': '@rolldown/binding-linux-s390x-gnu@1.0.0':
resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==} resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -1760,6 +1817,13 @@ packages:
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18':
resolution: {integrity: sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0': '@rolldown/binding-linux-x64-gnu@1.0.0':
resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==} resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -1767,6 +1831,13 @@ packages:
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.18':
resolution: {integrity: sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0': '@rolldown/binding-linux-x64-musl@1.0.0':
resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==} resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -1774,32 +1845,65 @@ packages:
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.18':
resolution: {integrity: sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0': '@rolldown/binding-openharmony-arm64@1.0.0':
resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==} resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [openharmony] os: [openharmony]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.18':
resolution: {integrity: sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/binding-wasm32-wasi@1.0.0': '@rolldown/binding-wasm32-wasi@1.0.0':
resolution: {integrity: sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==} resolution: {integrity: sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [wasm32] cpu: [wasm32]
'@rolldown/binding-wasm32-wasi@1.0.0-rc.18':
resolution: {integrity: sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [wasm32]
'@rolldown/binding-win32-arm64-msvc@1.0.0': '@rolldown/binding-win32-arm64-msvc@1.0.0':
resolution: {integrity: sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==} resolution: {integrity: sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18':
resolution: {integrity: sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/binding-win32-x64-msvc@1.0.0': '@rolldown/binding-win32-x64-msvc@1.0.0':
resolution: {integrity: sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==} resolution: {integrity: sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.18':
resolution: {integrity: sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@rolldown/pluginutils@1.0.0': '@rolldown/pluginutils@1.0.0':
resolution: {integrity: sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==} resolution: {integrity: sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==}
'@rolldown/pluginutils@1.0.0-rc.18':
resolution: {integrity: sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==}
'@rolldown/pluginutils@1.0.0-rc.2': '@rolldown/pluginutils@1.0.0-rc.2':
resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==}
@ -1949,9 +2053,6 @@ packages:
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/express-serve-static-core@5.1.0': '@types/express-serve-static-core@5.1.0':
resolution: {integrity: sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==} resolution: {integrity: sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==}
@ -2042,8 +2143,11 @@ packages:
'@types/node@18.19.130': '@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
'@types/node@24.12.3': '@types/node@24.12.2':
resolution: {integrity: sha512-8oljBDGun9cIsZRJR6fkihn0TSXJI0UDOOhncYaERq6M0JMDoPLxyscwruJcb4GKS6dvK/d8xebYBg27h/duaQ==} resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
'@types/node@25.6.2':
resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==}
'@types/node@25.7.0': '@types/node@25.7.0':
resolution: {integrity: sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==} resolution: {integrity: sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==}
@ -3205,8 +3309,8 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
es-module-lexer@2.1.0: es-module-lexer@2.0.0:
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
es-object-atoms@1.1.1: es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
@ -4122,6 +4226,9 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
hasBin: true hasBin: true
jsonc-parser@3.3.1:
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
jsonfile@4.0.0: jsonfile@4.0.0:
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
@ -5113,6 +5220,11 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true hasBin: true
rolldown@1.0.0-rc.18:
resolution: {integrity: sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
router@2.2.0: router@2.2.0:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
@ -5536,8 +5648,8 @@ packages:
tinycon@0.6.8: tinycon@0.6.8:
resolution: {integrity: sha512-bF8Lxm4JUXF6Cw0XlZdugJ44GV575OinZ0Pt8vQPr8ooNqd2yyNkoFdCHzmdpHlgoqfSLfcyk4HDP1EyllT+ug==} resolution: {integrity: sha512-bF8Lxm4JUXF6Cw0XlZdugJ44GV575OinZ0Pt8vQPr8ooNqd2yyNkoFdCHzmdpHlgoqfSLfcyk4HDP1EyllT+ug==}
tinyexec@1.1.2: tinyexec@1.1.1:
resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==}
engines: {node: '>=18'} engines: {node: '>=18'}
tinyglobby@0.2.16: tinyglobby@0.2.16:
@ -5686,11 +5798,14 @@ packages:
undici-types@7.16.0: undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici-types@7.19.2:
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
undici-types@7.21.0: undici-types@7.21.0:
resolution: {integrity: sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==} resolution: {integrity: sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==}
undici-types@7.25.0: undici-types@7.24.5:
resolution: {integrity: sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==} resolution: {integrity: sha512-kNh333UBSbgK35OIW7FwJTr9tTfVIG51Fm1tSVT7m8foPHfDVjsb7OIee/q/rs3bB2aV/3qOPgG5mHNWl1odiA==}
undici@7.25.0: undici@7.25.0:
resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
@ -5812,6 +5927,49 @@ packages:
'@babel/core': ^7.0.0 '@babel/core': ^7.0.0
vite: '>=7.3.2' vite: '>=7.3.2'
vite@8.0.11:
resolution: {integrity: sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
'@vitejs/devtools': ^0.1.18
esbuild: ^0.27.0 || ^0.28.0
jiti: '>=1.21.0'
less: ^4.0.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
'@vitejs/devtools':
optional: true
esbuild:
optional: true
jiti:
optional: true
less:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
vite@8.0.12: vite@8.0.12:
resolution: {integrity: sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==} resolution: {integrity: sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -6907,6 +7065,8 @@ snapshots:
'@oxc-minify/binding-win32-x64-msvc@0.130.0': '@oxc-minify/binding-win32-x64-msvc@0.130.0':
optional: true optional: true
'@oxc-project/types@0.128.0': {}
'@oxc-project/types@0.129.0': {} '@oxc-project/types@0.129.0': {}
'@paralleldrive/cuid2@2.2.2': '@paralleldrive/cuid2@2.2.2':
@ -7180,39 +7340,75 @@ snapshots:
'@rolldown/binding-android-arm64@1.0.0': '@rolldown/binding-android-arm64@1.0.0':
optional: true optional: true
'@rolldown/binding-android-arm64@1.0.0-rc.18':
optional: true
'@rolldown/binding-darwin-arm64@1.0.0': '@rolldown/binding-darwin-arm64@1.0.0':
optional: true optional: true
'@rolldown/binding-darwin-arm64@1.0.0-rc.18':
optional: true
'@rolldown/binding-darwin-x64@1.0.0': '@rolldown/binding-darwin-x64@1.0.0':
optional: true optional: true
'@rolldown/binding-darwin-x64@1.0.0-rc.18':
optional: true
'@rolldown/binding-freebsd-x64@1.0.0': '@rolldown/binding-freebsd-x64@1.0.0':
optional: true optional: true
'@rolldown/binding-freebsd-x64@1.0.0-rc.18':
optional: true
'@rolldown/binding-linux-arm-gnueabihf@1.0.0': '@rolldown/binding-linux-arm-gnueabihf@1.0.0':
optional: true optional: true
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18':
optional: true
'@rolldown/binding-linux-arm64-gnu@1.0.0': '@rolldown/binding-linux-arm64-gnu@1.0.0':
optional: true optional: true
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18':
optional: true
'@rolldown/binding-linux-arm64-musl@1.0.0': '@rolldown/binding-linux-arm64-musl@1.0.0':
optional: true optional: true
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.18':
optional: true
'@rolldown/binding-linux-ppc64-gnu@1.0.0': '@rolldown/binding-linux-ppc64-gnu@1.0.0':
optional: true optional: true
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18':
optional: true
'@rolldown/binding-linux-s390x-gnu@1.0.0': '@rolldown/binding-linux-s390x-gnu@1.0.0':
optional: true optional: true
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18':
optional: true
'@rolldown/binding-linux-x64-gnu@1.0.0': '@rolldown/binding-linux-x64-gnu@1.0.0':
optional: true optional: true
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.18':
optional: true
'@rolldown/binding-linux-x64-musl@1.0.0': '@rolldown/binding-linux-x64-musl@1.0.0':
optional: true optional: true
'@rolldown/binding-linux-x64-musl@1.0.0-rc.18':
optional: true
'@rolldown/binding-openharmony-arm64@1.0.0': '@rolldown/binding-openharmony-arm64@1.0.0':
optional: true optional: true
'@rolldown/binding-openharmony-arm64@1.0.0-rc.18':
optional: true
'@rolldown/binding-wasm32-wasi@1.0.0': '@rolldown/binding-wasm32-wasi@1.0.0':
dependencies: dependencies:
'@emnapi/core': 1.10.0 '@emnapi/core': 1.10.0
@ -7220,14 +7416,29 @@ snapshots:
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
optional: true optional: true
'@rolldown/binding-wasm32-wasi@1.0.0-rc.18':
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
optional: true
'@rolldown/binding-win32-arm64-msvc@1.0.0': '@rolldown/binding-win32-arm64-msvc@1.0.0':
optional: true optional: true
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18':
optional: true
'@rolldown/binding-win32-x64-msvc@1.0.0': '@rolldown/binding-win32-x64-msvc@1.0.0':
optional: true optional: true
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.18':
optional: true
'@rolldown/pluginutils@1.0.0': {} '@rolldown/pluginutils@1.0.0': {}
'@rolldown/pluginutils@1.0.0-rc.18': {}
'@rolldown/pluginutils@1.0.0-rc.2': {} '@rolldown/pluginutils@1.0.0-rc.2': {}
'@rolldown/pluginutils@1.0.0-rc.7': {} '@rolldown/pluginutils@1.0.0-rc.7': {}
@ -7331,14 +7542,14 @@ snapshots:
'@types/accepts@1.3.7': '@types/accepts@1.3.7':
dependencies: dependencies:
'@types/node': 25.7.0 '@types/node': 25.6.2
'@types/async@3.2.25': {} '@types/async@3.2.25': {}
'@types/body-parser@1.19.6': '@types/body-parser@1.19.6':
dependencies: dependencies:
'@types/connect': 3.4.38 '@types/connect': 3.4.38
'@types/node': 25.7.0 '@types/node': 25.6.2
'@types/chai@5.2.3': '@types/chai@5.2.3':
dependencies: dependencies:
@ -7351,7 +7562,7 @@ snapshots:
'@types/connect@3.4.38': '@types/connect@3.4.38':
dependencies: dependencies:
'@types/node': 25.7.0 '@types/node': 25.6.2
'@types/content-disposition@0.5.9': {} '@types/content-disposition@0.5.9': {}
@ -7366,15 +7577,15 @@ snapshots:
'@types/connect': 3.4.38 '@types/connect': 3.4.38
'@types/express': 5.0.6 '@types/express': 5.0.6
'@types/keygrip': 1.0.6 '@types/keygrip': 1.0.6
'@types/node': 25.7.0 '@types/node': 25.6.2
'@types/cors@2.8.19': '@types/cors@2.8.19':
dependencies: dependencies:
'@types/node': 25.7.0 '@types/node': 25.6.2
'@types/cross-spawn@6.0.6': '@types/cross-spawn@6.0.6':
dependencies: dependencies:
'@types/node': 25.7.0 '@types/node': 25.6.2
'@types/debug@4.1.12': '@types/debug@4.1.12':
dependencies: dependencies:
@ -7388,11 +7599,9 @@ snapshots:
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/estree@1.0.9': {}
'@types/express-serve-static-core@5.1.0': '@types/express-serve-static-core@5.1.0':
dependencies: dependencies:
'@types/node': 25.7.0 '@types/node': 25.6.2
'@types/qs': 6.14.0 '@types/qs': 6.14.0
'@types/range-parser': 1.2.7 '@types/range-parser': 1.2.7
'@types/send': 1.2.1 '@types/send': 1.2.1
@ -7409,11 +7618,11 @@ snapshots:
'@types/formidable@3.5.1': '@types/formidable@3.5.1':
dependencies: dependencies:
'@types/node': 25.7.0 '@types/node': 25.6.2
'@types/fs-extra@9.0.13': '@types/fs-extra@9.0.13':
dependencies: dependencies:
'@types/node': 25.7.0 '@types/node': 25.6.2
'@types/hast@3.0.4': '@types/hast@3.0.4':
dependencies: dependencies:
@ -7429,10 +7638,10 @@ snapshots:
'@types/jsdom@28.0.3': '@types/jsdom@28.0.3':
dependencies: dependencies:
'@types/node': 25.7.0 '@types/node': 25.6.2
'@types/tough-cookie': 4.0.5 '@types/tough-cookie': 4.0.5
parse5: 8.0.1 parse5: 8.0.1
undici-types: 7.25.0 undici-types: 7.24.5
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
@ -7443,7 +7652,7 @@ snapshots:
'@types/jsonwebtoken@9.0.10': '@types/jsonwebtoken@9.0.10':
dependencies: dependencies:
'@types/ms': 2.1.0 '@types/ms': 2.1.0
'@types/node': 25.7.0 '@types/node': 25.6.2
'@types/keygrip@1.0.6': {} '@types/keygrip@1.0.6': {}
@ -7460,7 +7669,7 @@ snapshots:
'@types/http-errors': 2.0.5 '@types/http-errors': 2.0.5
'@types/keygrip': 1.0.6 '@types/keygrip': 1.0.6
'@types/koa-compose': 3.2.8 '@types/koa-compose': 3.2.8
'@types/node': 25.7.0 '@types/node': 25.6.2
'@types/linkify-it@5.0.0': {} '@types/linkify-it@5.0.0': {}
@ -7489,17 +7698,21 @@ snapshots:
'@types/node-fetch@2.6.12': '@types/node-fetch@2.6.12':
dependencies: dependencies:
'@types/node': 25.7.0 '@types/node': 25.6.2
form-data: 4.0.5 form-data: 4.0.5
'@types/node@18.19.130': '@types/node@18.19.130':
dependencies: dependencies:
undici-types: 5.26.5 undici-types: 5.26.5
'@types/node@24.12.3': '@types/node@24.12.2':
dependencies: dependencies:
undici-types: 7.16.0 undici-types: 7.16.0
'@types/node@25.6.2':
dependencies:
undici-types: 7.19.2
'@types/node@25.7.0': '@types/node@25.7.0':
dependencies: dependencies:
undici-types: 7.21.0 undici-types: 7.21.0
@ -7508,11 +7721,11 @@ snapshots:
dependencies: dependencies:
'@types/keygrip': 1.0.6 '@types/keygrip': 1.0.6
'@types/koa': 3.0.0 '@types/koa': 3.0.0
'@types/node': 25.7.0 '@types/node': 25.6.2
'@types/pdfkit@0.17.6': '@types/pdfkit@0.17.6':
dependencies: dependencies:
'@types/node': 25.7.0 '@types/node': 25.6.2
'@types/qs@6.14.0': {} '@types/qs@6.14.0': {}
@ -7528,29 +7741,29 @@ snapshots:
'@types/readable-stream@4.0.23': '@types/readable-stream@4.0.23':
dependencies: dependencies:
'@types/node': 25.7.0 '@types/node': 25.6.2
'@types/semver@7.7.1': {} '@types/semver@7.7.1': {}
'@types/send@0.17.4': '@types/send@0.17.4':
dependencies: dependencies:
'@types/mime': 1.3.5 '@types/mime': 1.3.5
'@types/node': 25.7.0 '@types/node': 25.6.2
'@types/send@1.2.1': '@types/send@1.2.1':
dependencies: dependencies:
'@types/node': 25.7.0 '@types/node': 25.6.2
'@types/serve-static@1.15.7': '@types/serve-static@1.15.7':
dependencies: dependencies:
'@types/http-errors': 2.0.5 '@types/http-errors': 2.0.5
'@types/node': 25.7.0 '@types/node': 25.6.2
'@types/send': 0.17.4 '@types/send': 0.17.4
'@types/serve-static@2.2.0': '@types/serve-static@2.2.0':
dependencies: dependencies:
'@types/http-errors': 2.0.5 '@types/http-errors': 2.0.5
'@types/node': 25.7.0 '@types/node': 25.6.2
'@types/sinon@21.0.1': '@types/sinon@21.0.1':
dependencies: dependencies:
@ -7562,7 +7775,7 @@ snapshots:
dependencies: dependencies:
'@types/cookiejar': 2.1.5 '@types/cookiejar': 2.1.5
'@types/methods': 1.1.4 '@types/methods': 1.1.4
'@types/node': 25.7.0 '@types/node': 25.6.2
form-data: 4.0.5 form-data: 4.0.5
'@types/supertest@7.2.0': '@types/supertest@7.2.0':
@ -7577,7 +7790,7 @@ snapshots:
'@types/tar@6.1.13': '@types/tar@6.1.13':
dependencies: dependencies:
'@types/node': 25.7.0 '@types/node': 25.6.2
minipass: 4.2.8 minipass: 4.2.8
'@types/tough-cookie@4.0.5': {} '@types/tough-cookie@4.0.5': {}
@ -7715,7 +7928,7 @@ snapshots:
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@8.1.1)
globby: 11.1.0 globby: 11.1.0
is-glob: 4.0.3 is-glob: 4.0.3
minimatch: 10.2.5 minimatch: 9.0.9
semver: 7.8.0 semver: 7.8.0
ts-api-utils: 1.4.3(typescript@6.0.3) ts-api-utils: 1.4.3(typescript@6.0.3)
optionalDependencies: optionalDependencies:
@ -7834,10 +8047,10 @@ snapshots:
optionalDependencies: optionalDependencies:
babel-plugin-react-compiler: 19.1.0-rc.3 babel-plugin-react-compiler: 19.1.0-rc.3
'@vitejs/plugin-vue@6.0.5(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0))(vue@3.5.30(typescript@6.0.3))': '@vitejs/plugin-vue@6.0.5(vite@8.0.11(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0))(vue@3.5.30(typescript@6.0.3))':
dependencies: dependencies:
'@rolldown/pluginutils': 1.0.0-rc.2 '@rolldown/pluginutils': 1.0.0-rc.2
vite: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0) vite: 8.0.11(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0)
vue: 3.5.30(typescript@6.0.3) vue: 3.5.30(typescript@6.0.3)
'@vitest/expect@4.1.6': '@vitest/expect@4.1.6':
@ -8034,7 +8247,7 @@ snapshots:
'@swc/helpers': 0.5.21 '@swc/helpers': 0.5.21
'@types/command-line-args': 5.2.3 '@types/command-line-args': 5.2.3
'@types/command-line-usage': 5.0.4 '@types/command-line-usage': 5.0.4
'@types/node': 24.12.3 '@types/node': 24.12.2
command-line-args: 6.0.2 command-line-args: 6.0.2
command-line-usage: 7.0.4 command-line-usage: 7.0.4
flatbuffers: 25.9.23 flatbuffers: 25.9.23
@ -8619,7 +8832,7 @@ snapshots:
engine.io@6.6.5: engine.io@6.6.5:
dependencies: dependencies:
'@types/cors': 2.8.19 '@types/cors': 2.8.19
'@types/node': 25.7.0 '@types/node': 25.6.2
accepts: 1.3.8 accepts: 1.3.8
base64id: 2.0.0 base64id: 2.0.0
cookie: 0.7.2 cookie: 0.7.2
@ -8721,7 +8934,7 @@ snapshots:
es-errors@1.3.0: {} es-errors@1.3.0: {}
es-module-lexer@2.1.0: {} es-module-lexer@2.0.0: {}
es-object-atoms@1.1.1: es-object-atoms@1.1.1:
dependencies: dependencies:
@ -9049,7 +9262,7 @@ snapshots:
estree-walker@3.0.3: estree-walker@3.0.3:
dependencies: dependencies:
'@types/estree': 1.0.9 '@types/estree': 1.0.8
esutils@2.0.3: {} esutils@2.0.3: {}
@ -9850,6 +10063,8 @@ snapshots:
json5@2.2.3: {} json5@2.2.3: {}
jsonc-parser@3.3.1: {}
jsonfile@4.0.0: jsonfile@4.0.0:
optionalDependencies: optionalDependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
@ -10700,7 +10915,7 @@ snapshots:
proxy-agent@6.5.0: proxy-agent@6.5.0:
dependencies: dependencies:
agent-base: 7.1.3 agent-base: 7.1.3
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.1
http-proxy-agent: 7.0.2 http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6 https-proxy-agent: 7.0.6
lru-cache: 7.18.3 lru-cache: 7.18.3
@ -10952,6 +11167,27 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.0.0 '@rolldown/binding-win32-arm64-msvc': 1.0.0
'@rolldown/binding-win32-x64-msvc': 1.0.0 '@rolldown/binding-win32-x64-msvc': 1.0.0
rolldown@1.0.0-rc.18:
dependencies:
'@oxc-project/types': 0.128.0
'@rolldown/pluginutils': 1.0.0-rc.18
optionalDependencies:
'@rolldown/binding-android-arm64': 1.0.0-rc.18
'@rolldown/binding-darwin-arm64': 1.0.0-rc.18
'@rolldown/binding-darwin-x64': 1.0.0-rc.18
'@rolldown/binding-freebsd-x64': 1.0.0-rc.18
'@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.18
'@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.18
'@rolldown/binding-linux-arm64-musl': 1.0.0-rc.18
'@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.18
'@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.18
'@rolldown/binding-linux-x64-gnu': 1.0.0-rc.18
'@rolldown/binding-linux-x64-musl': 1.0.0-rc.18
'@rolldown/binding-openharmony-arm64': 1.0.0-rc.18
'@rolldown/binding-wasm32-wasi': 1.0.0-rc.18
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.18
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.18
router@2.2.0: router@2.2.0:
dependencies: dependencies:
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@8.1.1)
@ -11442,7 +11678,7 @@ snapshots:
'@azure/identity': 4.13.1 '@azure/identity': 4.13.1
'@azure/keyvault-keys': 4.10.0(@azure/core-client@1.10.1) '@azure/keyvault-keys': 4.10.0(@azure/core-client@1.10.1)
'@js-joda/core': 5.7.0 '@js-joda/core': 5.7.0
'@types/node': 25.7.0 '@types/node': 25.6.2
bl: 6.1.6 bl: 6.1.6
iconv-lite: 0.7.2 iconv-lite: 0.7.2
js-md4: 0.3.2 js-md4: 0.3.2
@ -11458,7 +11694,7 @@ snapshots:
tinycon@0.6.8: {} tinycon@0.6.8: {}
tinyexec@1.1.2: {} tinyexec@1.1.1: {}
tinyglobby@0.2.16: tinyglobby@0.2.16:
dependencies: dependencies:
@ -11634,9 +11870,11 @@ snapshots:
undici-types@7.16.0: {} undici-types@7.16.0: {}
undici-types@7.19.2: {}
undici-types@7.21.0: {} undici-types@7.21.0: {}
undici-types@7.25.0: {} undici-types@7.24.5: {}
undici@7.25.0: {} undici@7.25.0: {}
@ -11767,6 +12005,19 @@ snapshots:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
vite: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0) vite: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0)
vite@8.0.11(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.14
rolldown: 1.0.0-rc.18
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 25.7.0
esbuild: 0.28.0
fsevents: 2.3.3
tsx: 4.21.0
vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0): vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0):
dependencies: dependencies:
lightningcss: 1.32.0 lightningcss: 1.32.0
@ -11790,7 +12041,7 @@ snapshots:
'@shikijs/transformers': 3.23.0 '@shikijs/transformers': 3.23.0
'@shikijs/types': 3.23.0 '@shikijs/types': 3.23.0
'@types/markdown-it': 14.1.2 '@types/markdown-it': 14.1.2
'@vitejs/plugin-vue': 6.0.5(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0))(vue@3.5.30(typescript@6.0.3)) '@vitejs/plugin-vue': 6.0.5(vite@8.0.11(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0))(vue@3.5.30(typescript@6.0.3))
'@vue/devtools-api': 8.1.0 '@vue/devtools-api': 8.1.0
'@vue/shared': 3.5.30 '@vue/shared': 3.5.30
'@vueuse/core': 14.2.1(vue@3.5.30(typescript@6.0.3)) '@vueuse/core': 14.2.1(vue@3.5.30(typescript@6.0.3))
@ -11799,7 +12050,7 @@ snapshots:
mark.js: 8.11.1 mark.js: 8.11.1
minisearch: 7.2.0 minisearch: 7.2.0
shiki: 3.23.0 shiki: 3.23.0
vite: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0) vite: 8.0.11(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0)
vue: 3.5.30(typescript@6.0.3) vue: 3.5.30(typescript@6.0.3)
optionalDependencies: optionalDependencies:
oxc-minify: 0.130.0 oxc-minify: 0.130.0
@ -11839,7 +12090,7 @@ snapshots:
'@vitest/snapshot': 4.1.6 '@vitest/snapshot': 4.1.6
'@vitest/spy': 4.1.6 '@vitest/spy': 4.1.6
'@vitest/utils': 4.1.6 '@vitest/utils': 4.1.6
es-module-lexer: 2.1.0 es-module-lexer: 2.0.0
expect-type: 1.3.0 expect-type: 1.3.0
magic-string: 0.30.21 magic-string: 0.30.21
obug: 2.1.1 obug: 2.1.1
@ -11847,7 +12098,7 @@ snapshots:
picomatch: 4.0.4 picomatch: 4.0.4
std-env: 4.1.0 std-env: 4.1.0
tinybench: 2.9.0 tinybench: 2.9.0
tinyexec: 1.1.2 tinyexec: 1.1.1
tinyglobby: 0.2.16 tinyglobby: 0.2.16
tinyrainbow: 3.1.0 tinyrainbow: 3.1.0
vite: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0) vite: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0)

View file

@ -117,6 +117,25 @@
"admin_settings.current_restart.value": "Restart Etherpad", "admin_settings.current_restart.value": "Restart Etherpad",
"admin_settings.current_save.value": "Save Settings", "admin_settings.current_save.value": "Save Settings",
"admin_settings.invalid_json": "Invalid JSON", "admin_settings.invalid_json": "Invalid JSON",
"admin_settings.current_test.value": "Validate JSON",
"admin_settings.current_prettify.value": "Prettify JSON",
"admin_settings.toast.saved": "Settings saved successfully.",
"admin_settings.toast.save_failed": "Save failed: settings.json could not be written.",
"admin_settings.toast.json_invalid": "Syntax error: check commas, braces, and quotes.",
"admin_settings.toast.disconnected": "Cannot save: not connected to server.",
"admin_settings.toast.validation_ok": "JSON is valid.",
"admin_settings.toast.validation_failed": "JSON is invalid: please fix syntax errors.",
"admin_settings.toast.prettify_failed": "Cannot prettify: please fix syntax errors first.",
"admin_settings.prettify_confirm": "Prettifying will remove all comments. Continue?",
"admin_settings.mode.form": "Form",
"admin_settings.mode.raw": "Raw",
"admin_settings.mode.aria_label": "Editor mode",
"admin_settings.section.general": "General",
"admin_settings.parse_error.title": "Cannot parse settings.json",
"admin_settings.parse_error.cta": "Switch to raw to edit",
"admin_settings.env_pill.tooltip": "Reads from the {{variable}} environment variable. The value below is used when {{variable}} is unset.",
"admin_settings.env_pill.default_label": "default",
"admin_settings.env_pill.input_aria": "Default value for {{variable}}",
"admin_settings.page-title": "Settings - Etherpad", "admin_settings.page-title": "Settings - Etherpad",
"admin_settings.save_error": "Error saving settings", "admin_settings.save_error": "Error saving settings",
"admin_settings.saved_success": "Successfully saved settings", "admin_settings.saved_success": "Successfully saved settings",

View file

@ -46,10 +46,11 @@ exports.socketio = (hookName: string, {io}: any) => {
logger.info('Admin request to save settings through a socket on /admin/settings'); logger.info('Admin request to save settings through a socket on /admin/settings');
try { try {
await fsp.writeFile(settings.settingsFilename, newSettings); await fsp.writeFile(settings.settingsFilename, newSettings);
socket.emit('saveprogress', 'saved');
} catch (err) { } catch (err) {
logger.error(`Error saving settings: ${err}`); logger.error(`Error saving settings: ${err}`);
socket.emit('saveprogress', 'error', {message: String(err)});
} }
socket.emit('saveprogress', 'saved');
}); });

View file

@ -8,6 +8,10 @@ const ADMIN_URL = 'http://localhost:9001/admin';
const setErasureFlag = async (page: any, enabled: boolean) => { const setErasureFlag = async (page: any, enabled: boolean) => {
await page.goto(`${ADMIN_URL}/settings`); await page.goto(`${ADMIN_URL}/settings`);
// /admin/settings now defaults to the parsed form view; the raw
// textarea (.settings) is only mounted after switching modes.
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
await page.getByTestId('mode-toggle-raw').click();
await page.waitForSelector('.settings'); await page.waitForSelector('.settings');
const settings = page.locator('.settings'); const settings = page.locator('.settings');
await expect(settings).not.toHaveValue('', {timeout: 30000}); await expect(settings).not.toHaveValue('', {timeout: 30000});

View file

@ -14,6 +14,8 @@ test.describe('admin settings',()=> {
test('Are Settings visible, populated, does save work', async ({page}) => { test('Are Settings visible, populated, does save work', async ({page}) => {
await page.goto('http://localhost:9001/admin/settings'); await page.goto('http://localhost:9001/admin/settings');
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
await page.getByTestId('mode-toggle-raw').click();
await page.waitForSelector('.settings'); await page.waitForSelector('.settings');
const settings = page.locator('.settings'); const settings = page.locator('.settings');
await expect(settings).not.toHaveValue('', {timeout: 30000}); await expect(settings).not.toHaveValue('', {timeout: 30000});
@ -29,6 +31,8 @@ test.describe('admin settings',()=> {
// Check if the changes were actually saved // Check if the changes were actually saved
await page.reload() await page.reload()
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
await page.getByTestId('mode-toggle-raw').click();
await page.waitForSelector('.settings'); await page.waitForSelector('.settings');
await expect(settings).not.toHaveValue('', {timeout: 30000}); await expect(settings).not.toHaveValue('', {timeout: 30000});
@ -43,6 +47,8 @@ test.describe('admin settings',()=> {
await saveSettings(page) await saveSettings(page)
await page.reload() await page.reload()
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
await page.getByTestId('mode-toggle-raw').click();
await page.waitForSelector('.settings'); await page.waitForSelector('.settings');
await expect(settings).not.toHaveValue('', {timeout: 30000}); await expect(settings).not.toHaveValue('', {timeout: 30000});
const oldSettings = page.locator('.settings'); const oldSettings = page.locator('.settings');
@ -51,15 +57,312 @@ test.describe('admin settings',()=> {
expect(oldSettingsVal.length).toEqual(settingsLength) expect(oldSettingsVal.length).toEqual(settingsLength)
}) })
test('preserves /* */ comments after save round-trip', async ({page}) => {
await page.goto('http://localhost:9001/admin/settings');
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
await page.getByTestId('mode-toggle-raw').click();
await page.waitForSelector('.settings');
const settings = page.locator('.settings');
await expect(settings).not.toHaveValue('', {timeout: 30000});
const original = await settings.inputValue();
const withComment = `/* takeover-pr-comment-marker */\n${original}`;
await settings.fill(withComment);
await saveSettings(page);
await page.reload();
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
await page.getByTestId('mode-toggle-raw').click();
await page.waitForSelector('.settings');
await expect(settings).not.toHaveValue('', {timeout: 30000});
expect(await settings.inputValue()).toContain('takeover-pr-comment-marker');
// Restore original
await settings.fill(original);
await saveSettings(page);
});
test('validate button toasts success on valid JSON and failure on invalid', async ({page}) => {
await page.goto('http://localhost:9001/admin/settings');
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
await page.getByTestId('mode-toggle-raw').click();
await page.waitForSelector('.settings');
const settings = page.locator('.settings');
await expect(settings).not.toHaveValue('', {timeout: 30000});
const original = await settings.inputValue();
await page.getByTestId('test-settings-button').click();
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
await settings.fill('{"broken":');
await page.getByTestId('test-settings-button').click();
await expect(page.locator('.ToastRootFailure')).toBeVisible({timeout: 5000});
// Invalid JSON must not be accepted by save either
await page.getByTestId('save-settings-button').click();
await expect(page.locator('.ToastRootFailure')).toBeVisible({timeout: 5000});
// Restore so subsequent tests have valid settings
await settings.fill(original);
await saveSettings(page);
});
test('restart works', async function ({page}) { test('restart works', async function ({page}) {
await page.goto('http://localhost:9001/admin/settings'); await page.goto('http://localhost:9001/admin/settings');
await page.waitForSelector('.settings') await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
await page.getByTestId('mode-toggle-raw').click();
await page.waitForSelector('.settings');
await restartEtherpad(page) await restartEtherpad(page)
// Re-login after restart since session is lost // Re-login after restart since session is lost
await loginToAdmin(page, 'admin', 'changeme1') await loginToAdmin(page, 'admin', 'changeme1')
await page.goto('http://localhost:9001/admin/settings'); await page.goto('http://localhost:9001/admin/settings');
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
await page.getByTestId('mode-toggle-raw').click();
await page.waitForSelector('.settings') await page.waitForSelector('.settings')
const settings = page.locator('.settings'); const settings = page.locator('.settings');
await expect(settings).not.toHaveValue('', {timeout: 30000}); await expect(settings).not.toHaveValue('', {timeout: 30000});
}); });
test('form view derives label + help text from key comment', async ({page}) => {
await page.goto('http://localhost:9001/admin/settings');
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
// Inject a two-sentence comment above "title" so the first sentence becomes
// the row label and the rest renders as help text.
await page.getByTestId('mode-toggle-raw').click();
const raw = page.getByTestId('settings-raw-textarea');
await expect(raw).toBeVisible({timeout: 10000});
const original = await raw.inputValue();
const withComment = original.replace(
'"title"',
'/* CustomTitleLabel. ExtraHelpMarker about it. */\n"title"',
);
await raw.fill(withComment);
await page.getByTestId('save-settings-button').click();
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
await page.getByTestId('mode-toggle-form').click();
await page.waitForSelector('[data-testid="settings-form-view"]');
// The injected text appears somewhere in the title row — either as label
// (if no template comment precedes it) or as help text (if one does and
// the comments are concatenated). Either way, it must be rendered.
const titleLabel = page.locator('label[for="field-title"]');
await expect(titleLabel).toBeVisible({timeout: 10000});
const titleRow = titleLabel.locator('xpath=ancestor::*[contains(@class,"settings-row")][1]');
await expect(titleRow).toContainText('CustomTitleLabel');
await expect(titleRow).toContainText('ExtraHelpMarker');
// Restore
await page.getByTestId('mode-toggle-raw').click();
await raw.fill(original);
await page.getByTestId('save-settings-button').click();
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
});
test('form view falls back to template documentation when live comment is absent', async ({page}) => {
await page.goto('http://localhost:9001/admin/settings');
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
// settings.json.template documents `port` with the env-var explainer.
// Even if the live settings.json has no comment for port, the template
// fallback should populate label + help.
const portLabel = page.locator('label[for="field-port"]');
await expect(portLabel).toBeVisible({timeout: 10000});
// Label is a non-empty string (humanized 'Port' or template-derived).
expect((await portLabel.textContent())?.trim().length ?? 0).toBeGreaterThan(0);
});
test('editing title via form input round-trips through save', async ({page}) => {
await page.goto('http://localhost:9001/admin/settings');
// Wait for settings to load (form view renders once socket emits settings).
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
await page.getByTestId('mode-toggle-raw').click();
const raw = page.getByTestId('settings-raw-textarea');
await expect(raw).toBeVisible({timeout: 10000});
const original = await raw.inputValue();
await page.getByTestId('mode-toggle-form').click();
const titleField = page.getByTestId('field-title');
await expect(titleField).toBeVisible({timeout: 10000});
await titleField.fill('Etherpad-Form-Edit');
await page.getByTestId('save-settings-button').click();
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
await page.reload();
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
await page.getByTestId('mode-toggle-raw').click();
const after = await page.getByTestId('settings-raw-textarea').inputValue();
// jsonc-parser modify() preserves the file's existing spacing style.
// The shipped settings.json is compact (no spaces around colons).
expect(after).toMatch(/"title"\s*:\s*"Etherpad-Form-Edit"/);
// Other keys must still be present (file structure preserved).
expect(after).toContain('"requireAuthentication"');
// Restore
await page.getByTestId('settings-raw-textarea').fill(original);
await page.getByTestId('save-settings-button').click();
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
});
test('boolean toggle round-trips through save', async ({page}) => {
await page.goto('http://localhost:9001/admin/settings');
// Wait for settings to load (form view renders once socket emits settings).
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
await page.getByTestId('mode-toggle-raw').click();
const original = await page.getByTestId('settings-raw-textarea').inputValue();
await page.getByTestId('mode-toggle-form').click();
const toggle = page.getByTestId('field-requireAuthentication');
await expect(toggle).toBeVisible({timeout: 10000});
const before = await toggle.getAttribute('aria-checked');
await toggle.click();
await page.getByTestId('save-settings-button').click();
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
await page.reload();
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
const toggleAfter = page.getByTestId('field-requireAuthentication');
await expect(toggleAfter).toBeVisible({timeout: 10000});
const after = await toggleAfter.getAttribute('aria-checked');
expect(after).not.toEqual(before);
// Restore
await page.getByTestId('mode-toggle-raw').click();
await page.getByTestId('settings-raw-textarea').fill(original);
await page.getByTestId('save-settings-button').click();
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
});
test('env placeholder default value is editable inline and persists to raw', async ({page}) => {
await page.goto('http://localhost:9001/admin/settings');
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
// Grab the original raw content so we can restore at the end.
await page.getByTestId('mode-toggle-raw').click();
const raw = page.getByTestId('settings-raw-textarea');
await expect(raw).toBeVisible({timeout: 10000});
const original = await raw.inputValue();
// Switch to form and edit sso.issuer's env-placeholder default inline.
await page.getByTestId('mode-toggle-form').click();
const input = page.getByTestId('env-sso.issuer').first();
await expect(input).toBeVisible({timeout: 10000});
// Sanity: this IS the editable <input> (env pill no longer read-only).
expect(await input.evaluate((el) => el.tagName.toLowerCase())).toBe('input');
await input.fill('http://edited.example:9001');
await page.getByTestId('save-settings-button').click();
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
// Reload and confirm the raw JSON now embeds the new default.
await page.reload();
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
await page.getByTestId('mode-toggle-raw').click();
const after = await page.getByTestId('settings-raw-textarea').inputValue();
expect(after).toContain('${SSO_ISSUER:http://edited.example:9001}');
// Restore.
await page.getByTestId('settings-raw-textarea').fill(original);
await page.getByTestId('save-settings-button').click();
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
});
test('docker-like env-placeholder-heavy settings.json still has editable controls', async ({page}) => {
// Regression: a settings.json where every value is "${VAR:default}"
// (the shape of settings.json.docker) must NOT degrade the form into a
// read-only viewer. Editing any env default round-trips through save.
await page.goto('http://localhost:9001/admin/settings');
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
await page.getByTestId('mode-toggle-raw').click();
const raw = page.getByTestId('settings-raw-textarea');
await expect(raw).toBeVisible({timeout: 10000});
const original = await raw.inputValue();
// Replace with a minimal env-placeholder-heavy document.
const envHeavy = JSON.stringify({
title: '${TITLE:Etherpad-EnvHeavy}',
port: '${PORT:9001}',
ip: '${IP:0.0.0.0}',
requireAuthentication: '${REQUIRE_AUTHENTICATION:false}',
enableAdminUITests: true,
users: {admin: {password: 'changeme1', is_admin: true}},
});
await raw.fill(envHeavy);
await page.getByTestId('save-settings-button').click();
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
await page.reload();
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
// Every visible env-pill row must expose an editable <input>, not a
// read-only span.
for (const id of ['env-title', 'env-port', 'env-ip', 'env-requireAuthentication']) {
const el = page.getByTestId(id).first();
await expect(el).toBeVisible({timeout: 10000});
expect(await el.evaluate((n) => n.tagName.toLowerCase())).toBe('input');
}
// Editing one of them round-trips.
await page.getByTestId('env-title').fill('Etherpad-EnvEdit');
await page.getByTestId('save-settings-button').click();
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
await page.reload();
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
await page.getByTestId('mode-toggle-raw').click();
const after = await page.getByTestId('settings-raw-textarea').inputValue();
expect(after).toContain('${TITLE:Etherpad-EnvEdit}');
// Restore original.
await page.getByTestId('settings-raw-textarea').fill(original);
await page.getByTestId('save-settings-button').click();
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
});
// Regression for https://github.com/ether/etherpad/issues/7740.
// settings.json.template uses same-line `/* … */` annotations for the
// padShortcutEnabled keys, e.g.
// "altF9": true, /* focus on the File Menu and/or editbar */
// A previous heuristic in findLeading treated any line ending in `*/` as
// a comment continuation, so each subsequent key's "leading comment"
// absorbed every preceding sibling line. After the fix, altC's row must
// render with a clean key-derived label and the trailing comment in the
// help slot — and must not contain altF9's source line.
test('#7740 trailing-comment key renders clean label, comment as help', async ({page}) => {
await page.goto('http://localhost:9001/admin/settings');
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
const altCLabel = page.locator('label[for="field-padShortcutEnabled.altC"]');
await expect(altCLabel).toBeVisible({timeout: 10000});
const labelText = (await altCLabel.textContent() ?? '').trim();
expect(labelText).not.toMatch(/altF9/i);
expect(labelText).not.toMatch(/focus on the File Menu/i);
const altCRow = altCLabel.locator(
'xpath=ancestor::*[contains(@class,"settings-row")][1]',
);
await expect(altCRow).toContainText('focus on the Chat window');
});
test('toggling form on broken raw JSON shows parse error banner', async ({page}) => {
await page.goto('http://localhost:9001/admin/settings');
// Wait for settings to load (form view renders once socket emits settings).
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
await page.getByTestId('mode-toggle-raw').click();
const raw = page.getByTestId('settings-raw-textarea');
await expect(raw).toBeVisible({timeout: 10000});
const original = await raw.inputValue();
await raw.fill('{ "broken":');
await page.getByTestId('mode-toggle-form').click();
await expect(page.getByTestId('parse-error-banner')).toBeVisible();
// CTA returns to raw view
await page.getByTestId('parse-error-switch-raw').click();
await expect(raw).toBeVisible();
// Restore
await raw.fill(original);
await page.getByTestId('save-settings-button').click();
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
});
}) })

View file

@ -0,0 +1,40 @@
import {expect, test} from "@playwright/test";
import {loginToAdmin} from "../helper/adminhelper";
test.beforeEach(async ({ page })=>{
await loginToAdmin(page, 'admin', 'changeme1');
})
// Regression for the byte-offset React-key bug: when a JSON edit shifted
// every sibling's character offset, React remounted every input — eating
// focus mid-keystroke. The fix keys children on their JSONPath, which is
// invariant under value-length changes.
//
// Using Playwright's `.fill()` on the source input would itself steal
// focus before we could observe whether the target survived, so we drive
// the source via the native value setter + a bubbling `input` event:
// React's synthetic onChange fires and the form re-renders, but focus
// stays on whatever the test put it on.
test('editing a sibling array element does not lose focus on the focused one', async ({page}) => {
await page.goto('http://localhost:9001/admin/settings');
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
const firstInput = page.getByTestId('field-socketTransportProtocols.0');
const secondInput = page.getByTestId('field-socketTransportProtocols.1');
await expect(firstInput).toBeVisible();
await expect(secondInput).toBeVisible();
await secondInput.focus();
await expect(secondInput).toBeFocused();
await firstInput.evaluate((el, val) => {
const desc = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value',
);
desc?.set?.call(el, val);
el.dispatchEvent(new Event('input', {bubbles: true}));
}, 'websocket-long');
await expect(secondInput).toBeFocused();
});

View file

@ -12,16 +12,19 @@ export const loginToAdmin = async (page: Page, username: string, password: strin
export const saveSettings = async (page: Page) => { export const saveSettings = async (page: Page) => {
// Click save // If a success toast is already open (from a previous save), wait for it to
await page.locator('.settings-button-bar').locator('button').first().click() // close first so we don't mistake the stale open state for the new ack.
await page.waitForSelector('.ToastRootSuccess') // Radix Toast toggles data-state rather than removing the element.
const existing = page.locator('.ToastRootSuccess[data-state="open"]');
if (await existing.count() > 0) {
await existing.waitFor({state: 'hidden'}).catch(() => {});
}
await page.getByTestId('save-settings-button').click();
await page.waitForSelector('.ToastRootSuccess[data-state="open"]');
} }
export const restartEtherpad = async (page: Page) => { export const restartEtherpad = async (page: Page) => {
// Click restart const restartButton = page.getByTestId('restart-etherpad-button');
const restartButton = page.locator('.settings-button-bar').locator('.settingsButton').nth(1)
const settings = page.locator('.settings');
await expect(settings).not.toHaveValue('', {timeout: 30000});
await expect(restartButton).toBeVisible({timeout: 10000}) await expect(restartButton).toBeVisible({timeout: 10000})
await restartButton.click() await restartButton.click()
// Wait for the server to come back up by polling. // Wait for the server to come back up by polling.