@softize/opus 26.1.0 → 27.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,63 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
7
7
  `opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
8
8
  mudança cobra do seu código.
9
9
 
10
+ ## 27.0.0 — 2026-10-13
11
+
12
+ O aplicativo passa a saber, na recusa de um anexo, o que entrou na mesma ação, e o glob do runtime
13
+ de design passa a seguir a semântica usual de `**`. A seção **Breaking** traz as migrações, que
14
+ atingem só quem chama `onReject` diretamente ou passa um handler com um segundo parâmetro opcional de
15
+ outro tipo.
16
+
17
+ - **Recusa com os aceitos da mesma ação.** `ComposerAttachments.onReject` recebe um segundo
18
+ argumento, `ComposerRejectionContext`, com `accepted`: os arquivos aceitos na mesma escolha,
19
+ colagem ou soltura, ou uma lista vazia quando nada passou. Assim o aplicativo explica uma soltura
20
+ mista, como uma imagem e um PDF arrastados juntos, sem guardar estado entre as duas chamadas. O
21
+ tipo `ComposerRejectionContext` sai de `@softize/opus/ui/react`.
22
+ - **Ordem das chamadas documentada.** Em uma ação com arquivos aceitos e recusados, `onAddFiles`
23
+ roda antes de `onReject`, de forma síncrona e uma vez cada. O mesmo vale para o soltar sobre a
24
+ conversa do `Chat`. A ordem já era essa; agora faz parte do contrato.
25
+ - **`**` casa zero ou mais pastas no glob de `opusDesign`.** Em `entry` e `scenarios`, `**` como
26
+ pasta inteira segue a semântica usual. Antes, `design/scenarios/**/*.ts` exigia pelo menos uma
27
+ subpasta e ignorava, sem aviso, o cenário salvo direto em `design/scenarios/`. Um projeto com
28
+ `**/` no padrão pode passar a carregar arquivos que antes ficavam de fora; se dois cenários da
29
+ mesma action passarem a entrar, o runtime de design deixa de subir e o erro lista a action.
30
+ - **`?` literal no glob.** Antes, o `?` virava quantificador de regex, e `design/*/a?.ts` casava
31
+ `design/x/.ts`. Os demais padrões sem `**` continuam casando os mesmos arquivos.
32
+
33
+ ### Breaking
34
+
35
+ **O contexto de `onReject` é obrigatório na assinatura.** Quem implementa o callback com um só
36
+ parâmetro não muda nada. Duas situações deixam de compilar:
37
+
38
+ - quem chama `onReject` diretamente, como ao reaproveitar o tipo `ComposerAttachments` em uma área de
39
+ soltar própria. Migração: passe `{ accepted }`, com uma lista vazia quando nada foi aceito:
40
+
41
+ ```ts
42
+ // antes: attachments.onReject?.(rejected)
43
+ attachments.onReject?.(rejected, { accepted })
44
+ ```
45
+
46
+ - quem passa direto um handler que já declarava um segundo parâmetro opcional de outro tipo. Migração:
47
+ envolva o handler, como em `(rejections) => handle(rejections)`.
48
+
49
+ ## 26.2.0 — 2026-10-09
50
+
51
+ Portais podem organizar capítulos em subgrupos e abrir ferramentas relacionadas sem substituir
52
+ a página em leitura.
53
+
54
+ - `DocGroup.groups` declara subgrupos opcionais. Navegação, busca, URLs diretas e sequência
55
+ editorial percorrem todos os níveis, com as páginas próprias antes dos subgrupos. A busca
56
+ preserva os ancestrais dos resultados; no modo inicialmente recolhido, restaura o estado
57
+ manual ao limpar. No modo expandido, os ancestrais da página ativa são reabertos. Ramos
58
+ vazios não aparecem, sem sugerir outro termo quando não há consulta.
59
+ - `DocBrowser.sidebarLinks` preserva a navegação nativa para URLs HTTP(S) absolutas.
60
+ - `SidebarItem` e `DocBrowser.sidebarLinks` aceitam `newTab`: o link abre em outra aba com
61
+ `noopener noreferrer` e informa a abertura no nome acessível. No portal, também exibe
62
+ um ícone de nova aba. A opção não intercepta a navegação nem chama `onClick`.
63
+
64
+ As novas propriedades são opcionais. Consumidores sem subgrupos ou `newTab` mantêm seus
65
+ contratos; não há migração obrigatória nesta versão.
66
+
10
67
  ## 26.1.0 — 2026-10-09
11
68
 
12
69
  Subheadings descrevem a finalidade da superfície; orientações necessárias ficam visíveis no corpo
@@ -39,6 +39,19 @@ de ADRs ou versões.
39
39
 
40
40
  ### Hierarquia e leitura
41
41
 
42
+ > **Emenda — 2026-10-08, protótipo local:** `DocGroup.groups` permite subgrupos
43
+ > opcionais. Um assunto como Integrações pode pertencer a Domínios e ainda reunir
44
+ > páginas por sistema. A navegação, a busca, a seleção direta e a sequência de
45
+ > leitura percorrem os mesmos grupos aninhados. Páginas próprias aparecem antes
46
+ > dos subgrupos; ramos sem resultados somem da busca, sem perder a abertura manual.
47
+ > O contrato anterior continua válido quando `groups` é omitido. Uma árvore paralela
48
+ > no host e rótulos artificiais para simular profundidade foram descartados por
49
+ > duplicar comportamento ou perder a hierarquia. Testes de links diretos, busca,
50
+ > grupos vazios, rótulos repetidos e continuidade verificam a extensão.
51
+
52
+ > **Consolidação — 2026-10-09:** a extensão de subgrupos compõe o candidato da versão
53
+ > 26.2.0, sobre a base 26.1.0. Publicação e adoção permanecem etapas separadas.
54
+
42
55
  `sidebarSize` configura a largura inicial usando o `PaneSize` do `Split`. A omissão preserva
43
56
  `14rem`. `sidebarInitiallyCollapsed` permite começar com seções e grupos nomeados recolhidos,
44
57
  inclusive quando a pessoa abre uma página por URL direta. A busca revela os resultados
@@ -52,6 +65,16 @@ Esses destinos usam `SidebarItem.href`, não uma cópia do estilo da linha. A ex
52
65
  botão quando `href` é omitido, o foco nos dois modos e a abertura nativa em outra aba. Quando há
53
66
  `onClick`, somente a navegação simples é interceptada; o host mantém a decisão de roteamento.
54
67
 
68
+ > **Emenda — 2026-10-09:** destinos HTTP(S) absolutos em `sidebarLinks` usam a navegação
69
+ > nativa, pois podem apontar para outra aplicação. `newTab` é opcional em `SidebarItem`
70
+ > e `sidebarLinks`: com `href`, declara `target="_blank"`, `rel="noopener noreferrer"`
71
+ > e a abertura no nome acessível, sem chamar `onClick`. Sem `href`, não muda o botão.
72
+ > O portal acrescenta o ícone pelo slot `badge` do item canônico. Interceptar esses
73
+ > destinos no roteador ou copiar a linha no host foi descartado: o primeiro impede
74
+ > a navegação entre aplicações e o segundo duplica foco, acessibilidade e composição.
75
+ > Testes verificam links internos e absolutos, modificadores, nome acessível, ícone
76
+ > nos dois modos iniciais do portal e comportamento do item aberto ou recolhido.
77
+
55
78
  O modo opcional `editorial` acrescenta contexto, sumário dos headings realmente renderizados e
56
79
  links de continuidade segundo a ordem declarada. `additionalPages` preserva URLs de páginas fora
57
80
  da árvore e da sequência. O renderer de exemplos aceita `codeCollapsible={false}` quando ocultar
@@ -143,6 +166,10 @@ e separa a consulta documental da consulta local.
143
166
 
144
167
  - Testes de `DocBrowser`: busca local e externa, vazio, largura configurável, lateral opcional,
145
168
  persistência de grupos, expansão temporária, conteúdo do host, redirects e leitura editorial.
169
+ - Regressões da extensão: espaçamento dos grupos sem rótulo em diferentes níveis, acervo vazio
170
+ sem consulta, modificadores em links absolutos e ícone de nova aba nos dois modos iniciais.
171
+ - Testes de `SidebarItem`: nova aba com modificadores, botão habilitado sem `href`, estado
172
+ desabilitado e preservação do badge textual no nome acessível do item aberto.
146
173
  - Testes de `DocHeader`: slots, atributos nativos e composição com ou sem busca e navegação.
147
174
  - Testes de `DocCatalog`: busca local controlada, independência do topo, ausência de controles de
148
175
  coleção, texto integral das versões, incompatibilidades e abertura nativa em outra aba.
@@ -116,6 +116,18 @@ imagem. Assim, retenção, expiração, governança e dado pessoal ficam com que
116
116
  imagem, e `ChatMessage` continua só com texto. Se um consumidor precisar, uma nova decisão define
117
117
  como.
118
118
 
119
+ > **Emenda — 2026-10-13:** a ordem entre as duas chamadas da triagem passa a ser garantida. Em uma
120
+ > mesma escolha, colagem ou soltura com arquivos aceitos e recusados, `onAddFiles` roda antes de
121
+ > `onReject`, de forma síncrona e uma vez cada, também no soltar sobre a conversa do `Chat`.
122
+ > `onReject` recebe um segundo argumento, `context: ComposerRejectionContext`, cujo `accepted`
123
+ > traz os arquivos aceitos na mesma ação, ou uma lista vazia quando nada passou. Com ele, o app
124
+ > explica uma soltura mista sem guardar estado entre as duas chamadas; a adoção pela Grand Brasil
125
+ > mostrou a necessidade. O contexto é obrigatório na assinatura: opcional, ele obrigaria quem
126
+ > desestrutura `{ accepted }` a tratar `undefined`, embora o composer sempre o informe. Quem
127
+ > implementa o callback com um só parâmetro não muda nada; quem chama `onReject` diretamente precisa
128
+ > passar o contexto, e um handler com segundo parâmetro opcional de outro tipo precisa ser envolvido.
129
+ > Por essas migrações, a mudança saiu como major, na 27.0.0.
130
+
119
131
  ### 6. Preparação da imagem no navegador
120
132
 
121
133
  `prepareImage(file, options)` prepara a imagem antes do envio, com os tetos que o app passa:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "26.1.0",
3
+ "version": "27.0.0",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -79,6 +79,8 @@ export interface SidebarItemProps {
79
79
  className?: string;
80
80
  /** Destino nativo. Com onClick, intercepta somente a navegação simples; preserva novas abas. */
