upcontent 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/.github/workflows/ci.yml +61 -0
  2. package/.github/workflows/deploy.yml +59 -0
  3. package/.github/workflows/publish.yml +75 -0
  4. package/.github/workflows/reusable-pages.yml +74 -0
  5. package/.upcontent/config.json +85 -0
  6. package/.upcontent/favicon.svg +4 -0
  7. package/.upcontent/logo.svg +6 -0
  8. package/.upcontent/portal.css +139 -0
  9. package/CONTEXT.md +25 -0
  10. package/Makefile +33 -0
  11. package/README.md +132 -2
  12. package/SHOWCASE.mdx +163 -0
  13. package/assets/readme/portal-home.png +0 -0
  14. package/assets/readme/portal-showcase.png +0 -0
  15. package/astro.config.mjs +167 -0
  16. package/customization/config-json.md +100 -0
  17. package/customization/content.md +62 -0
  18. package/customization/environment.md +43 -0
  19. package/customization/index.md +33 -0
  20. package/customization/navigation.md +57 -0
  21. package/customization/site-identity.md +43 -0
  22. package/customization/theme.md +47 -0
  23. package/deployment/github-pages.md +46 -0
  24. package/deployment/index.md +25 -0
  25. package/deployment/npm.md +39 -0
  26. package/deployment/static-hosts.md +34 -0
  27. package/getting-started/consumer-repository.md +156 -0
  28. package/getting-started/first-build.md +78 -0
  29. package/guides/authoring-content.md +105 -0
  30. package/guides/validate-your-site.md +58 -0
  31. package/package.json +40 -4
  32. package/scripts/upcontent-cli.mjs +111 -0
  33. package/scripts/verify-external-build.mjs +32 -0
  34. package/scripts/verify-golden-build.mjs +9 -0
  35. package/src/components/MermaidLoader.astro +304 -0
  36. package/src/components/PaletteShowcase.astro +122 -0
  37. package/src/components/StructuredDataCopy.astro +22 -0
  38. package/src/content/__mocks__/astro-content.ts +7 -0
  39. package/src/content/__mocks__/astro-loaders.ts +3 -0
  40. package/src/content/i18n/en.json +1 -0
  41. package/src/content.config.test.ts +209 -0
  42. package/src/content.config.ts +113 -0
  43. package/src/lib/content-blocklist.test.ts +47 -0
  44. package/src/lib/content-blocklist.ts +55 -0
  45. package/src/lib/doc-links.test.ts +62 -0
  46. package/src/lib/doc-links.ts +31 -0
  47. package/src/lib/mermaid-render.test.ts +42 -0
  48. package/src/lib/mermaid-render.ts +24 -0
  49. package/src/lib/portal-config.test.ts +153 -0
  50. package/src/lib/portal-config.ts +187 -0
  51. package/src/lib/portal-routes.test.ts +62 -0
  52. package/src/lib/portal-routes.ts +59 -0
  53. package/src/lib/product-identity.ts +2 -0
  54. package/src/lib/rehype-callouts.test.ts +69 -0
  55. package/src/lib/rehype-callouts.ts +61 -0
  56. package/src/lib/remark-doc-links.test.ts +50 -0
  57. package/src/lib/remark-doc-links.ts +21 -0
  58. package/src/lib/remark-strip-duplicate-title.test.ts +73 -0
  59. package/src/lib/remark-strip-duplicate-title.ts +37 -0
  60. package/src/lib/remark-structured-data-preview.test.ts +104 -0
  61. package/src/lib/remark-structured-data-preview.ts +66 -0
  62. package/src/lib/remark-wiki-links.test.ts +102 -0
  63. package/src/lib/remark-wiki-links.ts +112 -0
  64. package/src/lib/seo-sitemap.test.ts +37 -0
  65. package/src/lib/seo-sitemap.ts +54 -0
  66. package/src/lib/sidebar.test.ts +142 -0
  67. package/src/lib/sidebar.ts +114 -0
  68. package/src/lib/structured-data-tree.test.ts +75 -0
  69. package/src/lib/structured-data-tree.ts +55 -0
  70. package/src/overrides/Footer.astro +114 -0
  71. package/src/overrides/Head.astro +103 -0
  72. package/src/pages/robots.txt.ts +46 -0
  73. package/src/styles/callouts.css +29 -0
  74. package/src/styles/structured-data-preview.css +95 -0
  75. package/src/upcontent-cli.test.ts +36 -0
  76. package/test-fixtures/external-consumer/.upcontent/config.json +30 -0
  77. package/test-fixtures/external-consumer/.upcontent/favicon.svg +4 -0
  78. package/test-fixtures/external-consumer/.upcontent/logo.svg +4 -0
  79. package/test-fixtures/external-consumer/.upcontent/theme.css +4 -0
  80. package/test-fixtures/external-consumer/.upcontent-renderer/README.md +3 -0
  81. package/test-fixtures/external-consumer/README.md +6 -0
  82. package/test-fixtures/external-consumer/forbidden.md +5 -0
  83. package/test-fixtures/external-consumer/noindex.md +7 -0
  84. package/test-fixtures/external-consumer/public.md +6 -0
  85. package/tsconfig.json +7 -0
  86. package/vitest.config.ts +18 -0
