upcontent 0.1.0 → 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 (41) hide show
  1. package/.github/workflows/ci.yml +3 -3
  2. package/.github/workflows/publish.yml +75 -0
  3. package/.upcontent/config.json +3 -0
  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 +60 -18
  10. package/customization/config-json.md +9 -1
  11. package/customization/content.md +14 -0
  12. package/deployment/index.md +1 -0
  13. package/deployment/npm.md +39 -0
  14. package/guides/validate-your-site.md +5 -0
  15. package/package.json +15 -9
  16. package/scripts/upcontent-cli.mjs +2 -4
  17. package/scripts/verify-external-build.mjs +9 -1
  18. package/scripts/verify-golden-build.mjs +9 -0
  19. package/src/components/MermaidLoader.astro +17 -3
  20. package/src/content/i18n/en.json +1 -0
  21. package/src/content.config.test.ts +47 -1
  22. package/src/content.config.ts +26 -3
  23. package/src/lib/portal-config.test.ts +11 -1
  24. package/src/lib/portal-config.ts +11 -0
  25. package/src/lib/portal-routes.test.ts +62 -0
  26. package/src/lib/portal-routes.ts +59 -0
  27. package/src/lib/remark-doc-links.test.ts +50 -0
  28. package/src/lib/remark-doc-links.ts +21 -0
  29. package/src/lib/remark-wiki-links.test.ts +8 -8
  30. package/src/lib/remark-wiki-links.ts +6 -4
  31. package/src/lib/seo-sitemap.test.ts +37 -0
  32. package/src/lib/seo-sitemap.ts +54 -0
  33. package/src/lib/sidebar.test.ts +2 -2
  34. package/src/lib/sidebar.ts +3 -2
  35. package/src/overrides/Head.astro +86 -3
  36. package/src/pages/robots.txt.ts +46 -0
  37. package/src/upcontent-cli.test.ts +36 -0
  38. package/test-fixtures/external-consumer/.upcontent/config.json +4 -0
  39. package/test-fixtures/external-consumer/noindex.md +7 -0
  40. package/test-fixtures/external-consumer/public.md +6 -0
  41. package/src/pages/index.astro +0 -20
@@ -0,0 +1,9 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+
3
+ const html = readFileSync('dist/index.html', 'utf8')
4
+ const requiredAssets = ['assets/readme/portal-home.png', 'assets/readme/portal-showcase.png']
5
+
6
+ for (const asset of requiredAssets) {
7
+ if (!existsSync(`dist/${asset}`)) throw new Error(`Golden build is missing: dist/${asset}`)
8
+ if (!html.includes(asset)) throw new Error(`Golden build does not reference: ${asset}`)
9
+ }
@@ -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,21 @@ 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('normalizes document ids while preserving nested routes', () => {
199
+ expect(toCollectionId('Guides/Getting-Started.mdx')).toBe('guides/getting-started')
200
+ })
201
+
202
+ it('normalizes alternative Markdown extensions', () => {
203
+ expect(toCollectionId('Guides/Legacy.markdown')).toBe('guides/legacy')
204
+ })
205
+
206
+ it('normalizes nested index documents to their directory route', () => {
207
+ expect(toCollectionId('customization/index.md')).toBe('customization')
208
+ })
209
+ })
@@ -2,7 +2,8 @@ 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,12 +11,24 @@ import { getPortalConfig } from './lib/portal-config'
10
11
 
11
12
  export { getBlocklist, isBlocked, toRelativeDocPath }
12
13
 
14
+ const MARKDOWN_EXTENSION = /\.(?:markdown|mdown|mkdn|mkd|mdwn|md|mdx)$/i
15
+
13
16
  // Campos de domínio específicos deste portal, além do schema padrão do
14
17
  // Starlight (title, description, sidebar, etc). Mantido isolado do
15
18
  // docsSchema() do Starlight pra ser testável sem precisar de um
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,16 @@ 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): string {
69
+ const normalized = relativeFilePath.split(path.sep).join('/').replace(MARKDOWN_EXTENSION, '').toLowerCase()
70
+ if (normalized === 'readme') return 'index'
71
+ return normalized.endsWith('/index') ? normalized.slice(0, -'/index'.length) : normalized
72
+ }
73
+
55
74
  function toCaseInsensitiveGlob(value: string): string {
56
75
  return value.replace(/[A-Za-z]/g, character => `[${character.toLowerCase()}${character.toUpperCase()}]`)
57
76
  }
