upcontent 0.1.0 → 0.1.2

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 (49) hide show
  1. package/.github/workflows/ci.yml +3 -3
  2. package/.github/workflows/publish.yml +75 -0
  3. package/.upcontent/config.json +6 -1
  4. package/CONTEXT.md +25 -0
  5. package/Makefile +6 -2
  6. package/README.md +57 -39
  7. package/assets/readme/portal-home.png +0 -0
  8. package/assets/readme/portal-showcase.png +0 -0
  9. package/astro.config.mjs +67 -18
  10. package/customization/config-json.md +9 -1
  11. package/customization/content.md +14 -0
  12. package/customization/navigation.md +2 -0
  13. package/deployment/index.md +1 -0
  14. package/deployment/npm.md +39 -0
  15. package/guides/validate-your-site.md +5 -0
  16. package/index.md +19 -0
  17. package/package.json +15 -9
  18. package/scripts/upcontent-cli.mjs +2 -4
  19. package/scripts/verify-external-build.mjs +9 -1
  20. package/scripts/verify-golden-build.mjs +13 -0
  21. package/src/components/MermaidLoader.astro +17 -3
  22. package/src/content/i18n/en.json +1 -0
  23. package/src/content.config.test.ts +52 -1
  24. package/src/content.config.ts +29 -4
  25. package/src/lib/content-blocklist.ts +2 -2
  26. package/src/lib/doc-links.test.ts +21 -1
  27. package/src/lib/doc-links.ts +9 -4
  28. package/src/lib/homepage.ts +16 -0
  29. package/src/lib/markdown.ts +6 -0
  30. package/src/lib/portal-config.test.ts +11 -1
  31. package/src/lib/portal-config.ts +11 -0
  32. package/src/lib/portal-routes.test.ts +73 -0
  33. package/src/lib/portal-routes.ts +65 -0
  34. package/src/lib/remark-doc-links.test.ts +50 -0
  35. package/src/lib/remark-doc-links.ts +21 -0
  36. package/src/lib/remark-wiki-links.test.ts +10 -8
  37. package/src/lib/remark-wiki-links.ts +17 -8
  38. package/src/lib/seo-sitemap.test.ts +46 -0
  39. package/src/lib/seo-sitemap.ts +55 -0
  40. package/src/lib/sidebar.test.ts +32 -3
  41. package/src/lib/sidebar.ts +26 -17
  42. package/src/overrides/Footer.astro +3 -5
  43. package/src/overrides/Head.astro +86 -3
  44. package/src/pages/robots.txt.ts +46 -0
  45. package/src/upcontent-cli.test.ts +36 -0
  46. package/test-fixtures/external-consumer/.upcontent/config.json +4 -0
  47. package/test-fixtures/external-consumer/noindex.md +7 -0
  48. package/test-fixtures/external-consumer/public.md +6 -0
  49. package/src/pages/index.astro +0 -20
package/package.json CHANGED
@@ -1,18 +1,31 @@
1
1
  {
2
2
  "name": "upcontent",
3
3
  "type": "module",
4
- "version": "0.1.0",
4
+ "version": "0.1.2",
5
+ "packageManager": "pnpm@10.20.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/lumamontes/upcontent.git"
9
+ },
5
10
  "bin": {
6
11
  "upcontent": "scripts/upcontent-cli.mjs"
7
12
  },
13
+ "scripts": {
14
+ "dev": "astro dev",
15
+ "build": "astro build",
16
+ "check": "astro check",
17
+ "test": "vitest run"
18
+ },
8
19
  "dependencies": {
9
20
  "@astrojs/markdown-remark": "^7.3.1",
21
+ "@astrojs/sitemap": "^3.7.4",
10
22
  "@astrojs/starlight": "^0.42.1",
11
23
  "@fontsource/poppins": "^5.3.0",
12
24
  "astro": "^7.3.3",
13
25
  "js-yaml": "^4.3.2",
14
26
  "mermaid": "^11.0.0",
15
27
  "papaparse": "^5.7.0",
28
+ "unified": "^11.0.5",
16
29
  "unist-util-visit": "^5.0.0",
17
30
  "zod": "^3.0.0"
18
31
  },
