upcontent 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +61 -0
- package/.github/workflows/deploy.yml +59 -0
- package/.github/workflows/publish.yml +75 -0
- package/.github/workflows/reusable-pages.yml +74 -0
- package/.upcontent/config.json +85 -0
- package/.upcontent/favicon.svg +4 -0
- package/.upcontent/logo.svg +6 -0
- package/.upcontent/portal.css +139 -0
- package/CONTEXT.md +25 -0
- package/Makefile +33 -0
- package/README.md +132 -2
- package/SHOWCASE.mdx +163 -0
- package/assets/readme/portal-home.png +0 -0
- package/assets/readme/portal-showcase.png +0 -0
- package/astro.config.mjs +167 -0
- package/customization/config-json.md +100 -0
- package/customization/content.md +62 -0
- package/customization/environment.md +43 -0
- package/customization/index.md +33 -0
- package/customization/navigation.md +57 -0
- package/customization/site-identity.md +43 -0
- package/customization/theme.md +47 -0
- package/deployment/github-pages.md +46 -0
- package/deployment/index.md +25 -0
- package/deployment/npm.md +39 -0
- package/deployment/static-hosts.md +34 -0
- package/getting-started/consumer-repository.md +156 -0
- package/getting-started/first-build.md +78 -0
- package/guides/authoring-content.md +105 -0
- package/guides/validate-your-site.md +58 -0
- package/package.json +40 -4
- package/scripts/upcontent-cli.mjs +111 -0
- package/scripts/verify-external-build.mjs +32 -0
- package/scripts/verify-golden-build.mjs +9 -0
- package/src/components/MermaidLoader.astro +304 -0
- package/src/components/PaletteShowcase.astro +122 -0
- package/src/components/StructuredDataCopy.astro +22 -0
- package/src/content/__mocks__/astro-content.ts +7 -0
- package/src/content/__mocks__/astro-loaders.ts +3 -0
- package/src/content/i18n/en.json +1 -0
- package/src/content.config.test.ts +209 -0
- package/src/content.config.ts +113 -0
- package/src/lib/content-blocklist.test.ts +47 -0
- package/src/lib/content-blocklist.ts +55 -0
- package/src/lib/doc-links.test.ts +62 -0
- package/src/lib/doc-links.ts +31 -0
- package/src/lib/mermaid-render.test.ts +42 -0
- package/src/lib/mermaid-render.ts +24 -0
- package/src/lib/portal-config.test.ts +153 -0
- package/src/lib/portal-config.ts +187 -0
- package/src/lib/portal-routes.test.ts +62 -0
- package/src/lib/portal-routes.ts +59 -0
- package/src/lib/product-identity.ts +2 -0
- package/src/lib/rehype-callouts.test.ts +69 -0
- package/src/lib/rehype-callouts.ts +61 -0
- package/src/lib/remark-doc-links.test.ts +50 -0
- package/src/lib/remark-doc-links.ts +21 -0
- package/src/lib/remark-strip-duplicate-title.test.ts +73 -0
- package/src/lib/remark-strip-duplicate-title.ts +37 -0
- package/src/lib/remark-structured-data-preview.test.ts +104 -0
- package/src/lib/remark-structured-data-preview.ts +66 -0
- package/src/lib/remark-wiki-links.test.ts +102 -0
- package/src/lib/remark-wiki-links.ts +112 -0
- package/src/lib/seo-sitemap.test.ts +37 -0
- package/src/lib/seo-sitemap.ts +54 -0
- package/src/lib/sidebar.test.ts +142 -0
- package/src/lib/sidebar.ts +114 -0
- package/src/lib/structured-data-tree.test.ts +75 -0
- package/src/lib/structured-data-tree.ts +55 -0
- package/src/overrides/Footer.astro +114 -0
- package/src/overrides/Head.astro +103 -0
- package/src/pages/robots.txt.ts +46 -0
- package/src/styles/callouts.css +29 -0
- package/src/styles/structured-data-preview.css +95 -0
- package/src/upcontent-cli.test.ts +36 -0
- package/test-fixtures/external-consumer/.upcontent/config.json +30 -0
- package/test-fixtures/external-consumer/.upcontent/favicon.svg +4 -0
- package/test-fixtures/external-consumer/.upcontent/logo.svg +4 -0
- package/test-fixtures/external-consumer/.upcontent/theme.css +4 -0
- package/test-fixtures/external-consumer/.upcontent-renderer/README.md +3 -0
- package/test-fixtures/external-consumer/README.md +6 -0
- package/test-fixtures/external-consumer/forbidden.md +5 -0
- package/test-fixtures/external-consumer/noindex.md +7 -0
- package/test-fixtures/external-consumer/public.md +6 -0
- package/tsconfig.json +7 -0
- package/vitest.config.ts +18 -0
|
@@ -0,0 +1,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 @@
|
|
|
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
|
+
}
|