upcontent 0.0.0-stage → 0.1.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.
Files changed (70) hide show
  1. package/.github/workflows/ci.yml +61 -0
  2. package/.github/workflows/deploy.yml +59 -0
  3. package/.github/workflows/reusable-pages.yml +74 -0
  4. package/.upcontent/config.json +82 -0
  5. package/.upcontent/favicon.svg +4 -0
  6. package/.upcontent/logo.svg +6 -0
  7. package/.upcontent/portal.css +139 -0
  8. package/Makefile +29 -0
  9. package/README.md +114 -2
  10. package/SHOWCASE.mdx +163 -0
  11. package/astro.config.mjs +125 -0
  12. package/customization/config-json.md +92 -0
  13. package/customization/content.md +48 -0
  14. package/customization/environment.md +43 -0
  15. package/customization/index.md +33 -0
  16. package/customization/navigation.md +57 -0
  17. package/customization/site-identity.md +43 -0
  18. package/customization/theme.md +47 -0
  19. package/deployment/github-pages.md +46 -0
  20. package/deployment/index.md +24 -0
  21. package/deployment/static-hosts.md +34 -0
  22. package/getting-started/consumer-repository.md +156 -0
  23. package/getting-started/first-build.md +78 -0
  24. package/guides/authoring-content.md +105 -0
  25. package/guides/validate-your-site.md +53 -0
  26. package/package.json +33 -3
  27. package/scripts/upcontent-cli.mjs +113 -0
  28. package/scripts/verify-external-build.mjs +24 -0
  29. package/src/components/MermaidLoader.astro +290 -0
  30. package/src/components/PaletteShowcase.astro +122 -0
  31. package/src/components/StructuredDataCopy.astro +22 -0
  32. package/src/content/__mocks__/astro-content.ts +7 -0
  33. package/src/content/__mocks__/astro-loaders.ts +3 -0
  34. package/src/content.config.test.ts +163 -0
  35. package/src/content.config.ts +90 -0
  36. package/src/lib/content-blocklist.test.ts +47 -0
  37. package/src/lib/content-blocklist.ts +55 -0
  38. package/src/lib/doc-links.test.ts +62 -0
  39. package/src/lib/doc-links.ts +31 -0
  40. package/src/lib/mermaid-render.test.ts +42 -0
  41. package/src/lib/mermaid-render.ts +24 -0
  42. package/src/lib/portal-config.test.ts +143 -0
  43. package/src/lib/portal-config.ts +176 -0
  44. package/src/lib/product-identity.ts +2 -0
  45. package/src/lib/rehype-callouts.test.ts +69 -0
  46. package/src/lib/rehype-callouts.ts +61 -0
  47. package/src/lib/remark-strip-duplicate-title.test.ts +73 -0
  48. package/src/lib/remark-strip-duplicate-title.ts +37 -0
  49. package/src/lib/remark-structured-data-preview.test.ts +104 -0
  50. package/src/lib/remark-structured-data-preview.ts +66 -0
  51. package/src/lib/remark-wiki-links.test.ts +102 -0
  52. package/src/lib/remark-wiki-links.ts +110 -0
  53. package/src/lib/sidebar.test.ts +142 -0
  54. package/src/lib/sidebar.ts +113 -0
  55. package/src/lib/structured-data-tree.test.ts +75 -0
  56. package/src/lib/structured-data-tree.ts +55 -0
  57. package/src/overrides/Footer.astro +114 -0
  58. package/src/overrides/Head.astro +20 -0
  59. package/src/pages/index.astro +20 -0
  60. package/src/styles/callouts.css +29 -0
  61. package/src/styles/structured-data-preview.css +95 -0
  62. package/test-fixtures/external-consumer/.upcontent/config.json +26 -0
  63. package/test-fixtures/external-consumer/.upcontent/favicon.svg +4 -0
  64. package/test-fixtures/external-consumer/.upcontent/logo.svg +4 -0
  65. package/test-fixtures/external-consumer/.upcontent/theme.css +4 -0
  66. package/test-fixtures/external-consumer/.upcontent-renderer/README.md +3 -0
  67. package/test-fixtures/external-consumer/README.md +6 -0
  68. package/test-fixtures/external-consumer/forbidden.md +5 -0
  69. package/tsconfig.json +7 -0
  70. package/vitest.config.ts +18 -0
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('node:fs', () => ({
4
+ readdirSync: vi.fn(),
5
+ statSync: vi.fn(),
6
+ existsSync: vi.fn(),
7
+ readFileSync: vi.fn(),
8
+ }))
9
+
10
+ import * as fs from 'node:fs'
11
+ import { buildSidebar } from './sidebar'
12
+ import { _resetPortalConfigCache } from './portal-config'
13
+
14
+ interface Tree {
15
+ [name: string]: Tree | null
16
+ }
17
+
18
+ // Monta um FS falso em memória: null = arquivo, objeto = diretório.
19
+ function mountFs(root: string, tree: Tree) {
20
+ function lookup(path: string): Tree | null | undefined {
21
+ if (path === root) return tree
22
+ const rel = path.slice(root.length + 1)
23
+ let node: Tree | null = tree
24
+ for (const part of rel.split('/')) {
25
+ if (node === null || typeof node !== 'object') return undefined
26
+ const next: Tree | null | undefined = node[part]
27
+ if (next === undefined) return undefined
28
+ node = next
29
+ }
30
+ return node
31
+ }
32
+
33
+ vi.mocked(fs.readdirSync).mockImplementation((path: unknown) => {
34
+ const node = lookup(String(path))
35
+ if (!node || typeof node !== 'object') return [] as never
36
+ return Object.keys(node) as never
37
+ })
38
+
39
+ vi.mocked(fs.statSync).mockImplementation((path: unknown) => {
40
+ const node = lookup(String(path))
41
+ return { isDirectory: () => node !== null && typeof node === 'object' } as never
42
+ })
43
+ }
44
+
45
+ const ROOT = '/content'
46
+
47
+ beforeEach(() => {
48
+ _resetPortalConfigCache()
49
+ vi.mocked(fs.existsSync).mockReturnValue(false)
50
+ })
51
+
52
+ describe('buildSidebar', () => {
53
+ it('achata domains/ e shared/ um nível, promovendo os filhos pro topo', () => {
54
+ mountFs(ROOT, {
55
+ domains: { historico: { 'prd.md': null }, licencas: { 'issues.md': null } },
56
+ shared: { docs: { 'guide.md': null } },
57
+ })
58
+ const sidebar = buildSidebar(ROOT) as { label: string }[]
59
+ expect(sidebar.map(e => e.label)).toEqual(['Docs', 'Historico', 'Licencas'])
60
+ })
61
+
62
+ it('aplica Title Case em todo nível, não só no topo', () => {
63
+ mountFs(ROOT, { domains: { licencas: { 'consistencia-licencas': { 'plan.md': null } } } })
64
+ const sidebar = buildSidebar(ROOT) as { label: string; items: { label: string }[] }[]
65
+ expect(sidebar[0].label).toBe('Licencas')
66
+ expect(sidebar[0].items[0].label).toBe('Consistencia Licencas')
67
+ })
68
+
69
+ it('gera slug em minúsculo sem extensão pra arquivos', () => {
70
+ mountFs(ROOT, { domains: { historico: { 'PRD.md': null } } })
71
+ const sidebar = buildSidebar(ROOT) as { label: string; items: { slug: string }[] }[]
72
+ expect(sidebar[0].items[0]).toEqual({ slug: 'domains/historico/prd' })
73
+ })
74
+
75
+ it('usa o slug da pasta para páginas index', () => {
76
+ mountFs(ROOT, { customization: { 'index.md': null, 'config-json.md': null } })
77
+ const sidebar = buildSidebar(ROOT) as { label: string; items: { slug: string }[] }[]
78
+
79
+ expect(sidebar[0].items.map(item => item.slug)).toEqual(['customization', 'customization/config-json'])
80
+ })
81
+
82
+ it('limita a navegação às raízes configuradas pelo consumer repo', () => {
83
+ vi.mocked(fs.existsSync).mockReturnValue(true)
84
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ navigation: { roots: ['docs'] } }))
85
+ mountFs(ROOT, { docs: { guide: { 'index.md': null } }, internal: { 'notes.md': null } })
86
+
87
+ const sidebar = buildSidebar(ROOT) as { label: string }[]
88
+
89
+ expect(sidebar.map(entry => entry.label)).toEqual(['Docs'])
90
+ })
91
+
92
+ it('ignora dotfiles e dot-directories', () => {
93
+ mountFs(ROOT, { '.claude': { 'x.md': null }, domains: { historico: { 'a.md': null } } })
94
+ const sidebar = buildSidebar(ROOT) as { label: string }[]
95
+ expect(sidebar).toEqual([{ label: 'Historico', items: [{ slug: 'domains/historico/a' }] }])
96
+ })
97
+
98
+ it('ignora arquivos bloqueados (floor hardcoded)', () => {
99
+ mountFs(ROOT, { 'CLAUDE.md': null, 'README.md': null })
100
+ const sidebar = buildSidebar(ROOT)
101
+ expect(sidebar).toEqual([{ slug: 'readme', label: 'Home' }])
102
+ })
103
+
104
+ it('fixa o README em primeiro, relabelado como Home, na frente de tudo', () => {
105
+ mountFs(ROOT, { 'README.md': null, domains: { historico: { 'a.md': null } } })
106
+ const sidebar = buildSidebar(ROOT) as { label: string }[]
107
+ expect(sidebar[0]).toEqual({ slug: 'readme', label: 'Home' })
108
+ expect(sidebar[1].label).toBe('Historico')
109
+ })
110
+
111
+ it('aplica labelOverrides do .upcontent/config.json em cima do Title Case', () => {
112
+ vi.mocked(fs.existsSync).mockReturnValue(true)
113
+ vi.mocked(fs.readFileSync).mockReturnValue(
114
+ JSON.stringify({ navigation: { labelOverrides: { historico: 'Histórico' } } }),
115
+ )
116
+ mountFs(ROOT, { domains: { historico: { 'a.md': null }, licencas: { 'b.md': null } } })
117
+ const sidebar = buildSidebar(ROOT) as { label: string }[]
118
+ expect(sidebar.map(e => e.label)).toEqual(['Histórico', 'Licencas'])
119
+ })
120
+
121
+ it('não inclui diretório que fica vazio após filtragem', () => {
122
+ mountFs(ROOT, { domains: { historico: {} } })
123
+ expect(buildSidebar(ROOT)).toEqual([])
124
+ })
125
+
126
+ it('retorna [] quando o content root não existe', () => {
127
+ vi.mocked(fs.readdirSync).mockImplementation(() => {
128
+ throw new Error('ENOENT')
129
+ })
130
+ expect(buildSidebar('/nao-existe')).toEqual([])
131
+ })
132
+
133
+ it('respeita blocklist configurável via .upcontent/config.json', () => {
134
+ vi.mocked(fs.existsSync).mockReturnValue(true)
135
+ vi.mocked(fs.readFileSync).mockReturnValue(
136
+ JSON.stringify({ navigation: { blocklist: { prefixes: ['docs/'] } } }),
137
+ )
138
+ mountFs(ROOT, { docs: { 'x.md': null }, domains: { historico: { 'a.md': null } } })
139
+ const sidebar = buildSidebar(ROOT) as { label: string }[]
140
+ expect(sidebar.map(e => e.label)).toEqual(['Historico'])
141
+ })
142
+ })
@@ -0,0 +1,113 @@
1
+ import { readdirSync, statSync } from 'node:fs'
2
+ import { isBlocked, resolveLabel } from './content-blocklist'
3
+ import { getPortalConfig } from './portal-config'
4
+
5
+ // Diretórios de topo que existem só como agrupamento estrutural do
6
+ // repositório de conteúdo, sem valor de navegação — seus filhos diretos
7
+ // são promovidos pra sidebar de topo em vez de aparecerem aninhados um
8
+ // nível a mais (ex: "domains/historico" vira grupo "Historico" direto,
9
+ // não "domains" > "historico").
10
+ const FLATTEN_TOP_LEVEL_DIRS = new Set(['domains', 'shared'])
11
+
12
+ interface SidebarGroup {
13
+ label: string
14
+ items: SidebarEntry[]
15
+ }
16
+
17
+ interface SidebarLink {
18
+ slug: string
19
+ label?: string
20
+ }
21
+
22
+ type SidebarEntry = SidebarGroup | SidebarLink
23
+
24
+ function isSidebarGroup(entry: SidebarEntry): entry is SidebarGroup {
25
+ return 'items' in entry
26
+ }
27
+
28
+ function labelOf(entry: SidebarEntry): string {
29
+ return isSidebarGroup(entry) ? entry.label : entry.slug
30
+ }
31
+
32
+ function sortEntries(entries: SidebarEntry[]): SidebarEntry[] {
33
+ return [...entries].sort((a, b) => labelOf(a).localeCompare(labelOf(b), 'pt-BR'))
34
+ }
35
+
36
+ function toSidebarSlug(relativePath: string): string {
37
+ const slug = relativePath.replace(/\.mdx?$/i, '').toLowerCase()
38
+ return slug.endsWith('/index') ? slug.slice(0, -'/index'.length) : slug
39
+ }
40
+
41
+ // Lista uma pasta ignorando dotfiles/dot-dirs e caminhos bloqueados
42
+ // (ver content-blocklist.ts) — relPath é relativo à raiz do content, sem
43
+ // barra inicial (ex: "domains/historico").
44
+ function listVisible(absDir: string, relPath: string): { name: string; isDir: boolean }[] {
45
+ return readdirSync(absDir)
46
+ .filter(name => !name.startsWith('.'))
47
+ .map(name => {
48
+ const abs = `${absDir}/${name}`
49
+ const isDir = statSync(abs).isDirectory()
50
+ const rel = relPath ? `${relPath}/${name}` : name
51
+ return { name, isDir, rel }
52
+ })
53
+ .filter(({ isDir, rel }) => !isBlocked(isDir ? `${rel}/` : rel))
54
+ .map(({ name, isDir }) => ({ name, isDir }))
55
+ }
56
+
57
+ // Constrói recursivamente o grupo de sidebar de um diretório — cada nível
58
+ // (não só o de topo) recebe label em Title Case, porque o autogenerate
59
+ // nativo do Starlight usa o nome literal da pasta em todo nível abaixo do
60
+ // primeiro e não expõe nenhum jeito de sobrescrever isso via config.
61
+ function buildDir(absDir: string, relPath: string): SidebarEntry[] {
62
+ const entries: SidebarEntry[] = []
63
+ for (const { name, isDir } of listVisible(absDir, relPath)) {
64
+ const rel = relPath ? `${relPath}/${name}` : name
65
+ if (isDir) {
66
+ const items = buildDir(`${absDir}/${name}`, rel)
67
+ if (items.length > 0) entries.push({ label: resolveLabel(name), items })
68
+ } else if (/\.mdx?$/i.test(name)) {
69
+ // Slug do Starlight = path relativo ao content root, sem extensão,
70
+ // minúsculo (ver ADR/nota em Footer.astro — mesmo mecanismo).
71
+ entries.push({ slug: toSidebarSlug(rel) })
72
+ }
73
+ }
74
+ return sortEntries(entries)
75
+ }
76
+
77
+ // Ponto de entrada: monta a sidebar completa a partir da raiz do content,
78
+ // achatando FLATTEN_TOP_LEVEL_DIRS um nível. docsRoot deve ser um path
79
+ // absoluto sem barra final; retorna [] se não existir (ex: astro check
80
+ // rodando sem o symlink de conteúdo criado).
81
+ export function buildSidebar(docsRoot: string): SidebarEntry[] {
82
+ let topLevel: { name: string; isDir: boolean }[]
83
+ try {
84
+ topLevel = listVisible(docsRoot, '')
85
+ } catch {
86
+ return []
87
+ }
88
+
89
+ const configuredRoots = getPortalConfig().navigation?.roots
90
+ const visibleTopLevel = configuredRoots
91
+ ? topLevel.filter(({ name }) => configuredRoots.includes(name))
92
+ : topLevel
93
+
94
+ const entries: SidebarEntry[] = []
95
+ for (const { name, isDir } of visibleTopLevel) {
96
+ if (isDir && FLATTEN_TOP_LEVEL_DIRS.has(name)) {
97
+ entries.push(...buildDir(`${docsRoot}/${name}`, name))
98
+ } else if (isDir) {
99
+ const items = buildDir(`${docsRoot}/${name}`, name)
100
+ if (items.length > 0) entries.push({ label: resolveLabel(name), items })
101
+ } else if (/\.mdx?$/i.test(name)) {
102
+ entries.push({ slug: toSidebarSlug(name) })
103
+ }
104
+ }
105
+
106
+ // README fica fixo em primeiro, relabelado como "Home" — é a landing
107
+ // page do portal, não deveria competir alfabeticamente nem aparecer com
108
+ // o nome literal do arquivo.
109
+ const readmeIndex = entries.findIndex(e => !isSidebarGroup(e) && e.slug === 'readme')
110
+ const readme = readmeIndex >= 0 ? entries.splice(readmeIndex, 1)[0] : undefined
111
+ const sorted = sortEntries(entries)
112
+ return readme ? [{ slug: 'readme', label: 'Home' }, ...sorted] : sorted
113
+ }
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { renderCsvTable, renderStructuredDataTree } from './structured-data-tree'
3
+
4
+ describe('renderStructuredDataTree', () => {
5
+ it('renderiza objeto simples como details com entradas', () => {
6
+ const html = renderStructuredDataTree({ name: 'Ana', age: 30 })
7
+ expect(html).toContain('<details class="sdp-node" open>')
8
+ expect(html).toContain('{ name, age }')
9
+ expect(html).toContain('class="sdp-copy"')
10
+ expect(html).toContain('Copy')
11
+ expect(html).toContain('<span class="sdp-key">name</span>')
12
+ expect(html).toContain('<span class="sdp-string">"Ana"</span>')
13
+ expect(html).toContain('<span class="sdp-number">30</span>')
14
+ })
15
+
16
+ it('renderiza array com itens indexados', () => {
17
+ const html = renderStructuredDataTree(['a', 'b'])
18
+ expect(html).toContain('[ 2 items ]')
19
+ expect(html).toContain('<span class="sdp-key">0</span>')
20
+ expect(html).toContain('<span class="sdp-key">1</span>')
21
+ })
22
+
23
+ it('objeto/array aninhado gera details aninhado', () => {
24
+ const html = renderStructuredDataTree({ user: { name: 'Ana' } })
25
+ const detailsCount = (html.match(/<details/g) ?? []).length
26
+ expect(detailsCount).toBe(2)
27
+ expect(html).toContain('<details class="sdp-node" open><summary>{ user }</summary>')
28
+ expect(html).toContain('<details class="sdp-node"><summary>{ name }</summary>')
29
+ })
30
+
31
+ it('valores null e boolean renderizam corretamente', () => {
32
+ const html = renderStructuredDataTree({ active: true, deleted: false, parent: null })
33
+ expect(html).toContain('<span class="sdp-boolean">true</span>')
34
+ expect(html).toContain('<span class="sdp-boolean">false</span>')
35
+ expect(html).toContain('<span class="sdp-null">null</span>')
36
+ })
37
+
38
+ it('escapa HTML em strings pra evitar quebra de markup', () => {
39
+ const html = renderStructuredDataTree({ label: '<script>alert(1)</script>' })
40
+ expect(html).not.toContain('<script>alert(1)</script>')
41
+ expect(html).toContain('&lt;script&gt;')
42
+ })
43
+
44
+ it('valor folha único (não objeto/array) renderiza sem details', () => {
45
+ const html = renderStructuredDataTree(42)
46
+ expect(html).not.toContain('<details')
47
+ expect(html).toContain('<span class="sdp-number">42</span>')
48
+ })
49
+
50
+ it('objeto vazio renderiza details sem entradas', () => {
51
+ const html = renderStructuredDataTree({})
52
+ expect(html).toContain('{}')
53
+ })
54
+
55
+ it('botão de cópia preserva a fonte original', () => {
56
+ const source = '{\n "name": "Ana"\n}'
57
+ const html = renderStructuredDataTree({ name: 'Ana' }, source)
58
+ expect(html).toContain(`data-sdp-copy="${encodeURIComponent(source)}"`)
59
+ })
60
+ })
61
+
62
+ describe('renderCsvTable', () => {
63
+ it('renderiza cabeçalho como thead e demais linhas como tbody', () => {
64
+ const html = renderCsvTable([['nome', 'idade'], ['Ana', '30']])
65
+ expect(html).toContain('<table class="sdp-csv-table">')
66
+ expect(html).toContain('<thead><tr><th>nome</th><th>idade</th></tr></thead>')
67
+ expect(html).toContain('<tbody><tr><td>Ana</td><td>30</td></tr></tbody>')
68
+ })
69
+
70
+ it('escapa HTML nas células pra evitar quebra de markup', () => {
71
+ const html = renderCsvTable([['label'], ['<script>alert(1)</script>']])
72
+ expect(html).not.toContain('<script>alert(1)</script>')
73
+ expect(html).toContain('&lt;script&gt;')
74
+ })
75
+ })
@@ -0,0 +1,55 @@
1
+ export function escapeHtml(value: string): string {
2
+ return value
3
+ .replace(/&/g, '&amp;')
4
+ .replace(/</g, '&lt;')
5
+ .replace(/>/g, '&gt;')
6
+ .replace(/"/g, '&quot;')
7
+ }
8
+
9
+ function renderLeaf(value: string | number | boolean | null): string {
10
+ if (value === null) return '<span class="sdp-null">null</span>'
11
+ if (typeof value === 'string') return `<span class="sdp-string">"${escapeHtml(value)}"</span>`
12
+ if (typeof value === 'boolean') return `<span class="sdp-boolean">${value}</span>`
13
+ return `<span class="sdp-number">${value}</span>`
14
+ }
15
+
16
+ function renderEntry(key: string, value: unknown): string {
17
+ return `<div class="sdp-entry"><span class="sdp-key">${escapeHtml(key)}</span><span class="sdp-colon">:</span> ${renderNode(value, false)}</div>`
18
+ }
19
+
20
+ // Recursivo: o nó raiz fica aberto para dar contexto imediato; objetos e arrays
21
+ // aninhados começam fechados para manter documentos grandes escaneáveis.
22
+ export function renderNode(value: unknown, open = true): string {
23
+ if (value === null || typeof value !== 'object') {
24
+ return renderLeaf(value as string | number | boolean | null)
25
+ }
26
+
27
+ if (Array.isArray(value)) {
28
+ const summary = value.length === 0 ? '[]' : `[ ${value.length} ${value.length === 1 ? 'item' : 'items'} ]`
29
+ const children = value.map((item, index) => renderEntry(String(index), item)).join('')
30
+ return `<details class="sdp-node"${open ? ' open' : ''}><summary>${summary}</summary><div class="sdp-children">${children}</div></details>`
31
+ }
32
+
33
+ const entries = Object.entries(value as Record<string, unknown>)
34
+ const summary = entries.length === 0
35
+ ? '{}'
36
+ : `{ ${entries.map(([key]) => escapeHtml(key)).join(', ')} }`
37
+ const children = entries.map(([key, val]) => renderEntry(key, val)).join('')
38
+ return `<details class="sdp-node"${open ? ' open' : ''}><summary>${summary}</summary><div class="sdp-children">${children}</div></details>`
39
+ }
40
+
41
+ export function renderStructuredDataTree(value: unknown, source = JSON.stringify(value, null, 2) ?? ''): string {
42
+ const encodedSource = encodeURIComponent(source)
43
+ return `<div class="sdp-root"><div class="sdp-toolbar"><button class="sdp-copy" type="button" aria-label="Copy structured data" data-sdp-copy="${encodedSource}"><span class="sdp-copy-label" aria-live="polite">Copy</span></button></div>${renderNode(value)}</div>`
44
+ }
45
+
46
+ // Tabela HTML semântica pra blocos ```csv — reaproveita o mesmo escapeHtml
47
+ // da árvore JSON/YAML, mas a saída é <table>, não <details>.
48
+ export function renderCsvTable(rows: string[][]): string {
49
+ const [header, ...body] = rows
50
+ const theadRow = `<tr>${header.map(cell => `<th>${escapeHtml(cell)}</th>`).join('')}</tr>`
51
+ const tbodyRows = body
52
+ .map(row => `<tr>${row.map(cell => `<td>${escapeHtml(cell)}</td>`).join('')}</tr>`)
53
+ .join('')
54
+ return `<table class="sdp-csv-table"><thead>${theadRow}</thead><tbody>${tbodyRows}</tbody></table>`
55
+ }
@@ -0,0 +1,114 @@
1
+ ---
2
+ // Override do Footer.astro do Starlight (registrado via starlight({ components })
3
+ // no astro.config.mjs) — reintroduz os links "Ver/Editar no GitHub" e a seção
4
+ // "Documentos relacionados" que existiam no DocLayout.astro custom antes da
5
+ // migração pro Starlight (ticket 02), agora via mecanismo de component override.
6
+ import Default from '@astrojs/starlight/components/Footer.astro'
7
+ import { getCollection } from 'astro:content'
8
+ import { toRelativeDocPath } from '../content.config'
9
+ import { buildGitHubUrl, resolveRelated } from '../lib/doc-links'
10
+ import { getPortalConfig } from '../lib/portal-config'
11
+
12
+ const { entry } = Astro.locals.starlightRoute
13
+
14
+ // entry.id é o slug de rota do Starlight (sem extensão) — buildGitHubUrl e o
15
+ // matching de resolveRelated precisam do path de arquivo real (com .md) pra
16
+ // apontar pro repositório de conteúdo corretamente.
17
+ const filePath = entry.filePath ? toRelativeDocPath(entry.filePath) : entry.id
18
+
19
+ const repoUrl = (import.meta.env.REPO_URL as string | undefined) || getPortalConfig().repo?.url
20
+ const viewUrl = buildGitHubUrl(repoUrl, filePath, 'view')
21
+ const editUrl = buildGitHubUrl(repoUrl, filePath, 'edit')
22
+
23
+ const related = entry.data.related?.length
24
+ ? resolveRelated(
25
+ entry.data.related,
26
+ (await getCollection('docs')).map(doc => ({
27
+ ...doc,
28
+ id: doc.filePath ? toRelativeDocPath(doc.filePath) : doc.id,
29
+ })),
30
+ )
31
+ : []
32
+ ---
33
+
34
+ <Default><slot /></Default>
35
+
36
+ {
37
+ (viewUrl || editUrl) && (
38
+ <div class="doc-actions">
39
+ {viewUrl && (
40
+ <a href={viewUrl} target="_blank" rel="noopener noreferrer" class="doc-action-link">
41
+ View on GitHub
42
+ </a>
43
+ )}
44
+ {editUrl && (
45
+ <a href={editUrl} target="_blank" rel="noopener noreferrer" class="doc-action-link">
46
+ Edit on GitHub
47
+ </a>
48
+ )}
49
+ </div>
50
+ )
51
+ }
52
+
53
+ {
54
+ related.length > 0 && (
55
+ <section class="related-docs">
56
+ <h2>Related documents</h2>
57
+ <ul>
58
+ {related.map(doc => (
59
+ <li><a href={`/${doc.slug}`}>{doc.title}</a></li>
60
+ ))}
61
+ </ul>
62
+ </section>
63
+ )
64
+ }
65
+
66
+ <style>
67
+ @layer starlight.components {
68
+ .doc-actions {
69
+ display: flex;
70
+ gap: 0.5rem;
71
+ margin-top: 1.5rem;
72
+ }
73
+ .doc-action-link {
74
+ font-size: var(--sl-text-xs);
75
+ color: var(--sl-color-gray-3);
76
+ text-decoration: none;
77
+ padding: 0.2rem 0.5rem;
78
+ border: 1px solid var(--sl-color-gray-5);
79
+ border-radius: 4px;
80
+ }
81
+ .doc-action-link:hover {
82
+ color: var(--sl-color-white);
83
+ background: var(--sl-color-gray-6);
84
+ }
85
+
86
+ .related-docs {
87
+ margin-top: 1.5rem;
88
+ padding-top: 1.5rem;
89
+ border-top: 1px solid var(--sl-color-gray-5);
90
+ }
91
+ .related-docs h2 {
92
+ font-size: var(--sl-text-sm);
93
+ font-weight: 600;
94
+ color: var(--sl-color-gray-3);
95
+ margin-bottom: 0.75rem;
96
+ }
97
+ .related-docs ul {
98
+ list-style: none;
99
+ padding: 0;
100
+ margin: 0;
101
+ }
102
+ .related-docs li {
103
+ margin: 0.3rem 0;
104
+ }
105
+ .related-docs a {
106
+ font-size: var(--sl-text-sm);
107
+ color: var(--sl-color-accent);
108
+ text-decoration: none;
109
+ }
110
+ .related-docs a:hover {
111
+ text-decoration: underline;
112
+ }
113
+ }
114
+ </style>
@@ -0,0 +1,20 @@
1
+ ---
2
+ // Override do Head.astro do Starlight (registrado via starlight({ components })
3
+ // no astro.config.mjs). A homepage usa o mesmo nome do produto como título da
4
+ // página e do site; remova o título duplicado só nessa rota.
5
+ import MermaidLoader from '../components/MermaidLoader.astro'
6
+ import StructuredDataCopy from '../components/StructuredDataCopy.astro'
7
+ import { getPortalConfig } from '../lib/portal-config'
8
+ import { PRODUCT_NAME } from '../lib/product-identity'
9
+
10
+ const { head } = Astro.locals.starlightRoute
11
+ const isHome = Astro.locals.starlightRoute.id === 'readme'
12
+ const pageHead = isHome ? head.filter(({ tag }) => tag !== 'title') : head
13
+ const siteTitle = getPortalConfig().site?.title ?? PRODUCT_NAME
14
+ ---
15
+
16
+ {pageHead.map(({ tag: Tag, attrs, content }) => <Tag {...attrs} set:html={content} />)}
17
+ <slot />
18
+ {isHome && <title>{siteTitle}</title>}
19
+ <MermaidLoader />
20
+ <StructuredDataCopy />
@@ -0,0 +1,20 @@
1
+ ---
2
+ // Redireciona pro README (raiz do conteúdo) — Starlight não tem homepage
3
+ // própria sem um index.md na collection.
4
+ import { getCollection } from 'astro:content'
5
+ import { PRODUCT_NAME } from '../lib/product-identity'
6
+
7
+ const all = await getCollection('docs')
8
+ const readme = all.find(e => e.id.toLowerCase() === 'readme')
9
+ ---
10
+
11
+ {readme ? (
12
+ <meta http-equiv="refresh" content={`0;url=/${readme.id}/`} />
13
+ ) : (
14
+ <html lang="pt-BR">
15
+ <body>
16
+ <h1>{PRODUCT_NAME}</h1>
17
+ <p>Rode <code>make dev</code> para visualizar os documentos.</p>
18
+ </body>
19
+ </html>
20
+ )}
@@ -0,0 +1,29 @@
1
+ .callout {
2
+ margin: 1rem 0;
3
+ padding: 0.85rem 1rem;
4
+ border-inline-start: 0.25rem solid var(--sl-color-accent);
5
+ border-radius: 0.25rem;
6
+ background: var(--sl-color-gray-6);
7
+ }
8
+
9
+ .callout-title {
10
+ display: flex;
11
+ gap: 0.4rem;
12
+ align-items: center;
13
+ margin-bottom: 0.35rem;
14
+ color: var(--sl-color-text-accent);
15
+ font-weight: 650;
16
+ }
17
+
18
+ .callout > :last-child {
19
+ margin-bottom: 0;
20
+ }
21
+
22
+ .callout-warning,
23
+ .callout-caution {
24
+ border-inline-start-color: var(--sl-color-orange, #d97706);
25
+ }
26
+
27
+ .callout-danger {
28
+ border-inline-start-color: var(--sl-color-red, #dc2626);
29
+ }
@@ -0,0 +1,95 @@
1
+ /* Tree view estática de blocos ```json/```yaml (ver ADR-0007) — usa os
2
+ tokens de cor do próprio Starlight, sem depender de tokens.css (não
3
+ conectado ao tema desde a migração). */
4
+ .sdp-root {
5
+ font-family: var(--__sl-font-mono, ui-monospace, monospace);
6
+ font-size: var(--sl-text-sm);
7
+ border: 1px solid var(--sl-color-gray-5);
8
+ border-radius: 0.5rem;
9
+ padding: 0.75rem 1rem;
10
+ background: var(--sl-color-gray-6);
11
+ overflow-x: auto;
12
+ }
13
+
14
+ .sdp-toolbar {
15
+ display: flex;
16
+ justify-content: flex-end;
17
+ margin-block-end: 0.5rem;
18
+ }
19
+
20
+ .sdp-copy {
21
+ color: var(--sl-color-gray-2);
22
+ background: transparent;
23
+ border: 1px solid var(--sl-color-gray-5);
24
+ border-radius: 0.25rem;
25
+ padding: 0.2rem 0.5rem;
26
+ font: inherit;
27
+ font-size: var(--sl-text-xs);
28
+ cursor: pointer;
29
+ }
30
+
31
+ .sdp-copy:hover,
32
+ .sdp-copy:focus-visible {
33
+ color: var(--sl-color-white);
34
+ border-color: var(--sl-color-accent);
35
+ }
36
+
37
+ .sdp-node {
38
+ margin: 0;
39
+ }
40
+ .sdp-node > summary {
41
+ cursor: pointer;
42
+ color: var(--sl-color-gray-3);
43
+ user-select: none;
44
+ }
45
+ .sdp-node > summary:hover {
46
+ color: var(--sl-color-white);
47
+ }
48
+
49
+ .sdp-children {
50
+ margin-inline-start: 1.25rem;
51
+ border-inline-start: 1px solid var(--sl-color-gray-5);
52
+ padding-inline-start: 0.75rem;
53
+ }
54
+
55
+ .sdp-entry {
56
+ white-space: nowrap;
57
+ }
58
+
59
+ .sdp-key {
60
+ color: var(--sl-color-accent-high, var(--sl-color-accent));
61
+ }
62
+ .sdp-colon {
63
+ color: var(--sl-color-gray-3);
64
+ margin-inline-end: 0.25rem;
65
+ }
66
+ .sdp-string {
67
+ color: var(--sl-color-green-high);
68
+ }
69
+ .sdp-number {
70
+ color: var(--sl-color-orange, #e6a13d);
71
+ }
72
+ .sdp-boolean {
73
+ color: var(--sl-color-purple, #b58cf2);
74
+ }
75
+ .sdp-null {
76
+ color: var(--sl-color-gray-3);
77
+ font-style: italic;
78
+ }
79
+
80
+ .sdp-csv-table {
81
+ font-size: var(--sl-text-sm);
82
+ border-collapse: collapse;
83
+ width: 100%;
84
+ overflow-x: auto;
85
+ }
86
+ .sdp-csv-table th,
87
+ .sdp-csv-table td {
88
+ border: 1px solid var(--sl-color-gray-5);
89
+ padding: 0.4rem 0.75rem;
90
+ text-align: left;
91
+ }
92
+ .sdp-csv-table th {
93
+ background: var(--sl-color-gray-6);
94
+ color: var(--sl-color-gray-2);
95
+ }