@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 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.5.0",
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 = headingId(text)
81
+ const base = documentationHeadingAnchor(text)
91
82
  if (base === '') continue
92
83
  let candidate = base
93
84
  let suffix = 1
@@ -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. |
@@ -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: [...dirs.map((d) => d.name), file.name].join('/'),
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
+ }