@@ -0,0 +1,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,2 @@
1
+ export const PRODUCT_NAME = 'Upcontent'
2
+ export const PRODUCT_TAGLINE = 'Create a beautiful, customizable Starlight portal from your documentation repository.'
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { unified } from 'unified'
3
+ import remarkParse from 'remark-parse'
4
+ import remarkRehype from 'remark-rehype'
5
+ import rehypeStringify from 'rehype-stringify'
6
+ import { rehypeCallouts } from './rehype-callouts'
7
+
8
+ function render(markdown: string): string {
9
+ return unified()
10
+ .use(remarkParse)
11
+ .use(remarkRehype)
12
+ .use(rehypeCallouts)
13
+ .use(rehypeStringify)
14
+ .processSync(markdown)
15
+ .toString()
16
+ }
17
+
18
+ describe('rehypeCallouts', () => {
19
+ it('converte [!info] sem texto adicional em callout com título default e ícone', () => {
20
+ const html = render('> [!info]')
21
+ expect(html).toContain('class="callout callout-info"')
22
+ expect(html).toContain('callout-title')
23
+ expect(html).toContain('ℹ️')
24
+ expect(html).toContain('Info')
25
+ expect(html).not.toContain('<blockquote>')
26
+ })
27
+
28
+ // formato real usado no conteúdo: marcador + texto tudo na mesma linha
29
+ it('converte [!info] com texto na mesma linha (formato usado no conteúdo real)', () => {
30
+ const html = render('> [!info] SDD — Artefato Plano técnico para a camada frontend')
31
+ expect(html).toContain('class="callout callout-info"')
32
+ expect(html).toContain('SDD — Artefato Plano técnico para a camada frontend')
33
+ })
34
+
35
+ it('converte [!warning] com título customizado', () => {
36
+ const html = render('> [!warning] Cuidado aqui\n> Detalhes do risco')
37
+ expect(html).toContain('class="callout callout-warning"')
38
+ expect(html).toContain('Cuidado aqui')
39
+ expect(html).toContain('⚠️')
40
+ })
41
+
42
+ it('cobre todos os tipos de callout usados no conteúdo', () => {
43
+ const types = ['info', 'note', 'warning', 'danger', 'tip', 'important', 'caution']
44
+ for (const type of types) {
45
+ const html = render(`> [!${type}] Texto`)
46
+ expect(html).toContain(`class="callout callout-${type}"`)
47
+ }
48
+ })
49
+
50
+ it('não quebra blockquote comum sem marcador de callout', () => {
51
+ const html = render('> Só uma citação normal, sem marcador')
52
+ expect(html).toContain('<blockquote>')
53
+ expect(html).not.toContain('callout')
54
+ })
55
+
56
+ it('callout dentro de uma lista não quebra a renderização', () => {
57
+ const html = render('- item 1\n > [!tip] dica dentro da lista\n- item 2')
58
+ expect(html).toContain('class="callout callout-tip"')
59
+ expect(html).toContain('item 1')
60
+ expect(html).toContain('item 2')
61
+ })
62
+
63
+ it('tipo desconhecido sem texto usa ícone default e title case do tipo', () => {
64
+ const html = render('> [!custom]')
65
+ expect(html).toContain('class="callout callout-custom"')
66
+ expect(html).toContain('Custom')
67
+ expect(html).toContain('ℹ️')
68
+ })
69
+ })
@@ -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
+ }