@softize/opus 25.4.1 → 25.5.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,21 @@ 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.5.0 — 2026-10-07
11
+
12
+ Aplicações que publicam documentação própria podem usar a nova entrada
13
+ `@softize/opus/ui/docs/browser`. Ela oferece `DocBrowser`, transformação de pastas, Markdown e os
14
+ tipos de navegação sem carregar o catálogo do Opus, os exemplos vivos nem o compilador usado por
15
+ esses exemplos. A entrada histórica `@softize/opus/ui/docs` continua incluindo o catálogo completo
16
+ e mantém seu comportamento atual.
17
+
18
+ O `DocBrowser` aceita busca local por título, badge e conteúdo pesquisável. A busca pode configurar
19
+ nome acessível, placeholder e mensagem sem resultados; o padrão orienta a tentar outro termo.
20
+ Páginas derivadas de pastas passam a informar `sourcePath` e `searchText`; `renderPage` permite
21
+ resolver links ou compor a apresentação sem duplicar descoberta, hierarquia e ordenação. O
22
+ Markdown compartilhado também pode reescrever links e gerar IDs estáveis para títulos. Não há
23
+ migração para consumidores existentes.
24
+
10
25
  ## 25.4.1 — 2026-10-05
11
26
 
12
27
  Corrige a busca remota de filtros `lookup` quando a `ActionList` também recebe opções iniciais em
