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.
- package/.github/workflows/ci.yml +3 -3
- package/.github/workflows/publish.yml +75 -0
- package/.upcontent/config.json +6 -1
- 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 +67 -18
- package/customization/config-json.md +9 -1
- package/customization/content.md +14 -0
- package/customization/navigation.md +2 -0
- package/deployment/index.md +1 -0
- package/deployment/npm.md +39 -0
- package/guides/validate-your-site.md +5 -0
- package/index.md +19 -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 +13 -0
- package/src/components/MermaidLoader.astro +17 -3
- package/src/content/i18n/en.json +1 -0
- package/src/content.config.test.ts +52 -1
- package/src/content.config.ts +29 -4
- package/src/lib/content-blocklist.ts +2 -2
- package/src/lib/doc-links.test.ts +21 -1
- package/src/lib/doc-links.ts +9 -4
- package/src/lib/homepage.ts +16 -0
- package/src/lib/markdown.ts +6 -0
- package/src/lib/portal-config.test.ts +11 -1
- package/src/lib/portal-config.ts +11 -0
- package/src/lib/portal-routes.test.ts +73 -0
- package/src/lib/portal-routes.ts +65 -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 +10 -8
- package/src/lib/remark-wiki-links.ts +17 -8
- package/src/lib/seo-sitemap.test.ts +46 -0
- package/src/lib/seo-sitemap.ts +55 -0
- package/src/lib/sidebar.test.ts +32 -3
- package/src/lib/sidebar.ts +26 -17
- package/src/overrides/Footer.astro +3 -5
- 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
package/package.json
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "upcontent",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.1.
|
|
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
|
-
|
|
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('
|
|
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/
|
|
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
|
-
|
|
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,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
|
+
})
|
package/src/content.config.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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 ??
|
|
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
|
})
|
package/src/lib/doc-links.ts
CHANGED
|
@@ -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.
|
|
25
|
-
const
|
|
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
|
|
28
|
-
const
|
|
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
|
-
|
|
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,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
|
+
}
|