@softize/opus 25.4.1 → 25.6.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,37 @@ 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
+ ## 25.6.0 — 2026-10-07
11
+
12
+ Aplicações que apresentam Markdown do próprio produto podem usar
13
+ `@softize/opus/ui/docs/links` para resolver caminhos relativos, derivar slugs e normalizar
14
+ fragmentos sem carregar React. A entrada `@softize/opus/ui/docs/browser` também reexporta esses
15
+ utilitários. O renderizador compartilhado usa a mesma normalização nos IDs dos títulos, inclusive
16
+ quando o texto contém hífens cercados por espaços ou repetidos; assim, links e headings deixam de
17
+ divergir nesses casos.
18
+
19
+ Não há migração geral obrigatória. Links profundos já salvos para títulos com hífen cercado por
20
+ espaços ou com hífens consecutivos precisam usar um único hífen; por exemplo, `a---b` passa a ser
21
+ `a-b`. Consumidores que já implementam essa gramática localmente podem substituí-la pelos
22
+ utilitários compartilhados e continuar decidindo a rota e o espaço de destino. O resolvedor aceita
23
+ fontes `.md` e `.mdx`; o caminho atual é relativo à raiz documental, e apenas `/docs/` com barra
24
+ inicial declara essa raiz num link absoluto.
25
+
26
+ ## 25.5.0 — 2026-10-07
27
+
28
+ Aplicações que publicam documentação própria podem usar a nova entrada
29
+ `@softize/opus/ui/docs/browser`. Ela oferece `DocBrowser`, transformação de pastas, Markdown e os
30
+ tipos de navegação sem carregar o catálogo do Opus, os exemplos vivos nem o compilador usado por
31
+ esses exemplos. A entrada histórica `@softize/opus/ui/docs` continua incluindo o catálogo completo
32
+ e mantém seu comportamento atual.
33
+
34
+ O `DocBrowser` aceita busca local por título, badge e conteúdo pesquisável. A busca pode configurar
35
+ nome acessível, placeholder e mensagem sem resultados; o padrão orienta a tentar outro termo.
36
+ Páginas derivadas de pastas passam a informar `sourcePath` e `searchText`; `renderPage` permite
37
+ resolver links ou compor a apresentação sem duplicar descoberta, hierarquia e ordenação. O
38
+ Markdown compartilhado também pode reescrever links e gerar IDs estáveis para títulos. Não há
39
+ migração para consumidores existentes.
40
+
10
41
  ## 25.4.1 — 2026-10-05
11
42
 
12
43
  Corrige a busca remota de filtros `lookup` quando a `ActionList` também recebe opções iniciais em
@@ -0,0 +1,85 @@
1
+ # ADR 0056 — DocBrowser separa o runtime leve do catálogo do Opus
2
+
3
+ - **Status:** aceita.
4
+ - **Data:** 2026-10-07.
5
+
6
+ ## Contexto
7
+
8
+ O ponto de entrada `@softize/opus/ui/docs` combina duas responsabilidades: o navegador de
9
+ documentação e o catálogo completo do próprio Opus. Esse catálogo importa páginas TSX, exemplos
10
+ vivos e o compilador usado para executá-los. A composição é adequada ao site do SDK, mas faz uma
11
+ aplicação que publica seus próprios Markdown carregar código que não consulta nem apresenta.
12
+
13
+ O portal da Grand Brasil precisa reutilizar navegação, busca, transformação de pastas e Markdown,
14
+ mantendo no produto a classificação dos documentos, os espaços de navegação e a política de
15
+ acesso. Copiar esses recursos para o consumidor criaria duas implementações; obrigá-lo a consumir
16
+ o catálogo do SDK ampliaria o bundle e confundiria a fronteira entre ferramenta e conteúdo.
17
+
18
+ ## Decisão
19
+
20
+ ### 1. O navegador base recebe o catálogo do consumidor
21
+
22
+ O `DocBrowser` base passa a exigir `sections`. Ele pode oferecer busca local sobre título, badge e
23
+ texto pesquisável, mas não conhece páginas específicas do Opus.
24
+
25
+ Os pontos de entrada históricos mantêm o comportamento atual por meio de adaptadores:
26
+ `@softize/opus/ui/docs` injeta o catálogo completo quando o consumidor não informa seções, e a
27
+ transformação de pastas usada pelo plugin e pelo standalone preserva exemplos vivos e blocos de
28
+ código. Assim, a mudança permanece compatível para os consumidores existentes.
29
+
30
+ ### 2. Um subpath explícito publica somente a superfície leve
31
+
32
+ `@softize/opus/ui/docs/browser` exporta o navegador base, os tipos de seção, a transformação de
33
+ pastas e o renderizador de Markdown. Seu grafo não pode alcançar o registro de páginas do Opus,
34
+ o renderizador de exemplos vivos nem o compilador desses exemplos.
35
+
36
+ A transformação de pastas fornece `sourcePath` e `searchText` e aceita `renderPage`. Esses pontos
37
+ permitem ao consumidor resolver links e compor a página sem reimplementar descoberta, ordenação ou
38
+ hierarquia.
39
+
40
+ ### 3. O Markdown básico admite comportamento documental opcional
41
+
42
+ O renderizador compartilhado pode reescrever links e criar IDs estáveis para títulos. Essas
43
+ opções são explícitas; o caminho usado em conversas continua sem IDs e sem tratamento editorial
44
+ quando elas não são pedidas. Frontmatter é removido somente nas superfícies documentais.
45
+
46
+ O subpath leve `@softize/opus/ui/docs/links` publica a normalização de âncoras, a derivação de slug a
47
+ partir do caminho da fonte e a resolução de links Markdown relativos sem carregar React. A entrada
48
+ `browser` também os reexporta por conveniência. O renderizador e os consumidores usam essas funções
49
+ em comum: uma aplicação decide em qual espaço ou superfície abrir a fonte resolvida, mas não
50
+ reimplementa a gramática de caminhos, prefixos numéricos e fragmentos.
51
+
52
+ ### 4. A superfície permanece no pacote Opus
53
+
54
+ O runtime leve continua em `@softize/opus` enquanto compartilha dependências, manutenção e ciclo
55
+ de release com os componentes de documentação existentes. Um pacote separado só será considerado
56
+ quando houver adoção independente, ciclo de release próprio ou dependências que não devam fazer
57
+ parte do SDK. O crescimento da quantidade de arquivos, isoladamente, não muda essa fronteira.
58
+
59
+ ## Alternativas consideradas
60
+
61
+ ### Consumir o catálogo completo em qualquer aplicação
62
+
63
+ Evitaria um novo subpath, mas carregaria páginas e compilação de exemplos que não pertencem ao
64
+ produto consumidor.
65
+
66
+ ### Copiar o navegador para cada produto
67
+
68
+ Manteria bundles isolados, mas duplicaria busca, navegação, ordenação e tratamento de Markdown.
69
+
70
+ ### Criar imediatamente outro pacote
71
+
72
+ Produziria isolamento mais forte, porém acrescentaria release e adoção independentes antes de
73
+ existir evidência operacional para sustentar essa separação.
74
+
75
+ ## Consequências
76
+
77
+ - Aplicações podem publicar documentação própria sem incluir o catálogo do Opus.
78
+ - O site do Opus preserva a API histórica e seus exemplos vivos.
79
+ - Classificação, autorização e conteúdo permanecem responsabilidade do consumidor.
80
+ - A busca é local e recebe o conteúdo no navegador; ela não substitui filtragem no servidor quando
81
+ houver documentos com audiências diferentes.
82
+ - Links Markdown relativos, slugs e fragmentos seguem uma gramática compartilhada; o consumidor
83
+ continua responsável por mapear a fonte resolvida para seu espaço, sua rota e sua política.
84
+ - Um teste de arquitetura percorre o grafo do novo subpath e reprova se ele alcançar catálogo,
85
+ exemplos vivos ou o compilador correspondente.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "25.4.1",
3
+ "version": "25.6.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",
@@ -217,6 +217,14 @@
217
217
  "types": "./src/ui/docs/index.ts",