@@ -0,0 +1,77 @@
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
+ ### 4. A superfície permanece no pacote Opus
47
+
48
+ O runtime leve continua em `@softize/opus` enquanto compartilha dependências, manutenção e ciclo
49
+ de release com os componentes de documentação existentes. Um pacote separado só será considerado
50
+ quando houver adoção independente, ciclo de release próprio ou dependências que não devam fazer
51
+ parte do SDK. O crescimento da quantidade de arquivos, isoladamente, não muda essa fronteira.
52
+
53
+ ## Alternativas consideradas
54
+
55
+ ### Consumir o catálogo completo em qualquer aplicação
56
+
57
+ Evitaria um novo subpath, mas carregaria páginas e compilação de exemplos que não pertencem ao
58
+ produto consumidor.
59
+
60
+ ### Copiar o navegador para cada produto
61
+
62
+ Manteria bundles isolados, mas duplicaria busca, navegação, ordenação e tratamento de Markdown.
63
+
64
+ ### Criar imediatamente outro pacote
65
+
66
+ Produziria isolamento mais forte, porém acrescentaria release e adoção independentes antes de
67
+ existir evidência operacional para sustentar essa separação.
68
+
69
+ ## Consequências
70
+
71
+ - Aplicações podem publicar documentação própria sem incluir o catálogo do Opus.
72
+ - O site do Opus preserva a API histórica e seus exemplos vivos.
73
+ - Classificação, autorização e conteúdo permanecem responsabilidade do consumidor.
74
+ - A busca é local e recebe o conteúdo no navegador; ela não substitui filtragem no servidor quando
75
+ houver documentos com audiências diferentes.
76
+ - Um teste de arquitetura percorre o grafo do novo subpath e reprova se ele alcançar catálogo,
77
+ 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.5.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,10 @@
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
+ },
220
224
  "./ui/docs/standalone": {
221
225
  "types": "./src/ui/docs/standalone.tsx",
222
226
  "default": "./src/ui/docs/standalone.tsx"
@@ -37,12 +37,88 @@ markdownIt.renderer.rules.image = (tokens, idx, options, env, self) => {
37
37
  export interface MarkdownProps {
38
38
  content: string
39
39
  className?: string
40
+ /** Reescreve links antes da renderização, por exemplo para resolver caminhos relativos. */
41
+ resolveHref?: (href: string) => string
42
+ /** Cria IDs estáveis nos títulos. Útil em documentos navegáveis; omitido no chat. */
43
+ headingIds?: boolean
44
+ }
45
+
46
+ function resolveTokenLinks(tokens: ReturnType<typeof markdownIt.parse>, resolveHref: (href: string) => string): void {
47
+ for (const token of tokens) {
48
+ if (token.type === 'link_open') {
49
+ const href = token.attrGet('href')
50
+ if (href !== null) {
51
+ const resolved = resolveHref(href)
52
+ const normalized = markdownIt.normalizeLink(resolved)
53
+ const withoutControls = resolved.replace(/[\u0000-\u001f\u007f]/g, '')
54
+ const safe =
55
+ markdownIt.validateLink(normalized) && markdownIt.validateLink(withoutControls)
56
+ token.attrSet('href', safe ? normalized : '')
57
+ }
58
+ }
59
+ if (token.children !== null) resolveTokenLinks(token.children, resolveHref)
60
+ }
61
+ }
62
+
63
+ function headingId(value: string): string {
64
+ return value
65
+ .normalize('NFD')
66
+ .replace(/[\u0300-\u036f]/g, '')
67
+ .toLocaleLowerCase('pt-BR')
68
+ .replace(/[^a-z0-9\s-]/g, '')
69
+ .trim()
70
+ .replace(/\s+/g, '-')
71
+ }
72
+
73
+ function visibleInlineText(tokens: ReturnType<typeof markdownIt.parse>): string {
74
+ return tokens
75
+ .map((token) => {
76
+ if (token.type === 'text' || token.type === 'code_inline') return token.content
77
+ if (token.type === 'softbreak' || token.type === 'hardbreak') return ' '
78
+ return token.children === null ? '' : visibleInlineText(token.children)
79
+ })
80
+ .join('')
81
+ }
82
+
83
+ function identifyHeadings(tokens: ReturnType<typeof markdownIt.parse>): void {
84
+ const used = new Set<string>()
85
+ for (let index = 0; index < tokens.length; index++) {
86
+ const opening = tokens[index]
87
+ const content = tokens[index + 1]
88
+ if (opening?.type !== 'heading_open' || content?.type !== 'inline') continue
89
+ const text = visibleInlineText(content.children ?? [])
90
+ const base = headingId(text)
91
+ if (base === '') continue
92
+ let candidate = base
93
+ let suffix = 1
94
+ while (used.has(candidate)) candidate = `${base}-${suffix++}`
95
+ used.add(candidate)
96
+ opening.attrSet('id', candidate)
97
+ }
98
+ }
99
+
100
+ /** Tokeniza Markdown de documentação com frontmatter removido, âncoras e links resolvidos. */
101
+ export function documentationMarkdownTokens(
102
+ content: string,
103
+ resolveHref?: (href: string) => string,
104
+ ): ReturnType<typeof markdownIt.parse> {
105
+ const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '')
106
+ const tokens = markdownIt.parse(body, {})
107
+ identifyHeadings(tokens)
108
+ if (resolveHref !== undefined) resolveTokenLinks(tokens, resolveHref)
109
+ return tokens
40
110
  }
41
111
 
42
112
  export const Markdown = React.memo(function Markdown({
43
113
  content,
44
114
  className,
115
+ resolveHref,
116
+ headingIds = false,
45
117
  }: MarkdownProps): React.ReactElement {
118
+ const html =
119
+ resolveHref === undefined && !headingIds
120
+ ? markdownIt.render(content)
121
+ : markdownIt.renderer.render(documentationMarkdownTokens(content, resolveHref), markdownIt.options, {})
46
122
  return (
47
123
  <div
48
124
  data-slot="markdown"
@@ -50,7 +126,7 @@ export const Markdown = React.memo(function Markdown({
50
126
  // margem é do `<p>`, dentro deste wrapper: quem tentava zerar de fora (a bolha do
51
127
  // chat fazia) acertava o wrapper e errava o parágrafo.
52
128
  className={`prose prose-sm max-w-none [&>:first-child]:mt-0 [&>:last-child]:mb-0${className !== undefined ? ` ${className}` : ''}`}
53
- dangerouslySetInnerHTML={{ __html: markdownIt.render(content) }}
129
+ dangerouslySetInnerHTML={{ __html: html }}
54
130
  />
55
131
  )
56
132
  })
@@ -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,11 @@
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 type { DocEntry, DocGroup, DocSection } from './types.ts'
10
+ export { Markdown } from '../components/primitives/markdown.tsx'
11
+ 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
+
@@ -16,10 +16,11 @@
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 type { DocEntry, DocGroup, DocSection } from './types.ts'
23
24
 
24
25
  interface FrontMatter {
25
26
  title?: string
@@ -39,7 +40,12 @@ function parseFrontMatter(raw: string): { data: FrontMatter; body: string } {
39
40
  }
40
41
  const n = kv.order !== undefined ? Number(kv.order) : NaN
41
42
  return {
42
- data: { title: kv.title, section: kv.section, group: kv.group, order: Number.isFinite(n) ? n : undefined },
43
+ data: {
44
+ title: kv.title,
45
+ section: kv.section,
46
+ group: kv.group,
47
+ order: Number.isFinite(n) ? n : undefined,
48
+ },
43
49
  body: raw.slice(m[0].length),
44
50
  }
45
51
  }
@@ -71,6 +77,15 @@ export interface FolderOptions {
71
77
  root?: string
72
78
  /** Rótulo da seção para arquivos na raiz da pasta. Default "Documentação". */
73
79
  rootLabel?: string
80
+ /** Substitui a renderização padrão da página sem reimplementar descoberta, ordem ou navegação. */
81
+ renderPage?: (page: FolderPage) => React.ReactElement
82
+ }
83
+
84
+ export interface FolderPage {
85
+ slug: string
86
+ title: string
87
+ body: string
88
+ sourcePath: string
74
89
  }
75
90
 
76
91
  const LAST = 1e9
@@ -79,6 +94,7 @@ interface Row {
79
94
  slug: string
80
95
  title: string
81
96
  body: string
97
+ sourcePath: string
82
98
  section: string
83
99
  group?: string
84
100
  secOrder: number
@@ -88,7 +104,8 @@ interface Row {
88
104
 
89
105
  /**
90
106
  * 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.
107
+ * conteúdo raw do .md). Cada página renderiza `<Markdown>` do corpo do arquivo, salvo quando o
108
+ * consumidor fornece `renderPage`.
92
109
  */
93
110
  export function docSectionsFromFolder(files: Record<string, string>, options: FolderOptions = {}): DocSection[] {
94
111
  const rootLabel = options.rootLabel ?? 'Documentação'
@@ -98,7 +115,8 @@ export function docSectionsFromFolder(files: Record<string, string>, options: Fo
98
115
  const rows: Row[] = []
99
116
 
100
117
  for (const [path, raw] of Object.entries(files)) {
101
- const rel = path.replace(stripRe, '').replace(/\.mdx?$/, '')
118
+ const sourcePath = path.replace(stripRe, '').replace(/^\/+/, '')
119
+ const rel = sourcePath.replace(/\.mdx?$/, '')
102
120
  const segs = rel.split('/').filter(Boolean)
103
121
  if (segs.length === 0) continue
104
122
  const { data, body } = parseFrontMatter(raw)
@@ -108,42 +126,95 @@ export function docSectionsFromFolder(files: Record<string, string>, options: Fo
108
126
  slug: [...dirs.map((d) => d.name), file.name].join('/'),
109
127
  title: data.title ?? firstH1(body) ?? humanize(file.name),
110
128
  body,
129
+ sourcePath,
111
130
  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,
131
+ group:
132
+ data.group ??
133
+ (dirs.length > 1
134
+ ? humanize(
135
+ dirs
136
+ .slice(1)
137
+ .map((d) => d.name)
138
+ .join('/'),
139
+ )
140
+ : undefined),
141
+ secOrder: dirs.length === 0 ? -1 : (dirs[0]!.order ?? LAST),
142
+ grpOrder: dirs.length > 1 ? (dirs[1]!.order ?? LAST) : -1,
115
143
  order: data.order ?? file.order ?? LAST,
116
144
  })
117
145
  }
118
146
 
119
147
  // 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[] }> }>()
148
+ const sections = new Map<
149
+ string,
150
+ {
151
+ order: number
152
+ groups: Map<string, { label?: string; order: number; rows: Row[] }>
153
+ }
154
+ >()
121
155
  for (const r of rows) {
122
- const sec = sections.get(r.section) ?? { order: r.secOrder, groups: new Map() }
156
+ const sec = sections.get(r.section) ?? {
157
+ order: r.secOrder,
158
+ groups: new Map(),
159
+ }
123
160
  sec.order = Math.min(sec.order, r.secOrder)
124
161
  const gkey = r.group ?? ''
125
- const grp = sec.groups.get(gkey) ?? { label: r.group, order: r.grpOrder, rows: [] }
162
+ const grp = sec.groups.get(gkey) ?? {
163
+ label: r.group,
164
+ order: r.grpOrder,
165
+ rows: [],
166
+ }
126
167
  grp.order = Math.min(grp.order, r.grpOrder)
127
168
  grp.rows.push(r)
128
169
  sec.groups.set(gkey, grp)
129
170
  sections.set(r.section, sec)
130
171
  }
131
172
 
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))
173
+ const byOrder =
174
+ <T,>(o: (t: T) => number, l: (t: T) => string) =>
175
+ (a: T, b: T) =>
176
+ o(a) - o(b) || l(a).localeCompare(l(b))
133
177
 
134
178
  return [...sections.entries()]
135
- .sort(byOrder(([, s]) => s.order, ([label]) => label))
179
+ .sort(
180
+ byOrder(
181
+ ([, s]) => s.order,
182
+ ([label]) => label,
183
+ ),
184
+ )
136
185
  .map(([label, s]): DocSection => ({
137
186
  label,
138
187
  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
- ),
188
+ .sort(
189
+ byOrder(
190
+ (g) => g.order,
191
+ (g) => g.label ?? '',
192
+ ),
193
+ )
194
+ .map((g): DocGroup => ({
195
+ label: g.label,
196
+ pages: g.rows
197
+ .sort(
198
+ byOrder(
199
+ (r) => r.order,
200
+ (r) => r.title,
201
+ ),
202
+ )
203
+ .map((r): DocEntry => {
204
+ const page: FolderPage = {
205
+ slug: r.slug,
206
+ title: r.title,
207
+ body: r.body,
208
+ sourcePath: r.sourcePath,
209
+ }
210
+ return {
211
+ slug: r.slug,
212
+ title: r.title,
213
+ searchText: r.body,
214
+ sourcePath: r.sourcePath,
215
+ render: () => options.renderPage?.(page) ?? <Markdown content={r.body} headingIds />,
216
+ }
217
+ }),
218
+ })),
148
219
  }))
149
220
  }
@@ -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.
@@ -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
+ }