@@ -77,7 +96,7 @@ function portalDocsLoader(): Loader {
77
96
  return context.parseData(props)
78
97
  },
79
98
  }
80
- await glob({ base: docsBasePath, pattern: patterns }).load(wrappedContext)
99
+ await glob({ base: docsBasePath, pattern: patterns, generateId: ({ entry }) => toCollectionId(entry) }).load(wrappedContext)
81
100
  },
82
101
  }
83
102
  }
@@ -87,4 +106,8 @@ export const collections = {
87
106
  loader: portalDocsLoader(),
88
107
  schema: docsSchema({ extend: domainFieldsSchema }),
89
108
  }),
109
+ i18n: defineCollection({
110
+ loader: i18nLoader(),
111
+ schema: i18nSchema(),
112
+ }),
90
113
  }
@@ -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,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,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
+ }
@@ -28,13 +28,13 @@ function renderStrict(markdown: string): string {
28
28
  describe('remarkWikiLinks', () => {
29
29
  it('resolve [[page]] simples pra /page', () => {
30
30
  const html = render('Veja [[backend/plan]] pra mais detalhes.')
31
- expect(html).toContain('href="/backend/plan"')
31
+ expect(html).toContain('href="/backend/plan/"')
32
32
  expect(html).toContain('>backend/plan<')
33
33
  })
34
34
 
35
35
  it('resolve [[page#heading]] combinando path e anchor', () => {
36
36
  const html = render('Ver [[DESIGN-licencas#Impact Summary]].')
37
- expect(html).toContain('href="/design-licencas#impact-summary"')
37
+ expect(html).toContain('href="/design-licencas/#impact-summary"')
38
38
  })
39
39
 
40
40
  it('resolve [[#heading]] como anchor local, sem path', () => {
@@ -44,13 +44,13 @@ describe('remarkWikiLinks', () => {
44
44
 
45
45
  it('resolve [[page|label]] usando o label como texto do link', () => {
46
46
  const html = render('Veja [[backend/plan|o plano]] aqui.')
47
- expect(html).toContain('href="/backend/plan"')
47
+ expect(html).toContain('href="/backend/plan/"')
48
48
  expect(html).toContain('>o plano<')
49
49
  })
50
50
 
51
51
  it('normaliza espaços e maiúsculas no path', () => {
52
52
  const html = render('Ver [[Meu Documento]].')
53
- expect(html).toContain('href="/meu-documento"')
53
+ expect(html).toContain('href="/meu-documento/"')
54
54
  })
55
55
 
56
56
  it('não afeta texto sem wiki links', () => {
@@ -61,8 +61,8 @@ describe('remarkWikiLinks', () => {
61
61
 
62
62
  it('resolve múltiplos wiki links na mesma linha', () => {
63
63
  const html = render('Ver [[a]] e também [[b]].')
64
- expect(html).toContain('href="/a"')
65
- expect(html).toContain('href="/b"')
64
+ expect(html).toContain('href="/a/"')
65
+ expect(html).toContain('href="/b/"')
66
66
  })
67
67
 
68
68
  // não corrompe rótulos de diagrama Mermaid que usam [[texto]] (sintaxe de subroutine node) —
@@ -78,12 +78,12 @@ describe('remarkWikiLinks', () => {
78
78
  })
79
79
 
80
80
  it('aceita um wiki link que aponta para um documento do consumer', () => {
81
- expect(renderStrict('Veja [[README]].')).toContain('href="/readme"')
81
+ expect(renderStrict('Veja [[README]].')).toContain('href="/"')
82
82
  })
83
83
 
84
84
  it('mantém os formatos suportados sob validação estrita', () => {
85
85
  expect(renderStrict('[[#Product]] [[README#Product]] [[README|início]]')).toContain('href="#product"')
86
- expect(renderStrict('[[#Product]] [[README#Product]] [[README|início]]')).toContain('href="/readme#product"')
86
+ expect(renderStrict('[[#Product]] [[README#Product]] [[README|início]]')).toContain('href="/#product"')
87
87
  })
88
88
 
89
89
  it('rejeita referências a arquivos que não são documentos', () => {
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import { resolve, sep } from 'node:path'
3
3
  import { visit } from 'unist-util-visit'
4
4
  import { PRODUCT_NAME } from './product-identity'
5
+ import { toPortalRoute } from './portal-routes'
5
6
 
6
7
  interface MdastText {
7
8
  type: 'text'
@@ -22,6 +23,7 @@ interface MdastParent {
22
23
 
23
24
  interface WikiLinkOptions {
24
25
  contentRoot?: string
26
+ basePath?: string
25
27
  failOnBrokenLinks?: boolean
26
28
  }
27
29
 
@@ -36,15 +38,15 @@ function slugifyHeading(heading: string): string {
36
38
  // Constrói a URL a partir da referência crua entre colchetes: [[#heading]] vira
37
39
  // anchor local; [[page]] vira path; [[page#heading]] combina os dois — path e
38
40
  // heading são fatiados (slugify) separadamente pra não perder o separador "#".
39
- function buildUrl(ref: string): string {
41
+ function buildUrl(ref: string, basePath = '/'): string {
40
42
  if (ref.startsWith('#')) return '#' + slugifyHeading(ref.slice(1))
41
43
  const hashIndex = ref.indexOf('#')
42
44
  if (hashIndex >= 0) {
43
45
  const pagePart = ref.slice(0, hashIndex)
44
46
  const headingPart = ref.slice(hashIndex + 1)
45
- return '/' + slugifyPath(pagePart) + '#' + slugifyHeading(headingPart)
47
+ return `${toPortalRoute(`${slugifyPath(pagePart)}.md`, basePath)}#${slugifyHeading(headingPart)}`
46
48
  }
47
- return '/' + slugifyPath(ref)
49
+ return toPortalRoute(`${slugifyPath(ref)}.md`, basePath)
48
50
  }
49
51
 
50
52
  function pagePartOf(ref: string): string {
@@ -89,7 +91,7 @@ export function remarkWikiLinks(options: WikiLinkOptions = {}) {
89
91
  const source = file.path ? ` in ${file.path}` : ''
90
92
  throw new Error(`[${PRODUCT_NAME}] Broken wiki link [[${inner}]]${source}`)
91
93
  }
92
- const url = buildUrl(ref)
94
+ const url = buildUrl(ref, options.basePath)
93
95
  parts.push({ type: 'link', url, title: null, children: [{ type: 'text', value: label }] })
94
96
  lastIndex = match.index + match[0].length
95
97
  }
@@ -0,0 +1,37 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import { getNoindexRoutes } from './seo-sitemap'
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
+ describe('getNoindexRoutes', () => {
14
+ it('maps noindex frontmatter to portal routes', () => {
15
+ const root = mkdtempSync(join(tmpdir(), 'upcontent-seo-'))
16
+ roots.push(root)
17
+ mkdirSync(join(root, 'guides'), { recursive: true })
18
+ writeFileSync(join(root, 'README.md'), '---\nnoindex: true\n---\n')
19
+ writeFileSync(join(root, 'guides', 'internal.md'), '---\nnoindex: true\n---\n')
20
+ writeFileSync(join(root, 'guides', 'legacy.markdown'), '---\nnoindex: true\n---\n')
21
+ writeFileSync(join(root, 'guides', 'Release Notes.md'), '---\nnoindex: true\n---\n')
22
+ writeFileSync(join(root, 'guides', 'release#notes.md'), '---\nnoindex: true\n---\n')
23
+ writeFileSync(join(root, 'guides', 'release;notes.md'), '---\nnoindex: true\n---\n')
24
+ writeFileSync(join(root, 'guides', 'guide%23notes.md'), '---\nnoindex: true\n---\n')
25
+ writeFileSync(join(root, 'public.md'), '---\nnoindex: false\n---\n')
26
+
27
+ expect(getNoindexRoutes(root)).toEqual(new Set([
28
+ '/',
29
+ '/guides/internal',
30
+ '/guides/legacy',
31
+ '/guides/release%20notes',
32
+ '/guides/release%23notes',
33
+ '/guides/release;notes',
34
+ '/guides/guide%2523notes',
35
+ ]))
36
+ })
37
+ })