@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 +31 -0
- package/docs/adr/0056-doc-browser-separa-runtime-leve-do-catalogo.md +85 -0
- package/package.json +9 -1
- package/src/ui/components/primitives/markdown.tsx +68 -1
- package/src/ui/docs/CatalogDocBrowser.tsx +15 -0
- package/src/ui/docs/DocBrowser.tsx +83 -15
- package/src/ui/docs/browser.tsx +18 -0
- package/src/ui/docs/catalog-folder.tsx +22 -0
- package/src/ui/docs/content/markdown.md +41 -0
- package/src/ui/docs/folder.tsx +96 -24
- package/src/ui/docs/index.ts +5 -5
- package/src/ui/docs/links.ts +102 -0
- package/src/ui/docs/markdown.tsx +27 -9
- package/src/ui/docs/registry.tsx +2 -21
- package/src/ui/docs/standalone.tsx +3 -3
- package/src/ui/docs/types.ts +24 -0
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.
|
|
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:
|
|
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 {
|
|
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).
|
|
42
|
-
sections
|
|
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
|
-
|
|
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
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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. |
|
package/src/ui/docs/folder.tsx
CHANGED
|
@@ -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.
|
|
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 {
|
|
22
|
-
import
|
|
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: {
|
|
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
|
|
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:
|
|
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:
|
|
113
|
-
|
|
114
|
-
|
|
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<
|
|
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) ?? {
|
|
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) ?? {
|
|
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 =
|
|
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(
|
|
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(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
}
|
package/src/ui/docs/index.ts
CHANGED
|
@@ -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 './
|
|
10
|
-
export type { DocBrowserProps } from './
|
|
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 './
|
|
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
|
+
}
|
package/src/ui/docs/markdown.tsx
CHANGED
|
@@ -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<{
|
|
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, {
|
|
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
|
|
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(
|
|
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')
|
|
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(
|
package/src/ui/docs/registry.tsx
CHANGED
|
@@ -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 './
|
|
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
|
+
}
|