@softize/opus 25.4.0 → 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 +27 -0
- package/docs/adr/0056-doc-browser-separa-runtime-leve-do-catalogo.md +77 -0
- package/package.json +5 -1
- package/src/ui/components/patterns/list.tsx +58 -9
- package/src/ui/components/primitives/markdown.tsx +77 -1
- package/src/ui/components/primitives/select.tsx +18 -5
- package/src/ui/docs/CatalogDocBrowser.tsx +15 -0
- package/src/ui/docs/DocBrowser.tsx +83 -15
- package/src/ui/docs/browser.tsx +11 -0
- package/src/ui/docs/catalog-folder.tsx +22 -0
- package/src/ui/docs/content/action-list.md +4 -1
- package/src/ui/docs/content/select.md +5 -3
- package/src/ui/docs/folder.tsx +94 -23
- package/src/ui/docs/index.ts +5 -5
- 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/src/ui/drivers/react.tsx +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,33 @@ 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
|
+
|
|
25
|
+
## 25.4.1 — 2026-10-05
|
|
26
|
+
|
|
27
|
+
Corrige a busca remota de filtros `lookup` quando a `ActionList` também recebe opções iniciais em
|
|
28
|
+
`filterOptions`, como nas dimensões disponíveis para agrupamento. Enquanto há um termo de busca, as
|
|
29
|
+
opções iniciais agora são filtradas localmente e combinadas com o resultado do servidor; antes,
|
|
30
|
+
elas permaneciam todas visíveis e podiam esconder o efeito da busca. Não há migração para o
|
|
31
|
+
consumidor. Valores já selecionados preservam o rótulo durante e depois da busca, inclusive quando
|
|
32
|
+
a opção veio apenas do servidor. Fechar o seletor limpa o termo também no lookup remoto. No
|
|
33
|
+
`Select` com `onSearch`, esse reset é comunicado imediatamente com `""` apenas quando uma busca não
|
|
34
|
+
vazia chegou a ser enviada; abrir e fechar sem digitar não repete a consulta inicial. Respostas
|
|
35
|
+
remotas fora de ordem não substituem mais o resultado da consulta mais recente.
|
|
36
|
+
|
|
10
37
|
## 25.4.0 — 2026-10-05
|
|
11
38
|
|
|
12
39
|
`ListAction.grouping.fields` passa a aceitar filtros `select` ou `lookup` cujas opções completas
|
|
@@ -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.
|
|
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"
|
|
@@ -516,14 +516,16 @@ function useLookupOptions(
|
|
|
516
516
|
): {
|
|
517
517
|
options: SelectOption[];
|
|
518
518
|
loading: boolean;
|
|
519
|
+
query: string;
|
|
519
520
|
onSearch?: (q: string) => void;
|
|
520
521
|
} {
|
|
522
|
+
const [query, setQuery] = useState("");
|
|
521
523
|
const lookup = spec.options?.kind === "lookup" ? spec.options : undefined;
|
|
522
524
|
const search = useLookupAction<Record<string, unknown>, SelectOption>({
|
|
523
525
|
name: lookup?.source ?? "noop",
|
|
524
526
|
kind: "list",
|
|
525
527
|
} as unknown as ActionDef);
|
|
526
|
-
if (lookup === undefined) return { options: [], loading: false };
|
|
528
|
+
if (lookup === undefined) return { options: [], loading: false, query: "" };
|
|
527
529
|
const depInput: Record<string, unknown> = {};
|
|
528
530
|
for (const d of lookup.depends ?? []) {
|
|
529
531
|
if (!isEmptyValue(allValues[d])) depInput[d] = allValues[d];
|
|
@@ -534,7 +536,9 @@ function useLookupOptions(
|
|
|
534
536
|
label: i.label,
|
|
535
537
|
})),
|
|
536
538
|
loading: search.isLoading,
|
|
539
|
+
query,
|
|
537
540
|
onSearch: (q: string) => {
|
|
541
|
+
setQuery(q);
|
|
538
542
|
void search.run({ ...(q !== "" ? { q } : {}), ...depInput });
|
|
539
543
|
},
|
|
540
544
|
};
|
|
@@ -577,21 +581,66 @@ function FilterField({
|
|
|
577
581
|
);
|
|
578
582
|
const lookup = useLookupOptions(spec, allValues);
|
|
579
583
|
const dicts = useDicts();
|
|
584
|
+
const resolvedOptionByValue = useRef(new Map<string, SelectOption>());
|
|
580
585
|
// Precedência: runtime/estáticas (prop `options`) > dictionary do provider;
|
|
581
586
|
// sem nenhuma, as do lookup (server-side).
|
|
582
587
|
const base = options.length > 0 ? options : dictOptionsFor(spec, dicts);
|
|
588
|
+
const baseByValue = new Map(base.map((option) => [option.value, option]));
|
|
589
|
+
const canonicalLookupOptions = lookup.options.map(
|
|
590
|
+
(option) => baseByValue.get(option.value) ?? option,
|
|
591
|
+
);
|
|
592
|
+
for (const option of canonicalLookupOptions) {
|
|
593
|
+
if (!resolvedOptionByValue.current.has(option.value)) {
|
|
594
|
+
resolvedOptionByValue.current.set(option.value, option);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
for (const option of base) {
|
|
598
|
+
resolvedOptionByValue.current.set(option.value, option);
|
|
599
|
+
}
|
|
600
|
+
const selectedValues = new Set(
|
|
601
|
+
Array.isArray(value)
|
|
602
|
+
? value.filter((item): item is string => typeof item === "string")
|
|
603
|
+
: typeof value === "string"
|
|
604
|
+
? value === ""
|
|
605
|
+
? []
|
|
606
|
+
: [value]
|
|
607
|
+
: defaultValue === ""
|
|
608
|
+
? []
|
|
609
|
+
: [defaultValue],
|
|
610
|
+
);
|
|
611
|
+
const runtimeOptions =
|
|
612
|
+
lookup.onSearch !== undefined && lookup.query !== ""
|
|
613
|
+
? base.filter((option) =>
|
|
614
|
+
selectedValues.has(option.value) ||
|
|
615
|
+
option.label
|
|
616
|
+
.toLocaleLowerCase()
|
|
617
|
+
.includes(lookup.query.toLocaleLowerCase()),
|
|
618
|
+
)
|
|
619
|
+
: base;
|
|
620
|
+
const selectedResolvedOptions = [...selectedValues]
|
|
621
|
+
.map((selectedValue) => resolvedOptionByValue.current.get(selectedValue))
|
|
622
|
+
.filter((option): option is SelectOption => option !== undefined);
|
|
623
|
+
const mergeUniqueOptions = (
|
|
624
|
+
...groups: SelectOption[][]
|
|
625
|
+
): SelectOption[] => {
|
|
626
|
+
const merged = new Map<string, SelectOption>();
|
|
627
|
+
for (const group of groups) {
|
|
628
|
+
for (const option of group) {
|
|
629
|
+
if (!merged.has(option.value)) merged.set(option.value, option);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return [...merged.values()];
|
|
633
|
+
};
|
|
583
634
|
const effOptions =
|
|
584
635
|
lookup.onSearch !== undefined && base.length > 0
|
|
585
|
-
?
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
),
|
|
591
|
-
]
|
|
636
|
+
? mergeUniqueOptions(
|
|
637
|
+
runtimeOptions,
|
|
638
|
+
canonicalLookupOptions,
|
|
639
|
+
selectedResolvedOptions,
|
|
640
|
+
)
|
|
592
641
|
: base.length > 0
|
|
593
642
|
? base
|
|
594
|
-
:
|
|
643
|
+
: mergeUniqueOptions(canonicalLookupOptions, selectedResolvedOptions);
|
|
595
644
|
const singleValue = (value as string | undefined) ?? defaultValue;
|
|
596
645
|
const changedFromDefault = singleValue !== defaultValue;
|
|
597
646
|
const acceptsEmptyValue = spec.allowEmpty !== false;
|
|
@@ -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:
|
|
129
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
54
130
|
/>
|
|
55
131
|
)
|
|
56
132
|
})
|
|
@@ -95,7 +95,7 @@ interface SelectCustomBase extends SelectBaseProps {
|
|
|
95
95
|
/** Liga a busca no próprio campo (lista longa/que cresce). */
|
|
96
96
|
searchable?: boolean;
|
|
97
97
|
/** Busca SERVER-SIDE (debounced): quando definida, o filtro local desliga e o pai
|
|
98
|
-
* atualiza `options`. Implica `searchable
|
|
98
|
+
* atualiza `options`. Implica `searchable`; ao fechar, limpa uma busca enviada. */
|
|
99
99
|
onSearch?: (query: string) => void;
|
|
100
100
|
/** "Buscando…" enquanto o fetch corre (use com `onSearch`). */
|
|
101
101
|
loading?: boolean;
|
|
@@ -279,6 +279,10 @@ function CustomSelect(
|
|
|
279
279
|
/** Fechando por Esc: o foco volta pro gatilho, e focar abre. Marca pra ignorar UM foco. */
|
|
280
280
|
const closingRef = React.useRef(false);
|
|
281
281
|
const searchRef = React.useRef<HTMLInputElement>(null);
|
|
282
|
+
const wasOpenRef = React.useRef(false);
|
|
283
|
+
const lastSearchedQueryRef = React.useRef("");
|
|
284
|
+
const onSearchRef = React.useRef(onSearch);
|
|
285
|
+
onSearchRef.current = onSearch;
|
|
282
286
|
|
|
283
287
|
const selectedOption = options.find((o) => o.value === singleValue);
|
|
284
288
|
|
|
@@ -303,7 +307,14 @@ function CustomSelect(
|
|
|
303
307
|
* reabrir filtrada por algo que a pessoa procurou minutos antes e já esqueceu.
|
|
304
308
|
*/
|
|
305
309
|
React.useEffect(() => {
|
|
306
|
-
if (!open)
|
|
310
|
+
if (!open && wasOpenRef.current) {
|
|
311
|
+
setQuery("");
|
|
312
|
+
if (lastSearchedQueryRef.current !== "") {
|
|
313
|
+
lastSearchedQueryRef.current = "";
|
|
314
|
+
onSearchRef.current?.("");
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
wasOpenRef.current = open;
|
|
307
318
|
}, [open]);
|
|
308
319
|
|
|
309
320
|
// O cmdk carimba o id DELE no input (a11y interna) e descarta o nosso — reaplicamos por
|
|
@@ -312,11 +323,13 @@ function CustomSelect(
|
|
|
312
323
|
if (id !== undefined && inputRef.current !== null) inputRef.current.id = id;
|
|
313
324
|
}, [id]);
|
|
314
325
|
|
|
315
|
-
const onSearchRef = React.useRef(onSearch);
|
|
316
|
-
onSearchRef.current = onSearch;
|
|
317
326
|
React.useEffect(() => {
|
|
318
327
|
if (onSearchRef.current === undefined || !open) return;
|
|
319
|
-
const t = setTimeout(() =>
|
|
328
|
+
const t = setTimeout(() => {
|
|
329
|
+
const normalizedQuery = query.trim();
|
|
330
|
+
lastSearchedQueryRef.current = normalizedQuery;
|
|
331
|
+
onSearchRef.current?.(normalizedQuery);
|
|
332
|
+
}, 200);
|
|
320
333
|
return () => clearTimeout(t);
|
|
321
334
|
}, [query, open]);
|
|
322
335
|
|
|
@@ -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,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
|
+
|
|
@@ -409,7 +409,10 @@ render(
|
|
|
409
409
|
- **`options: { kind: 'dictionary', ref: 'sessionStatus' }`:** resolve opções e rótulos por um
|
|
410
410
|
dicionário registrado no `OpusProvider`. `filterOptions` substitui essa fonte quando informado.
|
|
411
411
|
- **`options: { kind: 'lookup', source: 'x.lookup' }`:** consulta uma action com `{ q }` e os valores
|
|
412
|
-
das dependências. A resposta segue o formato `{ value, label }`.
|
|
412
|
+
das dependências. A resposta segue o formato `{ value, label }`. Quando o consumidor também
|
|
413
|
+
fornece `filterOptions`, essas opções são filtradas localmente pelo termo e combinadas com a
|
|
414
|
+
resposta remota. Para o mesmo `value`, o rótulo e a apresentação de `filterOptions` prevalecem;
|
|
415
|
+
valores selecionados preservam o rótulo mesmo depois que a busca é limpa.
|
|
413
416
|
|
|
414
417
|
```tsx
|
|
415
418
|
filters: {
|
|
@@ -140,8 +140,10 @@ render(
|
|
|
140
140
|
|
|
141
141
|
## Busca no servidor
|
|
142
142
|
|
|
143
|
-
Com `onSearch`, o consumidor consulta as opções ao abrir e ao digitar, com debounce.
|
|
144
|
-
|
|
143
|
+
Com `onSearch`, o consumidor consulta as opções ao abrir e ao digitar, com debounce. Ao fechar o
|
|
144
|
+
seletor depois de uma busca, recebe `""` imediatamente para restaurar as opções iniciais; abrir e
|
|
145
|
+
fechar sem digitar não repete a consulta vazia. Atualize `options` com a resposta e use `loading`
|
|
146
|
+
durante a consulta. Esse modo já habilita o campo de busca.
|
|
145
147
|
|
|
146
148
|
```tsx preview col md
|
|
147
149
|
const ASSIGNEES = [
|
|
@@ -395,7 +397,7 @@ render(
|
|
|
395
397
|
| `placeholder` | `string` | `'Selecione…'`; sem padrão com `native` | Texto do campo vazio. Com `native`, só existe quando declarado e vira uma opção desabilitada no início da lista. |
|
|
396
398
|
| `searchPlaceholder` | `string` | `'Buscar…'` | Placeholder da linha de busca dentro da lista. |
|
|
397
399
|
| `emptyText` | `string` | `'Nada encontrado.'` | Mensagem quando a busca não acha nada. |
|
|
398
|
-
| `onSearch` | `(query: string) => void` | | Busca no servidor, chamada ao abrir a lista e depois de uma breve pausa na digitação. Desliga o filtro local; o consumidor atualiza `options` com a resposta. |
|
|
400
|
+
| `onSearch` | `(query: string) => void` | | Busca no servidor, chamada ao abrir a lista e depois de uma breve pausa na digitação. Ao fechar depois de uma busca enviada, recebe `""` imediatamente para restaurar as opções iniciais. Desliga o filtro local; o consumidor atualiza `options` com a resposta. |
|
|
399
401
|
| `loading` | `boolean` | | Mostra “Buscando…” enquanto a consulta de `onSearch` está em andamento. |
|
|
400
402
|
| `clearable` | `boolean` | `false` | Mostra, quando há seleção, uma ação que limpa o valor sem abrir a lista. |
|
|
401
403
|
| `icon` | `React.ReactNode` | | Ícone decorativo no início do controle, em `1rem` e no tom atenuado. |
|
package/src/ui/docs/folder.tsx
CHANGED
|
@@ -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.
|
|
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 type { DocEntry, DocGroup, DocSection } from './
|
|
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: {
|
|
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
|
|
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:
|
|
113
|
-
|
|
114
|
-
|
|
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<
|
|
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) ?? {
|
|
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) ?? {
|
|
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 =
|
|
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(
|
|
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(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
}
|
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.
|
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
|
+
}
|
package/src/ui/drivers/react.tsx
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import {
|
|
15
15
|
useCallback,
|
|
16
16
|
useMemo,
|
|
17
|
+
useRef,
|
|
17
18
|
useState,
|
|
18
19
|
} from 'react'
|
|
19
20
|
import {
|
|
@@ -126,9 +127,11 @@ export function useLookupAction<TInput = unknown, TItem = unknown>(
|
|
|
126
127
|
): UseLookupActionResult<TInput, TItem> {
|
|
127
128
|
const client = useClient('useLookupAction')
|
|
128
129
|
const [state, setState] = useState<ListState<TItem>>(() => idleLookup<TItem>())
|
|
130
|
+
const latestRunRef = useRef(0)
|
|
129
131
|
|
|
130
132
|
const run = useCallback(
|
|
131
133
|
async (input: TInput): Promise<ListState<TItem>> => {
|
|
134
|
+
const runId = ++latestRunRef.current
|
|
132
135
|
setState((prev) => ({
|
|
133
136
|
...prev,
|
|
134
137
|
error: undefined,
|
|
@@ -162,13 +165,14 @@ export function useLookupAction<TInput = unknown, TItem = unknown>(
|
|
|
162
165
|
isError: true,
|
|
163
166
|
}
|
|
164
167
|
|
|
165
|
-
setState(next)
|
|
168
|
+
if (runId === latestRunRef.current) setState(next)
|
|
166
169
|
return next
|
|
167
170
|
},
|
|
168
171
|
[client, action],
|
|
169
172
|
)
|
|
170
173
|
|
|
171
174
|
const reset = useCallback(() => {
|
|
175
|
+
latestRunRef.current += 1
|
|
172
176
|
setState(idleLookup<TItem>())
|
|
173
177
|
}, [])
|
|
174
178
|
|