upcontent 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/.github/workflows/ci.yml +61 -0
  2. package/.github/workflows/deploy.yml +59 -0
  3. package/.github/workflows/publish.yml +75 -0
  4. package/.github/workflows/reusable-pages.yml +74 -0
  5. package/.upcontent/config.json +85 -0
  6. package/.upcontent/favicon.svg +4 -0
  7. package/.upcontent/logo.svg +6 -0
  8. package/.upcontent/portal.css +139 -0
  9. package/CONTEXT.md +25 -0
  10. package/Makefile +33 -0
  11. package/README.md +132 -2
  12. package/SHOWCASE.mdx +163 -0
  13. package/assets/readme/portal-home.png +0 -0
  14. package/assets/readme/portal-showcase.png +0 -0
  15. package/astro.config.mjs +167 -0
  16. package/customization/config-json.md +100 -0
  17. package/customization/content.md +62 -0
  18. package/customization/environment.md +43 -0
  19. package/customization/index.md +33 -0
  20. package/customization/navigation.md +57 -0
  21. package/customization/site-identity.md +43 -0
  22. package/customization/theme.md +47 -0
  23. package/deployment/github-pages.md +46 -0
  24. package/deployment/index.md +25 -0
  25. package/deployment/npm.md +39 -0
  26. package/deployment/static-hosts.md +34 -0
  27. package/getting-started/consumer-repository.md +156 -0
  28. package/getting-started/first-build.md +78 -0
  29. package/guides/authoring-content.md +105 -0
  30. package/guides/validate-your-site.md +58 -0
  31. package/package.json +40 -4
  32. package/scripts/upcontent-cli.mjs +111 -0
  33. package/scripts/verify-external-build.mjs +32 -0
  34. package/scripts/verify-golden-build.mjs +9 -0
  35. package/src/components/MermaidLoader.astro +304 -0
  36. package/src/components/PaletteShowcase.astro +122 -0
  37. package/src/components/StructuredDataCopy.astro +22 -0
  38. package/src/content/__mocks__/astro-content.ts +7 -0
  39. package/src/content/__mocks__/astro-loaders.ts +3 -0
  40. package/src/content/i18n/en.json +1 -0
  41. package/src/content.config.test.ts +209 -0
  42. package/src/content.config.ts +113 -0
  43. package/src/lib/content-blocklist.test.ts +47 -0
  44. package/src/lib/content-blocklist.ts +55 -0
  45. package/src/lib/doc-links.test.ts +62 -0
  46. package/src/lib/doc-links.ts +31 -0
  47. package/src/lib/mermaid-render.test.ts +42 -0
  48. package/src/lib/mermaid-render.ts +24 -0
  49. package/src/lib/portal-config.test.ts +153 -0
  50. package/src/lib/portal-config.ts +187 -0
  51. package/src/lib/portal-routes.test.ts +62 -0
  52. package/src/lib/portal-routes.ts +59 -0
  53. package/src/lib/product-identity.ts +2 -0
  54. package/src/lib/rehype-callouts.test.ts +69 -0
  55. package/src/lib/rehype-callouts.ts +61 -0
  56. package/src/lib/remark-doc-links.test.ts +50 -0
  57. package/src/lib/remark-doc-links.ts +21 -0
  58. package/src/lib/remark-strip-duplicate-title.test.ts +73 -0
  59. package/src/lib/remark-strip-duplicate-title.ts +37 -0
  60. package/src/lib/remark-structured-data-preview.test.ts +104 -0
  61. package/src/lib/remark-structured-data-preview.ts +66 -0
  62. package/src/lib/remark-wiki-links.test.ts +102 -0
  63. package/src/lib/remark-wiki-links.ts +112 -0
  64. package/src/lib/seo-sitemap.test.ts +37 -0
  65. package/src/lib/seo-sitemap.ts +54 -0
  66. package/src/lib/sidebar.test.ts +142 -0
  67. package/src/lib/sidebar.ts +114 -0
  68. package/src/lib/structured-data-tree.test.ts +75 -0
  69. package/src/lib/structured-data-tree.ts +55 -0
  70. package/src/overrides/Footer.astro +114 -0
  71. package/src/overrides/Head.astro +103 -0
  72. package/src/pages/robots.txt.ts +46 -0
  73. package/src/styles/callouts.css +29 -0
  74. package/src/styles/structured-data-preview.css +95 -0
  75. package/src/upcontent-cli.test.ts +36 -0
  76. package/test-fixtures/external-consumer/.upcontent/config.json +30 -0
  77. package/test-fixtures/external-consumer/.upcontent/favicon.svg +4 -0
  78. package/test-fixtures/external-consumer/.upcontent/logo.svg +4 -0
  79. package/test-fixtures/external-consumer/.upcontent/theme.css +4 -0
  80. package/test-fixtures/external-consumer/.upcontent-renderer/README.md +3 -0
  81. package/test-fixtures/external-consumer/README.md +6 -0
  82. package/test-fixtures/external-consumer/forbidden.md +5 -0
  83. package/test-fixtures/external-consumer/noindex.md +7 -0
  84. package/test-fixtures/external-consumer/public.md +6 -0
  85. package/tsconfig.json +7 -0
  86. package/vitest.config.ts +18 -0
