@softize/opus 25.5.0 → 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 +16 -0
- package/docs/adr/0056-doc-browser-separa-runtime-leve-do-catalogo.md +8 -0
- package/package.json +5 -1
- package/src/ui/components/primitives/markdown.tsx +2 -11
- package/src/ui/docs/browser.tsx +7 -0
- package/src/ui/docs/content/markdown.md +41 -0
- package/src/ui/docs/folder.tsx +2 -1
- package/src/ui/docs/links.ts +102 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ 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
|
+
|
|
10
26
|
## 25.5.0 — 2026-10-07
|
|
11
27
|
|
|
12
28
|
Aplicações que publicam documentação própria podem usar a nova entrada
|
|
@@ -43,6 +43,12 @@ O renderizador compartilhado pode reescrever links e criar IDs estáveis para t
|
|
|
43
43
|
opções são explícitas; o caminho usado em conversas continua sem IDs e sem tratamento editorial
|
|
44
44
|
quando elas não são pedidas. Frontmatter é removido somente nas superfícies documentais.
|
|
45
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
|
+
|
|
46
52
|
### 4. A superfície permanece no pacote Opus
|
|
47
53
|
|
|
48
54
|
O runtime leve continua em `@softize/opus` enquanto compartilha dependências, manutenção e ciclo
|
|
@@ -73,5 +79,7 @@ existir evidência operacional para sustentar essa separação.
|
|
|
73
79
|
- Classificação, autorização e conteúdo permanecem responsabilidade do consumidor.
|
|
74
80
|
- A busca é local e recebe o conteúdo no navegador; ela não substitui filtragem no servidor quando
|
|
75
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.
|
|
76
84
|
- Um teste de arquitetura percorre o grafo do novo subpath e reprova se ele alcançar catálogo,
|
|
77
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",
|
|
@@ -221,6 +221,10 @@
|
|
|
221
221
|
"types": "./src/ui/docs/browser.tsx",
|
|
222
222
|
"default": "./src/ui/docs/browser.tsx"
|
|
223
223
|
},
|
|
224
|
+
"./ui/docs/links": {
|
|
225
|
+
"types": "./src/ui/docs/links.ts",
|
|
226
|
+
"default": "./src/ui/docs/links.ts"
|
|
227
|
+
},
|
|
224
228
|
"./ui/docs/standalone": {
|
|
225
229
|
"types": "./src/ui/docs/standalone.tsx",
|
|
226
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. */
|
|
@@ -60,16 +61,6 @@ function resolveTokenLinks(tokens: ReturnType<typeof markdownIt.parse>, resolveH
|
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
63
|
|
|
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
64
|
function visibleInlineText(tokens: ReturnType<typeof markdownIt.parse>): string {
|
|
74
65
|
return tokens
|
|
75
66
|
.map((token) => {
|
|
@@ -87,7 +78,7 @@ function identifyHeadings(tokens: ReturnType<typeof markdownIt.parse>): void {
|
|
|
87
78
|
const content = tokens[index + 1]
|
|
88
79
|
if (opening?.type !== 'heading_open' || content?.type !== 'inline') continue
|
|
89
80
|
const text = visibleInlineText(content.children ?? [])
|
|
90
|
-
const base =
|
|
81
|
+
const base = documentationHeadingAnchor(text)
|
|
91
82
|
if (base === '') continue
|
|
92
83
|
let candidate = base
|
|
93
84
|
let suffix = 1
|
package/src/ui/docs/browser.tsx
CHANGED
|
@@ -6,6 +6,13 @@ export { DocBrowser } from './DocBrowser.tsx'
|
|
|
6
6
|
export type { DocBrowserProps } from './DocBrowser.tsx'
|
|
7
7
|
export { docSectionsFromFolder } from './folder.tsx'
|
|
8
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'
|
|
9
16
|
export type { DocEntry, DocGroup, DocSection } from './types.ts'
|
|
10
17
|
export { Markdown } from '../components/primitives/markdown.tsx'
|
|
11
18
|
export type { MarkdownProps } from '../components/primitives/markdown.tsx'
|
|
@@ -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
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* históricas injetam o renderizador de exemplos vivos em `catalog-folder.tsx`.
|
|
21
21
|
*/
|
|
22
22
|
import { Markdown } from '../components/primitives/markdown.tsx'
|
|
23
|
+
import { documentationSlugFromSourcePath } from './links.ts'
|
|
23
24
|
import type { DocEntry, DocGroup, DocSection } from './types.ts'
|
|
24
25
|
|
|
25
26
|
interface FrontMatter {
|
|
@@ -123,7 +124,7 @@ export function docSectionsFromFolder(files: Record<string, string>, options: Fo
|
|
|
123
124
|
const file = splitOrder(segs[segs.length - 1]!)
|
|
124
125
|
const dirs = segs.slice(0, -1).map(splitOrder)
|
|
125
126
|
rows.push({
|
|
126
|
-
slug:
|
|
127
|
+
slug: documentationSlugFromSourcePath(sourcePath),
|
|
127
128
|
title: data.title ?? firstH1(body) ?? humanize(file.name),
|
|
128
129
|
body,
|
|
129
130
|
sourcePath,
|
|
@@ -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
|
+
}
|