@@ -24,13 +37,6 @@
24
37
  "remark-parse": "^11.0.0",
25
38
  "remark-rehype": "^11.1.2",
26
39
  "typescript": "^5.0.0",
27
- "unified": "^11.0.5",
28
40
  "vitest": "^2.0.0"
29
- },
30
- "scripts": {
31
- "dev": "astro dev",
32
- "build": "astro build",
33
- "check": "astro check",
34
- "test": "vitest run"
35
41
  }
36
- }
42
+ }
@@ -56,7 +56,7 @@ on:
56
56
 
57
57
  jobs:
58
58
  publish:
59
- uses: lumamontes/upcontent/.github/workflows/reusable-pages.yml@main
59
+ uses: lumamontes/upcontent/.github/workflows/reusable-pages.yml@main
60
60
  permissions:
61
61
  contents: read
62
62
  pages: write
@@ -103,9 +103,7 @@ else if (command === 'dev') {
103
103
  process.exitCode = run('make', ['dev', `CONTENT_PATH=${root}`]) ? 0 : 1
104
104
  }
105
105
  else if (command === 'check') {
106
- const passed = run('pnpm', ['test'])
107
- && run('pnpm', ['check'])
108
- && run('make', ['build', `CONTENT_PATH=${root}`])
106
+ const passed = run('make', ['build', `CONTENT_PATH=${root}`])
109
107
  process.exitCode = passed ? 0 : 1
110
108
  }