218
218
  "default": "./src/ui/docs/index.ts"
219
219
  },
220
+ "./ui/docs/browser": {
221
+ "types": "./src/ui/docs/browser.tsx",
222
+ "default": "./src/ui/docs/browser.tsx"
223
+ },
224
+ "./ui/docs/links": {
225
+ "types": "./src/ui/docs/links.ts",
226
+ "default": "./src/ui/docs/links.ts"
227
+ },
220
228
  "./ui/docs/standalone": {
221
229
  "types": "./src/ui/docs/standalone.tsx",
222
230
  "default": "./src/ui/docs/standalone.tsx"
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import * as React from 'react'
14
14
  import MarkdownIt from 'markdown-it'
15
+ import { documentationHeadingAnchor } from '../../docs/links.ts'
15
16
 
16
17
  /** A instância ÚNICA — a doc (`DocMarkdown`) importa esta mesma. Duas instâncias com
17
18
  * opções próprias divergiriam no primeiro edge case, e "mesmo motor" viraria promessa. */
@@ -37,12 +38,78 @@ markdownIt.renderer.rules.image = (tokens, idx, options, env, self) => {
37
38
  export interface MarkdownProps {
38
39
  content: string
39
40
  className?: string
41
+ /** Reescreve links antes da renderização, por exemplo para resolver caminhos relativos. */
42
+ resolveHref?: (href: string) => string
43
+ /** Cria IDs estáveis nos títulos. Útil em documentos navegáveis; omitido no chat. */
44
+ headingIds?: boolean
45
+ }
46
+
47
+ function resolveTokenLinks(tokens: ReturnType<typeof markdownIt.parse>, resolveHref: (href: string) => string): void {
48
+ for (const token of tokens) {
49
+ if (token.type === 'link_open') {
50
+ const href = token.attrGet('href')
51
+ if (href !== null) {
52
+ const resolved = resolveHref(href)
53
+ const normalized = markdownIt.normalizeLink(resolved)
54
+ const withoutControls = resolved.replace(/[\u0000-\u001f\u007f]/g, '')
55
+ const safe =
56
+ markdownIt.validateLink(normalized) && markdownIt.validateLink(withoutControls)
57
+ token.attrSet('href', safe ? normalized : '')
58
+ }
59
+ }
60
+ if (token.children !== null) resolveTokenLinks(token.children, resolveHref)
61
+ }
62
+ }
63
+
64
+ function visibleInlineText(tokens: ReturnType<typeof markdownIt.parse>): string {
65
+ return tokens
66
+ .map((token) => {
67
+ if (token.type === 'text' || token.type === 'code_inline') return token.content
68
+ if (token.type === 'softbreak' || token.type === 'hardbreak') return ' '
69
+ return token.children === null ? '' : visibleInlineText(token.children)
70
+ })
71
+ .join('')
72
+ }
73
+
74
+ function identifyHeadings(tokens: ReturnType<typeof markdownIt.parse>): void {
75
+ const used = new Set<string>()
76
+ for (let index = 0; index < tokens.length; index++) {
77
+ const opening = tokens[index]
78
+ const content = tokens[index + 1]
79
+ if (opening?.type !== 'heading_open' || content?.type !== 'inline') continue
80
+ const text = visibleInlineText(content.children ?? [])
81
+ const base = documentationHeadingAnchor(text)
82
+ if (base === '') continue
83
+ let candidate = base
84
+ let suffix = 1
85
+ while (used.has(candidate)) candidate = `${base}-${suffix++}`
86
+ used.add(candidate)
87
+ opening.attrSet('id', candidate)
88
+ }
89
+ }
90
+
91
+ /** Tokeniza Markdown de documentação com frontmatter removido, âncoras e links resolvidos. */
92
+ export function documentationMarkdownTokens(
93
+ content: string,
94
+ resolveHref?: (href: string) => string,
95
+ ): ReturnType<typeof markdownIt.parse> {
96
+ const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '')
97
+ const tokens = markdownIt.parse(body, {})
98
+ identifyHeadings(tokens)
99
+ if (resolveHref !== undefined) resolveTokenLinks(tokens, resolveHref)
100
+ return tokens
40
101
  }
41
102
 
42
103
  export const Markdown = React.memo(function Markdown({
43
104
  content,
44
105
  className,
106
+ resolveHref,
107
+ headingIds = false,
45
108
  }: MarkdownProps): React.ReactElement {
109
+ const html =
110
+ resolveHref === undefined && !headingIds
111
+ ? markdownIt.render(content)
112
+ : markdownIt.renderer.render(documentationMarkdownTokens(content, resolveHref), markdownIt.options, {})
46
113
  return (
47
114
  <div
48
115
  data-slot="markdown"
@@ -50,7 +117,7 @@ export const Markdown = React.memo(function Markdown({
50
117
  // margem é do `<p>`, dentro deste wrapper: quem tentava zerar de fora (a bolha do
51
118
  // chat fazia) acertava o wrapper e errava o parágrafo.
52
119
  className={`prose prose-sm max-w-none [&>:first-child]:mt-0 [&>:last-child]:mb-0${className !== undefined ? ` ${className}` : ''}`}
53
- dangerouslySetInnerHTML={{ __html: markdownIt.render(content) }}
120
+ dangerouslySetInnerHTML={{ __html: html }}
54
121
  />
55
122
  )
56
123
  })
@@ -0,0 +1,15 @@
1
+ import { DocBrowser as BaseDocBrowser, type DocBrowserProps as BaseDocBrowserProps } from './DocBrowser.tsx'
2
+ import { DOC_SECTIONS } from './registry.tsx'
3
+
4
+ export interface DocBrowserProps extends Omit<BaseDocBrowserProps, 'sections'> {
5
+ /** Seções a exibir. O catálogo completo do Opus é usado quando elas não são informadas. */
6
+ sections?: BaseDocBrowserProps['sections']
7
+ }
8
+
9
+ /**
10
+ * Entrada histórica do catálogo do Opus. Consumidores que fornecem as próprias páginas devem usar
11
+ * `@softize/opus/ui/docs/browser`, que não inclui o catálogo nem os exemplos vivos no bundle.
12
+ */
13
+ export function DocBrowser({ sections = DOC_SECTIONS, ...props }: DocBrowserProps): React.ReactElement {
14
+ return <BaseDocBrowser {...props} sections={sections} />
15
+ }
@@ -12,9 +12,11 @@
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, useState } from "react";
16
- import { DOC_SECTIONS, type DocSection, type DocEntry } from "./registry";
15
+ import { useEffect, useMemo, useState } from "react";
16
+ import { Search } from "lucide-react";
17
+ import type { DocEntry, DocSection } from "./types.ts";
17
18
  import { Pane, Split } from "../components/patterns/split.tsx";
19
+ import { Input } from "../components/primitives/input.tsx";
18
20
  import {
19
21
  Sidebar,
20
22
  SidebarGroupLabel,
@@ -38,29 +40,58 @@ export interface DocBrowserProps {
38
40
  basePath?: string;
39
41
  /** Path controlado pelo host (modo Maestro). Ausente = standalone (gerencia o próprio). */
40
42
  path?: string;
41
- /** Seções a exibir (seção → grupo → página). Default = catálogo inteiro. */
42
- sections?: DocSection[];
43
+ /** Seções a exibir (seção → grupo → página). */
44
+ sections: DocSection[];
43
45
  /** Slugs antigos que devem abrir e substituir a URL pela página canônica. */
44
46
  redirects?: Readonly<Record<string, string>>;
45
47
  /** Monta o `document.title` a partir do título da página atual (ex.: `(t) => `${t} · Opus``).
46
48
  * Ausente = não mexe no title. */
47
49
  documentTitle?: (pageTitle: string) => string;
50
+ /** Busca local por título, badge e `searchText` das páginas. Omitida = sem campo de busca. */
51
+ search?: boolean | { label?: string; placeholder?: string; emptyMessage?: string };
52
+ }
53
+
54
+ function searchable(value: string): string {
55
+ return value
56
+ .normalize("NFD")
57
+ .replace(/[\u0300-\u036f]/g, "")
58
+ .toLocaleLowerCase("pt-BR");
59
+ }
60
+
61
+ function filterSections(sections: DocSection[], query: string): DocSection[] {
62
+ const terms = searchable(query).split(/\s+/).filter(Boolean);
63
+ if (terms.length === 0) return sections;
64
+ return sections.flatMap((section) => {
65
+ const groups = section.groups.flatMap((group) => {
66
+ const pages = group.pages.filter((page) => {
67
+ const text = searchable(
68
+ `${page.title}\n${page.badge ?? ""}\n${page.searchText ?? ""}`,
69
+ );
70
+ return terms.every((term) => text.includes(term));
71
+ });
72
+ return pages.length === 0 ? [] : [{ ...group, pages }];
73
+ });
74
+ return groups.length === 0 ? [] : [{ ...section, groups }];
75
+ });
48
76
  }
49
77
 
50
78
  function DocSidebarTree({
51
79
  sections,
52
80
  activeSlug,
53
81
  onSelect,
82
+ forceExpanded = false,
54
83
  }: {
55
84
  sections: DocSection[];
56
85
  activeSlug?: string;
57
86
  onSelect: (slug: string) => void;
87
+ forceExpanded?: boolean;
58
88
  }): React.ReactElement {
59
89
  const [closedGroups, setClosedGroups] = useState<Set<string>>(
60
90
  () => new Set(),
61
91
  );
62
92
 
63
93
  useEffect(() => {
94
+ if (forceExpanded) return;
64
95
  const activeGroup = sections
65
96
  .flatMap((section, sectionIndex) =>
66
97
  section.groups.map((group, groupIndex) => ({
@@ -79,7 +110,7 @@ function DocSidebarTree({
79
110
  next.delete(activeGroup.key);
80
111
  return next;
81
112
  });
82
- }, [activeSlug, sections]);
113
+ }, [activeSlug, forceExpanded, sections]);
83
114
 
84
115
  const toggleGroup = (key: string): void => {
85
116
  setClosedGroups((current) => {
@@ -120,15 +151,18 @@ function DocSidebarTree({
120
151
  return group.pages.map((page) => renderPage(page));
121
152
  }
122
153
 
123
- const expanded = !closedGroups.has(key);
154
+ const expanded = forceExpanded || !closedGroups.has(key);
124
155
  return (
125
156
  <div key={key} className="space-y-0.5">
126
157
  <SidebarItem
127
158
  label={group.label!}
128
159
  icon={group.icon}
129
160
  expanded={expanded}
130
- onToggle={() => toggleGroup(key)}
131
- onClick={() => toggleGroup(key)}
161
+ onToggle={forceExpanded ? undefined : () => toggleGroup(key)}
162
+ // Durante a busca, o grupo é contexto dos resultados e permanece aberto.
163
+ onClick={() => {
164
+ if (!forceExpanded) toggleGroup(key);
165
+ }}
132
166
  />
133
167
  {expanded && (
134
168
  <SidebarTreeGroup>
@@ -147,10 +181,12 @@ function DocSidebarTree({
147
181
  export function DocBrowser({
148
182
  basePath = "/docs",
149
183
  path: controlledPath,
150
- sections = DOC_SECTIONS,
184
+ sections,
151
185
  redirects = {},
152
186
  documentTitle,
187
+ search = false,
153
188
  }: DocBrowserProps): React.ReactElement {
189
+ const [query, setQuery] = useState("");
154
190
  // Standalone: o path vem do router da casa (`usePathname` — pathname É o estado).
155
191
  // Controlado: o host manda, e este valor é ignorado. O hook roda sempre (regra dos
156
192
  // hooks); o que muda é quem vence no `??`.
@@ -164,6 +200,8 @@ export function DocBrowser({
164
200
  const requestedSlug = rel.split("/").filter(Boolean).join("/") || defaultSlug;
165
201
  const slug = redirects[requestedSlug] ?? requestedSlug;
166
202
  const page = findPage(sections, slug) ?? findPage(sections, defaultSlug);
203
+ const visibleSections = useMemo(() => filterSections(sections, query), [query, sections]);
204
+ const searchOptions = typeof search === "object" ? search : {};
167
205
 
168
206
  // `navigate` emite `popstate`, então o host é notificado nos dois modos — inclusive o
169
207
  // que ainda escuta o evento na unha, que era como este componente avisava antes.
@@ -183,12 +221,42 @@ export function DocBrowser({
183
221
  <Split className="h-full">
184
222
  <Pane initialSize="14rem">
185
223
  <Sidebar className="w-full">
186
- <div className="min-h-0 flex-1 overflow-y-auto px-3 py-6">
187
- <DocSidebarTree
188
- sections={sections}
189
- activeSlug={page?.slug}
190
- onSelect={(nextSlug) => go(`${basePath}/${nextSlug}`)}
191
- />
224
+ <div className="flex min-h-0 flex-1 flex-col">
225
+ {search !== false && (
226
+ <div className="shrink-0 border-b border-border p-3">
227
+ <Input
228
+ type="search"
229
+ icon={<Search />}
230
+ aria-label={searchOptions.label ?? "Buscar na documentação"}
231
+ placeholder={
232
+ searchOptions.placeholder ?? "Buscar na documentação…"
233
+ }
234
+ value={query}
235
+ onChange={(event) => setQuery(event.target.value)}
236
+ />
237
+ </div>
238
+ )}
239
+ <div className="min-h-0 flex-1 overflow-y-auto px-3 py-6">
240
+ <p
241
+ className={
242
+ visibleSections.length === 0 ? "px-2 text-sm text-muted-foreground" : "sr-only"
243
+ }
244
+ role={search === false ? undefined : "status"}
245
+ aria-atomic={search === false ? undefined : "true"}
246
+ >
247
+ {visibleSections.length === 0
248
+ ? searchOptions.emptyMessage ?? "Nenhum documento encontrado. Tente outro termo."
249
+ : ""}
250
+ </p>
251
+ {visibleSections.length > 0 && (
252
+ <DocSidebarTree
253
+ sections={visibleSections}
254
+ activeSlug={page?.slug}
255
+ forceExpanded={query.trim() !== ""}
256
+ onSelect={(nextSlug) => go(`${basePath}/${nextSlug}`)}
257
+ />
258
+ )}
259
+ </div>
192
260
  </div>
193
261
  </Sidebar>
194
262
  </Pane>
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Superfície leve para aplicações que publicam a própria documentação. Não inclui o catálogo do
3
+ * Opus, o compilador de exemplos TSX nem os componentes usados apenas por esses exemplos.
4
+ */
5
+ export { DocBrowser } from './DocBrowser.tsx'
6
+ export type { DocBrowserProps } from './DocBrowser.tsx'
7
+ export { docSectionsFromFolder } from './folder.tsx'
8
+ export type { FolderOptions, FolderPage } from './folder.tsx'
9
+ export {
10
+ documentationAnchor,
11
+ documentationHeadingAnchor,
12
+ documentationSlugFromSourcePath,
13
+ resolveDocumentationLinkTarget,
14
+ } from './links.ts'
15
+ export type { DocumentationLinkTarget } from './links.ts'
16
+ export type { DocEntry, DocGroup, DocSection } from './types.ts'
17
+ export { Markdown } from '../components/primitives/markdown.tsx'
18
+ export type { MarkdownProps } from '../components/primitives/markdown.tsx'
@@ -0,0 +1,22 @@
1
+ import { docSectionsFromFolder as createSections } from './folder.tsx'
2
+ import type { DocSection } from './types.ts'
3
+ import { DocMarkdown } from './markdown.tsx'
4
+
5
+ export type { FolderOptions, FolderPage } from './folder.tsx'
6
+ import type { FolderOptions } from './folder.tsx'
7
+
8
+ /**
9
+ * Adaptador histórico de pastas: preserva exemplos vivos e blocos de código do catálogo do Opus.
10
+ * A entrada leve exporta diretamente o transformador base, cujo padrão é o Markdown básico.
11
+ */
12
+ export function docSectionsFromFolder(
13
+ files: Record<string, string>,
14
+ options: FolderOptions = {},
15
+ ): DocSection[] {
16
+ return createSections(files, {
17
+ ...options,
18
+ renderPage:
19
+ options.renderPage ?? ((page) => <DocMarkdown content={page.body} />),
20
+ })
21
+ }
22
+
@@ -34,6 +34,45 @@ const sample = [
34
34
  render(<Markdown content={sample} />)
35
35
  ```
36
36
 
37
+ ## Documentos navegáveis
38
+
39
+ Ao apresentar uma pasta de documentação, use `headingIds` para gerar âncoras e `resolveHref` para
40
+ transformar os links internos na rota da aplicação. Os utilitários de
41
+ `@softize/opus/ui/docs/links` mantêm os dois lados na mesma gramática sem decidir a URL final.
42
+
43
+ ```tsx
44
+ import {
45
+ documentationSlugFromSourcePath,
46
+ resolveDocumentationLinkTarget,
47
+ } from '@softize/opus/ui/docs/links'
48
+
49
+ const sourcePath = 'guides/current.md'
50
+
51
+ <Markdown
52
+ content={content}
53
+ headingIds
54
+ resolveHref={(href) => {
55
+ const target = resolveDocumentationLinkTarget(sourcePath, href)
56
+ if (target === null) return href
57
+ const slug = documentationSlugFromSourcePath(target.sourcePath)
58
+ return `/docs/project/${slug}${target.anchor === undefined ? '' : `#${target.anchor}`}`
59
+ }}
60
+ />
61
+ ```
62
+
63
+ Passe `sourcePath` relativo à raiz documental. Um link absoluto iniciado por `/docs/` volta a essa
64
+ raiz; `docs/` sem a barra inicial continua sendo uma subpasta comum. Protocolos, arquivos que não
65
+ são Markdown e caminhos que escapam da raiz retornam `null`, para a aplicação preservar ou tratar o
66
+ endereço original.
67
+
68
+ ## Referência de links documentais
69
+
70
+ | Função | Resultado |
71
+ |---|---|
72
+ | `resolveDocumentationLinkTarget(currentSourcePath, href)` | Devolve o caminho `.md` ou `.mdx` relativo à raiz e a âncora normalizada. Decodifica segmentos percentuais válidos e devolve `null` quando o endereço não pertence ao acervo. |
73
+ | `documentationSlugFromSourcePath(sourcePath)` | Remove a extensão e os prefixos numéricos de ordenação de cada segmento. Preserva a hierarquia restante. |
74
+ | `documentationAnchor(fragment)` | Decodifica e normaliza um fragmento URI para comparação com o ID do título. |
75
+ | `documentationHeadingAnchor(text)` | Normaliza o texto visível de um título sem interpretar sequências percentuais como URI. |
37
76
 
38
77
  ## Propriedades de Markdown
39
78
 
@@ -41,3 +80,5 @@ render(<Markdown content={sample} />)
41
80
  |---|---|---|---|
42
81
  | `content` | `string` | obrigatório | O texto em Markdown (CommonMark e tabelas do GFM, via markdown-it). HTML no texto é escapado, não interpretado, por isso o componente serve para texto escrito por pessoas ou gerado por modelos. Endereços soltos, como `https://…`, viram links. |
43
82
  | `className` | `string` | | Classes do contêiner. |
83
+ | `resolveHref` | `(href: string) => string` | | Reescreve cada link antes da validação e da renderização. Use para transformar caminhos documentais em rotas da aplicação. |
84
+ | `headingIds` | `boolean` | `false` | Gera IDs normalizados e únicos nos títulos. Ative em documentos navegáveis; mantenha desativado em mensagens comuns. |
@@ -16,10 +16,12 @@
16
16
  *
17
17
  * Frontmatter: parser mínimo browser-safe — este transform roda no bundle do app, e o
18
18
  * gray-matter usa Buffer/js-yaml (inóspito no browser). Cobre title/section/group/order;
19
- * YAML rico fica pra depois. Uso: ver `opusDocs({ source })` em ./plugin.ts.
19
+ * YAML rico fica pra depois. A entrada leve usa este transformador diretamente; as entradas
20
+ * históricas injetam o renderizador de exemplos vivos em `catalog-folder.tsx`.
20
21
  */
21
- import { DocMarkdown } from './markdown.tsx'
22
- import type { DocEntry, DocGroup, DocSection } from './registry.tsx'
22
+ import { Markdown } from '../components/primitives/markdown.tsx'
23
+ import { documentationSlugFromSourcePath } from './links.ts'
24
+ import type { DocEntry, DocGroup, DocSection } from './types.ts'
23
25
 
24
26
  interface FrontMatter {
25
27
  title?: string
@@ -39,7 +41,12 @@ function parseFrontMatter(raw: string): { data: FrontMatter; body: string } {
39
41
  }
40
42
  const n = kv.order !== undefined ? Number(kv.order) : NaN
41
43
  return {
42
- data: { title: kv.title, section: kv.section, group: kv.group, order: Number.isFinite(n) ? n : undefined },
44
+ data: {
45
+ title: kv.title,
46
+ section: kv.section,
47
+ group: kv.group,
48
+ order: Number.isFinite(n) ? n : undefined,
49
+ },
43
50
  body: raw.slice(m[0].length),
44
51
  }
45
52
  }
@@ -71,6 +78,15 @@ export interface FolderOptions {
71
78
  root?: string
72
79
  /** Rótulo da seção para arquivos na raiz da pasta. Default "Documentação". */
73
80
  rootLabel?: string
81
+ /** Substitui a renderização padrão da página sem reimplementar descoberta, ordem ou navegação. */
82
+ renderPage?: (page: FolderPage) => React.ReactElement
83
+ }
84
+
85
+ export interface FolderPage {
86
+ slug: string
87
+ title: string
88
+ body: string
89
+ sourcePath: string
74
90
  }
75
91
 
76
92
  const LAST = 1e9
@@ -79,6 +95,7 @@ interface Row {
79
95
  slug: string
80
96
  title: string
81
97
  body: string
98
+ sourcePath: string
82
99
  section: string
83
100
  group?: string
84
101
  secOrder: number
@@ -88,7 +105,8 @@ interface Row {
88
105
 
89
106
  /**
90
107
  * Constrói `DocSection[]` a partir do glob eager do Vite (chaves = caminhos, valores = o
91
- * conteúdo raw do .md). Cada página renderiza `<Markdown>` do corpo do arquivo.
108
+ * conteúdo raw do .md). Cada página renderiza `<Markdown>` do corpo do arquivo, salvo quando o
109
+ * consumidor fornece `renderPage`.
92
110
  */
93
111
  export function docSectionsFromFolder(files: Record<string, string>, options: FolderOptions = {}): DocSection[] {
94
112
  const rootLabel = options.rootLabel ?? 'Documentação'
@@ -98,52 +116,106 @@ export function docSectionsFromFolder(files: Record<string, string>, options: Fo
98
116
  const rows: Row[] = []
99
117
 
100
118
  for (const [path, raw] of Object.entries(files)) {
101
- const rel = path.replace(stripRe, '').replace(/\.mdx?$/, '')
119
+ const sourcePath = path.replace(stripRe, '').replace(/^\/+/, '')
120
+ const rel = sourcePath.replace(/\.mdx?$/, '')
102
121
  const segs = rel.split('/').filter(Boolean)
103
122
  if (segs.length === 0) continue
104
123
  const { data, body } = parseFrontMatter(raw)
105
124
  const file = splitOrder(segs[segs.length - 1]!)
106
125
  const dirs = segs.slice(0, -1).map(splitOrder)
107
126
  rows.push({
108
- slug: [...dirs.map((d) => d.name), file.name].join('/'),
127
+ slug: documentationSlugFromSourcePath(sourcePath),
109
128
  title: data.title ?? firstH1(body) ?? humanize(file.name),
110
129
  body,
130
+ sourcePath,
111
131
  section: data.section ?? (dirs[0] ? humanize(dirs[0].name) : rootLabel),
112
- group: data.group ?? (dirs.length > 1 ? humanize(dirs.slice(1).map((d) => d.name).join('/')) : undefined),
113
- secOrder: dirs.length === 0 ? -1 : dirs[0]!.order ?? LAST,
114
- grpOrder: dirs.length > 1 ? dirs[1]!.order ?? LAST : -1,
132
+ group:
133
+ data.group ??
134
+ (dirs.length > 1
135
+ ? humanize(
136
+ dirs
137
+ .slice(1)
138
+ .map((d) => d.name)
139
+ .join('/'),
140
+ )
141
+ : undefined),
142
+ secOrder: dirs.length === 0 ? -1 : (dirs[0]!.order ?? LAST),
143
+ grpOrder: dirs.length > 1 ? (dirs[1]!.order ?? LAST) : -1,
115
144
  order: data.order ?? file.order ?? LAST,
116
145
  })
117
146
  }
118
147
 
119
148
  // Agrega seção → grupo → páginas, guardando a MENOR ordem vista de cada nível.
120
- const sections = new Map<string, { order: number; groups: Map<string, { label?: string; order: number; rows: Row[] }> }>()
149
+ const sections = new Map<
150
+ string,
151
+ {
152
+ order: number
153
+ groups: Map<string, { label?: string; order: number; rows: Row[] }>
154
+ }
155
+ >()
121
156
  for (const r of rows) {
122
- const sec = sections.get(r.section) ?? { order: r.secOrder, groups: new Map() }
157
+ const sec = sections.get(r.section) ?? {
158
+ order: r.secOrder,
159
+ groups: new Map(),
160
+ }
123
161
  sec.order = Math.min(sec.order, r.secOrder)
124
162
  const gkey = r.group ?? ''
125
- const grp = sec.groups.get(gkey) ?? { label: r.group, order: r.grpOrder, rows: [] }
163
+ const grp = sec.groups.get(gkey) ?? {
164
+ label: r.group,
165
+ order: r.grpOrder,
166
+ rows: [],
167
+ }
126
168
  grp.order = Math.min(grp.order, r.grpOrder)
127
169
  grp.rows.push(r)
128
170
  sec.groups.set(gkey, grp)
129
171
  sections.set(r.section, sec)
130
172
  }
131
173
 
132
- const byOrder = <T,>(o: (t: T) => number, l: (t: T) => string) => (a: T, b: T) => o(a) - o(b) || l(a).localeCompare(l(b))
174
+ const byOrder =
175
+ <T,>(o: (t: T) => number, l: (t: T) => string) =>
176
+ (a: T, b: T) =>
177
+ o(a) - o(b) || l(a).localeCompare(l(b))
133
178
 
134
179
  return [...sections.entries()]
135
- .sort(byOrder(([, s]) => s.order, ([label]) => label))
180
+ .sort(
181
+ byOrder(
182
+ ([, s]) => s.order,
183
+ ([label]) => label,
184
+ ),
185
+ )
136
186
  .map(([label, s]): DocSection => ({
137
187
  label,
138
188
  groups: [...s.groups.values()]
139
- .sort(byOrder((g) => g.order, (g) => g.label ?? ''))
140
- .map(
141
- (g): DocGroup => ({
142
- label: g.label,
143
- pages: g.rows
144
- .sort(byOrder((r) => r.order, (r) => r.title))
145
- .map((r): DocEntry => ({ slug: r.slug, title: r.title, render: () => <DocMarkdown content={r.body} /> })),
146
- }),
147
- ),
189
+ .sort(
190
+ byOrder(
191
+ (g) => g.order,
192
+ (g) => g.label ?? '',
193
+ ),
194
+ )
195
+ .map((g): DocGroup => ({
196
+ label: g.label,
197
+ pages: g.rows
198
+ .sort(
199
+ byOrder(
200
+ (r) => r.order,
201
+ (r) => r.title,
202
+ ),
203
+ )
204
+ .map((r): DocEntry => {
205
+ const page: FolderPage = {
206
+ slug: r.slug,
207
+ title: r.title,
208
+ body: r.body,
209
+ sourcePath: r.sourcePath,
210
+ }
211
+ return {
212
+ slug: r.slug,
213
+ title: r.title,
214
+ searchText: r.body,
215
+ sourcePath: r.sourcePath,
216
+ render: () => options.renderPage?.(page) ?? <Markdown content={r.body} headingIds />,
217
+ }
218
+ }),
219
+ })),
148
220
  }))
149
221
  }
@@ -6,12 +6,12 @@
6
6
  * - Rota dev `/__docs` de um app: o plugin `opusDocs()` (vite) usa `mountDocs` de
7
7
  * `@softize/opus/ui/docs/standalone` — não passa por aqui.
8
8
  */
9
- export { DocBrowser } from './DocBrowser.tsx'
10
- export type { DocBrowserProps } from './DocBrowser.tsx'
9
+ export { DocBrowser } from './CatalogDocBrowser.tsx'
10
+ export type { DocBrowserProps } from './CatalogDocBrowser.tsx'
11
11
  export { DOC_SECTIONS, PROTOCOL_SECTIONS, UI_SECTIONS } from './registry.tsx'
12
- export type { DocEntry, DocGroup, DocSection } from './registry.tsx'
13
- export { docSectionsFromFolder } from './folder.tsx'
14
- export type { FolderOptions } from './folder.tsx'
12
+ export type { DocEntry, DocGroup, DocSection } from './types.ts'
13
+ export { docSectionsFromFolder } from './catalog-folder.tsx'
14
+ export type { FolderOptions, FolderPage } from './catalog-folder.tsx'
15
15
  // Primitivos de página reusáveis (o mesmo Example/CodeBlock das páginas TSX do DocBrowser).
16
16
  export { Example, CodeBlock, DocPage } from './doc.tsx'
17
17
  // Renderer de markdown do DocBrowser: prosa + fence ```tsx preview vira componente vivo.
@@ -0,0 +1,102 @@
1
+ export interface DocumentationLinkTarget {
2
+ sourcePath: string
3
+ anchor?: string
4
+ }
5
+
6
+ function decodePathSegment(segment: string): string {
7
+ try {
8
+ return decodeURIComponent(segment)
9
+ } catch {
10
+ return segment
11
+ }
12
+ }
13
+
14
+ function normalizeDocumentationPath(path: string): string | null {
15
+ const segments: string[] = []
16
+ for (const encodedSegment of path.split('/')) {
17
+ const segment = decodePathSegment(encodedSegment)
18
+ if (segment === '' || segment === '.') continue
19
+ if (segment === '..') {
20
+ if (segments.length === 0) return null
21
+ segments.pop()
22
+ continue
23
+ }
24
+ if (segment.includes('/') || segment.includes('\\')) return null
25
+ segments.push(segment)
26
+ }
27
+ return segments.join('/')
28
+ }
29
+
30
+ function normalizeDocumentationAnchor(value: string, decodeUri: boolean): string {
31
+ let decoded = value
32
+ if (decodeUri) {
33
+ try {
34
+ decoded = decodeURIComponent(value)
35
+ } catch {
36
+ // Fragmentos malformados continuam navegáveis pela normalização conservadora.
37
+ }
38
+ }
39
+ return decoded
40
+ .normalize('NFD')
41
+ .replace(/[\u0300-\u036f]/g, '')
42
+ .toLocaleLowerCase('pt-BR')
43
+ .replace(/[^a-z0-9\s-]/g, '')
44
+ .trim()
45
+ .replace(/\s+/g, '-')
46
+ .replace(/-+/g, '-')
47
+ }
48
+
49
+ /** Normaliza um fragmento URI para a âncora usada pela documentação. */
50
+ export function documentationAnchor(value: string): string {
51
+ return normalizeDocumentationAnchor(value, true)
52
+ }
53
+
54
+ /** Normaliza o texto visível de um título sem interpretar sequências percentuais como URI. */
55
+ export function documentationHeadingAnchor(value: string): string {
56
+ return normalizeDocumentationAnchor(value, false)
57
+ }
58
+
59
+ /** Deriva o slug de um caminho já relativo à raiz documental do consumidor. */
60
+ export function documentationSlugFromSourcePath(sourcePath: string): string {
61
+ return sourcePath
62
+ .replace(/^\/+/, '')
63
+ .replace(/\.mdx?$/i, '')
64
+ .split('/')
65
+ .filter(Boolean)
66
+ .map((segment) => segment.replace(/^\d+[-_](.+)$/, '$1'))
67
+ .join('/')
68
+ }
69
+
70
+ /**
71
+ * Resolve links Markdown internos. `currentSourcePath` deve ser relativo à raiz documental.
72
+ * `/docs/` indica essa raiz em um link absoluto; `docs/` sem a barra inicial é uma pasta normal.
73
+ * Retorna `null` para protocolos, arquivos que não são Markdown e caminhos fora da raiz.
74
+ */
75
+ export function resolveDocumentationLinkTarget(
76
+ currentSourcePath: string,
77
+ href: string,
78
+ ): DocumentationLinkTarget | null {
79
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(href)) return null
80
+
81
+ const current = normalizeDocumentationPath(currentSourcePath.replace(/^\/+/, ''))
82
+ if (current === null) return null
83
+ if (href.startsWith('#')) {
84
+ return { sourcePath: current, anchor: documentationAnchor(href.slice(1)) }
85
+ }
86
+
87
+ const fragmentIndex = href.indexOf('#')
88
+ const target = fragmentIndex < 0 ? href : href.slice(0, fragmentIndex)
89
+ const fragment = fragmentIndex < 0 ? undefined : href.slice(fragmentIndex + 1)
90
+ if (target.startsWith('/') && !target.startsWith('/docs/')) return null
91
+ const currentDirectory = current.slice(0, current.lastIndexOf('/') + 1)
92
+ const candidate = normalizeDocumentationPath(
93
+ target.startsWith('/docs/')
94
+ ? target.slice('/docs/'.length)
95
+ : `${currentDirectory}${target}`,
96
+ )
97
+ if (candidate === null || !/\.mdx?$/i.test(candidate)) return null
98
+ return {
99
+ sourcePath: candidate,
100
+ ...(fragment === undefined ? {} : { anchor: documentationAnchor(fragment) }),
101
+ }
102
+ }
@@ -13,12 +13,11 @@
13
13
  * Só fences de nível 0 viram preview/CodeBlock; fences aninhados caem no HTML do markdown-it.
14
14
  */
15
15
  import * as React from 'react'
16
- import { markdownIt as md } from '../components/primitives/markdown.tsx'
16
+ import { documentationMarkdownTokens, markdownIt as md } from '../components/primitives/markdown.tsx'
17
17
  import { transform } from 'sucrase'
18
18
  import { CodeBlock, Example } from './doc.tsx'
19
19
  import * as opusUi from '../react.tsx'
20
20
 
21
-
22
21
  // Scope default estável (computado uma vez) — evita re-eval do LiveBlock a cada render. Os blocos
23
22
  // `tsx preview` enxergam o opus/ui (componentes) + os hooks do React (bare `useState`… além de
24
23
  // `React.useState`). Ícones (lucide) NÃO entram aqui de propósito: quem monta a doc injeta a paleta
@@ -43,14 +42,20 @@ function LiveBlock({
43
42
  scope: Record<string, unknown>
44
43
  previewClassName?: string
45
44
  }): React.ReactElement {
46
- const result = React.useMemo<{ Comp?: () => React.ReactNode; error?: string }>(() => {
45
+ const result = React.useMemo<{
46
+ Comp?: () => React.ReactNode
47
+ error?: string
48
+ }>(() => {
47
49
  try {
48
50
  // Exemplo com ESTADO: se o snippet chama `render(<…/>)`, ele roda como CORPO de um
49
51
  // componente (os hooks rodam no lugar certo). Bare JSX (uma OU várias irmãs) → embrulho
50
52
  // num Fragment antes do render() — várias expressões soltas não passam como um argumento só.
51
53
  const hasRender = /\brender\s*\(/.test(code)
52
54
  const body = `let __el = null; const render = (e) => { __el = e; };\n${hasRender ? code.trim() : `render(<>${code.trim()}</>)`}\nreturn __el;`
53
- const js = transform(body, { transforms: ['jsx', 'typescript'], jsxRuntime: 'classic' }).code
55
+ const js = transform(body, {
56
+ transforms: ['jsx', 'typescript'],
57
+ jsxRuntime: 'classic',
58
+ }).code
54
59
  // Só passa pro new Function os identificadores que o snippet realmente cita (o scope tem
55
60
  // ~1500 nomes com o lucide) e que são identificador válido (ex.: 'default' fica de fora).
56
61
  // React entra SEMPRE — o JSX clássico compila pra React.createElement.
@@ -80,23 +85,32 @@ export interface DocMarkdownProps {
80
85
  /** ESTENDE o scope dos blocos ```tsx preview (opus/ui + lucide + hooks). Ex.: um pattern injeta
81
86
  * o palco doc-client. Omitido = só o default. */
82
87
  scope?: Record<string, unknown>
88
+ /** Reescreve links do Markdown antes da renderização. Útil para resolver caminhos relativos
89
+ * quando o documento é publicado sob uma rota do aplicativo. */
90
+ resolveHref?: (href: string) => string
83
91
  }
84
92
 
85
- export function DocMarkdown({ content, scope }: DocMarkdownProps): React.ReactElement {
93
+ export function DocMarkdown({ content, scope, resolveHref }: DocMarkdownProps): React.ReactElement {
86
94
  // O `scope` ESTENDE o default (não substitui): o pattern injeta o palco doc-client
87
95
  // (DocBrowserActionProvider + contratos) sem perder o opus/ui + lucide + hooks. Memoiza pra
88
96
  // não re-avaliar os LiveBlocks a cada render (o scope injetado é estável no chamador).
89
97
  const liveScope = React.useMemo(() => (scope ? { ...DEFAULT_SCOPE, ...scope } : DEFAULT_SCOPE), [scope])
90
98
  // Ignora o frontmatter (---\n…\n---) — a nav já tem o título e o corpo tem o H1. Sem isso,
91
99
  // o markdown-it renderiza "title: …" como texto (é robusto pros dois caminhos: registry e folder).
92
- const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '')
93
- const tokens = md.parse(body, {})
100
+ const tokens = documentationMarkdownTokens(content, resolveHref)
94
101
  const parts: React.ReactNode[] = []
95
102
  let buffer: typeof tokens = []
96
103
  let key = 0
97
104
  const flush = (): void => {
98
105
  if (buffer.length === 0) return
99
- parts.push(<div key={key++} dangerouslySetInnerHTML={{ __html: md.renderer.render(buffer, md.options, {}) }} />)
106
+ parts.push(
107
+ <div
108
+ key={key++}
109
+ dangerouslySetInnerHTML={{
110
+ __html: md.renderer.render(buffer, md.options, {}),
111
+ }}
112
+ />,
113
+ )
100
114
  buffer = []
101
115
  }
102
116
  for (const t of tokens) {
@@ -109,7 +123,11 @@ export function DocMarkdown({ content, scope }: DocMarkdownProps): React.ReactEl
109
123
  // Palco do preview via flags no info (ex.: `tsx preview col md`): `col`/`col-start` = layout
110
124
  // em coluna (bloco: Alert, AspectRatio, grupo vertical); um token de largura (`md`, `2xl`…) =
111
125
  // cap de `max-w-*` (form/campo que não deve esticar). Viram o previewClassName do Example.
112
- const dir = flags.includes('col-start') ? 'flex-col items-start' : flags.includes('col') ? 'flex-col items-stretch' : ''
126
+ const dir = flags.includes('col-start')
127
+ ? 'flex-col items-start'
128
+ : flags.includes('col')
129
+ ? 'flex-col items-stretch'
130
+ : ''
113
131
  const widthTok = flags.find((f) => /^(sm|md|lg|xl|2xl|3xl|4xl)$/.test(f))
114
132
  const previewClassName = [dir, widthTok ? `max-w-${widthTok}` : ''].filter(Boolean).join(' ') || undefined
115
133
  parts.push(
@@ -22,6 +22,8 @@ import { navigate } from "../router.ts";
22
22
  import { DocPage } from "./doc.tsx";
23
23
  import { DocMarkdown } from "./markdown.tsx";
24
24
  import { OpusChangelog } from "./changelog.tsx";
25
+ import type { DocSection } from "./types.ts";
26
+ export type { DocEntry, DocGroup, DocSection } from "./types.ts";
25
27
 
26
28
  // — Conceituais (Começar/Framework/Design/Introdução) —
27
29
  import gettingStartedMd from "./content/getting-started.md?raw";
@@ -133,27 +135,6 @@ import splitMd from "./content/split.md?raw";
133
135
  import routerMd from "./content/router.md?raw";
134
136
  import dataStateMd from "./content/data-state.md?raw";
135
137
 
136
- export interface DocEntry {
137
- slug: string;
138
- title: string;
139
- /** Tag técnica opcional exibida na nav ao lado do título — o subpath do SDK (ex.: 'auth'). */
140
- badge?: string;
141
- render: () => React.ReactElement;
142
- }
143
-
144
- export interface DocGroup {
145
- /** Sub-rótulo opcional dentro da seção (ex.: Primitivos). Ausente = páginas direto na seção. */
146
- label?: string;
147
- /** Ícone opcional apresentado no nó expansível do grupo. */
148
- icon?: React.ReactNode;
149
- pages: DocEntry[];
150
- }
151
-
152
- export interface DocSection {
153
- label: string;
154
- groups: DocGroup[];
155
- }
156
-
157
138
  /**
158
139
  * O scope de previews da doc do Opus: a paleta de ícones (lucide inteiro) que os exemplos citam
159
140
  * por nome. Fica AQUI (no catálogo), não no DocMarkdown — assim só a superfície que exibe estes
@@ -11,15 +11,15 @@ import { StrictMode, useState } from 'react'
11
11
  import { createRoot } from 'react-dom/client'
12
12
  import { Moon, Sun } from 'lucide-react'
13
13
  import { Button, Toaster, TooltipProvider } from '../react.tsx'
14
- import { DocBrowser } from './DocBrowser.tsx'
14
+ import { DocBrowser } from './CatalogDocBrowser.tsx'
15
15
  import type { DocSection } from './registry.tsx'
16
16
 
17
17
  const THEME_KEY = 'opus-theme'
18
18
 
19
19
  // Reexporta o adaptador de pasta: o entry do plugin `opusDocs({ source })` importa
20
20
  // `mountDocs` + `docSectionsFromFolder` daqui (mesmo subpath), sem export novo no package.
21
- export { docSectionsFromFolder } from './folder.tsx'
22
- export type { FolderOptions } from './folder.tsx'
21
+ export { docSectionsFromFolder } from './catalog-folder.tsx'
22
+ export type { FolderOptions, FolderPage } from './catalog-folder.tsx'
23
23
 
24
24
  function StandaloneDocBrowser({
25
25
  basePath,
@@ -0,0 +1,24 @@
1
+ import type * as React from 'react'
2
+
3
+ export interface DocEntry {
4
+ slug: string
5
+ title: string
6
+ /** Tag técnica opcional exibida na navegação ao lado do título. */
7
+ badge?: string
8
+ /** Texto adicional consultado pela busca. Não é apresentado na navegação. */
9
+ searchText?: string
10
+ /** Caminho da fonte quando a página foi derivada de arquivo. */
11
+ sourcePath?: string
12
+ render: () => React.ReactElement
13
+ }
14
+
15
+ export interface DocGroup {
16
+ label?: string
17
+ icon?: React.ReactNode
18
+ pages: DocEntry[]
19
+ }
20
+
21
+ export interface DocSection {
22
+ label: string
23
+ groups: DocGroup[]
24
+ }