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.
- package/.github/workflows/ci.yml +61 -0
- package/.github/workflows/deploy.yml +59 -0
- package/.github/workflows/reusable-pages.yml +74 -0
- package/.upcontent/config.json +82 -0
- package/.upcontent/favicon.svg +4 -0
- package/.upcontent/logo.svg +6 -0
- package/.upcontent/portal.css +139 -0
- package/Makefile +29 -0
- package/README.md +114 -2
- package/SHOWCASE.mdx +163 -0
- package/astro.config.mjs +125 -0
- package/customization/config-json.md +92 -0
- package/customization/content.md +48 -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 +24 -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 +53 -0
- package/package.json +33 -3
- package/scripts/upcontent-cli.mjs +113 -0
- package/scripts/verify-external-build.mjs +24 -0
- package/src/components/MermaidLoader.astro +290 -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.config.test.ts +163 -0
- package/src/content.config.ts +90 -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 +143 -0
- package/src/lib/portal-config.ts +176 -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-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 +110 -0
- package/src/lib/sidebar.test.ts +142 -0
- package/src/lib/sidebar.ts +113 -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 +20 -0
- package/src/pages/index.astro +20 -0
- package/src/styles/callouts.css +29 -0
- package/src/styles/structured-data-preview.css +95 -0
- package/test-fixtures/external-consumer/.upcontent/config.json +26 -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/tsconfig.json +7 -0
- package/vitest.config.ts +18 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
vi.mock('node:fs', () => ({
|
|
4
|
+
existsSync: vi.fn(),
|
|
5
|
+
readFileSync: vi.fn(),
|
|
6
|
+
}))
|
|
7
|
+
|
|
8
|
+
import * as fs from 'node:fs'
|
|
9
|
+
import { isBlocked, resolveLabel, toTitleCase } from './content-blocklist'
|
|
10
|
+
import { _resetPortalConfigCache } from './portal-config'
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
_resetPortalConfigCache()
|
|
14
|
+
vi.mocked(fs.existsSync).mockReturnValue(false)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('toTitleCase', () => {
|
|
18
|
+
it('deixa o resto da palavra em minúsculo mesmo quando o nome original é tudo maiúsculo', () => {
|
|
19
|
+
expect(toTitleCase('README')).toBe('Readme')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('capitaliza cada palavra separada por hífen', () => {
|
|
23
|
+
expect(toTitleCase('gestao-licencas-acessos')).toBe('Gestao Licencas Acessos')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('resolveLabel', () => {
|
|
28
|
+
it('usa toTitleCase quando não há override configurado', () => {
|
|
29
|
+
expect(resolveLabel('historico')).toBe('Historico')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('usa o override do .upcontent/config.json quando presente (case-insensitive)', () => {
|
|
33
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
34
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
35
|
+
JSON.stringify({ navigation: { labelOverrides: { historico: 'Histórico' } } }),
|
|
36
|
+
)
|
|
37
|
+
expect(resolveLabel('historico')).toBe('Histórico')
|
|
38
|
+
expect(resolveLabel('Historico')).toBe('Histórico')
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('isBlocked', () => {
|
|
43
|
+
it('protects the renderer checkout from external consumer content', () => {
|
|
44
|
+
expect(isBlocked('.upcontent-renderer/README.md')).toBe(true)
|
|
45
|
+
expect(isBlocked('.UPCONTENT-RENDERER/README.md')).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { getPortalConfig } from './portal-config'
|
|
2
|
+
|
|
3
|
+
// Extraído de content.config.ts: precisa ser importável de astro.config.mjs
|
|
4
|
+
// (sidebar.ts) e vitest sem depender de 'astro:content', que só resolve
|
|
5
|
+
// dentro do pipeline do Astro — content.config.ts importa esse módulo em vez
|
|
6
|
+
// de definir a lógica localmente.
|
|
7
|
+
|
|
8
|
+
// Floor hardcoded: nunca aparecem independentemente do config.json.
|
|
9
|
+
// Comparação sempre case-insensitive — arquivos reais podem usar qualquer
|
|
10
|
+
// caixa (ex: shared/INDEX.md em vez de shared/index.md).
|
|
11
|
+
const FLOOR_EXACT = new Set(['claude.md', 'rules.md', 'shared/index.md'])
|
|
12
|
+
const FLOOR_PREFIXES = ['.claude/', '.github/', '.upcontent/', '.upcontent-renderer/']
|
|
13
|
+
|
|
14
|
+
const DOCS_BASE = 'src/content/docs/'
|
|
15
|
+
|
|
16
|
+
// entry.filePath de collections com o loader glob()/docsLoader() é relativo
|
|
17
|
+
// à raiz do projeto (ex: "src/content/docs/RULES.md") — isBlocked() espera
|
|
18
|
+
// o path relativo ao content base (ex: "RULES.md").
|
|
19
|
+
export function toRelativeDocPath(filePath: string): string {
|
|
20
|
+
return filePath.startsWith(DOCS_BASE) ? filePath.slice(DOCS_BASE.length) : filePath
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isBlocked(path: string): boolean {
|
|
24
|
+
const normalizedPath = path.toLowerCase()
|
|
25
|
+
if (FLOOR_EXACT.has(normalizedPath)) return true
|
|
26
|
+
if (FLOOR_PREFIXES.some(prefix => normalizedPath.startsWith(prefix.toLowerCase()))) return true
|
|
27
|
+
const cfg = getPortalConfig()
|
|
28
|
+
const exact = cfg.navigation?.blocklist?.exact ?? []
|
|
29
|
+
const prefixes = cfg.navigation?.blocklist?.prefixes ?? []
|
|
30
|
+
if (exact.some(e => normalizedPath === e.toLowerCase())) return true
|
|
31
|
+
return prefixes.some(prefix => normalizedPath.startsWith(prefix.toLowerCase()))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getBlocklist(): string[] {
|
|
35
|
+
const cfg = getPortalConfig()
|
|
36
|
+
const extraExact = cfg.navigation?.blocklist?.exact ?? []
|
|
37
|
+
const extraPrefixes = cfg.navigation?.blocklist?.prefixes ?? []
|
|
38
|
+
return [...FLOOR_EXACT, ...FLOOR_PREFIXES, ...extraExact, ...extraPrefixes]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function toTitleCase(filenameWithoutExt: string): string {
|
|
42
|
+
const withoutNumericPrefix = filenameWithoutExt.replace(/^\d+[-_]/, '')
|
|
43
|
+
const words = withoutNumericPrefix.split(/[-_\s]+/).filter(Boolean)
|
|
44
|
+
if (words.length === 0) return filenameWithoutExt
|
|
45
|
+
return words.map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(' ')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Nomes de pasta/arquivo em ASCII (sem acento) não podem ser recuperados
|
|
49
|
+
// automaticamente pro português — quem conhece o vocabulário é o
|
|
50
|
+
// repositório de conteúdo, via navigation.labelOverrides no .upcontent/config.json.
|
|
51
|
+
// Fallback é sempre toTitleCase(name) quando não há override.
|
|
52
|
+
export function resolveLabel(name: string): string {
|
|
53
|
+
const override = getPortalConfig().navigation?.labelOverrides?.[name.toLowerCase()]
|
|
54
|
+
return override ?? toTitleCase(name)
|
|
55
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildGitHubUrl, resolveRelated } from './doc-links'
|
|
3
|
+
|
|
4
|
+
describe('buildGitHubUrl', () => {
|
|
5
|
+
it('retorna undefined quando repoUrl não definido', () => {
|
|
6
|
+
expect(buildGitHubUrl(undefined, 'domains/foo/bar.md', 'view')).toBeUndefined()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('retorna link view quando repoUrl definido', () => {
|
|
10
|
+
const url = buildGitHubUrl('https://github.com/org/repo', 'domains/foo/bar.md', 'view')
|
|
11
|
+
expect(url).toBe('https://github.com/org/repo/blob/main/domains/foo/bar.md')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('retorna link edit quando repoUrl definido', () => {
|
|
15
|
+
const url = buildGitHubUrl('https://github.com/org/repo', 'domains/foo/bar.md', 'edit')
|
|
16
|
+
expect(url).toBe('https://github.com/org/repo/edit/main/domains/foo/bar.md')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('não adiciona .md duplo quando entryId já termina em .md', () => {
|
|
20
|
+
const url = buildGitHubUrl('https://github.com/org/repo', 'domains/foo/bar.md', 'view')
|
|
21
|
+
expect(url).not.toContain('.md.md')
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('resolveRelated', () => {
|
|
26
|
+
const allDocs = [
|
|
27
|
+
{ id: 'domains/foo/prd.md', data: { title: 'Foo PRD' } },
|
|
28
|
+
{ id: 'shared/glossary.md', data: { title: 'Glossário' } },
|
|
29
|
+
{ id: 'domains/bar/trd.md', data: {} },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
it('retorna lista vazia quando related é undefined', () => {
|
|
33
|
+
expect(resolveRelated(undefined, allDocs as any)).toEqual([])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('retorna lista vazia quando related é array vazio', () => {
|
|
37
|
+
expect(resolveRelated([], allDocs as any)).toEqual([])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('resolve entry com .md normalizado', () => {
|
|
41
|
+
const result = resolveRelated(['domains/foo/prd'], allDocs as any)
|
|
42
|
+
expect(result).toHaveLength(1)
|
|
43
|
+
expect(result[0].title).toBe('Foo PRD')
|
|
44
|
+
expect(result[0].slug).toBe('domains/foo/prd')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('resolve entry referenciado com extensão .md', () => {
|
|
48
|
+
const result = resolveRelated(['shared/glossary.md'], allDocs as any)
|
|
49
|
+
expect(result).toHaveLength(1)
|
|
50
|
+
expect(result[0].title).toBe('Glossário')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('ignora referências que não existem na coleção', () => {
|
|
54
|
+
const result = resolveRelated(['domains/nao-existe'], allDocs as any)
|
|
55
|
+
expect(result).toHaveLength(0)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('usa o id como título fallback quando title ausente', () => {
|
|
59
|
+
const result = resolveRelated(['domains/bar/trd'], allDocs as any)
|
|
60
|
+
expect(result[0].title).toBe('domains/bar/trd')
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { CollectionEntry } from 'astro:content'
|
|
2
|
+
|
|
3
|
+
export function buildGitHubUrl(
|
|
4
|
+
repoUrl: string | undefined,
|
|
5
|
+
entryId: string,
|
|
6
|
+
mode: 'view' | 'edit',
|
|
7
|
+
): string | undefined {
|
|
8
|
+
if (!repoUrl) return undefined
|
|
9
|
+
const action = mode === 'edit' ? 'edit' : 'blob'
|
|
10
|
+
return `${repoUrl}/${action}/main/${entryId}`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RelatedDoc {
|
|
14
|
+
slug: string
|
|
15
|
+
title: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolveRelated(
|
|
19
|
+
related: string[] | undefined,
|
|
20
|
+
allDocs: CollectionEntry<'docs'>[],
|
|
21
|
+
): RelatedDoc[] {
|
|
22
|
+
if (!related || related.length === 0) return []
|
|
23
|
+
return related.flatMap(ref => {
|
|
24
|
+
const normalized = ref.endsWith('.md') ? ref : `${ref}.md`
|
|
25
|
+
const entry = allDocs.find(d => d.id === normalized)
|
|
26
|
+
if (!entry) return []
|
|
27
|
+
const slug = normalized.replace(/\.md$/, '')
|
|
28
|
+
const title = (entry.data as Record<string, unknown>).title as string | undefined ?? slug
|
|
29
|
+
return [{ slug, title }]
|
|
30
|
+
})
|
|
31
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { renderMermaidDiagram, type MermaidLib } from './mermaid-render'
|
|
3
|
+
|
|
4
|
+
describe('renderMermaidDiagram', () => {
|
|
5
|
+
it('código válido → retorna o SVG renderizado', async () => {
|
|
6
|
+
const mermaid: MermaidLib = {
|
|
7
|
+
render: vi.fn().mockResolvedValue({ svg: '<svg>diagrama</svg>' }),
|
|
8
|
+
}
|
|
9
|
+
const result = await renderMermaidDiagram(mermaid, 'flowchart TD\nA-->B')
|
|
10
|
+
expect(result).toEqual({ status: 'ok', svg: '<svg>diagrama</svg>' })
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('código inválido → retorna a fonte original + mensagem de erro, sem lançar', async () => {
|
|
14
|
+
const mermaid: MermaidLib = {
|
|
15
|
+
render: vi.fn().mockRejectedValue(new Error('Parse error on line 1')),
|
|
16
|
+
}
|
|
17
|
+
const source = 'isso não é mermaid válido'
|
|
18
|
+
const result = await renderMermaidDiagram(mermaid, source)
|
|
19
|
+
expect(result).toEqual({ status: 'error', source, message: 'Parse error on line 1' })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('erro que não é instância de Error também vira mensagem string, sem lançar', async () => {
|
|
23
|
+
const mermaid: MermaidLib = {
|
|
24
|
+
render: vi.fn().mockRejectedValue('algo deu errado'),
|
|
25
|
+
}
|
|
26
|
+
const result = await renderMermaidDiagram(mermaid, 'fonte')
|
|
27
|
+
expect(result).toEqual({ status: 'error', source: 'fonte', message: 'algo deu errado' })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('cada chamada usa um id único (evita colisão entre diagramas na mesma página)', async () => {
|
|
31
|
+
const ids: string[] = []
|
|
32
|
+
const mermaid: MermaidLib = {
|
|
33
|
+
render: vi.fn().mockImplementation(async (id: string) => {
|
|
34
|
+
ids.push(id)
|
|
35
|
+
return { svg: '<svg/>' }
|
|
36
|
+
}),
|
|
37
|
+
}
|
|
38
|
+
await renderMermaidDiagram(mermaid, 'a')
|
|
39
|
+
await renderMermaidDiagram(mermaid, 'b')
|
|
40
|
+
expect(ids[0]).not.toBe(ids[1])
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Interface mínima da lib mermaid que este módulo precisa — permite mockar
|
|
2
|
+
// em teste sem importar a lib real (que depende de DOM/canvas).
|
|
3
|
+
export interface MermaidLib {
|
|
4
|
+
render(id: string, source: string): Promise<{ svg: string }>
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type MermaidRenderResult =
|
|
8
|
+
| { status: 'ok'; svg: string }
|
|
9
|
+
| { status: 'error'; source: string; message: string }
|
|
10
|
+
|
|
11
|
+
let renderCounter = 0
|
|
12
|
+
|
|
13
|
+
// Função pura de parse/render: nunca lança — sintaxe inválida vira um
|
|
14
|
+
// resultado 'error' carregando a fonte original + mensagem, pro caller
|
|
15
|
+
// decidir como exibir (em vez de deixar a página em branco).
|
|
16
|
+
export async function renderMermaidDiagram(mermaid: MermaidLib, source: string): Promise<MermaidRenderResult> {
|
|
17
|
+
const id = `mermaid-diagram-${renderCounter++}`
|
|
18
|
+
try {
|
|
19
|
+
const { svg } = await mermaid.render(id, source)
|
|
20
|
+
return { status: 'ok', svg }
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return { status: 'error', source, message: error instanceof Error ? error.message : String(error) }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// Mock node:fs before importing the module under test
|
|
4
|
+
vi.mock('node:fs', () => ({
|
|
5
|
+
existsSync: vi.fn(),
|
|
6
|
+
readFileSync: vi.fn(),
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
import * as fs from 'node:fs'
|
|
10
|
+
|
|
11
|
+
describe('getPortalConfig', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.resetModules()
|
|
14
|
+
vi.resetAllMocks()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('retorna config vazia quando config.json não existe', async () => {
|
|
18
|
+
vi.mocked(fs.existsSync).mockReturnValue(false)
|
|
19
|
+
const { getPortalConfig } = await import('./portal-config')
|
|
20
|
+
const config = getPortalConfig()
|
|
21
|
+
expect(config).toEqual({})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('retorna config parseada quando config.json existe e é válido', async () => {
|
|
25
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
26
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
27
|
+
JSON.stringify({
|
|
28
|
+
site: {
|
|
29
|
+
title: 'Meu Portal',
|
|
30
|
+
description: 'Documentação do time',
|
|
31
|
+
url: 'https://docs.example.com',
|
|
32
|
+
logo: { src: '/logo.svg', alt: 'Meu Portal', replacesTitle: true },
|
|
33
|
+
favicon: '/favicon.svg',
|
|
34
|
+
},
|
|
35
|
+
repo: { url: 'https://github.com/org/repo' },
|
|
36
|
+
theme: { customCss: ['.upcontent/theme.css'] },
|
|
37
|
+
starlight: {
|
|
38
|
+
social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/org/repo' }],
|
|
39
|
+
tableOfContents: { minHeadingLevel: 2, maxHeadingLevel: 3 },
|
|
40
|
+
lastUpdated: true,
|
|
41
|
+
pagination: false,
|
|
42
|
+
expressiveCode: { styleOverrides: { borderRadius: '8px' } },
|
|
43
|
+
},
|
|
44
|
+
navigation: { roots: ['domains', 'shared'] },
|
|
45
|
+
})
|
|
46
|
+
)
|
|
47
|
+
const { getPortalConfig } = await import('./portal-config')
|
|
48
|
+
const config = getPortalConfig()
|
|
49
|
+
expect(config.site?.title).toBe('Meu Portal')
|
|
50
|
+
expect(config.site?.description).toBe('Documentação do time')
|
|
51
|
+
expect(config.site?.url).toBe('https://docs.example.com')
|
|
52
|
+
expect(config.site?.logo?.src).toBe('/logo.svg')
|
|
53
|
+
expect(config.site?.favicon).toBe('/favicon.svg')
|
|
54
|
+
expect(config.repo?.url).toBe('https://github.com/org/repo')
|
|
55
|
+
expect(config.theme?.customCss).toEqual(['.upcontent/theme.css'])
|
|
56
|
+
expect(config.starlight?.social?.[0].icon).toBe('github')
|
|
57
|
+
expect(config.starlight?.tableOfContents).toEqual({ minHeadingLevel: 2, maxHeadingLevel: 3 })
|
|
58
|
+
expect(config.starlight?.lastUpdated).toBe(true)
|
|
59
|
+
expect(config.starlight?.pagination).toBe(false)
|
|
60
|
+
expect(config.starlight?.expressiveCode).toEqual({ styleOverrides: { borderRadius: '8px' } })
|
|
61
|
+
expect(config.navigation?.roots).toEqual(['domains', 'shared'])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('retorna config vazia quando config.json é JSON inválido', async () => {
|
|
65
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
66
|
+
vi.mocked(fs.readFileSync).mockReturnValue('{ invalid json }')
|
|
67
|
+
const { getPortalConfig } = await import('./portal-config')
|
|
68
|
+
const config = getPortalConfig()
|
|
69
|
+
expect(config).toEqual({})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('normaliza tipos inválidos sem quebrar a configuração do portal', async () => {
|
|
73
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
74
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
75
|
+
JSON.stringify({ site: 'invalid', theme: { customCss: 'invalid' }, navigation: { roots: [1, 'docs'] } }),
|
|
76
|
+
)
|
|
77
|
+
const { getPortalConfig } = await import('./portal-config')
|
|
78
|
+
|
|
79
|
+
const config = getPortalConfig()
|
|
80
|
+
|
|
81
|
+
expect(config.site?.title).toBeUndefined()
|
|
82
|
+
expect(config.theme?.customCss).toBeUndefined()
|
|
83
|
+
expect(config.navigation?.roots).toEqual(['docs'])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('navigation.roots com valor customizado sobrepõe default', async () => {
|
|
87
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
88
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
89
|
+
JSON.stringify({ navigation: { roots: ['domains'] } })
|
|
90
|
+
)
|
|
91
|
+
const { getPortalConfig } = await import('./portal-config')
|
|
92
|
+
const config = getPortalConfig()
|
|
93
|
+
expect(config.navigation?.roots).toEqual(['domains'])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('navigation.blocklist extra é retornado quando definido', async () => {
|
|
97
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
98
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
99
|
+
JSON.stringify({
|
|
100
|
+
navigation: {
|
|
101
|
+
blocklist: { exact: ['rules.md'], prefixes: ['docs/'] },
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
)
|
|
105
|
+
const { getPortalConfig } = await import('./portal-config')
|
|
106
|
+
const config = getPortalConfig()
|
|
107
|
+
expect(config.navigation?.blocklist?.exact).toContain('rules.md')
|
|
108
|
+
expect(config.navigation?.blocklist?.prefixes).toContain('docs/')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('descarta tipos inválidos nas opções curadas do Starlight', async () => {
|
|
112
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
113
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
114
|
+
JSON.stringify({
|
|
115
|
+
starlight: {
|
|
116
|
+
social: [{ icon: 'github' }, { icon: 'github', label: 'GitHub', href: 'https://github.com' }],
|
|
117
|
+
tableOfContents: { minHeadingLevel: '2', maxHeadingLevel: 7 },
|
|
118
|
+
lastUpdated: 'yes',
|
|
119
|
+
pagination: 1,
|
|
120
|
+
expressiveCode: { styleOverrides: { borderRadius: 8, padding: '1rem' } },
|
|
121
|
+
},
|
|
122
|
+
}),
|
|
123
|
+
)
|
|
124
|
+
const { getPortalConfig } = await import('./portal-config')
|
|
125
|
+
const config = getPortalConfig()
|
|
126
|
+
|
|
127
|
+
expect(config.starlight?.social).toEqual([{ icon: 'github', label: 'GitHub', href: 'https://github.com' }])
|
|
128
|
+
expect(config.starlight?.tableOfContents).toEqual({ minHeadingLevel: undefined, maxHeadingLevel: undefined })
|
|
129
|
+
expect(config.starlight?.lastUpdated).toBeUndefined()
|
|
130
|
+
expect(config.starlight?.pagination).toBeUndefined()
|
|
131
|
+
expect(config.starlight?.expressiveCode).toEqual({ styleOverrides: { padding: '1rem' } })
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('descarta um intervalo inválido do sumário', async () => {
|
|
135
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
136
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
137
|
+
JSON.stringify({ starlight: { tableOfContents: { minHeadingLevel: 4, maxHeadingLevel: 2 } } }),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
const { getPortalConfig } = await import('./portal-config')
|
|
141
|
+
expect(getPortalConfig().starlight?.tableOfContents).toBeUndefined()
|
|
142
|
+
})
|
|
143
|
+
})
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export interface PortalConfigNavigation {
|
|
5
|
+
roots?: string[]
|
|
6
|
+
blocklist?: {
|
|
7
|
+
exact?: string[]
|
|
8
|
+
prefixes?: string[]
|
|
9
|
+
}
|
|
10
|
+
// Sobrescreve o label derivado automaticamente (Title Case, sem acentos)
|
|
11
|
+
// pro nome de pasta/arquivo indicado — chave em minúsculo, sem extensão
|
|
12
|
+
// (ex: "historico" -> "Histórico"). Sem isso não há como recuperar
|
|
13
|
+
// acentuação a partir de um nome de arquivo ASCII.
|
|
14
|
+
labelOverrides?: Record<string, string>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PortalConfigLogo {
|
|
18
|
+
src: string
|
|
19
|
+
alt?: string
|
|
20
|
+
replacesTitle?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PortalConfigSite {
|
|
24
|
+
title?: string
|
|
25
|
+
description?: string
|
|
26
|
+
url?: string
|
|
27
|
+
logo?: PortalConfigLogo
|
|
28
|
+
favicon?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PortalConfigTheme {
|
|
32
|
+
customCss?: string[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PortalConfigStarlight {
|
|
36
|
+
social?: { icon: string; label: string; href: string }[]
|
|
37
|
+
tableOfContents?: false | { minHeadingLevel?: number; maxHeadingLevel?: number }
|
|
38
|
+
lastUpdated?: boolean
|
|
39
|
+
pagination?: boolean
|
|
40
|
+
expressiveCode?: false | { styleOverrides?: Record<string, string> }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PortalConfig {
|
|
44
|
+
site?: PortalConfigSite
|
|
45
|
+
repo?: { url?: string }
|
|
46
|
+
theme?: PortalConfigTheme
|
|
47
|
+
starlight?: PortalConfigStarlight
|
|
48
|
+
navigation?: PortalConfigNavigation
|
|
49
|
+
content?: { titleField?: string }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const CONFIG_PATH = join(process.cwd(), 'src/content/docs/.upcontent/config.json')
|
|
53
|
+
|
|
54
|
+
let _cache: PortalConfig | null = null
|
|
55
|
+
|
|
56
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
57
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function asString(value: unknown): string | undefined {
|
|
61
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function asStringArray(value: unknown): string[] | undefined {
|
|
65
|
+
if (!Array.isArray(value)) return undefined
|
|
66
|
+
const values = value.filter((item): item is string => typeof item === 'string' && item.length > 0)
|
|
67
|
+
return values.length > 0 ? values : []
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function asStringRecord(value: unknown): Record<string, string> | undefined {
|
|
71
|
+
const record = asRecord(value)
|
|
72
|
+
const entries: [string, string][] = []
|
|
73
|
+
for (const [key, item] of Object.entries(record)) {
|
|
74
|
+
if (typeof item === 'string') entries.push([key, item])
|
|
75
|
+
}
|
|
76
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function asSocialLinks(value: unknown): PortalConfigStarlight['social'] {
|
|
80
|
+
if (!Array.isArray(value)) return undefined
|
|
81
|
+
const links = value.flatMap(item => {
|
|
82
|
+
const link = asRecord(item)
|
|
83
|
+
const icon = asString(link.icon)
|
|
84
|
+
const label = asString(link.label)
|
|
85
|
+
const href = asString(link.href)
|
|
86
|
+
return icon && label && href ? [{ icon, label, href }] : []
|
|
87
|
+
})
|
|
88
|
+
return links.length > 0 ? links : []
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function asHeadingLevel(value: unknown): number | undefined {
|
|
92
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 6 ? value : undefined
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function asTableOfContents(value: unknown): PortalConfigStarlight['tableOfContents'] {
|
|
96
|
+
if (value === false) return false
|
|
97
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
98
|
+
|
|
99
|
+
const raw = asRecord(value)
|
|
100
|
+
const minHeadingLevel = asHeadingLevel(raw.minHeadingLevel)
|
|
101
|
+
const maxHeadingLevel = asHeadingLevel(raw.maxHeadingLevel)
|
|
102
|
+
if (minHeadingLevel !== undefined && maxHeadingLevel !== undefined && minHeadingLevel > maxHeadingLevel) {
|
|
103
|
+
return undefined
|
|
104
|
+
}
|
|
105
|
+
return { minHeadingLevel, maxHeadingLevel }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalizeConfig(value: unknown): PortalConfig {
|
|
109
|
+
const raw = asRecord(value)
|
|
110
|
+
const rawSite = asRecord(raw.site)
|
|
111
|
+
const rawLogo = asRecord(rawSite.logo)
|
|
112
|
+
const rawRepo = asRecord(raw.repo)
|
|
113
|
+
const rawTheme = asRecord(raw.theme)
|
|
114
|
+
const rawStarlight = asRecord(raw.starlight)
|
|
115
|
+
const rawExpressiveCode = rawStarlight.expressiveCode
|
|
116
|
+
const rawExpressive = asRecord(rawExpressiveCode)
|
|
117
|
+
const rawNavigation = asRecord(raw.navigation)
|
|
118
|
+
const rawBlocklist = asRecord(rawNavigation.blocklist)
|
|
119
|
+
const rawContent = asRecord(raw.content)
|
|
120
|
+
|
|
121
|
+
const logoSrc = asString(rawLogo.src)
|
|
122
|
+
const logo = logoSrc
|
|
123
|
+
? { src: logoSrc, alt: asString(rawLogo.alt), replacesTitle: rawLogo.replacesTitle === true }
|
|
124
|
+
: undefined
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
site: {
|
|
128
|
+
title: asString(rawSite.title),
|
|
129
|
+
description: asString(rawSite.description),
|
|
130
|
+
url: asString(rawSite.url),
|
|
131
|
+
logo,
|
|
132
|
+
favicon: asString(rawSite.favicon),
|
|
133
|
+
},
|
|
134
|
+
repo: { url: asString(rawRepo.url) },
|
|
135
|
+
theme: { customCss: asStringArray(rawTheme.customCss) },
|
|
136
|
+
starlight: {
|
|
137
|
+
social: asSocialLinks(rawStarlight.social),
|
|
138
|
+
tableOfContents: asTableOfContents(rawStarlight.tableOfContents),
|
|
139
|
+
lastUpdated: typeof rawStarlight.lastUpdated === 'boolean' ? rawStarlight.lastUpdated : undefined,
|
|
140
|
+
pagination: typeof rawStarlight.pagination === 'boolean' ? rawStarlight.pagination : undefined,
|
|
141
|
+
expressiveCode: rawExpressiveCode === false
|
|
142
|
+
? false
|
|
143
|
+
: rawExpressiveCode && typeof rawExpressiveCode === 'object'
|
|
144
|
+
? { styleOverrides: asStringRecord(rawExpressive.styleOverrides) }
|
|
145
|
+
: undefined,
|
|
146
|
+
},
|
|
147
|
+
navigation: {
|
|
148
|
+
roots: asStringArray(rawNavigation.roots),
|
|
149
|
+
blocklist: {
|
|
150
|
+
exact: asStringArray(rawBlocklist.exact),
|
|
151
|
+
prefixes: asStringArray(rawBlocklist.prefixes),
|
|
152
|
+
},
|
|
153
|
+
labelOverrides: asStringRecord(rawNavigation.labelOverrides),
|
|
154
|
+
},
|
|
155
|
+
content: { titleField: asString(rawContent.titleField) },
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function getPortalConfig(): PortalConfig {
|
|
160
|
+
if (_cache !== null) return _cache
|
|
161
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
162
|
+
_cache = {}
|
|
163
|
+
return _cache
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
_cache = normalizeConfig(JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')))
|
|
167
|
+
} catch {
|
|
168
|
+
_cache = {}
|
|
169
|
+
}
|
|
170
|
+
return _cache
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Exposed for testing — resets the singleton cache
|
|
174
|
+
export function _resetPortalConfigCache(): void {
|
|
175
|
+
_cache = null
|
|
176
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { unified } from 'unified'
|
|
3
|
+
import remarkParse from 'remark-parse'
|
|
4
|
+
import remarkRehype from 'remark-rehype'
|
|
5
|
+
import rehypeStringify from 'rehype-stringify'
|
|
6
|
+
import { rehypeCallouts } from './rehype-callouts'
|
|
7
|
+
|
|
8
|
+
function render(markdown: string): string {
|
|
9
|
+
return unified()
|
|
10
|
+
.use(remarkParse)
|
|
11
|
+
.use(remarkRehype)
|
|
12
|
+
.use(rehypeCallouts)
|
|
13
|
+
.use(rehypeStringify)
|
|
14
|
+
.processSync(markdown)
|
|
15
|
+
.toString()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('rehypeCallouts', () => {
|
|
19
|
+
it('converte [!info] sem texto adicional em callout com título default e ícone', () => {
|
|
20
|
+
const html = render('> [!info]')
|
|
21
|
+
expect(html).toContain('class="callout callout-info"')
|
|
22
|
+
expect(html).toContain('callout-title')
|
|
23
|
+
expect(html).toContain('ℹ️')
|
|
24
|
+
expect(html).toContain('Info')
|
|
25
|
+
expect(html).not.toContain('<blockquote>')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// formato real usado no conteúdo: marcador + texto tudo na mesma linha
|
|
29
|
+
it('converte [!info] com texto na mesma linha (formato usado no conteúdo real)', () => {
|
|
30
|
+
const html = render('> [!info] SDD — Artefato Plano técnico para a camada frontend')
|
|
31
|
+
expect(html).toContain('class="callout callout-info"')
|
|
32
|
+
expect(html).toContain('SDD — Artefato Plano técnico para a camada frontend')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('converte [!warning] com título customizado', () => {
|
|
36
|
+
const html = render('> [!warning] Cuidado aqui\n> Detalhes do risco')
|
|
37
|
+
expect(html).toContain('class="callout callout-warning"')
|
|
38
|
+
expect(html).toContain('Cuidado aqui')
|
|
39
|
+
expect(html).toContain('⚠️')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('cobre todos os tipos de callout usados no conteúdo', () => {
|
|
43
|
+
const types = ['info', 'note', 'warning', 'danger', 'tip', 'important', 'caution']
|
|
44
|
+
for (const type of types) {
|
|
45
|
+
const html = render(`> [!${type}] Texto`)
|
|
46
|
+
expect(html).toContain(`class="callout callout-${type}"`)
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('não quebra blockquote comum sem marcador de callout', () => {
|
|
51
|
+
const html = render('> Só uma citação normal, sem marcador')
|
|
52
|
+
expect(html).toContain('<blockquote>')
|
|
53
|
+
expect(html).not.toContain('callout')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('callout dentro de uma lista não quebra a renderização', () => {
|
|
57
|
+
const html = render('- item 1\n > [!tip] dica dentro da lista\n- item 2')
|
|
58
|
+
expect(html).toContain('class="callout callout-tip"')
|
|
59
|
+
expect(html).toContain('item 1')
|
|
60
|
+
expect(html).toContain('item 2')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('tipo desconhecido sem texto usa ícone default e title case do tipo', () => {
|
|
64
|
+
const html = render('> [!custom]')
|
|
65
|
+
expect(html).toContain('class="callout callout-custom"')
|
|
66
|
+
expect(html).toContain('Custom')
|
|
67
|
+
expect(html).toContain('ℹ️')
|
|
68
|
+
})
|
|
69
|
+
})
|