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.
- package/.github/workflows/ci.yml +61 -0
- package/.github/workflows/deploy.yml +59 -0
- package/.github/workflows/publish.yml +75 -0
- package/.github/workflows/reusable-pages.yml +74 -0
- package/.upcontent/config.json +85 -0
- package/.upcontent/favicon.svg +4 -0
- package/.upcontent/logo.svg +6 -0
- package/.upcontent/portal.css +139 -0
- package/CONTEXT.md +25 -0
- package/Makefile +33 -0
- package/README.md +132 -2
- package/SHOWCASE.mdx +163 -0
- package/assets/readme/portal-home.png +0 -0
- package/assets/readme/portal-showcase.png +0 -0
- package/astro.config.mjs +167 -0
- package/customization/config-json.md +100 -0
- package/customization/content.md +62 -0
- package/customization/environment.md +43 -0
- package/customization/index.md +33 -0
- package/customization/navigation.md +57 -0
- package/customization/site-identity.md +43 -0
- package/customization/theme.md +47 -0
- package/deployment/github-pages.md +46 -0
- package/deployment/index.md +25 -0
- package/deployment/npm.md +39 -0
- package/deployment/static-hosts.md +34 -0
- package/getting-started/consumer-repository.md +156 -0
- package/getting-started/first-build.md +78 -0
- package/guides/authoring-content.md +105 -0
- package/guides/validate-your-site.md +58 -0
- package/package.json +40 -4
- package/scripts/upcontent-cli.mjs +111 -0
- package/scripts/verify-external-build.mjs +32 -0
- package/scripts/verify-golden-build.mjs +9 -0
- package/src/components/MermaidLoader.astro +304 -0
- package/src/components/PaletteShowcase.astro +122 -0
- package/src/components/StructuredDataCopy.astro +22 -0
- package/src/content/__mocks__/astro-content.ts +7 -0
- package/src/content/__mocks__/astro-loaders.ts +3 -0
- package/src/content/i18n/en.json +1 -0
- package/src/content.config.test.ts +209 -0
- package/src/content.config.ts +113 -0
- package/src/lib/content-blocklist.test.ts +47 -0
- package/src/lib/content-blocklist.ts +55 -0
- package/src/lib/doc-links.test.ts +62 -0
- package/src/lib/doc-links.ts +31 -0
- package/src/lib/mermaid-render.test.ts +42 -0
- package/src/lib/mermaid-render.ts +24 -0
- package/src/lib/portal-config.test.ts +153 -0
- package/src/lib/portal-config.ts +187 -0
- package/src/lib/portal-routes.test.ts +62 -0
- package/src/lib/portal-routes.ts +59 -0
- package/src/lib/product-identity.ts +2 -0
- package/src/lib/rehype-callouts.test.ts +69 -0
- package/src/lib/rehype-callouts.ts +61 -0
- package/src/lib/remark-doc-links.test.ts +50 -0
- package/src/lib/remark-doc-links.ts +21 -0
- package/src/lib/remark-strip-duplicate-title.test.ts +73 -0
- package/src/lib/remark-strip-duplicate-title.ts +37 -0
- package/src/lib/remark-structured-data-preview.test.ts +104 -0
- package/src/lib/remark-structured-data-preview.ts +66 -0
- package/src/lib/remark-wiki-links.test.ts +102 -0
- package/src/lib/remark-wiki-links.ts +112 -0
- package/src/lib/seo-sitemap.test.ts +37 -0
- package/src/lib/seo-sitemap.ts +54 -0
- package/src/lib/sidebar.test.ts +142 -0
- package/src/lib/sidebar.ts +114 -0
- package/src/lib/structured-data-tree.test.ts +75 -0
- package/src/lib/structured-data-tree.ts +55 -0
- package/src/overrides/Footer.astro +114 -0
- package/src/overrides/Head.astro +103 -0
- package/src/pages/robots.txt.ts +46 -0
- package/src/styles/callouts.css +29 -0
- package/src/styles/structured-data-preview.css +95 -0
- package/src/upcontent-cli.test.ts +36 -0
- package/test-fixtures/external-consumer/.upcontent/config.json +30 -0
- package/test-fixtures/external-consumer/.upcontent/favicon.svg +4 -0
- package/test-fixtures/external-consumer/.upcontent/logo.svg +4 -0
- package/test-fixtures/external-consumer/.upcontent/theme.css +4 -0
- package/test-fixtures/external-consumer/.upcontent-renderer/README.md +3 -0
- package/test-fixtures/external-consumer/README.md +6 -0
- package/test-fixtures/external-consumer/forbidden.md +5 -0
- package/test-fixtures/external-consumer/noindex.md +7 -0
- package/test-fixtures/external-consumer/public.md +6 -0
- package/tsconfig.json +7 -0
- 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('<script>')
|
|
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('<script>')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export function escapeHtml(value: string): string {
|
|
2
|
+
return value
|
|
3
|
+
.replace(/&/g, '&')
|
|
4
|
+
.replace(/</g, '<')
|
|
5
|
+
.replace(/>/g, '>')
|
|
6
|
+
.replace(/"/g, '"')
|
|
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
|
+
}
|