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,153 @@
|
|
|
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('mantém SEO desativado quando a política não está configurada', async () => {
|
|
25
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
26
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ site: { url: 'https://docs.example.com' } }))
|
|
27
|
+
const { getPortalConfig } = await import('./portal-config')
|
|
28
|
+
|
|
29
|
+
expect(getPortalConfig().seo?.enabled).toBe(false)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('retorna config parseada quando config.json existe e é válido', async () => {
|
|
33
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
34
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
35
|
+
JSON.stringify({
|
|
36
|
+
site: {
|
|
37
|
+
title: 'Meu Portal',
|
|
38
|
+
description: 'Documentação do time',
|
|
39
|
+
url: 'https://docs.example.com',
|
|
40
|
+
logo: { src: '/logo.svg', alt: 'Meu Portal', replacesTitle: true },
|
|
41
|
+
favicon: '/favicon.svg',
|
|
42
|
+
},
|
|
43
|
+
seo: { enabled: true },
|
|
44
|
+
repo: { url: 'https://github.com/org/repo' },
|
|
45
|
+
theme: { customCss: ['.upcontent/theme.css'] },
|
|
46
|
+
starlight: {
|
|
47
|
+
social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/org/repo' }],
|
|
48
|
+
tableOfContents: { minHeadingLevel: 2, maxHeadingLevel: 3 },
|
|
49
|
+
lastUpdated: true,
|
|
50
|
+
pagination: false,
|
|
51
|
+
expressiveCode: { styleOverrides: { borderRadius: '8px' } },
|
|
52
|
+
},
|
|
53
|
+
navigation: { roots: ['domains', 'shared'] },
|
|
54
|
+
})
|
|
55
|
+
)
|
|
56
|
+
const { getPortalConfig } = await import('./portal-config')
|
|
57
|
+
const config = getPortalConfig()
|
|
58
|
+
expect(config.site?.title).toBe('Meu Portal')
|
|
59
|
+
expect(config.site?.description).toBe('Documentação do time')
|
|
60
|
+
expect(config.site?.url).toBe('https://docs.example.com')
|
|
61
|
+
expect(config.seo?.enabled).toBe(true)
|
|
62
|
+
expect(config.site?.logo?.src).toBe('/logo.svg')
|
|
63
|
+
expect(config.site?.favicon).toBe('/favicon.svg')
|
|
64
|
+
expect(config.repo?.url).toBe('https://github.com/org/repo')
|
|
65
|
+
expect(config.theme?.customCss).toEqual(['.upcontent/theme.css'])
|
|
66
|
+
expect(config.starlight?.social?.[0].icon).toBe('github')
|
|
67
|
+
expect(config.starlight?.tableOfContents).toEqual({ minHeadingLevel: 2, maxHeadingLevel: 3 })
|
|
68
|
+
expect(config.starlight?.lastUpdated).toBe(true)
|
|
69
|
+
expect(config.starlight?.pagination).toBe(false)
|
|
70
|
+
expect(config.starlight?.expressiveCode).toEqual({ styleOverrides: { borderRadius: '8px' } })
|
|
71
|
+
expect(config.navigation?.roots).toEqual(['domains', 'shared'])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('retorna config vazia quando config.json é JSON inválido', async () => {
|
|
75
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
76
|
+
vi.mocked(fs.readFileSync).mockReturnValue('{ invalid json }')
|
|
77
|
+
const { getPortalConfig } = await import('./portal-config')
|
|
78
|
+
const config = getPortalConfig()
|
|
79
|
+
expect(config).toEqual({})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('normaliza tipos inválidos sem quebrar a configuração do portal', async () => {
|
|
83
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
84
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
85
|
+
JSON.stringify({ site: 'invalid', theme: { customCss: 'invalid' }, navigation: { roots: [1, 'docs'] } }),
|
|
86
|
+
)
|
|
87
|
+
const { getPortalConfig } = await import('./portal-config')
|
|
88
|
+
|
|
89
|
+
const config = getPortalConfig()
|
|
90
|
+
|
|
91
|
+
expect(config.site?.title).toBeUndefined()
|
|
92
|
+
expect(config.theme?.customCss).toBeUndefined()
|
|
93
|
+
expect(config.navigation?.roots).toEqual(['docs'])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('navigation.roots com valor customizado sobrepõe default', async () => {
|
|
97
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
98
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
99
|
+
JSON.stringify({ navigation: { roots: ['domains'] } })
|
|
100
|
+
)
|
|
101
|
+
const { getPortalConfig } = await import('./portal-config')
|
|
102
|
+
const config = getPortalConfig()
|
|
103
|
+
expect(config.navigation?.roots).toEqual(['domains'])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('navigation.blocklist extra é retornado quando definido', async () => {
|
|
107
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
108
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
109
|
+
JSON.stringify({
|
|
110
|
+
navigation: {
|
|
111
|
+
blocklist: { exact: ['rules.md'], prefixes: ['docs/'] },
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
)
|
|
115
|
+
const { getPortalConfig } = await import('./portal-config')
|
|
116
|
+
const config = getPortalConfig()
|
|
117
|
+
expect(config.navigation?.blocklist?.exact).toContain('rules.md')
|
|
118
|
+
expect(config.navigation?.blocklist?.prefixes).toContain('docs/')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('descarta tipos inválidos nas opções curadas do Starlight', async () => {
|
|
122
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
123
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
124
|
+
JSON.stringify({
|
|
125
|
+
starlight: {
|
|
126
|
+
social: [{ icon: 'github' }, { icon: 'github', label: 'GitHub', href: 'https://github.com' }],
|
|
127
|
+
tableOfContents: { minHeadingLevel: '2', maxHeadingLevel: 7 },
|
|
128
|
+
lastUpdated: 'yes',
|
|
129
|
+
pagination: 1,
|
|
130
|
+
expressiveCode: { styleOverrides: { borderRadius: 8, padding: '1rem' } },
|
|
131
|
+
},
|
|
132
|
+
}),
|
|
133
|
+
)
|
|
134
|
+
const { getPortalConfig } = await import('./portal-config')
|
|
135
|
+
const config = getPortalConfig()
|
|
136
|
+
|
|
137
|
+
expect(config.starlight?.social).toEqual([{ icon: 'github', label: 'GitHub', href: 'https://github.com' }])
|
|
138
|
+
expect(config.starlight?.tableOfContents).toEqual({ minHeadingLevel: undefined, maxHeadingLevel: undefined })
|
|
139
|
+
expect(config.starlight?.lastUpdated).toBeUndefined()
|
|
140
|
+
expect(config.starlight?.pagination).toBeUndefined()
|
|
141
|
+
expect(config.starlight?.expressiveCode).toEqual({ styleOverrides: { padding: '1rem' } })
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('descarta um intervalo inválido do sumário', async () => {
|
|
145
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
146
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
147
|
+
JSON.stringify({ starlight: { tableOfContents: { minHeadingLevel: 4, maxHeadingLevel: 2 } } }),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
const { getPortalConfig } = await import('./portal-config')
|
|
151
|
+
expect(getPortalConfig().starlight?.tableOfContents).toBeUndefined()
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
socialImage?: string
|
|
28
|
+
locale?: string
|
|
29
|
+
logo?: PortalConfigLogo
|
|
30
|
+
favicon?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PortalConfigSeo {
|
|
34
|
+
enabled?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PortalConfigTheme {
|
|
38
|
+
customCss?: string[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PortalConfigStarlight {
|
|
42
|
+
social?: { icon: string; label: string; href: string }[]
|
|
43
|
+
tableOfContents?: false | { minHeadingLevel?: number; maxHeadingLevel?: number }
|
|
44
|
+
lastUpdated?: boolean
|
|
45
|
+
pagination?: boolean
|
|
46
|
+
expressiveCode?: false | { styleOverrides?: Record<string, string> }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface PortalConfig {
|
|
50
|
+
site?: PortalConfigSite
|
|
51
|
+
seo?: PortalConfigSeo
|
|
52
|
+
repo?: { url?: string }
|
|
53
|
+
theme?: PortalConfigTheme
|
|
54
|
+
starlight?: PortalConfigStarlight
|
|
55
|
+
navigation?: PortalConfigNavigation
|
|
56
|
+
content?: { titleField?: string }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const CONFIG_PATH = join(process.cwd(), 'src/content/docs/.upcontent/config.json')
|
|
60
|
+
|
|
61
|
+
let _cache: PortalConfig | null = null
|
|
62
|
+
|
|
63
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
64
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function asString(value: unknown): string | undefined {
|
|
68
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function asStringArray(value: unknown): string[] | undefined {
|
|
72
|
+
if (!Array.isArray(value)) return undefined
|
|
73
|
+
const values = value.filter((item): item is string => typeof item === 'string' && item.length > 0)
|
|
74
|
+
return values.length > 0 ? values : []
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function asStringRecord(value: unknown): Record<string, string> | undefined {
|
|
78
|
+
const record = asRecord(value)
|
|
79
|
+
const entries: [string, string][] = []
|
|
80
|
+
for (const [key, item] of Object.entries(record)) {
|
|
81
|
+
if (typeof item === 'string') entries.push([key, item])
|
|
82
|
+
}
|
|
83
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function asSocialLinks(value: unknown): PortalConfigStarlight['social'] {
|
|
87
|
+
if (!Array.isArray(value)) return undefined
|
|
88
|
+
const links = value.flatMap(item => {
|
|
89
|
+
const link = asRecord(item)
|
|
90
|
+
const icon = asString(link.icon)
|
|
91
|
+
const label = asString(link.label)
|
|
92
|
+
const href = asString(link.href)
|
|
93
|
+
return icon && label && href ? [{ icon, label, href }] : []
|
|
94
|
+
})
|
|
95
|
+
return links.length > 0 ? links : []
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function asHeadingLevel(value: unknown): number | undefined {
|
|
99
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 6 ? value : undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function asTableOfContents(value: unknown): PortalConfigStarlight['tableOfContents'] {
|
|
103
|
+
if (value === false) return false
|
|
104
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
105
|
+
|
|
106
|
+
const raw = asRecord(value)
|
|
107
|
+
const minHeadingLevel = asHeadingLevel(raw.minHeadingLevel)
|
|
108
|
+
const maxHeadingLevel = asHeadingLevel(raw.maxHeadingLevel)
|
|
109
|
+
if (minHeadingLevel !== undefined && maxHeadingLevel !== undefined && minHeadingLevel > maxHeadingLevel) {
|
|
110
|
+
return undefined
|
|
111
|
+
}
|
|
112
|
+
return { minHeadingLevel, maxHeadingLevel }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeConfig(value: unknown): PortalConfig {
|
|
116
|
+
const raw = asRecord(value)
|
|
117
|
+
const rawSite = asRecord(raw.site)
|
|
118
|
+
const rawSeo = asRecord(raw.seo)
|
|
119
|
+
const rawLogo = asRecord(rawSite.logo)
|
|
120
|
+
const rawRepo = asRecord(raw.repo)
|
|
121
|
+
const rawTheme = asRecord(raw.theme)
|
|
122
|
+
const rawStarlight = asRecord(raw.starlight)
|
|
123
|
+
const rawExpressiveCode = rawStarlight.expressiveCode
|
|
124
|
+
const rawExpressive = asRecord(rawExpressiveCode)
|
|
125
|
+
const rawNavigation = asRecord(raw.navigation)
|
|
126
|
+
const rawBlocklist = asRecord(rawNavigation.blocklist)
|
|
127
|
+
const rawContent = asRecord(raw.content)
|
|
128
|
+
|
|
129
|
+
const logoSrc = asString(rawLogo.src)
|
|
130
|
+
const logo = logoSrc
|
|
131
|
+
? { src: logoSrc, alt: asString(rawLogo.alt), replacesTitle: rawLogo.replacesTitle === true }
|
|
132
|
+
: undefined
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
site: {
|
|
136
|
+
title: asString(rawSite.title),
|
|
137
|
+
description: asString(rawSite.description),
|
|
138
|
+
url: asString(rawSite.url),
|
|
139
|
+
socialImage: asString(rawSite.socialImage),
|
|
140
|
+
locale: asString(rawSite.locale),
|
|
141
|
+
logo,
|
|
142
|
+
favicon: asString(rawSite.favicon),
|
|
143
|
+
},
|
|
144
|
+
seo: { enabled: rawSeo.enabled === true },
|
|
145
|
+
repo: { url: asString(rawRepo.url) },
|
|
146
|
+
theme: { customCss: asStringArray(rawTheme.customCss) },
|
|
147
|
+
starlight: {
|
|
148
|
+
social: asSocialLinks(rawStarlight.social),
|
|
149
|
+
tableOfContents: asTableOfContents(rawStarlight.tableOfContents),
|
|
150
|
+
lastUpdated: typeof rawStarlight.lastUpdated === 'boolean' ? rawStarlight.lastUpdated : undefined,
|
|
151
|
+
pagination: typeof rawStarlight.pagination === 'boolean' ? rawStarlight.pagination : undefined,
|
|
152
|
+
expressiveCode: rawExpressiveCode === false
|
|
153
|
+
? false
|
|
154
|
+
: rawExpressiveCode && typeof rawExpressiveCode === 'object'
|
|
155
|
+
? { styleOverrides: asStringRecord(rawExpressive.styleOverrides) }
|
|
156
|
+
: undefined,
|
|
157
|
+
},
|
|
158
|
+
navigation: {
|
|
159
|
+
roots: asStringArray(rawNavigation.roots),
|
|
160
|
+
blocklist: {
|
|
161
|
+
exact: asStringArray(rawBlocklist.exact),
|
|
162
|
+
prefixes: asStringArray(rawBlocklist.prefixes),
|
|
163
|
+
},
|
|
164
|
+
labelOverrides: asStringRecord(rawNavigation.labelOverrides),
|
|
165
|
+
},
|
|
166
|
+
content: { titleField: asString(rawContent.titleField) },
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function getPortalConfig(): PortalConfig {
|
|
171
|
+
if (_cache !== null) return _cache
|
|
172
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
173
|
+
_cache = {}
|
|
174
|
+
return _cache
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
_cache = normalizeConfig(JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')))
|
|
178
|
+
} catch {
|
|
179
|
+
_cache = {}
|
|
180
|
+
}
|
|
181
|
+
return _cache
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Exposed for testing — resets the singleton cache
|
|
185
|
+
export function _resetPortalConfigCache(): void {
|
|
186
|
+
_cache = null
|
|
187
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { resolveMarkdownLink } from './portal-routes'
|
|
6
|
+
|
|
7
|
+
const roots: string[] = []
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
function fixture(files: string[]) {
|
|
14
|
+
const root = mkdtempSync(join(tmpdir(), 'upcontent-routes-'))
|
|
15
|
+
roots.push(root)
|
|
16
|
+
for (const file of files) {
|
|
17
|
+
mkdirSync(dirname(join(root, file)), { recursive: true })
|
|
18
|
+
writeFileSync(join(root, file), '')
|
|
19
|
+
}
|
|
20
|
+
return root
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('resolveMarkdownLink', () => {
|
|
24
|
+
it('resolves a README link relative to the content root', () => {
|
|
25
|
+
const root = fixture(['README.md', 'acervos/resource.md'])
|
|
26
|
+
expect(resolveMarkdownLink('acervos/resource.md', join(root, 'README.md'), root)).toBe('/acervos/resource/')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('resolves nested links relative to the source document', () => {
|
|
30
|
+
const root = fixture(['guides/topic/current.md', 'guides/shared.md'])
|
|
31
|
+
expect(resolveMarkdownLink('../shared.md', join(root, 'guides/topic/current.md'), root)).toBe('/guides/shared/')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('normalizes index documents to their directory route', () => {
|
|
35
|
+
const root = fixture(['guides/current.md', 'guides/index.md'])
|
|
36
|
+
expect(resolveMarkdownLink('index.md#Overview', join(root, 'guides/current.md'), root)).toBe('/guides/#Overview')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('normalizes route casing to match content collection ids', () => {
|
|
40
|
+
const root = fixture(['README.md', 'Guides/Getting-Started.md'])
|
|
41
|
+
expect(resolveMarkdownLink('Guides/Getting-Started.md', join(root, 'README.md'), root)).toBe('/guides/getting-started/')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('maps README links to the portal root and preserves anchors', () => {
|
|
45
|
+
const root = fixture(['README.md'])
|
|
46
|
+
expect(resolveMarkdownLink('README.md#start', join(root, 'README.md'), root)).toBe('/#start')
|
|
47
|
+
expect(resolveMarkdownLink('README#start', join(root, 'README.md'), root)).toBe('/#start')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('prefixes generated routes with the configured base path', () => {
|
|
51
|
+
const root = fixture(['README.md', 'guide.md'])
|
|
52
|
+
expect(resolveMarkdownLink('guide.md', join(root, 'README.md'), root, '/recursos/')).toBe('/recursos/guide/')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('leaves external and non-document links unchanged', () => {
|
|
56
|
+
const root = fixture(['README.md', 'image.png'])
|
|
57
|
+
const source = join(root, 'README.md')
|
|
58
|
+
expect(resolveMarkdownLink('https://example.com/guide.md', source, root)).toBe('https://example.com/guide.md')
|
|
59
|
+
expect(resolveMarkdownLink('image.png', source, root)).toBe('image.png')
|
|
60
|
+
expect(resolveMarkdownLink('#section', source, root)).toBe('#section')
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { dirname, extname, relative, resolve, sep } from 'node:path'
|
|
3
|
+
|
|
4
|
+
function splitHref(href: string): { path: string; suffix: string } {
|
|
5
|
+
const match = href.match(/^([^?#]*)([?#].*)?$/)
|
|
6
|
+
return { path: match?.[1] ?? href, suffix: match?.[2] ?? '' }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function normalizeBasePath(basePath: string): string {
|
|
10
|
+
if (!basePath || basePath === '/') return ''
|
|
11
|
+
return `/${basePath.replace(/^\/+|\/+$/g, '')}`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isExternalHref(href: string): boolean {
|
|
15
|
+
return /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(href)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function documentPath(path: string): string | undefined {
|
|
19
|
+
if (extname(path).toLowerCase() === '.md' || extname(path).toLowerCase() === '.mdx') {
|
|
20
|
+
return existsSync(path) ? path : undefined
|
|
21
|
+
}
|
|
22
|
+
if (existsSync(`${path}.md`)) return `${path}.md`
|
|
23
|
+
if (existsSync(`${path}.mdx`)) return `${path}.mdx`
|
|
24
|
+
if (path.endsWith('/') && existsSync(`${path}index.md`)) return `${path}index.md`
|
|
25
|
+
if (path.endsWith('/') && existsSync(`${path}index.mdx`)) return `${path}index.mdx`
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function toPortalRoute(path: string, basePath = '/'): string {
|
|
30
|
+
const withoutExtension = path.replace(/\.mdx?$/i, '')
|
|
31
|
+
const normalizedPath = withoutExtension.toLowerCase()
|
|
32
|
+
const route = normalizedPath === 'readme'
|
|
33
|
+
? ''
|
|
34
|
+
: normalizedPath.endsWith('/index')
|
|
35
|
+
? normalizedPath.slice(0, -'/index'.length)
|
|
36
|
+
: normalizedPath
|
|
37
|
+
const prefix = normalizeBasePath(basePath)
|
|
38
|
+
return `${prefix}/${route.replace(/^\/+|\/+$/g, '')}/`.replace(/\/\/$/, '/')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function resolveMarkdownLink(
|
|
42
|
+
href: string,
|
|
43
|
+
sourcePath: string,
|
|
44
|
+
contentRoot: string,
|
|
45
|
+
basePath = '/',
|
|
46
|
+
): string {
|
|
47
|
+
if (!href || href.startsWith('#') || isExternalHref(href)) return href
|
|
48
|
+
|
|
49
|
+
const { path, suffix } = splitHref(href)
|
|
50
|
+
const candidate = resolve(path.startsWith('/') ? contentRoot : dirname(sourcePath), path)
|
|
51
|
+
const candidatePath = path.endsWith('/') ? `${candidate}${sep}` : candidate
|
|
52
|
+
const root = resolve(contentRoot)
|
|
53
|
+
if (candidate !== root && !candidate.startsWith(`${root}${sep}`)) return href
|
|
54
|
+
|
|
55
|
+
const markdownPath = documentPath(candidatePath)
|
|
56
|
+
if (!markdownPath) return href
|
|
57
|
+
const relativePath = relative(root, markdownPath).split(sep).join('/')
|
|
58
|
+
return `${toPortalRoute(relativePath, basePath)}${suffix}`
|
|
59
|
+
}
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { visit } from 'unist-util-visit'
|
|
2
|
+
|
|
3
|
+
interface HastText {
|
|
4
|
+
type: 'text'
|
|
5
|
+
value: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface HastElement {
|
|
9
|
+
type: 'element'
|
|
10
|
+
tagName: string
|
|
11
|
+
properties?: Record<string, unknown>
|
|
12
|
+
children: (HastElement | HastText)[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ICONS: Record<string, string> = {
|
|
16
|
+
info: 'ℹ️',
|
|
17
|
+
note: '📝',
|
|
18
|
+
warning: '⚠️',
|
|
19
|
+
danger: '🚨',
|
|
20
|
+
tip: '💡',
|
|
21
|
+
important: '❗',
|
|
22
|
+
caution: '⚠️',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Rehype plugin: converts > [!type] Title callouts (formato Obsidian) AFTER remark-to-rehype
|
|
26
|
+
export function rehypeCallouts() {
|
|
27
|
+
return (tree: HastElement) => {
|
|
28
|
+
visit(tree, 'element', (node: HastElement) => {
|
|
29
|
+
if (node.tagName !== 'blockquote') return
|
|
30
|
+
const firstP = node.children?.find((c): c is HastElement => c.type === 'element' && c.tagName === 'p')
|
|
31
|
+
if (!firstP) return
|
|
32
|
+
const firstText = firstP.children?.find((c): c is HastText => c.type === 'text')
|
|
33
|
+
if (!firstText) return
|
|
34
|
+
const match = firstText.value.match(/^\[!([\w]+)\](?:\s+(.+))?/)
|
|
35
|
+
if (!match) return
|
|
36
|
+
|
|
37
|
+
const type = match[1].toLowerCase()
|
|
38
|
+
const titleText = match[2]?.trim() ?? (type.charAt(0).toUpperCase() + type.slice(1))
|
|
39
|
+
const afterMarker = firstText.value.replace(/^\[![\w]+\][^\n]*\n?/, '').trimStart()
|
|
40
|
+
|
|
41
|
+
if (afterMarker) {
|
|
42
|
+
firstText.value = afterMarker
|
|
43
|
+
} else {
|
|
44
|
+
firstP.children = firstP.children.filter(c => c !== firstText)
|
|
45
|
+
if (firstP.children.length === 0) node.children = node.children.filter(c => c !== firstP)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
node.tagName = 'div'
|
|
49
|
+
node.properties = { className: [`callout`, `callout-${type}`] }
|
|
50
|
+
|
|
51
|
+
node.children.unshift({
|
|
52
|
+
type: 'element', tagName: 'div',
|
|
53
|
+
properties: { className: ['callout-title'] },
|
|
54
|
+
children: [
|
|
55
|
+
{ type: 'element', tagName: 'span', properties: {}, children: [{ type: 'text', value: ICONS[type] ?? 'ℹ️' }] },
|
|
56
|
+
{ type: 'text', value: ' ' + titleText },
|
|
57
|
+
],
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { unified } from 'unified'
|
|
6
|
+
import remarkParse from 'remark-parse'
|
|
7
|
+
import remarkRehype from 'remark-rehype'
|
|
8
|
+
import rehypeStringify from 'rehype-stringify'
|
|
9
|
+
import { remarkDocumentLinks } from './remark-doc-links'
|
|
10
|
+
|
|
11
|
+
const roots: string[] = []
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
function render(markdown: string, source: string, root: string, basePath?: string): string {
|
|
18
|
+
return unified()
|
|
19
|
+
.use(remarkParse)
|
|
20
|
+
.use(remarkDocumentLinks, { contentRoot: root, basePath })
|
|
21
|
+
.use(remarkRehype)
|
|
22
|
+
.use(rehypeStringify)
|
|
23
|
+
.processSync({ value: markdown, path: source })
|
|
24
|
+
.toString()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fixture(files: string[]) {
|
|
28
|
+
const root = mkdtempSync(join(tmpdir(), 'upcontent-markdown-links-'))
|
|
29
|
+
roots.push(root)
|
|
30
|
+
for (const file of files) {
|
|
31
|
+
mkdirSync(dirname(join(root, file)), { recursive: true })
|
|
32
|
+
writeFileSync(join(root, file), '')
|
|
33
|
+
}
|
|
34
|
+
return root
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('remarkDocumentLinks', () => {
|
|
38
|
+
it('converts relative Markdown links using the source file path', () => {
|
|
39
|
+
const root = fixture(['README.md', 'acervos/resource.md'])
|
|
40
|
+
const html = render('[Resource](acervos/resource.md)', join(root, 'README.md'), root)
|
|
41
|
+
expect(html).toContain('href="/acervos/resource/"')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('preserves external links and applies the base path to document routes', () => {
|
|
45
|
+
const root = fixture(['README.md', 'guide.md'])
|
|
46
|
+
const html = render('[Guide](guide.md) [External](https://example.com/guide.md)', join(root, 'README.md'), root, '/recursos/')
|
|
47
|
+
expect(html).toContain('href="/recursos/guide/"')
|
|
48
|
+
expect(html).toContain('href="https://example.com/guide.md"')
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { visit } from 'unist-util-visit'
|
|
2
|
+
import { resolveMarkdownLink } from './portal-routes'
|
|
3
|
+
|
|
4
|
+
interface LinkNode {
|
|
5
|
+
type: 'link'
|
|
6
|
+
url: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface DocumentLinkOptions {
|
|
10
|
+
contentRoot: string
|
|
11
|
+
basePath?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function remarkDocumentLinks(options: DocumentLinkOptions) {
|
|
15
|
+
return (tree: any, file: { path?: string }) => {
|
|
16
|
+
if (!file.path) return
|
|
17
|
+
visit(tree, 'link', (node: LinkNode) => {
|
|
18
|
+
node.url = resolveMarkdownLink(node.url, file.path!, options.contentRoot, options.basePath)
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
}
|