upcontent 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.github/workflows/ci.yml +61 -0
  2. package/.github/workflows/deploy.yml +59 -0
  3. package/.github/workflows/reusable-pages.yml +74 -0
  4. package/.upcontent/config.json +82 -0
  5. package/.upcontent/favicon.svg +4 -0
  6. package/.upcontent/logo.svg +6 -0
  7. package/.upcontent/portal.css +139 -0
  8. package/Makefile +29 -0
  9. package/README.md +114 -2
  10. package/SHOWCASE.mdx +163 -0
  11. package/astro.config.mjs +125 -0
  12. package/customization/config-json.md +92 -0
  13. package/customization/content.md +48 -0
  14. package/customization/environment.md +43 -0
  15. package/customization/index.md +33 -0
  16. package/customization/navigation.md +57 -0
  17. package/customization/site-identity.md +43 -0
  18. package/customization/theme.md +47 -0
  19. package/deployment/github-pages.md +46 -0
  20. package/deployment/index.md +24 -0
  21. package/deployment/static-hosts.md +34 -0
  22. package/getting-started/consumer-repository.md +156 -0
  23. package/getting-started/first-build.md +78 -0
  24. package/guides/authoring-content.md +105 -0
  25. package/guides/validate-your-site.md +53 -0
  26. package/package.json +33 -3
  27. package/scripts/upcontent-cli.mjs +113 -0
  28. package/scripts/verify-external-build.mjs +24 -0
  29. package/src/components/MermaidLoader.astro +290 -0
  30. package/src/components/PaletteShowcase.astro +122 -0
  31. package/src/components/StructuredDataCopy.astro +22 -0
  32. package/src/content/__mocks__/astro-content.ts +7 -0
  33. package/src/content/__mocks__/astro-loaders.ts +3 -0
  34. package/src/content.config.test.ts +163 -0
  35. package/src/content.config.ts +90 -0
  36. package/src/lib/content-blocklist.test.ts +47 -0
  37. package/src/lib/content-blocklist.ts +55 -0
  38. package/src/lib/doc-links.test.ts +62 -0
  39. package/src/lib/doc-links.ts +31 -0
  40. package/src/lib/mermaid-render.test.ts +42 -0
  41. package/src/lib/mermaid-render.ts +24 -0
  42. package/src/lib/portal-config.test.ts +143 -0
  43. package/src/lib/portal-config.ts +176 -0
  44. package/src/lib/product-identity.ts +2 -0
  45. package/src/lib/rehype-callouts.test.ts +69 -0
  46. package/src/lib/rehype-callouts.ts +61 -0
  47. package/src/lib/remark-strip-duplicate-title.test.ts +73 -0
  48. package/src/lib/remark-strip-duplicate-title.ts +37 -0
  49. package/src/lib/remark-structured-data-preview.test.ts +104 -0
  50. package/src/lib/remark-structured-data-preview.ts +66 -0
  51. package/src/lib/remark-wiki-links.test.ts +102 -0
  52. package/src/lib/remark-wiki-links.ts +110 -0
  53. package/src/lib/sidebar.test.ts +142 -0
  54. package/src/lib/sidebar.ts +113 -0
  55. package/src/lib/structured-data-tree.test.ts +75 -0
  56. package/src/lib/structured-data-tree.ts +55 -0
  57. package/src/overrides/Footer.astro +114 -0
  58. package/src/overrides/Head.astro +20 -0
  59. package/src/pages/index.astro +20 -0
  60. package/src/styles/callouts.css +29 -0
  61. package/src/styles/structured-data-preview.css +95 -0
  62. package/test-fixtures/external-consumer/.upcontent/config.json +26 -0
  63. package/test-fixtures/external-consumer/.upcontent/favicon.svg +4 -0
  64. package/test-fixtures/external-consumer/.upcontent/logo.svg +4 -0
  65. package/test-fixtures/external-consumer/.upcontent/theme.css +4 -0
  66. package/test-fixtures/external-consumer/.upcontent-renderer/README.md +3 -0
  67. package/test-fixtures/external-consumer/README.md +6 -0
  68. package/test-fixtures/external-consumer/forbidden.md +5 -0
  69. package/tsconfig.json +7 -0
  70. package/vitest.config.ts +18 -0
@@ -0,0 +1,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,2 @@
1
+ export const PRODUCT_NAME = 'Upcontent'
2
+ export const PRODUCT_TAGLINE = 'Create a beautiful, customizable Starlight portal from your documentation repository.'
@@ -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
+ })