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.
- package/.github/workflows/ci.yml +3 -3
- package/.github/workflows/publish.yml +75 -0
- package/.upcontent/config.json +3 -0
- package/CONTEXT.md +25 -0
- package/Makefile +6 -2
- package/README.md +57 -39
- package/assets/readme/portal-home.png +0 -0
- package/assets/readme/portal-showcase.png +0 -0
- package/astro.config.mjs +60 -18
- package/customization/config-json.md +9 -1
- package/customization/content.md +14 -0
- package/deployment/index.md +1 -0
- package/deployment/npm.md +39 -0
- package/guides/validate-your-site.md +5 -0
- package/package.json +15 -9
- package/scripts/upcontent-cli.mjs +2 -4
- package/scripts/verify-external-build.mjs +9 -1
- package/scripts/verify-golden-build.mjs +9 -0
- package/src/components/MermaidLoader.astro +17 -3
- package/src/content/i18n/en.json +1 -0
- package/src/content.config.test.ts +47 -1
- package/src/content.config.ts +26 -3
- package/src/lib/portal-config.test.ts +11 -1
- package/src/lib/portal-config.ts +11 -0
- package/src/lib/portal-routes.test.ts +62 -0
- package/src/lib/portal-routes.ts +59 -0
- package/src/lib/remark-doc-links.test.ts +50 -0
- package/src/lib/remark-doc-links.ts +21 -0
- package/src/lib/remark-wiki-links.test.ts +8 -8
- package/src/lib/remark-wiki-links.ts +6 -4
- package/src/lib/seo-sitemap.test.ts +37 -0
- package/src/lib/seo-sitemap.ts +54 -0
- package/src/lib/sidebar.test.ts +2 -2
- package/src/lib/sidebar.ts +3 -2
- package/src/overrides/Head.astro +86 -3
- package/src/pages/robots.txt.ts +46 -0
- package/src/upcontent-cli.test.ts +36 -0
- package/test-fixtures/external-consumer/.upcontent/config.json +4 -0
- package/test-fixtures/external-consumer/noindex.md +7 -0
- package/test-fixtures/external-consumer/public.md +6 -0
- 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
|
-
|
|
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
|
|
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
|
+
})
|
package/src/content.config.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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')
|
package/src/lib/portal-config.ts
CHANGED
|
@@ -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
|
|
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="/
|
|
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="
|
|
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
|
|
47
|
+
return `${toPortalRoute(`${slugifyPath(pagePart)}.md`, basePath)}#${slugifyHeading(headingPart)}`
|
|
46
48
|
}
|
|
47
|
-
return
|
|
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
|
+
})
|