111
109
  else {
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
2
 
3
- const html = readFileSync('dist/readme/index.html', 'utf8')
3
+ const html = readFileSync('dist/index.html', 'utf8')
4
4
  const required = [
5
5
  'External Consumer',
6
6
  'https://github.com/example/external-consumer',
@@ -13,7 +13,15 @@ for (const value of required) {
13
13
 
14
14
  if (!existsSync('dist/upcontent-assets/favicon.svg')) throw new Error('External consumer favicon was not copied')
15
15
  if (!html.includes('alt="External Consumer"')) throw new Error('External consumer logo was not rendered')
16
+ if (!html.includes('application/ld+json')) throw new Error('External consumer JSON-LD metadata is missing')
17
+ if (!existsSync('dist/robots.txt')) throw new Error('External consumer robots.txt is missing')
18
+ if (!existsSync('dist/sitemap-index.xml')) throw new Error('External consumer sitemap is missing')
19
+ if (!readFileSync('dist/noindex/index.html', 'utf8').includes('noindex, nofollow')) throw new Error('External consumer noindex page is not marked noindex')
20
+ const sitemap = readFileSync('dist/sitemap-0.xml', 'utf8')
21
+ if (!sitemap.includes('<loc>https://docs.example.com/</loc>')) throw new Error('External consumer root route is missing from sitemap')
22
+ if (sitemap.includes('/noindex/')) throw new Error('External consumer noindex page leaked into sitemap')
16
23
  if (existsSync('dist/forbidden/index.html')) throw new Error('External consumer blocklist leaked forbidden.md')
24
+ if (existsSync('dist/assets/readme')) throw new Error('Golden README assets leaked into external consumer build')
17
25
  if (existsSync('dist/upcontent-renderer/readme/index.html')) {
18
26
  throw new Error('External consumer build leaked renderer checkout content')
19
27
  }
@@ -0,0 +1,13 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+
3
+ const htmlPath = existsSync('dist/readme/index.html') ? 'dist/readme/index.html' : 'dist/index.html'
4
+ const html = readFileSync(htmlPath, 'utf8')
5
+ const requiredAssets = ['assets/readme/portal-home.png', 'assets/readme/portal-showcase.png']
6
+
7
+ for (const asset of requiredAssets) {
8
+ if (!existsSync(`dist/${asset}`)) throw new Error(`Golden build is missing: dist/${asset}`)
9
+ if (existsSync('dist/readme/index.html') && !existsSync(`dist/readme/${asset}`)) {
10
+ throw new Error(`Golden build is missing README route asset: dist/readme/${asset}`)
11
+ }
12
+ if (!html.includes(asset)) throw new Error(`Golden build does not reference: ${asset}`)
13
+ }
@@ -2,14 +2,21 @@
2
2
  ---
3
3
 
4
4
  <script>
5
- import mermaid from 'mermaid'
6
5
  import { renderMermaidDiagram } from '../lib/mermaid-render'
7
6
 
8
7
  function mermaidTheme() {
9
8
  return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'default'
10
9
  }
11
10
 
12
- mermaid.initialize({ startOnLoad: false, theme: mermaidTheme() })
11
+ let mermaidPromise: Promise<typeof import('mermaid').default> | undefined
12
+
13
+ function loadMermaid() {
14
+ mermaidPromise ??= import('mermaid').then(({ default: mermaid }) => {
15
+ mermaid.initialize({ startOnLoad: false, theme: mermaidTheme() })
16
+ return mermaid
17
+ })
18
+ return mermaidPromise
19
+ }
13
20
 
14
21
  let scale = 1
15
22
  let translateX = 0
@@ -105,6 +112,9 @@
105
112
 
106
113
  async function processDiagrams() {
107
114
  const nodes = Array.from(document.querySelectorAll<HTMLElement>('div.mermaid'))
115
+ if (nodes.length === 0) return
116
+
117
+ const mermaid = await loadMermaid()
108
118
  for (const node of nodes) {
109
119
  // <br/> inside a div becomes a DOM element before textContent reads it — não deveria
110
120
  // acontecer (remarkMermaid já normaliza), mas o replace aqui é defensivo.
@@ -162,8 +172,12 @@
162
172
  }
163
173
 
164
174
  async function rerenderExistingDiagrams() {
175
+ const containers = Array.from(document.querySelectorAll<HTMLElement>('[data-mermaid-source]'))
176
+ if (containers.length === 0) return
177
+
178
+ const mermaid = await loadMermaid()
165
179
  mermaid.initialize({ startOnLoad: false, theme: mermaidTheme() })
166
- for (const container of document.querySelectorAll<HTMLElement>('[data-mermaid-source]')) {
180
+ for (const container of containers) {
167
181
  const source = container.dataset.mermaidSource
168
182
  if (!source) continue
169
183
  const result = await renderMermaidDiagram(mermaid, source)
@@ -0,0 +1 @@
1
+ {}
@@ -6,7 +6,7 @@ vi.mock('node:fs', () => ({
6
6
  }))
7
7
 
8
8
  import * as fs from 'node:fs'
9
- import { domainFieldsSchema, isBlocked, resolveTitle, toRelativeDocPath } from './content.config'
9
+ import { domainFieldsSchema, isBlocked, resolveTitle, toCollectionId, toRelativeDocPath } from './content.config'
10
10
  import { _resetPortalConfigCache } from './lib/portal-config'
11
11
 
12
12
  beforeEach(() => {
@@ -28,6 +28,28 @@ describe('domainFieldsSchema', () => {
28
28
  expect(result.success).toBe(true)
29
29
  })
30
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
+
31
53
  it('aceita created/updated como objeto Date (YAML parseia datas automaticamente)', () => {
32
54
  const result = domainFieldsSchema.safeParse({
33
55
  created: new Date('2026-08-07'),
@@ -72,6 +94,12 @@ describe('resolveTitle', () => {
72
94
  expect(data.title).toBe('Trd Backend')
73
95
  })
74
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
+
75
103
  it('remove prefixo numérico do filename antes de converter', () => {
76
104
  const data: Record<string, unknown> = {}
77
105
  resolveTitle('docs/adr/0001-self-hosted.md', data)
@@ -161,3 +189,26 @@ describe('toRelativeDocPath', () => {
161
189
  expect(toRelativeDocPath('RULES.md')).toBe('RULES.md')
162
190
  })
163
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('keeps README separate when root index.md is the homepage', () => {
199
+ expect(toCollectionId('index.md', 'index')).toBe('index')
200
+ expect(toCollectionId('README.md', 'index')).toBe('readme')
201
+ })
202
+
203
+ it('normalizes document ids while preserving nested routes', () => {
204
+ expect(toCollectionId('Guides/Getting-Started.mdx')).toBe('guides/getting-started')
205
+ })
206
+
207
+ it('normalizes alternative Markdown extensions', () => {
208
+ expect(toCollectionId('Guides/Legacy.markdown')).toBe('guides/legacy')
209
+ })
210
+
211
+ it('normalizes nested index documents to their directory route', () => {
212
+ expect(toCollectionId('customization/index.md')).toBe('customization')
213
+ })
214
+ })
@@ -2,10 +2,13 @@ import path from 'node:path'
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { defineCollection } from 'astro:content'
4
4
  import { z } from 'astro/zod'
5
- import { docsSchema } from '@astrojs/starlight/schema'
5
+ import { docsSchema, i18nSchema } from '@astrojs/starlight/schema'
6
+ import { i18nLoader } from '@astrojs/starlight/loaders'
6
7
  import { glob } from 'astro/loaders'
7
8
  import type { Loader, LoaderContext } from 'astro/loaders'
8
9
  import { getBlocklist, isBlocked, toRelativeDocPath, toTitleCase } from './lib/content-blocklist'
10
+ import { hasRootIndex } from './lib/homepage'
11
+ import { MARKDOWN_EXTENSION, stripMarkdownExtension } from './lib/markdown'
9
12
  import { getPortalConfig } from './lib/portal-config'
10
13
 
11
14
  export { getBlocklist, isBlocked, toRelativeDocPath }
@@ -16,6 +19,16 @@ export { getBlocklist, isBlocked, toRelativeDocPath }
16
19
  // SchemaContext (o título obrigatório do Starlight é resolvido pelo loader,
17
20
  // não pelo schema — ver resolveTitle()).
18
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(),
19
32
  type: z.string().optional(),
20
33
  status: z.string().optional(),
21
34
  created: z.string().or(z.date().transform(d => d.toISOString().split('T')[0])).optional(),
@@ -48,10 +61,17 @@ export function resolveTitle(relativeFilePath: string, data: Record<string, unkn
48
61
  }
49
62
 
50
63
  const filename = relativeFilePath.split('/').pop() ?? relativeFilePath
51
- const withoutExt = filename.replace(/\.mdx?$/i, '')
64
+ const withoutExt = filename.replace(MARKDOWN_EXTENSION, '')
52
65
  data.title = toTitleCase(withoutExt)
53
66
  }
54
67
 
68
+ export function toCollectionId(relativeFilePath: string, homepage: 'index' | 'readme' = 'readme'): string {
69
+ const normalized = stripMarkdownExtension(relativeFilePath.split(path.sep).join('/')).toLowerCase()
70
+ if (normalized === homepage) return 'index'
71
+ if (normalized === 'readme') return 'readme'
72
+ return normalized.endsWith('/index') ? normalized.slice(0, -'/index'.length) : normalized
73
+ }
74
+
55
75
  function toCaseInsensitiveGlob(value: string): string {
56
76
  return value.replace(/[A-Za-z]/g, character => `[${character.toLowerCase()}${character.toUpperCase()}]`)
57
77
  }
@@ -63,6 +83,7 @@ function portalDocsLoader(): Loader {
63
83
  name: 'portal-docs-loader',
64
84
  async load(context: LoaderContext) {
65
85
  const docsBasePath = fileURLToPath(new URL('src/content/docs/', context.config.root))
86
+ const homepage = hasRootIndex(docsBasePath) ? 'index' : 'readme'
66
87
  const patterns = [
67
88
  '**/[^_]*.{markdown,mdown,mkdn,mkd,mdwn,md,mdx}',
68
89
  ...getBlocklist().map(blocked => `!${toCaseInsensitiveGlob(blocked)}${blocked.endsWith('/') ? '**' : ''}`),
@@ -77,14 +98,18 @@ function portalDocsLoader(): Loader {
77
98
  return context.parseData(props)
78
99
  },
79
100
  }
80
- await glob({ base: docsBasePath, pattern: patterns }).load(wrappedContext)
101
+ await glob({ base: docsBasePath, pattern: patterns, generateId: ({ entry }) => toCollectionId(entry, homepage) }).load(wrappedContext)
81
102
  },
82
103
  }
83
104
  }
84
105
 
85
106
  export const collections = {
86
107
  docs: defineCollection({
87
- loader: portalDocsLoader(),
108
+ loader: portalDocsLoader(),
88
109
  schema: docsSchema({ extend: domainFieldsSchema }),
89
110
  }),
111
+ i18n: defineCollection({
112
+ loader: i18nLoader(),
113
+ schema: i18nSchema(),
114
+ }),
90
115
  }
@@ -49,7 +49,7 @@ export function toTitleCase(filenameWithoutExt: string): string {
49
49
  // automaticamente pro português — quem conhece o vocabulário é o
50
50
  // repositório de conteúdo, via navigation.labelOverrides no .upcontent/config.json.
51
51
  // Fallback é sempre toTitleCase(name) quando não há override.
52
- export function resolveLabel(name: string): string {
52
+ export function resolveLabel(name: string, fallback = toTitleCase(name)): string {
53
53
  const override = getPortalConfig().navigation?.labelOverrides?.[name.toLowerCase()]
54
- return override ?? toTitleCase(name)
54
+ return override ?? fallback
55
55
  }
@@ -41,7 +41,7 @@ describe('resolveRelated', () => {
41
41
  const result = resolveRelated(['domains/foo/prd'], allDocs as any)
42
42
  expect(result).toHaveLength(1)
43
43
  expect(result[0].title).toBe('Foo PRD')
44
- expect(result[0].slug).toBe('domains/foo/prd')
44
+ expect(result[0].slug).toBe('domains/foo/prd/')
45
45
  })
46
46
 
47
47
  it('resolve entry referenciado com extensão .md', () => {
@@ -59,4 +59,24 @@ describe('resolveRelated', () => {
59
59
  const result = resolveRelated(['domains/bar/trd'], allDocs as any)
60
60
  expect(result[0].title).toBe('domains/bar/trd')
61
61
  })
62
+
63
+ it('resolve extensões alternativas e as rotas distintas de index e README', () => {
64
+ const docs = [
65
+ { id: 'index', data: { title: 'Home' } },
66
+ { id: 'readme', data: { title: 'README' } },
67
+ { id: 'guides/legacy', data: { title: 'Legacy' } },
68
+ ]
69
+
70
+ expect(resolveRelated(['index.md', 'README.markdown', 'guides/legacy.mdx'], docs as any)).toEqual([
71
+ { slug: '', title: 'Home' },
72
+ { slug: 'readme/', title: 'README' },
73
+ { slug: 'guides/legacy/', title: 'Legacy' },
74
+ ])
75
+ })
76
+
77
+ it('mantém links para README em consumers sem index dedicado', () => {
78
+ expect(resolveRelated(['README.markdown'], [{ id: 'index', data: { title: 'README' } }] as any)).toEqual([
79
+ { slug: '', title: 'README' },
80
+ ])
81
+ })
62
82
  })
@@ -1,4 +1,5 @@
1
1
  import type { CollectionEntry } from 'astro:content'
2
+ import { stripMarkdownExtension } from './markdown'
2
3
 
3
4
  export function buildGitHubUrl(
4
5
  repoUrl: string | undefined,
@@ -21,11 +22,15 @@ export function resolveRelated(
21
22
  ): RelatedDoc[] {
22
23
  if (!related || related.length === 0) return []
23
24
  return related.flatMap(ref => {
24
- const normalized = ref.endsWith('.md') ? ref : `${ref}.md`
25
- const entry = allDocs.find(d => d.id === normalized)
25
+ const normalized = stripMarkdownExtension(ref).toLowerCase()
26
+ const candidates = normalized === 'readme' ? ['readme', 'index'] : [normalized]
27
+ const entry = candidates.flatMap(candidate => allDocs.filter(d => [d.id, d.filePath]
28
+ .filter((path): path is string => Boolean(path))
29
+ .some(path => stripMarkdownExtension(path).toLowerCase() === candidate)))[0]
26
30
  if (!entry) return []
27
- const slug = normalized.replace(/\.md$/, '')
28
- const title = (entry.data as Record<string, unknown>).title as string | undefined ?? slug
31
+ const entryId = stripMarkdownExtension(entry.id).toLowerCase()
32
+ const slug = entryId === 'index' ? '' : entryId === 'readme' ? 'readme/' : `${entryId}/`
33
+ const title = (entry.data as Record<string, unknown>).title as string | undefined ?? entryId
29
34
  return [{ slug, title }]
30
35
  })
31
36
  }
@@ -0,0 +1,16 @@
1
+ import { readdirSync, statSync } from 'node:fs'
2
+ import { isBlocked } from './content-blocklist'
3
+ import { MARKDOWN_EXTENSION, stripMarkdownExtension } from './markdown'
4
+
5
+ export function hasRootIndex(docsRoot: string): boolean {
6
+ try {
7
+ return readdirSync(docsRoot).some(name =>
8
+ MARKDOWN_EXTENSION.test(name)
9
+ && statSync(`${docsRoot}/${name}`).isFile()
10
+ && stripMarkdownExtension(name).toLowerCase() === 'index'
11
+ && !isBlocked(name),
12
+ )
13
+ } catch {
14
+ return false
15
+ }
16
+ }
@@ -0,0 +1,6 @@
1
+ export const MARKDOWN_EXTENSIONS = ['.markdown', '.mdown', '.mkdn', '.mkd', '.mdwn', '.md', '.mdx'] as const
2
+ export const MARKDOWN_EXTENSION = /\.(?:markdown|mdown|mkdn|mkd|mdwn|md|mdx)$/i
3
+
4
+ export function stripMarkdownExtension(path: string): string {
5
+ return path.replace(MARKDOWN_EXTENSION, '')
6
+ }
@@ -21,6 +21,14 @@ describe('getPortalConfig', () => {
21
21
  expect(config).toEqual({})
22
22
  })
23
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
+
24
32
  it('retorna config parseada quando config.json existe e é válido', async () => {
25
33
  vi.mocked(fs.existsSync).mockReturnValue(true)
26
34
  vi.mocked(fs.readFileSync).mockReturnValue(
@@ -32,7 +40,8 @@ describe('getPortalConfig', () => {
32
40
  logo: { src: '/logo.svg', alt: 'Meu Portal', replacesTitle: true },
33
41
  favicon: '/favicon.svg',
34
42
  },
35
- repo: { url: 'https://github.com/org/repo' },
43
+ seo: { enabled: true },
44
+ repo: { url: 'https://github.com/org/repo' },
36
45
  theme: { customCss: ['.upcontent/theme.css'] },
37
46
  starlight: {
38
47
  social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/org/repo' }],
@@ -49,6 +58,7 @@ describe('getPortalConfig', () => {
49
58
  expect(config.site?.title).toBe('Meu Portal')
50
59
  expect(config.site?.description).toBe('Documentação do time')
51
60
  expect(config.site?.url).toBe('https://docs.example.com')
61
+ expect(config.seo?.enabled).toBe(true)
52
62
  expect(config.site?.logo?.src).toBe('/logo.svg')
53
63
  expect(config.site?.favicon).toBe('/favicon.svg')
54
64
  expect(config.repo?.url).toBe('https://github.com/org/repo')
@@ -24,10 +24,16 @@ export interface PortalConfigSite {
24
24
  title?: string
25
25
  description?: string
26
26
  url?: string
27
+ socialImage?: string
28
+ locale?: string
27
29
  logo?: PortalConfigLogo
28
30
  favicon?: string
29
31
  }
30
32
 
33
+ export interface PortalConfigSeo {
34
+ enabled?: boolean
35
+ }
36
+
31
37
  export interface PortalConfigTheme {
32
38
  customCss?: string[]
33
39
  }
@@ -42,6 +48,7 @@ export interface PortalConfigStarlight {
42
48
 
43
49
  export interface PortalConfig {
44
50
  site?: PortalConfigSite
51
+ seo?: PortalConfigSeo
45
52
  repo?: { url?: string }
46
53
  theme?: PortalConfigTheme
47
54
  starlight?: PortalConfigStarlight
@@ -108,6 +115,7 @@ function asTableOfContents(value: unknown): PortalConfigStarlight['tableOfConten
108
115
  function normalizeConfig(value: unknown): PortalConfig {
109
116
  const raw = asRecord(value)
110
117
  const rawSite = asRecord(raw.site)
118
+ const rawSeo = asRecord(raw.seo)
111
119
  const rawLogo = asRecord(rawSite.logo)
112
120
  const rawRepo = asRecord(raw.repo)
113
121
  const rawTheme = asRecord(raw.theme)
@@ -128,9 +136,12 @@ function normalizeConfig(value: unknown): PortalConfig {
128
136
  title: asString(rawSite.title),
129
137
  description: asString(rawSite.description),
130
138
  url: asString(rawSite.url),
139
+ socialImage: asString(rawSite.socialImage),
140
+ locale: asString(rawSite.locale),
131
141
  logo,
132
142
  favicon: asString(rawSite.favicon),
133
143
  },
144
+ seo: { enabled: rawSeo.enabled === true },
134
145
  repo: { url: asString(rawRepo.url) },
135
146
  theme: { customCss: asStringArray(rawTheme.customCss) },
136
147
  starlight: {
@@ -0,0 +1,73 @@
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('resolves trailing-slash links to sibling Markdown documents', () => {
40
+ const root = fixture(['README.md', 'showcase.md'])
41
+ expect(resolveMarkdownLink('showcase/', join(root, 'README.md'), root)).toBe('/showcase/')
42
+ })
43
+
44
+ it('normalizes route casing to match content collection ids', () => {
45
+ const root = fixture(['README.md', 'Guides/Getting-Started.md'])
46
+ expect(resolveMarkdownLink('Guides/Getting-Started.md', join(root, 'README.md'), root)).toBe('/guides/getting-started/')
47
+ })
48
+
49
+ it('maps README links to the portal root and preserves anchors', () => {
50
+ const root = fixture(['README.md'])
51
+ expect(resolveMarkdownLink('README.md#start', join(root, 'README.md'), root)).toBe('/#start')
52
+ expect(resolveMarkdownLink('README#start', join(root, 'README.md'), root)).toBe('/#start')
53
+ })
54
+
55
+ it('uses root index.md as the homepage when README.md is also present', () => {
56
+ const root = fixture(['README.md', 'index.markdown'])
57
+ expect(resolveMarkdownLink('index.markdown', join(root, 'README.md'), root)).toBe('/')
58
+ expect(resolveMarkdownLink('README.md', join(root, 'index.md'), root)).toBe('/readme/')
59
+ })
60
+
61
+ it('prefixes generated routes with the configured base path', () => {
62
+ const root = fixture(['README.md', 'guide.md'])
63
+ expect(resolveMarkdownLink('guide.md', join(root, 'README.md'), root, '/recursos/')).toBe('/recursos/guide/')
64
+ })
65
+
66
+ it('leaves external and non-document links unchanged', () => {
67
+ const root = fixture(['README.md', 'image.png'])
68
+ const source = join(root, 'README.md')
69
+ expect(resolveMarkdownLink('https://example.com/guide.md', source, root)).toBe('https://example.com/guide.md')
70
+ expect(resolveMarkdownLink('image.png', source, root)).toBe('image.png')
71
+ expect(resolveMarkdownLink('#section', source, root)).toBe('#section')
72
+ })
73
+ })
@@ -0,0 +1,65 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { dirname, relative, resolve, sep } from 'node:path'
3
+ import { hasRootIndex } from './homepage'
4
+ import { MARKDOWN_EXTENSION, MARKDOWN_EXTENSIONS, stripMarkdownExtension } from './markdown'
5
+
6
+ function splitHref(href: string): { path: string; suffix: string } {
7
+ const match = href.match(/^([^?#]*)([?#].*)?$/)
8
+ return { path: match?.[1] ?? href, suffix: match?.[2] ?? '' }
9
+ }
10
+
11
+ function normalizeBasePath(basePath: string): string {
12
+ if (!basePath || basePath === '/') return ''
13
+ return `/${basePath.replace(/^\/+|\/+$/g, '')}`
14
+ }
15
+
16
+ function isExternalHref(href: string): boolean {
17
+ return /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(href)
18
+ }
19
+
20
+ function documentPath(path: string): string | undefined {
21
+ if (MARKDOWN_EXTENSION.test(path)) {
22
+ return existsSync(path) ? path : undefined
23
+ }
24
+ const stem = path.endsWith(sep) ? path.slice(0, -sep.length) : path
25
+ for (const extension of MARKDOWN_EXTENSIONS) {
26
+ if (existsSync(`${stem}${extension}`)) return `${stem}${extension}`
27
+ if (path.endsWith('/') && existsSync(`${path}index${extension}`)) return `${path}index${extension}`
28
+ }
29
+ return undefined
30
+ }
31
+
32
+ export function toPortalRoute(path: string, basePath = '/', homepage: 'index' | 'readme' = 'readme'): string {
33
+ const withoutExtension = stripMarkdownExtension(path)
34
+ const normalizedPath = withoutExtension.toLowerCase()
35
+ const route = normalizedPath === homepage
36
+ ? ''
37
+ : normalizedPath === 'readme'
38
+ ? 'readme'
39
+ : normalizedPath.endsWith('/index')
40
+ ? normalizedPath.slice(0, -'/index'.length)
41
+ : normalizedPath
42
+ const prefix = normalizeBasePath(basePath)
43
+ return `${prefix}/${route.replace(/^\/+|\/+$/g, '')}/`.replace(/\/\/$/, '/')
44
+ }
45
+
46
+ export function resolveMarkdownLink(
47
+ href: string,
48
+ sourcePath: string,
49
+ contentRoot: string,
50
+ basePath = '/',
51
+ ): string {
52
+ if (!href || href.startsWith('#') || isExternalHref(href)) return href
53
+
54
+ const { path, suffix } = splitHref(href)
55
+ const candidate = resolve(path.startsWith('/') ? contentRoot : dirname(sourcePath), path)
56
+ const candidatePath = path.endsWith('/') ? `${candidate}${sep}` : candidate
57
+ const root = resolve(contentRoot)
58
+ if (candidate !== root && !candidate.startsWith(`${root}${sep}`)) return href
59
+
60
+ const markdownPath = documentPath(candidatePath)
61
+ if (!markdownPath) return href
62
+ const relativePath = relative(root, markdownPath).split(sep).join('/')
63
+ const homepage = hasRootIndex(contentRoot) ? 'index' : 'readme'
64
+ return `${toPortalRoute(relativePath, basePath, homepage)}${suffix}`
65
+ }