upcontent 0.0.0-stage → 0.1.1

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 (86) hide show
  1. package/.github/workflows/ci.yml +61 -0
  2. package/.github/workflows/deploy.yml +59 -0
  3. package/.github/workflows/publish.yml +75 -0
  4. package/.github/workflows/reusable-pages.yml +74 -0
  5. package/.upcontent/config.json +85 -0
  6. package/.upcontent/favicon.svg +4 -0
  7. package/.upcontent/logo.svg +6 -0
  8. package/.upcontent/portal.css +139 -0
  9. package/CONTEXT.md +25 -0
  10. package/Makefile +33 -0
  11. package/README.md +132 -2
  12. package/SHOWCASE.mdx +163 -0
  13. package/assets/readme/portal-home.png +0 -0
  14. package/assets/readme/portal-showcase.png +0 -0
  15. package/astro.config.mjs +167 -0
  16. package/customization/config-json.md +100 -0
  17. package/customization/content.md +62 -0
  18. package/customization/environment.md +43 -0
  19. package/customization/index.md +33 -0
  20. package/customization/navigation.md +57 -0
  21. package/customization/site-identity.md +43 -0
  22. package/customization/theme.md +47 -0
  23. package/deployment/github-pages.md +46 -0
  24. package/deployment/index.md +25 -0
  25. package/deployment/npm.md +39 -0
  26. package/deployment/static-hosts.md +34 -0
  27. package/getting-started/consumer-repository.md +156 -0
  28. package/getting-started/first-build.md +78 -0
  29. package/guides/authoring-content.md +105 -0
  30. package/guides/validate-your-site.md +58 -0
  31. package/package.json +40 -4
  32. package/scripts/upcontent-cli.mjs +111 -0
  33. package/scripts/verify-external-build.mjs +32 -0
  34. package/scripts/verify-golden-build.mjs +9 -0
  35. package/src/components/MermaidLoader.astro +304 -0
  36. package/src/components/PaletteShowcase.astro +122 -0
  37. package/src/components/StructuredDataCopy.astro +22 -0
  38. package/src/content/__mocks__/astro-content.ts +7 -0
  39. package/src/content/__mocks__/astro-loaders.ts +3 -0
  40. package/src/content/i18n/en.json +1 -0
  41. package/src/content.config.test.ts +209 -0
  42. package/src/content.config.ts +113 -0
  43. package/src/lib/content-blocklist.test.ts +47 -0
  44. package/src/lib/content-blocklist.ts +55 -0
  45. package/src/lib/doc-links.test.ts +62 -0
  46. package/src/lib/doc-links.ts +31 -0
  47. package/src/lib/mermaid-render.test.ts +42 -0
  48. package/src/lib/mermaid-render.ts +24 -0
  49. package/src/lib/portal-config.test.ts +153 -0
  50. package/src/lib/portal-config.ts +187 -0
  51. package/src/lib/portal-routes.test.ts +62 -0
  52. package/src/lib/portal-routes.ts +59 -0
  53. package/src/lib/product-identity.ts +2 -0
  54. package/src/lib/rehype-callouts.test.ts +69 -0
  55. package/src/lib/rehype-callouts.ts +61 -0
  56. package/src/lib/remark-doc-links.test.ts +50 -0
  57. package/src/lib/remark-doc-links.ts +21 -0
  58. package/src/lib/remark-strip-duplicate-title.test.ts +73 -0
  59. package/src/lib/remark-strip-duplicate-title.ts +37 -0
  60. package/src/lib/remark-structured-data-preview.test.ts +104 -0
  61. package/src/lib/remark-structured-data-preview.ts +66 -0
  62. package/src/lib/remark-wiki-links.test.ts +102 -0
  63. package/src/lib/remark-wiki-links.ts +112 -0
  64. package/src/lib/seo-sitemap.test.ts +37 -0
  65. package/src/lib/seo-sitemap.ts +54 -0
  66. package/src/lib/sidebar.test.ts +142 -0
  67. package/src/lib/sidebar.ts +114 -0
  68. package/src/lib/structured-data-tree.test.ts +75 -0
  69. package/src/lib/structured-data-tree.ts +55 -0
  70. package/src/overrides/Footer.astro +114 -0
  71. package/src/overrides/Head.astro +103 -0
  72. package/src/pages/robots.txt.ts +46 -0
  73. package/src/styles/callouts.css +29 -0
  74. package/src/styles/structured-data-preview.css +95 -0
  75. package/src/upcontent-cli.test.ts +36 -0
  76. package/test-fixtures/external-consumer/.upcontent/config.json +30 -0
  77. package/test-fixtures/external-consumer/.upcontent/favicon.svg +4 -0
  78. package/test-fixtures/external-consumer/.upcontent/logo.svg +4 -0
  79. package/test-fixtures/external-consumer/.upcontent/theme.css +4 -0
  80. package/test-fixtures/external-consumer/.upcontent-renderer/README.md +3 -0
  81. package/test-fixtures/external-consumer/README.md +6 -0
  82. package/test-fixtures/external-consumer/forbidden.md +5 -0
  83. package/test-fixtures/external-consumer/noindex.md +7 -0
  84. package/test-fixtures/external-consumer/public.md +6 -0
  85. package/tsconfig.json +7 -0
  86. 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: 'index', 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: 'index', 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,114 @@
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
+ if (slug === 'readme') return 'index'
39
+ return slug.endsWith('/index') ? slug.slice(0, -'/index'.length) : slug
40
+ }
41
+
42
+ // Lista uma pasta ignorando dotfiles/dot-dirs e caminhos bloqueados
43
+ // (ver content-blocklist.ts) — relPath é relativo à raiz do content, sem
44
+ // barra inicial (ex: "domains/historico").
45
+ function listVisible(absDir: string, relPath: string): { name: string; isDir: boolean }[] {
46
+ return readdirSync(absDir)
47
+ .filter(name => !name.startsWith('.'))
48
+ .map(name => {
49
+ const abs = `${absDir}/${name}`
50
+ const isDir = statSync(abs).isDirectory()
51
+ const rel = relPath ? `${relPath}/${name}` : name
52
+ return { name, isDir, rel }
53
+ })
54
+ .filter(({ isDir, rel }) => !isBlocked(isDir ? `${rel}/` : rel))
55
+ .map(({ name, isDir }) => ({ name, isDir }))
56
+ }
57
+
58
+ // Constrói recursivamente o grupo de sidebar de um diretório — cada nível
59
+ // (não só o de topo) recebe label em Title Case, porque o autogenerate
60
+ // nativo do Starlight usa o nome literal da pasta em todo nível abaixo do
61
+ // primeiro e não expõe nenhum jeito de sobrescrever isso via config.
62
+ function buildDir(absDir: string, relPath: string): SidebarEntry[] {
63
+ const entries: SidebarEntry[] = []
64
+ for (const { name, isDir } of listVisible(absDir, relPath)) {
65
+ const rel = relPath ? `${relPath}/${name}` : name
66
+ if (isDir) {
67
+ const items = buildDir(`${absDir}/${name}`, rel)
68
+ if (items.length > 0) entries.push({ label: resolveLabel(name), items })
69
+ } else if (/\.mdx?$/i.test(name)) {
70
+ // Slug do Starlight = path relativo ao content root, sem extensão,
71
+ // minúsculo (ver ADR/nota em Footer.astro — mesmo mecanismo).
72
+ entries.push({ slug: toSidebarSlug(rel) })
73
+ }
74
+ }
75
+ return sortEntries(entries)
76
+ }
77
+
78
+ // Ponto de entrada: monta a sidebar completa a partir da raiz do content,
79
+ // achatando FLATTEN_TOP_LEVEL_DIRS um nível. docsRoot deve ser um path
80
+ // absoluto sem barra final; retorna [] se não existir (ex: astro check
81
+ // rodando sem o symlink de conteúdo criado).
82
+ export function buildSidebar(docsRoot: string): SidebarEntry[] {
83
+ let topLevel: { name: string; isDir: boolean }[]
84
+ try {
85
+ topLevel = listVisible(docsRoot, '')
86
+ } catch {
87
+ return []
88
+ }
89
+
90
+ const configuredRoots = getPortalConfig().navigation?.roots
91
+ const visibleTopLevel = configuredRoots
92
+ ? topLevel.filter(({ name }) => configuredRoots.includes(name))
93
+ : topLevel
94
+
95
+ const entries: SidebarEntry[] = []
96
+ for (const { name, isDir } of visibleTopLevel) {
97
+ if (isDir && FLATTEN_TOP_LEVEL_DIRS.has(name)) {
98
+ entries.push(...buildDir(`${docsRoot}/${name}`, name))
99
+ } else if (isDir) {
100
+ const items = buildDir(`${docsRoot}/${name}`, name)
101
+ if (items.length > 0) entries.push({ label: resolveLabel(name), items })
102
+ } else if (/\.mdx?$/i.test(name)) {
103
+ entries.push({ slug: toSidebarSlug(name) })
104
+ }
105
+ }
106
+
107
+ // README fica fixo em primeiro, relabelado como "Home" — é a landing
108
+ // page do portal, não deveria competir alfabeticamente nem aparecer com
109
+ // o nome literal do arquivo.
110
+ const readmeIndex = entries.findIndex(e => !isSidebarGroup(e) && e.slug === 'index')
111
+ const readme = readmeIndex >= 0 ? entries.splice(readmeIndex, 1)[0] : undefined
112
+ const sorted = sortEntries(entries)
113
+ return readme ? [{ slug: 'index', label: 'Home' }, ...sorted] : sorted
114
+ }
@@ -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,103 @@
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 { entry } = Astro.locals.starlightRoute
12
+ const normalizedBasePath = import.meta.env.BASE_URL.replace(/\/+$/, '')
13
+ const isHome = Astro.url.pathname.replace(/\/+$/, '') === normalizedBasePath
14
+ const baseHead = isHome ? head.filter(({ tag }) => tag !== 'title') : head
15
+ const config = getPortalConfig()
16
+ const seoEnabled = config.seo?.enabled === true && Boolean(Astro.site)
17
+ const siteTitle = config.site?.title ?? PRODUCT_NAME
18
+ const pageData = entry?.data ?? {}
19
+ const seoHead = seoEnabled
20
+ ? baseHead
21
+ : baseHead.filter(({ tag, attrs }) => {
22
+ const property = typeof attrs?.property === 'string' ? attrs.property : undefined
23
+ const name = typeof attrs?.name === 'string' ? attrs.name : undefined
24
+ return !(tag === 'meta' && (property?.startsWith('og:') || name?.startsWith('twitter:')))
25
+ })
26
+ const canonicalTag = seoHead.find(({ tag, attrs }) => tag === 'link' && attrs?.rel === 'canonical')
27
+ const canonicalUrl = seoEnabled
28
+ ? typeof pageData.canonical === 'string'
29
+ ? pageData.canonical
30
+ : typeof canonicalTag?.attrs?.href === 'string'
31
+ ? canonicalTag.attrs.href
32
+ : undefined
33
+ : undefined
34
+ const pageHead = pageData.canonical || config.site?.locale
35
+ ? seoHead.filter(({ tag, attrs }) =>
36
+ !(pageData.canonical && tag === 'link' && attrs?.rel === 'canonical')
37
+ && !(pageData.canonical && tag === 'meta' && attrs?.property === 'og:url')
38
+ && !(config.site?.locale && tag === 'meta' && attrs?.property === 'og:locale'),
39
+ )
40
+ : seoHead
41
+ const socialImage = seoEnabled && (typeof pageData.image === 'string' ? pageData.image : config.site?.socialImage)
42
+ const robots = !seoEnabled || pageData.noindex === true ? 'noindex, nofollow' : undefined
43
+ const pageTitle = typeof pageData.title === 'string' ? pageData.title : siteTitle
44
+ const pageDescription = typeof pageData.description === 'string' ? pageData.description : config.site?.description
45
+ const absoluteImage = resolveOptionalUrl(socialImage, canonicalUrl)
46
+ const siteRoot = canonicalUrl && Astro.site ? new URL(import.meta.env.BASE_URL, Astro.site).href : undefined
47
+ const breadcrumbItems = canonicalUrl
48
+ ? [{ '@type': 'ListItem', position: 1, name: siteTitle, item: siteRoot },
49
+ ...(isHome ? [] : [{ '@type': 'ListItem', position: 2, name: pageTitle, item: canonicalUrl }])]
50
+ : []
51
+ const structuredData = canonicalUrl
52
+ ? {
53
+ '@context': 'https://schema.org',
54
+ '@graph': [
55
+ {
56
+ '@type': 'WebSite',
57
+ '@id': `${siteRoot}#website`,
58
+ url: siteRoot,
59
+ name: siteTitle,
60
+ description: config.site?.description,
61
+ inLanguage: config.site?.locale,
62
+ },
63
+ {
64
+ '@type': 'TechArticle',
65
+ '@id': `${canonicalUrl}#article`,
66
+ url: canonicalUrl,
67
+ headline: pageTitle,
68
+ description: pageDescription,
69
+ isPartOf: { '@id': `${siteRoot}#website` },
70
+ image: absoluteImage,
71
+ dateModified: pageData.updated,
72
+ breadcrumb: { '@type': 'BreadcrumbList', itemListElement: breadcrumbItems },
73
+ },
74
+ ],
75
+ }
76
+ : undefined;
77
+ const structuredDataJson = structuredData
78
+ ? JSON.stringify(structuredData).replace(/[<>&]/g, character => ({ '<': '\\u003c', '>': '\\u003e', '&': '\\u0026' })[character]!)
79
+ : undefined
80
+
81
+ function resolveOptionalUrl(value: unknown, baseUrl: string | undefined): string | undefined {
82
+ if (typeof value !== 'string' || value.length === 0) return undefined
83
+ try {
84
+ const url = new URL(value, baseUrl)
85
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : undefined
86
+ } catch {
87
+ return undefined
88
+ }
89
+ }
90
+ ---
91
+
92
+ {pageHead.map(({ tag: Tag, attrs, content }) => <Tag {...attrs} set:html={content} />)}
93
+ <slot />
94
+ {isHome && <title>{siteTitle}</title>}
95
+ {seoEnabled && pageData.canonical && <link rel="canonical" href={canonicalUrl} />}
96
+ {seoEnabled && pageData.canonical && <meta property="og:url" content={canonicalUrl} />}
97
+ {seoEnabled && config.site?.locale && <meta property="og:locale" content={config.site.locale} />}
98
+ {absoluteImage && <meta property="og:image" content={absoluteImage} />}
99
+ {absoluteImage && <meta name="twitter:image" content={absoluteImage} />}
100
+ {robots && <meta name="robots" content={robots} />}
101
+ {structuredDataJson && <script type="application/ld+json" set:html={structuredDataJson} />}
102
+ <MermaidLoader />
103
+ <StructuredDataCopy />
@@ -0,0 +1,46 @@
1
+ import { getPortalConfig } from '../lib/portal-config'
2
+
3
+ export const prerender = true
4
+
5
+ export function GET() {
6
+ const config = getPortalConfig()
7
+ const configuredUrl = process.env.SITE_URL || config.site?.url
8
+ const seoEnabled = config.seo?.enabled === true && Boolean(configuredUrl)
9
+ if (!seoEnabled) {
10
+ return disallowRobots()
11
+ }
12
+
13
+ const basePath = process.env.BASE_PATH || ''
14
+ let siteUrl
15
+ try {
16
+ siteUrl = configuredUrl ? addBasePath(configuredUrl, basePath) : undefined
17
+ } catch {
18
+ return disallowRobots()
19
+ }
20
+ const sitemap = siteUrl ? `${siteUrl}/sitemap-index.xml` : undefined
21
+ const body = ['User-agent: *', 'Allow: /', sitemap && `Sitemap: ${sitemap}`].filter(Boolean).join('\n') + '\n'
22
+
23
+ return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } })
24
+ }
25
+
26
+ function addBasePath(siteUrl: string, basePath: string): string {
27
+ const url = new URL(siteUrl)
28
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('unsupported protocol')
29
+ if (url.username || url.password) throw new Error('userinfo is not allowed')
30
+ url.search = ''
31
+ url.hash = ''
32
+ const normalizedBasePath = basePath.replace(/^\/+|\/+$/g, '')
33
+
34
+ if (normalizedBasePath) {
35
+ url.pathname = `/${normalizedBasePath}`
36
+ return url.toString().replace(/\/+$/, '')
37
+ }
38
+
39
+ return url.toString().replace(/\/+$/, '')
40
+ }
41
+
42
+ function disallowRobots(): Response {
43
+ return new Response('User-agent: *\nDisallow: /\n', {
44
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
45
+ })
46
+ }