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,290 @@
1
+ ---
2
+ ---
3
+
4
+ <script>
5
+ import mermaid from 'mermaid'
6
+ import { renderMermaidDiagram } from '../lib/mermaid-render'
7
+
8
+ function mermaidTheme() {
9
+ return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'default'
10
+ }
11
+
12
+ mermaid.initialize({ startOnLoad: false, theme: mermaidTheme() })
13
+
14
+ let scale = 1
15
+ let translateX = 0
16
+ let translateY = 0
17
+ let dialogEl: HTMLDialogElement | null = null
18
+ let contentEl: HTMLDivElement | null = null
19
+
20
+ function applyTransform() {
21
+ if (contentEl) contentEl.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`
22
+ }
23
+
24
+ function resetTransform() {
25
+ scale = 1
26
+ translateX = 0
27
+ translateY = 0
28
+ applyTransform()
29
+ }
30
+
31
+ function ensureDialog() {
32
+ if (dialogEl) return
33
+
34
+ dialogEl = document.createElement('dialog')
35
+ dialogEl.className = 'mermaid-fullscreen-dialog'
36
+
37
+ const closeBtn = document.createElement('button')
38
+ closeBtn.type = 'button'
39
+ closeBtn.className = 'mermaid-fullscreen-close'
40
+ closeBtn.setAttribute('aria-label', 'Fechar')
41
+ closeBtn.textContent = '✕'
42
+ closeBtn.addEventListener('click', () => dialogEl?.close())
43
+
44
+ const viewport = document.createElement('div')
45
+ viewport.className = 'mermaid-fullscreen-viewport'
46
+
47
+ contentEl = document.createElement('div')
48
+ contentEl.className = 'mermaid-fullscreen-content'
49
+ viewport.appendChild(contentEl)
50
+
51
+ dialogEl.append(closeBtn, viewport)
52
+ document.body.appendChild(dialogEl)
53
+
54
+ dialogEl.addEventListener('click', e => {
55
+ if (e.target === dialogEl) dialogEl?.close()
56
+ })
57
+ dialogEl.addEventListener('close', resetTransform)
58
+
59
+ viewport.addEventListener(
60
+ 'wheel',
61
+ e => {
62
+ e.preventDefault()
63
+ const delta = e.deltaY > 0 ? -0.1 : 0.1
64
+ scale = Math.min(Math.max(scale + delta, 0.2), 5)
65
+ applyTransform()
66
+ },
67
+ { passive: false },
68
+ )
69
+
70
+ let dragging = false
71
+ let lastX = 0
72
+ let lastY = 0
73
+ viewport.addEventListener('pointerdown', e => {
74
+ dragging = true
75
+ lastX = e.clientX
76
+ lastY = e.clientY
77
+ viewport.setPointerCapture(e.pointerId)
78
+ })
79
+ viewport.addEventListener('pointermove', e => {
80
+ if (!dragging) return
81
+ translateX += e.clientX - lastX
82
+ translateY += e.clientY - lastY
83
+ lastX = e.clientX
84
+ lastY = e.clientY
85
+ applyTransform()
86
+ })
87
+ viewport.addEventListener('pointerup', () => { dragging = false })
88
+ viewport.addEventListener('pointerleave', () => { dragging = false })
89
+ }
90
+
91
+ function openFullscreen(svg: string) {
92
+ ensureDialog()
93
+ if (!dialogEl || !contentEl) return
94
+ contentEl.innerHTML = svg
95
+ resetTransform()
96
+ dialogEl.showModal()
97
+ }
98
+
99
+ async function copyToClipboard(button: HTMLButtonElement, text: string) {
100
+ await navigator.clipboard.writeText(text)
101
+ const original = button.textContent
102
+ button.textContent = 'Copiado!'
103
+ setTimeout(() => { button.textContent = original }, 2000)
104
+ }
105
+
106
+ async function processDiagrams() {
107
+ const nodes = Array.from(document.querySelectorAll<HTMLElement>('div.mermaid'))
108
+ for (const node of nodes) {
109
+ // <br/> inside a div becomes a DOM element before textContent reads it — não deveria
110
+ // acontecer (remarkMermaid já normaliza), mas o replace aqui é defensivo.
111
+ const source = (node.textContent ?? '').replace(/ /g, ' ')
112
+ const result = await renderMermaidDiagram(mermaid, source)
113
+
114
+ if (result.status === 'ok') {
115
+ const container = document.createElement('div')
116
+ container.className = 'mermaid-container'
117
+ container.dataset.mermaidSource = source
118
+
119
+ const copyBtn = document.createElement('button')
120
+ copyBtn.type = 'button'
121
+ copyBtn.className = 'mermaid-copy-btn'
122
+ copyBtn.textContent = 'Copiar'
123
+ copyBtn.setAttribute('aria-label', 'Copiar código-fonte do diagrama')
124
+ copyBtn.addEventListener('click', e => {
125
+ e.stopPropagation()
126
+ void copyToClipboard(copyBtn, source)
127
+ })
128
+
129
+ const diagram = document.createElement('div')
130
+ diagram.className = 'mermaid-diagram'
131
+ diagram.innerHTML = result.svg
132
+ diagram.setAttribute('role', 'button')
133
+ diagram.setAttribute('tabindex', '0')
134
+ diagram.setAttribute('aria-label', 'Abrir diagrama em tela cheia')
135
+ diagram.addEventListener('click', () => openFullscreen(diagram.innerHTML))
136
+ diagram.addEventListener('keydown', e => {
137
+ if (e.key === 'Enter' || e.key === ' ') {
138
+ e.preventDefault()
139
+ openFullscreen(diagram.innerHTML)
140
+ }
141
+ })
142
+
143
+ container.append(copyBtn, diagram)
144
+ node.replaceWith(container)
145
+ } else {
146
+ const errorBox = document.createElement('div')
147
+ errorBox.className = 'mermaid-error'
148
+ errorBox.dataset.mermaidSource = source
149
+
150
+ const message = document.createElement('p')
151
+ message.className = 'mermaid-error-message'
152
+ message.textContent = `Erro ao renderizar diagrama Mermaid: ${result.message}`
153
+
154
+ const pre = document.createElement('pre')
155
+ pre.className = 'mermaid-error-source'
156
+ pre.textContent = result.source
157
+
158
+ errorBox.append(message, pre)
159
+ node.replaceWith(errorBox)
160
+ }
161
+ }
162
+ }
163
+
164
+ async function rerenderExistingDiagrams() {
165
+ mermaid.initialize({ startOnLoad: false, theme: mermaidTheme() })
166
+ for (const container of document.querySelectorAll<HTMLElement>('[data-mermaid-source]')) {
167
+ const source = container.dataset.mermaidSource
168
+ if (!source) continue
169
+ const result = await renderMermaidDiagram(mermaid, source)
170
+ if (result.status === 'ok') {
171
+ const diagram = container.querySelector('.mermaid-diagram')
172
+ if (diagram) diagram.innerHTML = result.svg
173
+ }
174
+ }
175
+ }
176
+
177
+ if (document.readyState === 'loading') {
178
+ document.addEventListener('DOMContentLoaded', () => void processDiagrams())
179
+ } else {
180
+ void processDiagrams()
181
+ }
182
+
183
+ new MutationObserver(() => void rerenderExistingDiagrams()).observe(document.documentElement, {
184
+ attributes: true,
185
+ attributeFilter: ['data-theme'],
186
+ })
187
+ </script>
188
+
189
+ <style is:global>
190
+ .mermaid-container {
191
+ position: relative;
192
+ }
193
+
194
+ .mermaid-diagram {
195
+ cursor: zoom-in;
196
+ }
197
+
198
+ .mermaid-diagram:focus-visible {
199
+ outline: 2px solid var(--sl-color-accent);
200
+ outline-offset: 0.25rem;
201
+ }
202
+
203
+ .mermaid-copy-btn {
204
+ position: absolute;
205
+ top: 0.5rem;
206
+ right: 0.5rem;
207
+ padding: 0.25rem 0.5rem;
208
+ font-size: var(--sl-text-2xs, 0.75rem);
209
+ border: 1px solid var(--sl-color-gray-5);
210
+ border-radius: 0.25rem;
211
+ background: var(--sl-color-gray-6);
212
+ color: var(--sl-color-gray-2);
213
+ cursor: pointer;
214
+ opacity: 0;
215
+ transition: opacity 0.15s;
216
+ }
217
+ .mermaid-container:hover .mermaid-copy-btn,
218
+ .mermaid-copy-btn:focus-visible {
219
+ opacity: 1;
220
+ }
221
+
222
+ @media (prefers-reduced-motion: reduce) {
223
+ .mermaid-copy-btn {
224
+ transition: none;
225
+ }
226
+ }
227
+
228
+ .mermaid-error {
229
+ border: 1px solid var(--sl-color-red, #c53434);
230
+ border-radius: 0.5rem;
231
+ padding: 1rem;
232
+ }
233
+ .mermaid-error-message {
234
+ color: var(--sl-color-red, #c53434);
235
+ font-weight: 600;
236
+ margin: 0 0 0.5rem;
237
+ }
238
+ .mermaid-error-source {
239
+ white-space: pre-wrap;
240
+ overflow-x: auto;
241
+ margin: 0;
242
+ }
243
+
244
+ .mermaid-fullscreen-dialog {
245
+ padding: 0;
246
+ border: none;
247
+ width: 100vw;
248
+ height: 100vh;
249
+ max-width: 100vw;
250
+ max-height: 100vh;
251
+ background: var(--sl-color-bg, var(--sl-color-black));
252
+ }
253
+ .mermaid-fullscreen-dialog::backdrop {
254
+ background-color: var(--sl-color-backdrop-overlay, rgba(0, 0, 0, 0.6));
255
+ }
256
+
257
+ .mermaid-fullscreen-close {
258
+ position: absolute;
259
+ top: 1rem;
260
+ right: 1rem;
261
+ z-index: 1;
262
+ border: 1px solid var(--sl-color-gray-5);
263
+ border-radius: 0.375rem;
264
+ background: var(--sl-color-gray-6);
265
+ color: var(--sl-color-gray-2);
266
+ width: 2.5rem;
267
+ height: 2.5rem;
268
+ cursor: pointer;
269
+ }
270
+
271
+ .mermaid-fullscreen-viewport {
272
+ width: 100%;
273
+ height: 100%;
274
+ overflow: hidden;
275
+ touch-action: none;
276
+ cursor: grab;
277
+ }
278
+ .mermaid-fullscreen-viewport:active {
279
+ cursor: grabbing;
280
+ }
281
+
282
+ .mermaid-fullscreen-content {
283
+ width: 100%;
284
+ height: 100%;
285
+ display: flex;
286
+ align-items: center;
287
+ justify-content: center;
288
+ transform-origin: center center;
289
+ }
290
+ </style>
@@ -0,0 +1,122 @@
1
+ ---
2
+ const palettes = {
3
+ quiet: { label: 'Quiet / default', accent: '#334155', surface: '#f8fafc', ink: '#0f172a' },
4
+ river: { label: 'River', accent: '#0f766e', surface: '#f0fdfa', ink: '#134e4a' },
5
+ signal: { label: 'Signal', accent: '#be123c', surface: '#fff1f2', ink: '#4c0519' },
6
+ }
7
+ ---
8
+
9
+ <div class="palette-showcase" data-palette="quiet" data-palettes={JSON.stringify(palettes)}>
10
+ <div class="palette-controls" role="group" aria-label="Preview palettes">
11
+ {Object.entries(palettes).map(([id, palette], index) => (
12
+ <button
13
+ type="button"
14
+ class="palette-button"
15
+ data-palette-button={id}
16
+ aria-pressed={index === 0 ? 'true' : 'false'}
17
+ >
18
+ {palette.label}
19
+ </button>
20
+ ))}
21
+ </div>
22
+ <div class="palette-preview" aria-live="polite">
23
+ <div class="palette-swatch" data-swatch="accent"></div>
24
+ <div class="palette-swatch" data-swatch="surface"></div>
25
+ <div class="palette-swatch" data-swatch="ink"></div>
26
+ <div class="palette-preview-copy">
27
+ <strong data-palette-label>{palettes.quiet.label}</strong>
28
+ <span>One configuration seam, many possible identities.</span>
29
+ </div>
30
+ </div>
31
+ </div>
32
+
33
+ <script>
34
+ document.querySelectorAll<HTMLElement>('.palette-showcase').forEach(showcase => {
35
+ const palettes: Record<string, { accent: string; surface: string; ink: string; label: string }> = JSON.parse(
36
+ showcase.dataset.palettes ?? '{}',
37
+ )
38
+ const label = showcase.querySelector('[data-palette-label]')
39
+
40
+ showcase.querySelectorAll<HTMLButtonElement>('[data-palette-button]').forEach(button => {
41
+ button.addEventListener('click', () => {
42
+ const paletteId = button.dataset.paletteButton
43
+ const palette = paletteId ? palettes[paletteId] : undefined
44
+ if (!palette) return
45
+ showcase.dataset.palette = paletteId
46
+ showcase.style.setProperty('--palette-accent', palette.accent)
47
+ showcase.style.setProperty('--palette-surface', palette.surface)
48
+ showcase.style.setProperty('--palette-ink', palette.ink)
49
+ if (label) label.textContent = palette.label
50
+ showcase.querySelectorAll('[data-palette-button]').forEach(control => {
51
+ control.setAttribute('aria-pressed', String(control === button))
52
+ })
53
+ })
54
+ })
55
+
56
+ showcase.querySelector('[data-palette-button]')?.dispatchEvent(new Event('click'))
57
+ })
58
+ </script>
59
+
60
+ <style>
61
+ .palette-showcase {
62
+ --palette-accent: #334155;
63
+ --palette-surface: #f8fafc;
64
+ --palette-ink: #0f172a;
65
+ margin: 2rem 0;
66
+ }
67
+
68
+ .palette-controls {
69
+ display: flex;
70
+ flex-wrap: wrap;
71
+ gap: 0.5rem;
72
+ margin-bottom: 1rem;
73
+ }
74
+
75
+ .palette-button {
76
+ border: 1px solid var(--sl-color-gray-5);
77
+ border-radius: 999px;
78
+ background: transparent;
79
+ color: var(--sl-color-text);
80
+ cursor: pointer;
81
+ padding: 0.45rem 0.75rem;
82
+ }
83
+
84
+ .palette-button[aria-pressed='true'] {
85
+ border-color: var(--sl-color-accent);
86
+ background: var(--sl-color-accent);
87
+ color: var(--sl-color-bg);
88
+ }
89
+
90
+ .palette-preview {
91
+ display: grid;
92
+ grid-template-columns: repeat(3, 3rem) 1fr;
93
+ gap: 0.75rem;
94
+ align-items: center;
95
+ border: 1px solid var(--sl-color-gray-5);
96
+ border-radius: 0.5rem;
97
+ padding: 1rem;
98
+ background: var(--palette-surface);
99
+ color: var(--palette-ink);
100
+ }
101
+
102
+ .palette-swatch {
103
+ width: 3rem;
104
+ height: 3rem;
105
+ border: 1px solid color-mix(in srgb, var(--palette-ink), transparent 70%);
106
+ border-radius: 0.35rem;
107
+ }
108
+
109
+ .palette-swatch[data-swatch='accent'] { background: var(--palette-accent); }
110
+ .palette-swatch[data-swatch='surface'] { background: var(--palette-surface); }
111
+ .palette-swatch[data-swatch='ink'] { background: var(--palette-ink); }
112
+
113
+ .palette-preview-copy {
114
+ display: grid;
115
+ gap: 0.25rem;
116
+ }
117
+
118
+ @media (max-width: 560px) {
119
+ .palette-preview { grid-template-columns: repeat(3, 2.25rem) 1fr; }
120
+ .palette-swatch { width: 2.25rem; height: 2.25rem; }
121
+ }
122
+ </style>
@@ -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,163 @@
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, 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 created/updated como objeto Date (YAML parseia datas automaticamente)', () => {
32
+ const result = domainFieldsSchema.safeParse({
33
+ created: new Date('2026-08-07'),
34
+ updated: new Date('2026-08-07'),
35
+ })
36
+ expect(result.success).toBe(true)
37
+ if (result.success) {
38
+ expect(result.data.created).toBe('2026-08-07')
39
+ expect(result.data.updated).toBe('2026-08-07')
40
+ }
41
+ })
42
+
43
+ it('aceita todos os campos de domínio preenchidos', () => {
44
+ const result = domainFieldsSchema.safeParse({
45
+ type: 'architecture',
46
+ status: 'active',
47
+ created: '2026-08-07',
48
+ updated: '2026-08-07',
49
+ teams: ['my-team'],
50
+ impacted_teams: [],
51
+ domains: [],
52
+ repos: ['my-frontend-repo'],
53
+ areas: [],
54
+ tags: ['sdd/prd'],
55
+ aliases: [],
56
+ related: [],
57
+ })
58
+ expect(result.success).toBe(true)
59
+ })
60
+ })
61
+
62
+ describe('resolveTitle', () => {
63
+ it('mantém o title do frontmatter quando presente', () => {
64
+ const data: Record<string, unknown> = { title: 'Meu Título' }
65
+ resolveTitle('docs/algo.md', data)
66
+ expect(data.title).toBe('Meu Título')
67
+ })
68
+
69
+ it('deriva do filename em Title Case quando title ausente', () => {
70
+ const data: Record<string, unknown> = {}
71
+ resolveTitle('domains/trd-backend.md', data)
72
+ expect(data.title).toBe('Trd Backend')
73
+ })
74
+
75
+ it('remove prefixo numérico do filename antes de converter', () => {
76
+ const data: Record<string, unknown> = {}
77
+ resolveTitle('docs/adr/0001-self-hosted.md', data)
78
+ expect(data.title).toBe('Self Hosted')
79
+ })
80
+
81
+ it('usa apenas o nome do arquivo, ignorando o diretório', () => {
82
+ const data: Record<string, unknown> = {}
83
+ resolveTitle('domains/licencas/gestao-acessos.md', data)
84
+ expect(data.title).toBe('Gestao Acessos')
85
+ })
86
+
87
+ it('usa o campo configurado em content.titleField quando presente', () => {
88
+ vi.mocked(fs.existsSync).mockReturnValue(true)
89
+ vi.mocked(fs.readFileSync).mockReturnValue(
90
+ JSON.stringify({ content: { titleField: 'heading' } })
91
+ )
92
+ const data: Record<string, unknown> = { heading: 'Título Alternativo' }
93
+ resolveTitle('domains/algo.md', data)
94
+ expect(data.title).toBe('Título Alternativo')
95
+ })
96
+
97
+ it('cai no fallback de filename quando content.titleField está configurado mas ausente no frontmatter', () => {
98
+ vi.mocked(fs.existsSync).mockReturnValue(true)
99
+ vi.mocked(fs.readFileSync).mockReturnValue(
100
+ JSON.stringify({ content: { titleField: 'heading' } })
101
+ )
102
+ const data: Record<string, unknown> = {}
103
+ resolveTitle('domains/algo-especifico.md', data)
104
+ expect(data.title).toBe('Algo Especifico')
105
+ })
106
+ })
107
+
108
+ describe('isBlocked', () => {
109
+ it('bloqueia CLAUDE.md (case-insensitive)', () => {
110
+ expect(isBlocked('CLAUDE.md')).toBe(true)
111
+ expect(isBlocked('claude.md')).toBe(true)
112
+ })
113
+
114
+ it('bloqueia RULES.md (case-insensitive)', () => {
115
+ expect(isBlocked('RULES.md')).toBe(true)
116
+ expect(isBlocked('rules.md')).toBe(true)
117
+ })
118
+
119
+ it('bloqueia qualquer arquivo dentro de .claude/', () => {
120
+ expect(isBlocked('.claude/settings.json')).toBe(true)
121
+ })
122
+
123
+ it('bloqueia qualquer arquivo dentro de .github/', () => {
124
+ expect(isBlocked('.github/workflows/ci.yml')).toBe(true)
125
+ })
126
+
127
+ it('bloqueia qualquer arquivo dentro de .upcontent/', () => {
128
+ expect(isBlocked('.upcontent/config.json')).toBe(true)
129
+ expect(isBlocked('.upcontent/assets/logo.svg')).toBe(true)
130
+ })
131
+
132
+ // adversarial: arquivo com nome parecido mas que NÃO deve ser bloqueado
133
+ it('não bloqueia domains/README.md', () => {
134
+ expect(isBlocked('domains/README.md')).toBe(false)
135
+ })
136
+
137
+ it('bloqueia shared/INDEX.md (case-insensitive — arquivo real usa maiúsculo)', () => {
138
+ expect(isBlocked('shared/INDEX.md')).toBe(true)
139
+ })
140
+
141
+ it('não bloqueia docs/prd/prd.md', () => {
142
+ expect(isBlocked('docs/prd/prd.md')).toBe(false)
143
+ })
144
+
145
+ // adversarial: arquivo com prefixo ".cl" que NÃO é ".claude/"
146
+ it('não bloqueia um arquivo hipotético .clue/something.md', () => {
147
+ expect(isBlocked('.clue/something.md')).toBe(false)
148
+ })
149
+ })
150
+
151
+ describe('toRelativeDocPath', () => {
152
+ // o loader glob() do Astro gera entry.filePath relativo à raiz do projeto
153
+ // (ex: "src/content/docs/RULES.md"), não relativo ao content base — isBlocked()
154
+ // espera o path relativo ao content base (ex: "RULES.md")
155
+ it('remove o prefixo src/content/docs/', () => {
156
+ expect(toRelativeDocPath('src/content/docs/RULES.md')).toBe('RULES.md')
157
+ expect(toRelativeDocPath('src/content/docs/shared/index.md')).toBe('shared/index.md')
158
+ })
159
+
160
+ it('mantém o path como está se o prefixo não estiver presente', () => {
161
+ expect(toRelativeDocPath('RULES.md')).toBe('RULES.md')
162
+ })
163
+ })
@@ -0,0 +1,90 @@
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 } from '@astrojs/starlight/schema'
6
+ import { glob } from 'astro/loaders'
7
+ import type { Loader, LoaderContext } from 'astro/loaders'
8
+ import { getBlocklist, isBlocked, toRelativeDocPath, toTitleCase } from './lib/content-blocklist'
9
+ import { getPortalConfig } from './lib/portal-config'
10
+
11
+ export { getBlocklist, isBlocked, toRelativeDocPath }
12
+
13
+ // Campos de domínio específicos deste portal, além do schema padrão do
14
+ // Starlight (title, description, sidebar, etc). Mantido isolado do
15
+ // docsSchema() do Starlight pra ser testável sem precisar de um
16
+ // SchemaContext (o título obrigatório do Starlight é resolvido pelo loader,
17
+ // não pelo schema — ver resolveTitle()).
18
+ export const domainFieldsSchema = z.object({
19
+ type: z.string().optional(),
20
+ status: z.string().optional(),
21
+ created: z.string().or(z.date().transform(d => d.toISOString().split('T')[0])).optional(),
22
+ updated: z.string().or(z.date().transform(d => d.toISOString().split('T')[0])).optional(),
23
+ teams: z.array(z.string()).optional(),
24
+ impacted_teams: z.array(z.string()).optional(),
25
+ domains: z.array(z.string()).optional(),
26
+ repos: z.array(z.string()).optional(),
27
+ areas: z.array(z.string()).optional(),
28
+ tags: z.array(z.string()).optional(),
29
+ aliases: z.array(z.string()).optional(),
30
+ related: z.array(z.string()).optional(),
31
+ })
32
+
33
+ // Resolve o título obrigatório do Starlight ANTES da validação do schema:
34
+ // usa content.titleField do .upcontent/config.json se presente no frontmatter,
35
+ // senão deriva do nome do arquivo em Title Case (prefixo numérico removido).
36
+ // Muda `data` in-place — é chamado de dentro do generateId/parseData do
37
+ // loader, no ponto em que o frontmatter ainda não foi validado.
38
+ export function resolveTitle(relativeFilePath: string, data: Record<string, unknown>): void {
39
+ if (typeof data.title === 'string' && data.title.trim().length > 0) return
40
+
41
+ const titleField = getPortalConfig().content?.titleField ?? 'title'
42
+ if (titleField !== 'title') {
43
+ const custom = data[titleField]
44
+ if (typeof custom === 'string' && custom.trim().length > 0) {
45
+ data.title = custom
46
+ return
47
+ }
48
+ }
49
+
50
+ const filename = relativeFilePath.split('/').pop() ?? relativeFilePath
51
+ const withoutExt = filename.replace(/\.mdx?$/i, '')
52
+ data.title = toTitleCase(withoutExt)
53
+ }
54
+
55
+ function toCaseInsensitiveGlob(value: string): string {
56
+ return value.replace(/[A-Za-z]/g, character => `[${character.toLowerCase()}${character.toUpperCase()}]`)
57
+ }
58
+
59
+ // Carrega Markdown com glob(): injeta o fallback de título antes da validação
60
+ // e aplica a blocklist como exclusões de glob antes do parse dos documentos.
61
+ function portalDocsLoader(): Loader {
62
+ return {
63
+ name: 'portal-docs-loader',
64
+ async load(context: LoaderContext) {
65
+ const docsBasePath = fileURLToPath(new URL('src/content/docs/', context.config.root))
66
+ const patterns = [
67
+ '**/[^_]*.{markdown,mdown,mkdn,mkd,mdwn,md,mdx}',
68
+ ...getBlocklist().map(blocked => `!${toCaseInsensitiveGlob(blocked)}${blocked.endsWith('/') ? '**' : ''}`),
69
+ ]
70
+ const wrappedContext: LoaderContext = {
71
+ ...context,
72
+ parseData: async (props) => {
73
+ if (props.filePath) {
74
+ const relative = path.relative(docsBasePath, props.filePath).split(path.sep).join('/')
75
+ resolveTitle(relative, props.data)
76
+ }
77
+ return context.parseData(props)
78
+ },
79
+ }
80
+ await glob({ base: docsBasePath, pattern: patterns }).load(wrappedContext)
81
+ },
82
+ }
83
+ }
84
+
85
+ export const collections = {
86
+ docs: defineCollection({
87
+ loader: portalDocsLoader(),
88
+ schema: docsSchema({ extend: domainFieldsSchema }),
89
+ }),
90
+ }