@@ -0,0 +1,22 @@
1
+ <script>
2
+ const buttons = document.querySelectorAll<HTMLButtonElement>('[data-sdp-copy]')
3
+
4
+ for (const button of buttons) {
5
+ button.addEventListener('click', async () => {
6
+ const source = button.dataset.sdpCopy
7
+ const label = button.querySelector('.sdp-copy-label')
8
+ if (!source || !label) return
9
+
10
+ try {
11
+ await navigator.clipboard.writeText(decodeURIComponent(source))
12
+ label.textContent = 'Copied'
13
+ } catch {
14
+ label.textContent = 'Copy failed'
15
+ }
16
+
17
+ window.setTimeout(() => {
18
+ label.textContent = 'Copy'
19
+ }, 1500)
20
+ })
21
+ }
22
+ </script>
@@ -0,0 +1,7 @@
1
+ import { z } from 'zod'
2
+
3
+ export { z }
4
+
5
+ export function defineCollection(config: unknown) {
6
+ return config
7
+ }
@@ -0,0 +1,3 @@
1
+ export function glob(_config: unknown) {
2
+ return {}
3
+ }
@@ -0,0 +1 @@
1
+ {}
@@ -0,0 +1,209 @@
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 { domainFieldsSchema, isBlocked, resolveTitle, toCollectionId, toRelativeDocPath } from './content.config'
10
+ import { _resetPortalConfigCache } from './lib/portal-config'
11
+
12
+ beforeEach(() => {
13
+ _resetPortalConfigCache()
14
+ vi.mocked(fs.existsSync).mockReturnValue(false)
15
+ })
16
+
17
+ // domainFieldsSchema cobre só os campos de domínio deste portal — o título
18
+ // obrigatório do Starlight é resolvido por resolveTitle() antes da
19
+ // validação (ver describe('resolveTitle') abaixo), não faz parte deste schema.
20
+ describe('domainFieldsSchema', () => {
21
+ it('aceita frontmatter completamente vazio', () => {
22
+ const result = domainFieldsSchema.safeParse({})
23
+ expect(result.success).toBe(true)
24
+ })
25
+
26
+ it('aceita status outdated', () => {
27
+ const result = domainFieldsSchema.safeParse({ status: 'outdated' })
28
+ expect(result.success).toBe(true)
29
+ })
30
+
31
+ it('aceita SEO por página com canonical absoluto e noindex', () => {
32
+ const result = domainFieldsSchema.safeParse({
33
+ canonical: 'https://docs.example.com/guides/seo/',
34
+ image: 'https://docs.example.com/social-card.png',
35
+ noindex: true,
36
+ })
37
+
38
+ expect(result.success).toBe(true)
39
+ })
40
+
41
+ it('rejeita canonical relativo', () => {
42
+ const result = domainFieldsSchema.safeParse({ canonical: '/guides/seo/' })
43
+
44
+ expect(result.success).toBe(false)
45
+ })
46
+
47
+ it('rejeita canonical com esquema que não é HTTP(S)', () => {
48
+ const result = domainFieldsSchema.safeParse({ canonical: 'mailto:docs@example.com' })
49
+
50
+ expect(result.success).toBe(false)
51
+ })
52
+
53
+ it('aceita created/updated como objeto Date (YAML parseia datas automaticamente)', () => {
54
+ const result = domainFieldsSchema.safeParse({
55
+ created: new Date('2026-08-07'),
56
+ updated: new Date('2026-08-07'),
57
+ })
58
+ expect(result.success).toBe(true)
59
+ if (result.success) {
60
+ expect(result.data.created).toBe('2026-08-07')
61
+ expect(result.data.updated).toBe('2026-08-07')
62
+ }
63
+ })
64
+
65
+ it('aceita todos os campos de domínio preenchidos', () => {
66
+ const result = domainFieldsSchema.safeParse({
67
+ type: 'architecture',
68
+ status: 'active',
69
+ created: '2026-08-07',
70
+ updated: '2026-08-07',
71
+ teams: ['my-team'],
72
+ impacted_teams: [],
73
+ domains: [],
74
+ repos: ['my-frontend-repo'],
75
+ areas: [],
76
+ tags: ['sdd/prd'],
77
+ aliases: [],
78
+ related: [],
79
+ })
80
+ expect(result.success).toBe(true)
81
+ })
82
+ })
83
+
84
+ describe('resolveTitle', () => {
85
+ it('mantém o title do frontmatter quando presente', () => {
86
+ const data: Record<string, unknown> = { title: 'Meu Título' }
87
+ resolveTitle('docs/algo.md', data)
88
+ expect(data.title).toBe('Meu Título')
89
+ })
90
+
91
+ it('deriva do filename em Title Case quando title ausente', () => {
92
+ const data: Record<string, unknown> = {}
93
+ resolveTitle('domains/trd-backend.md', data)
94
+ expect(data.title).toBe('Trd Backend')
95
+ })
96
+
97
+ it('remove todas as extensões Markdown suportadas no título', () => {
98
+ const data: Record<string, unknown> = {}
99
+ resolveTitle('guides/legacy.markdown', data)
100
+ expect(data.title).toBe('Legacy')
101
+ })
102
+
103
+ it('remove prefixo numérico do filename antes de converter', () => {
104
+ const data: Record<string, unknown> = {}
105
+ resolveTitle('docs/adr/0001-self-hosted.md', data)
106
+ expect(data.title).toBe('Self Hosted')
107
+ })
108
+
109
+ it('usa apenas o nome do arquivo, ignorando o diretório', () => {
110
+ const data: Record<string, unknown> = {}
111
+ resolveTitle('domains/licencas/gestao-acessos.md', data)
112
+ expect(data.title).toBe('Gestao Acessos')
113
+ })
114
+
115
+ it('usa o campo configurado em content.titleField quando presente', () => {
116
+ vi.mocked(fs.existsSync).mockReturnValue(true)
117
+ vi.mocked(fs.readFileSync).mockReturnValue(
118
+ JSON.stringify({ content: { titleField: 'heading' } })
119
+ )
120
+ const data: Record<string, unknown> = { heading: 'Título Alternativo' }
121
+ resolveTitle('domains/algo.md', data)
122
+ expect(data.title).toBe('Título Alternativo')
123
+ })
124
+
125
+ it('cai no fallback de filename quando content.titleField está configurado mas ausente no frontmatter', () => {
126
+ vi.mocked(fs.existsSync).mockReturnValue(true)
127
+ vi.mocked(fs.readFileSync).mockReturnValue(
128
+ JSON.stringify({ content: { titleField: 'heading' } })
129
+ )
130
+ const data: Record<string, unknown> = {}
131
+ resolveTitle('domains/algo-especifico.md', data)
132
+ expect(data.title).toBe('Algo Especifico')
133
+ })
134
+ })
135
+
136
+ describe('isBlocked', () => {
137
+ it('bloqueia CLAUDE.md (case-insensitive)', () => {
138
+ expect(isBlocked('CLAUDE.md')).toBe(true)
139
+ expect(isBlocked('claude.md')).toBe(true)
140
+ })
141
+
142
+ it('bloqueia RULES.md (case-insensitive)', () => {
143
+ expect(isBlocked('RULES.md')).toBe(true)
144
+ expect(isBlocked('rules.md')).toBe(true)
145
+ })
146
+
147
+ it('bloqueia qualquer arquivo dentro de .claude/', () => {
148
+ expect(isBlocked('.claude/settings.json')).toBe(true)
149
+ })
150
+
151
+ it('bloqueia qualquer arquivo dentro de .github/', () => {
152
+ expect(isBlocked('.github/workflows/ci.yml')).toBe(true)
153
+ })
154
+
155
+ it('bloqueia qualquer arquivo dentro de .upcontent/', () => {
156
+ expect(isBlocked('.upcontent/config.json')).toBe(true)
157
+ expect(isBlocked('.upcontent/assets/logo.svg')).toBe(true)
158
+ })
159
+
160
+ // adversarial: arquivo com nome parecido mas que NÃO deve ser bloqueado
161
+ it('não bloqueia domains/README.md', () => {
162
+ expect(isBlocked('domains/README.md')).toBe(false)
163
+ })
164
+
165
+ it('bloqueia shared/INDEX.md (case-insensitive — arquivo real usa maiúsculo)', () => {
166
+ expect(isBlocked('shared/INDEX.md')).toBe(true)
167
+ })
168
+
169
+ it('não bloqueia docs/prd/prd.md', () => {
170
+ expect(isBlocked('docs/prd/prd.md')).toBe(false)
171
+ })
172
+
173
+ // adversarial: arquivo com prefixo ".cl" que NÃO é ".claude/"
174
+ it('não bloqueia um arquivo hipotético .clue/something.md', () => {
175
+ expect(isBlocked('.clue/something.md')).toBe(false)
176
+ })
177
+ })
178
+
179
+ describe('toRelativeDocPath', () => {
180
+ // o loader glob() do Astro gera entry.filePath relativo à raiz do projeto
181
+ // (ex: "src/content/docs/RULES.md"), não relativo ao content base — isBlocked()
182
+ // espera o path relativo ao content base (ex: "RULES.md")
183
+ it('remove o prefixo src/content/docs/', () => {
184
+ expect(toRelativeDocPath('src/content/docs/RULES.md')).toBe('RULES.md')
185
+ expect(toRelativeDocPath('src/content/docs/shared/index.md')).toBe('shared/index.md')
186
+ })
187
+
188
+ it('mantém o path como está se o prefixo não estiver presente', () => {
189
+ expect(toRelativeDocPath('RULES.md')).toBe('RULES.md')
190
+ })
191
+ })
192
+
193
+ describe('toCollectionId', () => {
194
+ it('maps the root README to the Starlight homepage id', () => {
195
+ expect(toCollectionId('README.md')).toBe('index')
196
+ })
197
+
198
+ it('normalizes document ids while preserving nested routes', () => {
199
+ expect(toCollectionId('Guides/Getting-Started.mdx')).toBe('guides/getting-started')
200
+ })
201
+
202
+ it('normalizes alternative Markdown extensions', () => {
203
+ expect(toCollectionId('Guides/Legacy.markdown')).toBe('guides/legacy')
204
+ })
205
+
206
+ it('normalizes nested index documents to their directory route', () => {
207
+ expect(toCollectionId('customization/index.md')).toBe('customization')
208
+ })
209
+ })
@@ -0,0 +1,113 @@
1
+ import path from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { defineCollection } from 'astro:content'
4
+ import { z } from 'astro/zod'
5
+ import { docsSchema, i18nSchema } from '@astrojs/starlight/schema'
6
+ import { i18nLoader } from '@astrojs/starlight/loaders'
7
+ import { glob } from 'astro/loaders'
8
+ import type { Loader, LoaderContext } from 'astro/loaders'
9
+ import { getBlocklist, isBlocked, toRelativeDocPath, toTitleCase } from './lib/content-blocklist'
10
+ import { getPortalConfig } from './lib/portal-config'
11
+
12
+ export { getBlocklist, isBlocked, toRelativeDocPath }
13
+
14
+ const MARKDOWN_EXTENSION = /\.(?:markdown|mdown|mkdn|mkd|mdwn|md|mdx)$/i
15
+
16
+ // Campos de domínio específicos deste portal, além do schema padrão do
17
+ // Starlight (title, description, sidebar, etc). Mantido isolado do
18
+ // docsSchema() do Starlight pra ser testável sem precisar de um
19
+ // SchemaContext (o título obrigatório do Starlight é resolvido pelo loader,
20
+ // não pelo schema — ver resolveTitle()).
21
+ export const domainFieldsSchema = z.object({
22
+ canonical: z.string().refine(value => {
23
+ try {
24
+ const url = new URL(value)
25
+ return (url.protocol === 'http:' || url.protocol === 'https:') && !url.username && !url.password
26
+ } catch {
27
+ return false
28
+ }
29
+ }, 'canonical must be an absolute HTTP(S) URL').optional(),
30
+ image: z.string().optional(),
31
+ noindex: z.boolean().optional(),
32
+ type: z.string().optional(),
33
+ status: z.string().optional(),
34
+ created: z.string().or(z.date().transform(d => d.toISOString().split('T')[0])).optional(),
35
+ updated: z.string().or(z.date().transform(d => d.toISOString().split('T')[0])).optional(),
36
+ teams: z.array(z.string()).optional(),
37
+ impacted_teams: z.array(z.string()).optional(),
38
+ domains: z.array(z.string()).optional(),
39
+ repos: z.array(z.string()).optional(),
40
+ areas: z.array(z.string()).optional(),
41
+ tags: z.array(z.string()).optional(),
42
+ aliases: z.array(z.string()).optional(),
43
+ related: z.array(z.string()).optional(),
44
+ })
45
+
46
+ // Resolve o título obrigatório do Starlight ANTES da validação do schema:
47
+ // usa content.titleField do .upcontent/config.json se presente no frontmatter,
48
+ // senão deriva do nome do arquivo em Title Case (prefixo numérico removido).
49
+ // Muda `data` in-place — é chamado de dentro do generateId/parseData do
50
+ // loader, no ponto em que o frontmatter ainda não foi validado.
51
+ export function resolveTitle(relativeFilePath: string, data: Record<string, unknown>): void {
52
+ if (typeof data.title === 'string' && data.title.trim().length > 0) return
53
+
54
+ const titleField = getPortalConfig().content?.titleField ?? 'title'
55
+ if (titleField !== 'title') {
56
+ const custom = data[titleField]
57
+ if (typeof custom === 'string' && custom.trim().length > 0) {
58
+ data.title = custom
59
+ return
60
+ }
61
+ }
62
+
63
+ const filename = relativeFilePath.split('/').pop() ?? relativeFilePath
64
+ const withoutExt = filename.replace(MARKDOWN_EXTENSION, '')
65
+ data.title = toTitleCase(withoutExt)
66
+ }
67
+
68
+ export function toCollectionId(relativeFilePath: string): string {
69
+ const normalized = relativeFilePath.split(path.sep).join('/').replace(MARKDOWN_EXTENSION, '').toLowerCase()
70
+ if (normalized === 'readme') return 'index'
71
+ return normalized.endsWith('/index') ? normalized.slice(0, -'/index'.length) : normalized
72
+ }
73
+
74
+ function toCaseInsensitiveGlob(value: string): string {
75
+ return value.replace(/[A-Za-z]/g, character => `[${character.toLowerCase()}${character.toUpperCase()}]`)
76
+ }
77
+
78
+ // Carrega Markdown com glob(): injeta o fallback de título antes da validação
79
+ // e aplica a blocklist como exclusões de glob antes do parse dos documentos.
80
+ function portalDocsLoader(): Loader {
81
+ return {
82
+ name: 'portal-docs-loader',
83
+ async load(context: LoaderContext) {
84
+ const docsBasePath = fileURLToPath(new URL('src/content/docs/', context.config.root))
85
+ const patterns = [
86
+ '**/[^_]*.{markdown,mdown,mkdn,mkd,mdwn,md,mdx}',
87
+ ...getBlocklist().map(blocked => `!${toCaseInsensitiveGlob(blocked)}${blocked.endsWith('/') ? '**' : ''}`),
88
+ ]
89
+ const wrappedContext: LoaderContext = {
90
+ ...context,
91
+ parseData: async (props) => {
92
+ if (props.filePath) {
93
+ const relative = path.relative(docsBasePath, props.filePath).split(path.sep).join('/')
94
+ resolveTitle(relative, props.data)
95
+ }
96
+ return context.parseData(props)
97
+ },
98
+ }
99
+ await glob({ base: docsBasePath, pattern: patterns, generateId: ({ entry }) => toCollectionId(entry) }).load(wrappedContext)
100
+ },
101
+ }
102
+ }
103
+
104
+ export const collections = {
105
+ docs: defineCollection({
106
+ loader: portalDocsLoader(),
107
+ schema: docsSchema({ extend: domainFieldsSchema }),
108
+ }),
109
+ i18n: defineCollection({
110
+ loader: i18nLoader(),
111
+ schema: i18nSchema(),
112
+ }),
113
+ }
@@ -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
+ }