81
81
  href?: string;
82
+ /** Abre o destino em nova aba, sem interceptar a navegação nativa. */
83
+ newTab?: boolean;
82
84
  onClick?: () => void;
83
85
  }
84
86
 
@@ -98,11 +100,19 @@ export function SidebarItem({
98
100
  className,
99
101
  onClick,
100
102
  href,
103
+ newTab = false,
101
104
  }: SidebarItemProps): React.ReactElement {
102
105
  const collapsed = useSidebarCollapsed();
103
106
  const Target = href !== undefined && !disabled ? "a" : "button";
104
107
  const targetProps =
105
- Target === "a" ? { href } : { type: "button" as const, disabled };
108
+ Target === "a"
109
+ ? {
110
+ href,
111
+ ...(newTab ? { target: "_blank", rel: "noopener noreferrer" } : {}),
112
+ }
113
+ : { type: "button" as const, disabled };
114
+ const accessibleLabel =
115
+ Target === "a" && newTab ? `${label} (abre em nova aba)` : label;
106
116
  const activate = (
107
117
  event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>,
108
118
  ) => {
@@ -113,6 +123,7 @@ export function SidebarItem({
113
123
  event.ctrlKey ||
114
124
  event.shiftKey ||
115
125
  event.altKey ||
126
+ newTab ||
116
127
  !onClick
117
128
  )
118
129
  return;
@@ -129,7 +140,7 @@ export function SidebarItem({
129
140
  const button = (
130
141
  <Target
131
142
  {...targetProps}
132
- aria-label={label}
143
+ aria-label={accessibleLabel}
133
144
  aria-current={active ? "page" : undefined}
134
145
  onClick={activate}
135
146
  className={cn(
@@ -156,7 +167,7 @@ export function SidebarItem({
156
167
  <Tooltip>
157
168
  <TooltipTrigger asChild>{button}</TooltipTrigger>
158
169
  <TooltipContent side="right">
159
- {label}
170
+ {accessibleLabel}
160
171
  {tooltipHint}
161
172
  </TooltipContent>
162
173
  </Tooltip>
@@ -275,6 +286,9 @@ export function SidebarItem({
275
286
  {badge}
276
287
  </span>
277
288
  )}
289
+ {Target === "a" && newTab && (
290
+ <span className="sr-only"> (abre em nova aba)</span>
291
+ )}
278
292
  </Target>
279
293
  {hasActions && (
280
294
  <div
@@ -29,14 +29,25 @@ export interface ComposerFileRejection {
29
29
  reason: "type" | "limit";
30
30
  }
31
31
 
32
+ /** O que mais aconteceu na mesma ação (escolher, colar ou soltar) que gerou as recusas. */
33
+ export interface ComposerRejectionContext {
34
+ /** Arquivos aceitos na mesma ação, já entregues a `onAddFiles`; vazio quando nenhum passou. */
35
+ accepted: File[];
36
+ }
37
+
32
38
  /**
33
39
  * Anexos da mensagem, controlados pelo app (ADR 0058). O composer faz a triagem e mostra a faixa;
34
40
  * preparar, enviar, guardar e limpar depois do envio são do app, que é o dono do transporte.
41
+ *
42
+ * Em uma mesma ação com arquivos aceitos e recusados, `onAddFiles` é chamado antes de `onReject`,
43
+ * de forma síncrona e uma vez cada. Ação sem aceito não chama `onAddFiles`; sem recusado, não
44
+ * chama `onReject`.
35
45
  */
36
46
  export interface ComposerAttachments {
37
47
  /** Anexos pendentes, na ordem em que aparecem na faixa. */
38
48
  items: ComposerAttachment[];
39
- /** Recebe os arquivos aceitos pela triagem — do botão, do colar ou do soltar. */
49
+ /** Recebe os arquivos aceitos pela triagem — do botão, do colar ou do soltar. Na mesma ação,
50
+ * roda antes de `onReject`. */
40
51
  onAddFiles: (files: File[]) => void;
41
52
  /** A pessoa removeu um anexo da faixa. */
42
53
  onRemove: (id: string) => void;
@@ -46,8 +57,10 @@ export interface ComposerAttachments {
46
57
  * Não há curinga (`image/*`): a triagem compara o tipo exato do arquivo. */
47
58
  accept?: readonly AiImageMediaType[];
48
59
  /** Recebe os arquivos recusados. Presente, a explicação da recusa é do app; ausente, o
49
- * composer a mostra acima do campo. */
50
- onReject?: (rejections: ComposerFileRejection[]) => void;
60
+ * composer a mostra acima do campo. Roda depois de `onAddFiles` na mesma ação, e
61
+ * `context.accepted` traz os arquivos aceitos nela (vazio quando nenhum passou) — o app
62
+ * distingue "nada entrou" de "parte entrou" sem guardar estado entre as duas chamadas. */
63
+ onReject?: (rejections: ComposerFileRejection[], context: ComposerRejectionContext) => void;
51
64
  }
52
65
 
53
66
  /** Limite padrão de anexos por mensagem. */
@@ -296,7 +309,8 @@ export function Composer({
296
309
  const twoRow = actions !== undefined || attachments !== undefined;
297
310
 
298
311
  /** Triagem: tipo aceito e espaço na mensagem. Os aceitos vão ao app; os recusados, ao
299
- * `onReject` do app ou ao aviso do composer. */
312
+ * `onReject` do app ou ao aviso do composer. A ordem (aceitos antes, recusados depois, com os
313
+ * aceitos no contexto) é contrato público de `ComposerAttachments`. */
300
314
  const intake = (files: File[]): void => {
301
315
  if (attachments === undefined || files.length === 0) return;
302
316
  const remaining = Math.max(0, max - items.length);
@@ -309,7 +323,7 @@ export function Composer({
309
323
  }
310
324
  if (accepted.length > 0) attachments.onAddFiles(accepted);
311
325
  if (rejected.length > 0 && attachments.onReject !== undefined) {
312
- attachments.onReject(rejected);
326
+ attachments.onReject(rejected, { accepted });
313
327
  setNotice(null);
314
328
  } else {
315
329
  setNotice(rejected.length > 0 ? rejectionMessage(rejected, accept, max) : null);
@@ -12,8 +12,15 @@
12
12
  * Nos dois, navegar emite `popstate`: host que guarda o path por conta própria acompanha.
13
13
  * `basePath` adapta o prefixo das URLs (`/docs` no Maestro, `/__docs` nos apps).
14
14
  */
15
- import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
16
- import { ArrowLeft, ArrowRight, Search } from "lucide-react";
15
+ import {
16
+ Fragment,
17
+ useEffect,
18
+ useMemo,
19
+ useRef,
20
+ useState,
21
+ type ReactNode,
22
+ } from "react";
23
+ import { ArrowLeft, ArrowRight, ExternalLink, Search } from "lucide-react";
17
24
  import type { DocEntry, DocGroup, DocSection } from "./types.ts";
18
25
  import { Pane, Split, type PaneSize } from "../components/patterns/split.tsx";
19
26
  import { Input } from "../components/primitives/input.tsx";
@@ -33,14 +40,28 @@ import {
33
40
  } from "../components/patterns/sidebar.tsx";
34
41
  import { navigate, usePathname } from "../router.ts";
35
42
 
43
+ function documentEntries(
44
+ sections: DocSection[],
45
+ ): Array<{ entry: DocEntry; context: string }> {
46
+ const entriesFrom = (
47
+ groups: DocGroup[],
48
+ parent: string,
49
+ ): Array<{ entry: DocEntry; context: string }> =>
50
+ groups.flatMap((group) => {
51
+ const context = group.label?.trim() || parent;
52
+ return [
53
+ ...group.pages.map((entry) => ({ entry, context })),
54
+ ...entriesFrom(group.groups ?? [], context),
55
+ ];
56
+ });
57
+ return sections.flatMap((section) =>
58
+ entriesFrom(section.groups, section.label),
59
+ );
60
+ }
61
+
36
62
  function findPage(sections: DocSection[], slug: string): DocEntry | undefined {
37
- for (const s of sections) {
38
- for (const g of s.groups) {
39
- const p = g.pages.find((p) => p.slug === slug);
40
- if (p) return p;
41
- }
42
- }
43
- return undefined;
63
+ return documentEntries(sections).find(({ entry }) => entry.slug === slug)
64
+ ?.entry;
44
65
  }
45
66
 
46
67
  interface DocBrowserSearchCopy {
@@ -58,7 +79,7 @@ export interface DocBrowserProps {
58
79
  basePath?: string;
59
80
  /** Path controlado pelo host (modo Maestro). Ausente = standalone (gerencia o próprio). */
60
81
  path?: string;
61
- /** Seções a exibir (seção → grupo → página). */
82
+ /** Seções a exibir; grupos podem conter páginas e subgrupos. */
62
83
  sections: DocSection[];
63
84
  /** Slugs antigos que devem abrir e substituir a URL pela página canônica. */
64
85
  redirects?: Readonly<Record<string, string>>;
@@ -79,8 +100,13 @@ export interface DocBrowserProps {
79
100
  additionalPages?: DocEntry[];
80
101
  /** Substitui a leitura, mantendo a árvore; o host resolve conteúdo, título e rota. */
81
102
  content?: ReactNode;
82
- /** Destinos fora dos capítulos; não participam da sequência de leitura. */
83
- sidebarLinks?: Array<{ label: string; href: string; active?: boolean }>;
103
+ /** Destinos fora dos capítulos; `newTab` abre em nova aba com ícone e descrição acessível. */
104
+ sidebarLinks?: Array<{
105
+ label: string;
106
+ href: string;
107
+ active?: boolean;
108
+ newTab?: boolean;
109
+ }>;
84
110
  /** Seções e grupos começam recolhidos; a busca revela resultados sem mudar a escolha manual. */
85
111
  sidebarInitiallyCollapsed?: boolean;
86
112
  }
@@ -116,14 +142,7 @@ function EditorialDocument({
116
142
  const [headings, setHeadings] = useState<
117
143
  Array<{ id: string; title: string }>
118
144
  >([]);
119
- const entries = sections.flatMap((section) =>
120
- section.groups.flatMap((group) =>
121
- group.pages.map((entry) => ({
122
- entry,
123
- context: group.label || section.label,
124
- })),
125
- ),
126
- );
145
+ const entries = documentEntries(sections);
127
146
  const index = entries.findIndex(({ entry }) => entry.slug === page.slug);
128
147
  const previous = index > 0 ? entries[index - 1]?.entry : undefined;
129
148
  const next = index >= 0 ? entries[index + 1]?.entry : undefined;
@@ -271,9 +290,14 @@ function searchable(value: string): string {
271
290
  .toLocaleLowerCase("pt-BR");
272
291
  }
273
292
 
293
+ type SidebarGroup = Omit<DocGroup, "groups"> & {
294
+ navigationKey: string;
295
+ groups: SidebarGroup[];
296
+ };
297
+
274
298
  type SidebarSection = Omit<DocSection, "groups"> & {
275
299
  navigationKey: string;
276
- groups: Array<DocGroup & { navigationKey: string }>;
300
+ groups: SidebarGroup[];
277
301
  };
278
302
 
279
303
  function filterSections(
@@ -282,25 +306,35 @@ function filterSections(
282
306
  ): SidebarSection[] {
283
307
  const terms = searchable(query).split(/\s+/).filter(Boolean);
284
308
  // Identidade vem do manifesto completo, antes de remover ramos pela busca.
309
+ const indexGroups = (groups: DocGroup[], parent: string): SidebarGroup[] =>
310
+ groups.map((group, index) => {
311
+ const navigationKey = `${parent}:${index}`;
312
+ return {
313
+ ...group,
314
+ navigationKey,
315
+ groups: indexGroups(group.groups ?? [], navigationKey),
316
+ };
317
+ });
285
318
  const indexedSections = sections.map((section, sectionIndex) => ({
286
319
  ...section,
287
320
  navigationKey: `section:${sectionIndex}`,
288
- groups: section.groups.map((group, groupIndex) => ({
289
- ...group,
290
- navigationKey: `group:${sectionIndex}:${groupIndex}`,
291
- })),
321
+ groups: indexGroups(section.groups, `group:${sectionIndex}`),
292
322
  }));
293
- if (terms.length === 0) return indexedSections;
294
- return indexedSections.flatMap((section) => {
295
- const groups = section.groups.flatMap((group) => {
323
+ const matchingGroups = (groups: SidebarGroup[]): SidebarGroup[] =>
324
+ groups.flatMap((group) => {
296
325
  const pages = group.pages.filter((page) => {
297
326
  const text = searchable(
298
327
  `${page.title}\n${page.navigationTitle ?? ""}\n${page.badge ?? ""}\n${page.searchText ?? ""}`,
299
328
  );
300
329
  return terms.every((term) => text.includes(term));
301
330
  });
302
- return pages.length === 0 ? [] : [{ ...group, pages }];
331
+ const children = matchingGroups(group.groups);
332
+ return pages.length === 0 && children.length === 0
333
+ ? []
334
+ : [{ ...group, pages, groups: children }];
303
335
  });
336
+ return indexedSections.flatMap((section) => {
337
+ const groups = matchingGroups(section.groups);
304
338
  return groups.length === 0 ? [] : [{ ...section, groups }];
305
339
  });
306
340
  }
@@ -327,22 +361,21 @@ function DocSidebarTree({
327
361
 
328
362
  useEffect(() => {
329
363
  if (forceExpanded || initiallyCollapsed) return;
330
- const activeGroup = sections
331
- .flatMap((section) =>
332
- section.groups.map((group) => ({
333
- key: group.navigationKey,
334
- group,
335
- })),
336
- )
337
- .find(({ group }) =>
338
- group.pages.some((page) => page.slug === activeSlug),
339
- );
340
-
341
- if (activeGroup === undefined) return;
364
+ const activePath = (groups: SidebarGroup[]): string[] => {
365
+ for (const group of groups) {
366
+ if (group.pages.some((page) => page.slug === activeSlug))
367
+ return [group.navigationKey];
368
+ const children = activePath(group.groups);
369
+ if (children.length > 0) return [group.navigationKey, ...children];
370
+ }
371
+ return [];
372
+ };
373
+ const keys = sections.flatMap((section) => activePath(section.groups));
374
+ if (keys.length === 0) return;
342
375
  setToggledBranches((current) => {
343
- if (!current.has(activeGroup.key)) return current;
376
+ if (!keys.some((key) => current.has(key))) return current;
344
377
  const next = new Set(current);
345
- next.delete(activeGroup.key);
378
+ keys.forEach((key) => next.delete(key));
346
379
  return next;
347
380
  });
348
381
  }, [activeSlug, forceExpanded, sections, initiallyCollapsed]);
@@ -372,6 +405,36 @@ function DocSidebarTree({
372
405
  />
373
406
  );
374
407
 
408
+ const renderGroup = (group: SidebarGroup): ReactNode => {
409
+ const key = group.navigationKey;
410
+ const children = [
411
+ ...group.pages.map(renderPage),
412
+ ...group.groups.map(renderGroup),
413
+ ];
414
+ if ((group.label ?? "").trim() === "") {
415
+ return <Fragment key={key}>{children}</Fragment>;
416
+ }
417
+ const expanded =
418
+ forceExpanded ||
419
+ (initiallyCollapsed
420
+ ? toggledBranches.has(key)
421
+ : !toggledBranches.has(key));
422
+ return (
423
+ <div key={key} className="space-y-0.5">
424
+ <SidebarItem
425
+ label={group.label!}
426
+ icon={group.icon}
427
+ expanded={expanded}
428
+ onToggle={forceExpanded ? undefined : () => toggleGroup(key)}
429
+ onClick={() => {
430
+ if (!forceExpanded) toggleGroup(key);
431
+ }}
432
+ />
433
+ {expanded && <SidebarTreeGroup>{children}</SidebarTreeGroup>}
434
+ </div>
435
+ );
436
+ };
437
+
375
438
  return (
376
439
  <nav
377
440
  aria-label="Navegação da documentação"
@@ -384,38 +447,7 @@ function DocSidebarTree({
384
447
  initiallyCollapsed && section.label.trim() !== "";
385
448
  const sectionExpanded =
386
449
  forceExpanded || toggledBranches.has(sectionBranch);
387
- const groups = section.groups.map((group) => {
388
- const key = group.navigationKey;
389
- if (group.pages.length === 0) return null;
390
- if ((group.label ?? "").trim() === "") {
391
- return group.pages.map((page) => renderPage(page));
392
- }
393
-
394
- const expanded =
395
- forceExpanded ||
396
- (initiallyCollapsed
397
- ? toggledBranches.has(key)
398
- : !toggledBranches.has(key));
399
- return (
400
- <div key={key} className="space-y-0.5">
401
- <SidebarItem
402
- label={group.label!}
403
- icon={group.icon}
404
- expanded={expanded}
405
- onToggle={forceExpanded ? undefined : () => toggleGroup(key)}
406
- // Durante a busca, o grupo é contexto dos resultados e permanece aberto.
407
- onClick={() => {
408
- if (!forceExpanded) toggleGroup(key);
409
- }}
410
- />
411
- {expanded && (
412
- <SidebarTreeGroup>
413
- {group.pages.map((page) => renderPage(page))}
414
- </SidebarTreeGroup>
415
- )}
416
- </div>
417
- );
418
- });
450
+ const groups = section.groups.map(renderGroup);
419
451
  return (
420
452
  <div key={sectionBranch} className="space-y-0.5">
421
453
  {collapsibleSection ? (
@@ -452,9 +484,24 @@ function DocSidebarTree({
452
484
  key={link.href}
453
485
  href={link.href}
454
486
  active={link.active}
455
- onClick={() => navigate(link.href)}
487
+ newTab={link.newTab}
488
+ badge={
489
+ link.newTab ? (
490
+ <ExternalLink
491
+ aria-hidden="true"
492
+ className="size-3.5 text-muted-foreground"
493
+ />
494
+ ) : undefined
495
+ }
496
+ onClick={
497
+ link.newTab || /^https?:\/\//i.test(link.href)
498
+ ? undefined
499
+ : () => navigate(link.href)
500
+ }
456
501
  label={link.label}
457
- icon={initiallyCollapsed ? <span aria-hidden="true" /> : undefined}
502
+ icon={
503
+ initiallyCollapsed ? <span aria-hidden="true" /> : undefined
504
+ }
458
505
  />
459
506
  ))}
460
507
  </div>
@@ -485,8 +532,8 @@ export function DocBrowser({
485
532
  // hooks); o que muda é quem vence no `??`.
486
533
  const livePath = usePathname();
487
534
  const path = controlledPath ?? livePath;
488
- // Default = 1ª página do 1º grupo da 1ª seção (a doc abre na 1ª página).
489
- const defaultSlug = sections[0]?.groups[0]?.pages[0]?.slug ?? "";
535
+ // Abre na primeira página do percurso, inclusive dentro de subgrupos.
536
+ const defaultSlug = documentEntries(sections)[0]?.entry.slug ?? "";
490
537
  // Slug = TODOS os segmentos após o basePath, unidos (rota aninhada: `/docs/guias/deploy`
491
538
  // → `guias/deploy`). Slug flat de 1 segmento continua igual (`join` de 1 elemento = ele).
492
539
  const rel = path.startsWith(basePath) ? path.slice(basePath.length) : "";
@@ -567,14 +614,14 @@ export function DocBrowser({
567
614
  <div className="min-h-0 flex-1 overflow-y-auto px-3 py-6">
568
615
  <p
569
616
  className={
570
- visibleSections.length === 0
617
+ query.trim() !== "" && visibleSections.length === 0
571
618
  ? "px-2 text-sm text-muted-foreground"
572
619
  : "sr-only"
573
620
  }
574
621
  role={search === false ? undefined : "status"}
575
622
  aria-atomic={search === false ? undefined : "true"}
576
623
  >
577
- {visibleSections.length === 0
624
+ {query.trim() !== "" && visibleSections.length === 0
578
625
  ? (searchOptions.emptyMessage ??
579
626
  "Nenhum documento encontrado. Tente outro termo.")
580
627
  : ""}
@@ -321,9 +321,11 @@ export const eventDetail = defineDesignScenario(eventGet, (input) => ({
321
321
 
322
322
  A resposta é tipada pela entrada e pela saída do contrato; em `list`, é a página inteira. Informe ao
323
323
  plugin onde os cenários estão, com caminhos ou globs:
324
- `opusDesign({ scenarios: 'design/scenarios/*.ts' })`. O cenário vence a resposta gerada. Um cenário
325
- de uma action que não existe, ou dois cenários para a mesma action, impedem o runtime de design de
326
- subir, e o erro lista os nomes.
324
+ `opusDesign({ scenarios: 'design/scenarios/**/*.ts' })`. Nos globs, `*` casa parte do nome dentro
325
+ de uma pasta e `**` casa qualquer número de pastas, inclusive nenhuma: o padrão do exemplo carrega
326
+ tanto `design/scenarios/pedidos.ts` quanto `design/scenarios/pedidos/lista.ts`. O cenário vence a
327
+ resposta gerada. Um cenário de uma action que não existe, ou dois cenários para a mesma action,
328
+ impedem o runtime de design de subir, e o erro lista os nomes.
327
329
 
328
330
  O Opus não decide onde os cenários ficam nem se entram no repositório. Eles não fazem parte da
329
331
  aplicação: podem ficar em uma pasta ignorada pelo git e ser escritos durante a sessão de design,
@@ -261,6 +261,11 @@ O arraste de arquivos sobre a conversa é consumido pelo Chat e não sobe para o
261
261
  aplicativo: um contêiner com o próprio soltar, como uma área de envio de arquivos, não recebe a mesma
262
262
  imagem duas vezes. Sem `composerAttachments`, o arraste segue para o contêiner, como antes.
263
263
 
264
+ O soltar sobre a conversa passa pela mesma triagem do composer. Quando ele traz imagens aceitas e
265
+ arquivos recusados juntos, `onAddFiles` recebe as imagens primeiro, e `onReject`, quando presente,
266
+ recebe os recusados logo depois, com as imagens da mesma ação em `accepted`. Veja
267
+ [recusas no Composer](/ui/composer#com-imagens-anexadas).
268
+
264
269
  ## Propriedades de Chat
265
270
 
266
271
  | Propriedade | Tipo | Padrão | Descrição |
@@ -143,6 +143,23 @@ curinga como `image/*`. O que fica de fora é explicado acima do campo, como “
143
143
  Para dar outra explicação, passe `onReject`: o composer deixa de mostrar o aviso e entrega os
144
144
  arquivos recusados, com o motivo, ao aplicativo.
145
145
 
146
+ Uma mesma escolha, colagem ou soltura pode trazer arquivos aceitos e recusados, como uma imagem e um
147
+ PDF arrastados juntos. O segundo argumento de `onReject`, do tipo `ComposerRejectionContext`, traz
148
+ em `accepted` os aceitos da mesma ação, e a lista vem vazia quando nada passou. Em uma ação mista,
149
+ `onAddFiles` roda antes de `onReject`. Assim o aplicativo explica a recusa conforme o resultado, sem
150
+ guardar estado entre as duas chamadas:
151
+
152
+ ```ts
153
+ const onReject: ComposerAttachments['onReject'] = (rejections, { accepted }) => {
154
+ const names = rejections.map((r) => r.file.name).join(', ')
155
+ notify(
156
+ accepted.length > 0
157
+ ? `Parte dos arquivos foi anexada. Ficaram de fora: ${names}.`
158
+ : `Nenhum arquivo foi anexado. Ficaram de fora: ${names}.`,
159
+ )
160
+ }
161
+ ```
162
+
146
163
  Colar texto continua colando texto, mesmo quando a área de transferência também traz uma imagem, como
147
164
  ao copiar células de uma planilha. Uma imagem colada sem texto, como um print, recebe um nome que a
148
165
  pessoa reconhece na conversa: “Imagem colada 1.png”. A numeração não volta atrás: remover a primeira e
@@ -197,11 +214,11 @@ mesmos que o servidor confere.
197
214
  | Propriedade | Tipo | Padrão | Descrição |
198
215
  |---|---|---|---|
199
216
  | `items` | `ComposerAttachment[]` | | Imagens anexadas à mensagem que está sendo escrita, na ordem da faixa. |
200
- | `onAddFiles` | `(files: File[]) => void` | | Recebe os arquivos aceitos pela triagem, vindos do botão, do colar ou do soltar. |
217
+ | `onAddFiles` | `(files: File[]) => void` | | Recebe os arquivos aceitos pela triagem, vindos do botão, do colar ou do soltar. Em uma ação com aceitos e recusados, é chamado antes de `onReject`. |
201
218
  | `onRemove` | `(id: string) => void` | | A pessoa removeu uma imagem da faixa; o foco volta ao campo. |
202
219
  | `max` | `number` | `4` | Limite de imagens por mensagem. O botão de anexar fica desabilitado quando ele é atingido. |
203
220
  | `accept` | `readonly AiImageMediaType[]` | PNG, JPEG e WebP | Formatos aceitos pelo botão e pela triagem, comparados com o tipo exato do arquivo. |
204
- | `onReject` | `(rejections: ComposerFileRejection[]) => void` | | Recebe os arquivos recusados, com o motivo `type` ou `limit`. Sem ele, o composer explica a recusa acima do campo. |
221
+ | `onReject` | `(rejections: ComposerFileRejection[], context: ComposerRejectionContext) => void` | | Recebe os arquivos recusados, com o motivo `type` ou `limit`, depois de `onAddFiles`; `context.accepted` (`File[]`) traz os arquivos aceitos na mesma ação e vem vazio quando nenhum passou. Sem ele, o composer explica a recusa acima do campo. |
205
222
 
206
223
  `ComposerAttachment` é `{ id, name, status, previewUrl?, error? }`. `status` vale `uploading`,
207
224
  `ready` ou `failed`; `previewUrl` é o endereço da miniatura e `error` é o motivo mostrado quando a
@@ -46,6 +46,36 @@ conceitos relacionados, não para reproduzir automaticamente a árvore de arquiv
46
46
  tem `slug`, `title` e `render`; `searchText` acrescenta conteúdo à busca e `navigationTitle` permite
47
47
  um rótulo lateral curto sem mudar o título completo.
48
48
 
49
+ Um grupo pode conter páginas próprias e subgrupos em `groups`. Por exemplo, uma documentação
50
+ por domínio pode reunir as conexões externas em Integrações e manter um grupo por sistema:
51
+
52
+ ```tsx
53
+ const sections: DocSection[] = [{
54
+ label: 'Domínios',
55
+ groups: [{
56
+ label: 'Integrações',
57
+ pages: [{ slug: 'integrations', title: 'Integrações', render: () => <p>Conexões com sistemas externos.</p> }],
58
+ groups: [{
59
+ label: 'Insider',
60
+ pages: [{ slug: 'integrations/insider', title: 'Insider', render: () => <p>Envio de atributos e eventos.</p> }],
61
+ }],
62
+ }],
63
+ }]
64
+ ```
65
+
66
+ As páginas próprias vêm antes dos subgrupos na sequência de leitura. Um grupo com `pages: []`
67
+ pode reunir somente subgrupos; ramos sem páginas em nenhum nível não aparecem na lateral.
68
+ O contexto editorial usa o grupo nomeado mais próximo. A busca revela os ancestrais dos
69
+ resultados e, no modo `sidebarInitiallyCollapsed`, restaura os ramos abertos manualmente
70
+ quando a consulta é limpa. No modo expandido padrão, os ancestrais da página ativa são
71
+ reabertos para mantê-la visível. `groups` é opcional:
72
+ as declarações existentes continuam válidas. Essa hierarquia é uma escolha de leitura do host,
73
+ não exige mover arquivos nem muda a descoberta por pastas.
74
+
75
+ Se o host também percorre as páginas para montar índices ou resolver links, esse percurso
76
+ precisa visitar `group.groups` recursivamente. Achatar somente `section.groups[].pages`
77
+ não inclui as páginas dos subgrupos.
78
+
49
79
  Com `editorial`, a leitura recebe contexto, sumário dos títulos renderizados e links de página
50
80
  anterior e seguinte. `additionalPages` preserva destinos diretos que não entram nessa sequência.
51
81
  A busca documental do topo mantém seu rótulo, valor e função ao navegar para catálogos e detalhes;
@@ -64,6 +94,19 @@ o contrato desktop a partir de `64rem`. O aplicativo organiza a moldura do porta
64
94
 
65
95
  ADRs e changelog mantêm o mapa dos guias, sem acrescentar uma árvore para cada registro. Use `sidebarLinks` para oferecer entradas
66
96
  no mesmo nível dos grupos, após os capítulos, sem incluí-las na sequência anterior/próxima.
97
+ Destinos com URL HTTP(S) absoluta usam navegação nativa, inclusive para outro aplicativo;
98
+ destinos relativos usam o roteamento compartilhado.
99
+
100
+ Para abrir uma ferramenta sem substituir a leitura atual, declare `newTab` no destino:
101
+
102
+ ```tsx
103
+ <DocBrowser sections={sections}
104
+ sidebarLinks={[{ label: 'Lente', href: '/lens', newTab: true }]} />
105
+ ```
106
+
107
+ O link exibe um ícone de nova aba, informa a abertura no nome acessível e usa
108
+ `rel="noopener noreferrer"`. Sem `newTab`, mantém a navegação na aba atual.
109
+
67
110
  `DocCatalog` recebe registros derivados dos Markdown e uma consulta local independente do cabeçalho.
68
111
  Passe o catálogo em `content` para preservar a lateral:
69
112
 
@@ -148,7 +191,7 @@ não define as rotas nem a autorização do aplicativo.
148
191
 
149
192
  | Propriedade | Tipo | Padrão | Uso |
150
193
  | --- | --- | --- | --- |
151
- | `sections` | `DocSection[]` | Obrigatória na entrada leve | Seções, grupos e páginas na ordem de leitura. |
194
+ | `sections` | `DocSection[]` | Obrigatória na entrada leve | Seções, grupos, subgrupos opcionais e páginas na ordem de leitura. |
152
195
  | `basePath` | `string` | `/docs` | Prefixo das rotas. |
153
196
  | `path` | `string` | Rota compartilhada | Caminho controlado por um host externo. |
154
197
  | `redirects` | `Record<string, string>` | `{}` | Slugs anteriores e seus destinos canônicos. |
@@ -160,7 +203,7 @@ não define as rotas nem a autorização do aplicativo.
160
203
  | `navigationLabel` | `string` | `Documentação` | Nome da área no contexto editorial; por exemplo, `UI` para o catálogo de componentes. |
161
204
  | `additionalPages` | `DocEntry[]` | `[]` | Destinos diretos fora da árvore e da sequência. |
162
205
  | `content` | `ReactNode` | — | Conteúdo do host no lugar da página documental, mantendo a lateral. O host resolve rota e título; `null` deixa a leitura vazia. |
163
- | `sidebarLinks` | `{ label: string; href: string; active?: boolean }[]` | `[]` | Destinos globais fora dos capítulos; `active` marca o catálogo atual. |
206
+ | `sidebarLinks` | `{ label: string; href: string; active?: boolean; newTab?: boolean }[]` | `[]` | Destinos globais fora dos capítulos; `active` marca o catálogo atual e `newTab` abre em outra aba com ícone e descrição acessível. |
164
207
  | `sidebarInitiallyCollapsed` | `boolean` | `false` | Seções e grupos nomeados começam recolhidos, inclusive em links diretos. A busca revela resultados temporariamente sem alterar os ramos abertos pela pessoa. |
165
208
 
166
209
  A entrada histórica `@softize/opus/ui/docs` inclui o catálogo e oferece suas páginas quando
@@ -73,6 +73,10 @@ diretamente quando a composição precisar de controles, badges ou hierarquia pa
73
73
  Quando o host usa um roteador, forneça também `onClick`: somente o clique simples é interceptado;
74
74
  cliques com modificadores preservam o comportamento do link. Sem `href`, o item continua sendo um botão.
75
75
 
76
+ Para manter a página atual aberta ao visitar outro destino, declare `newTab` junto de `href`.
77
+ O link usa `target="_blank"` e `rel="noopener noreferrer"`, não chama `onClick` e informa
78
+ “abre em nova aba” no nome acessível, inclusive no rail recolhido. Sem `href`, `newTab` não tem efeito.
79
+
76
80
  ## Colapso
77
81
 
78
82
  `collapsed` informa o estado compacto aos itens. Forneça labels acessíveis mesmo quando somente os
@@ -99,3 +103,22 @@ render(
99
103
  - `Sidebar` oferece a coluna de navegação.
100
104
  - `SidebarNav`, `SidebarItem` e elementos nativos organizam o conteúdo interno.
101
105
  - `Surface` oferece anatomia e tratamento visual quando a região não é uma sidebar.
106
+
107
+ ## Propriedades de SidebarItem
108
+
109
+ | Propriedade | Tipo | Padrão | Uso |
110
+ | --- | --- | --- | --- |
111
+ | `label` | `string` | Obrigatória | Nome do destino, também usado no modo recolhido. |
112
+ | `href` | `string` | — | Renderiza um link nativo quando o item está habilitado; sem ela, renderiza um botão. |
113
+ | `newTab` | `boolean` | `false` | Com `href`, abre em outra aba, informa a abertura no nome acessível e não chama `onClick`. |
114
+ | `onClick` | `() => void` | — | Ativa o botão ou intercepta o clique simples do link, exceto com `newTab`. |
115
+ | `icon`, `badge` | `ReactNode` | — | Ícone e complemento visual da linha. O modo recolhido mostra somente o ícone. |
116
+ | `active` | `boolean` | `false` | Destaca o destino atual e declara `aria-current="page"`. |
117
+ | `disabled` | `boolean` | `false` | Desabilita o alvo principal e impede que seja um link. |
118
+ | `actions` | `ReactNode` | — | Controles irmãos do alvo principal, no modo aberto. |
119
+ | `onToggle` | `() => void` | — | Exibe um controle separado para expandir ou recolher descendentes no modo aberto. |
120
+ | `expanded` | `boolean` | `false` | Estado visual e acessível do controle de expansão. |
121
+ | `dropPosition` | `'before' \| 'inside' \| 'after'` | — | Indicação visual de posição durante arraste, no modo aberto. |
122
+ | `dragProps` | `HTMLAttributes<HTMLDivElement>` | — | Eventos e atributos de arraste aplicados à linha. |
123
+ | `tooltipHint` | `ReactNode` | — | Complementa o tooltip do modo recolhido. |
124
+ | `className` | `string` | — | Classes da linha nos dois modos. |
@@ -18,6 +18,8 @@ export interface DocGroup {
18
18
  label?: string
19
19
  icon?: React.ReactNode
20
20
  pages: DocEntry[]
21
+ /** Subgrupos opcionais; páginas próprias vêm antes dos grupos na sequência de leitura. */
22
+ groups?: DocGroup[]
21
23
  }
22
24
 
23
25
  export interface DocSection {
package/src/ui/react.tsx CHANGED
@@ -73,6 +73,7 @@ export type {
73
73
  ComposerAttachmentStatus,
74
74
  ComposerAttachments,
75
75
  ComposerFileRejection,
76
+ ComposerRejectionContext,
76
77
  } from "./components/primitives/composer.tsx";
77
78
  // Preparação da imagem no navegador antes do envio (ADR 0058).
78
79
  export { prepareImage } from "./lib/prepare-image.ts";
@@ -82,7 +82,9 @@ export interface OpusDesignOptions {
82
82
  * CADA rebuild — domínio novo entra no ar ao salvar o arquivo, sem editar
83
83
  * entry nenhum. Dos módulos casados entram os exports (default e named) com
84
84
  * shape de registrável; schemas/helpers exportados junto ficam de fora.
85
- * Pode misturar estáticos e globs no array.
85
+ * Pode misturar estáticos e globs no array. No glob, `*` casa parte de um
86
+ * nome dentro de uma pasta e `**` casa zero ou mais pastas:
87
+ * 'design/scenarios/**\/*.ts' inclui o arquivo solto em `design/scenarios/`.
86
88
  */
87
89
  entry?: string | string[]
88
90
 
@@ -531,17 +533,33 @@ async function loadGlobEntry(server: ViteDevServer, pattern: string): Promise<un
531
533
  }
532
534
 
533
535
  /**
534
- * Matcher próprio de propósito (sem dep nova): `*` casa um segmento (sem '/'),
535
- * `**` casa qualquer profundidade. Cobre o entry por pasta de domínio; padrão
536
- * exótico não suportado = aponte entries estáticos.
536
+ * Matcher próprio de propósito (sem dep nova): `*` casa parte de um segmento
537
+ * (sem '/'); `**` como segmento inteiro é globstar e casa zero ou mais pastas
538
+ * — `design/**\/*.ts` casa `design/a.ts`, `design/x/a.ts` e `design/x/y/a.ts`.
539
+ * No fim do padrão (`design/**`), casa qualquer arquivo abaixo da pasta. Cobre o
540
+ * entry por pasta de domínio e os cenários; padrão exótico não suportado =
541
+ * aponte entries estáticos.
542
+ *
543
+ * Exportada só para teste: o índice de `@softize/opus/vite` não a reexporta.
537
544
  */
538
- function globToRegExp(pattern: string): RegExp {
539
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&')
540
- // `\u0000` como placeholder do `**`: nunca ocorre num path real.
541
- const body = escaped
542
- .replace(/\*\*/g, '\u0000')
543
- .replace(/\*/g, '[^/]*')
544
- .replace(/\u0000/g, '.*')
545
+ export function globToRegExp(pattern: string): RegExp {
546
+ const segments = pattern.split('/')
547
+ const body = segments
548
+ .map((segment, index) => {
549
+ const last = index === segments.length - 1
550
+ // Globstar no começo ou no meio: o grupo opcional leva a barra junto, então
551
+ // "nenhuma pasta" casa sem exigir `//` no path.
552
+ if (segment === '**') return last ? '.*' : '(?:.*/)?'
553
+ const escaped = segment.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
554
+ // `**` colado a outro texto no segmento (`a**`) mantém o comportamento
555
+ // anterior: qualquer sequência. `\u0000` é placeholder: nunca ocorre num path.
556
+ const converted = escaped
557
+ .replace(/\*\*/g, '\u0000')
558
+ .replace(/\*/g, '[^/]*')
559
+ .replace(/\u0000/g, '.*')
560
+ return last ? converted : `${converted}/`
561
+ })
562
+ .join('')
545
563
  return new RegExp(`^${body}$`)
546
564
  }
547
565