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
|
@@ -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,14 @@ 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="/readme/"')
|
|
82
|
+
expect(renderStrict('Veja [[README.md]].')).toContain('href="/readme/"')
|
|
83
|
+
expect(renderStrict('Veja [[index]].')).toContain('href="/"')
|
|
82
84
|
})
|
|
83
85
|
|
|
84
86
|
it('mantém os formatos suportados sob validação estrita', () => {
|
|
85
87
|
expect(renderStrict('[[#Product]] [[README#Product]] [[README|início]]')).toContain('href="#product"')
|
|
86
|
-
expect(renderStrict('[[#Product]] [[README#Product]] [[README|início]]')).toContain('href="/readme
|
|
88
|
+
expect(renderStrict('[[#Product]] [[README#Product]] [[README|início]]')).toContain('href="/readme/#product"')
|
|
87
89
|
})
|
|
88
90
|
|
|
89
91
|
it('rejeita referências a arquivos que não são documentos', () => {
|
|
@@ -2,6 +2,9 @@ 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 { hasRootIndex } from './homepage'
|
|
6
|
+
import { MARKDOWN_EXTENSION, MARKDOWN_EXTENSIONS, stripMarkdownExtension } from './markdown'
|
|
7
|
+
import { toPortalRoute } from './portal-routes'
|
|
5
8
|
|
|
6
9
|
interface MdastText {
|
|
7
10
|
type: 'text'
|
|
@@ -22,6 +25,7 @@ interface MdastParent {
|
|
|
22
25
|
|
|
23
26
|
interface WikiLinkOptions {
|
|
24
27
|
contentRoot?: string
|
|
28
|
+
basePath?: string
|
|
25
29
|
failOnBrokenLinks?: boolean
|
|
26
30
|
}
|
|
27
31
|
|
|
@@ -33,18 +37,23 @@ function slugifyHeading(heading: string): string {
|
|
|
33
37
|
return heading.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '')
|
|
34
38
|
}
|
|
35
39
|
|
|
40
|
+
function pageRoute(pagePart: string, basePath: string, homepage: 'index' | 'readme'): string {
|
|
41
|
+
const withoutExtension = stripMarkdownExtension(pagePart)
|
|
42
|
+
return toPortalRoute(`${slugifyPath(withoutExtension)}.md`, basePath, homepage)
|
|
43
|
+
}
|
|
44
|
+
|
|
36
45
|
// Constrói a URL a partir da referência crua entre colchetes: [[#heading]] vira
|
|
37
46
|
// anchor local; [[page]] vira path; [[page#heading]] combina os dois — path e
|
|
38
47
|
// heading são fatiados (slugify) separadamente pra não perder o separador "#".
|
|
39
|
-
function buildUrl(ref: string): string {
|
|
48
|
+
function buildUrl(ref: string, basePath = '/', homepage: 'index' | 'readme' = 'readme'): string {
|
|
40
49
|
if (ref.startsWith('#')) return '#' + slugifyHeading(ref.slice(1))
|
|
41
50
|
const hashIndex = ref.indexOf('#')
|
|
42
51
|
if (hashIndex >= 0) {
|
|
43
52
|
const pagePart = ref.slice(0, hashIndex)
|
|
44
53
|
const headingPart = ref.slice(hashIndex + 1)
|
|
45
|
-
return
|
|
54
|
+
return `${pageRoute(pagePart, basePath, homepage)}#${slugifyHeading(headingPart)}`
|
|
46
55
|
}
|
|
47
|
-
return
|
|
56
|
+
return pageRoute(ref, basePath, homepage)
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
function pagePartOf(ref: string): string {
|
|
@@ -57,16 +66,16 @@ function wikiLinkResolves(ref: string, contentRoot: string): boolean {
|
|
|
57
66
|
if (!pagePart) return true
|
|
58
67
|
const target = resolve(contentRoot, pagePart)
|
|
59
68
|
if (!target.startsWith(`${resolve(contentRoot)}${sep}`)) return false
|
|
60
|
-
if (/\.
|
|
61
|
-
const withoutExtension = target
|
|
62
|
-
return
|
|
63
|
-
.some(candidate => existsSync(candidate))
|
|
69
|
+
if (/\.[^/]+$/i.test(pagePart) && !MARKDOWN_EXTENSION.test(pagePart)) return false
|
|
70
|
+
const withoutExtension = stripMarkdownExtension(target)
|
|
71
|
+
return MARKDOWN_EXTENSIONS.some(extension => existsSync(`${withoutExtension}${extension}`) || existsSync(resolve(target, `index${extension}`)))
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
// Remark plugin: converts [[page]] / [[#heading]] / [[page#heading]] / [[page|label]]
|
|
67
75
|
// Obsidian wiki links pra links markdown normais.
|
|
68
76
|
export function remarkWikiLinks(options: WikiLinkOptions = {}) {
|
|
69
77
|
return (tree: MdastParent, file: { path?: string }) => {
|
|
78
|
+
const homepage = options.contentRoot && hasRootIndex(options.contentRoot) ? 'index' : 'readme'
|
|
70
79
|
visit(tree, 'text', (node: MdastText, index: number | undefined, parent: MdastParent | undefined) => {
|
|
71
80
|
if (!node.value.includes('[[')) return
|
|
72
81
|
const parts: (MdastText | MdastLink)[] = []
|
|
@@ -89,7 +98,7 @@ export function remarkWikiLinks(options: WikiLinkOptions = {}) {
|
|
|
89
98
|
const source = file.path ? ` in ${file.path}` : ''
|
|
90
99
|
throw new Error(`[${PRODUCT_NAME}] Broken wiki link [[${inner}]]${source}`)
|
|
91
100
|
}
|
|
92
|
-
const url = buildUrl(ref)
|
|
101
|
+
const url = buildUrl(ref, options.basePath, homepage)
|
|
93
102
|
parts.push({ type: 'link', url, title: null, children: [{ type: 'text', value: label }] })
|
|
94
103
|
lastIndex = match.index + match[0].length
|
|
95
104
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
|
|
38
|
+
it('keeps a noindex README separate when index.md is the homepage', () => {
|
|
39
|
+
const root = mkdtempSync(join(tmpdir(), 'upcontent-seo-'))
|
|
40
|
+
roots.push(root)
|
|
41
|
+
writeFileSync(join(root, 'index.md'), '# Home\n')
|
|
42
|
+
writeFileSync(join(root, 'README.md'), '---\nnoindex: true\n---\n')
|
|
43
|
+
|
|
44
|
+
expect(getNoindexRoutes(root)).toEqual(new Set(['/readme']))
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { join, relative } from 'node:path'
|
|
3
|
+
import { load as parseYaml } from 'js-yaml'
|
|
4
|
+
import { hasRootIndex } from './homepage'
|
|
5
|
+
import { MARKDOWN_EXTENSION, stripMarkdownExtension } from './markdown'
|
|
6
|
+
|
|
7
|
+
export function getNoindexRoutes(docsRoot: string): Set<string> {
|
|
8
|
+
const routes = new Set<string>()
|
|
9
|
+
const homepage = hasRootIndex(docsRoot) ? 'index' : 'readme'
|
|
10
|
+
|
|
11
|
+
function visitDirectory(directory: string): void {
|
|
12
|
+
for (const name of readdirSync(directory)) {
|
|
13
|
+
if (name.startsWith('.')) continue
|
|
14
|
+
const filePath = join(directory, name)
|
|
15
|
+
if (statSync(filePath).isDirectory()) {
|
|
16
|
+
visitDirectory(filePath)
|
|
17
|
+
continue
|
|
18
|
+
}
|
|
19
|
+
if (!MARKDOWN_EXTENSION.test(name)) continue
|
|
20
|
+
|
|
21
|
+
const source = readFileSync(filePath, 'utf8')
|
|
22
|
+
const frontmatter = source.match(/^---\s*\n([\s\S]*?)\n---(?:\s|$)/)?.[1]
|
|
23
|
+
if (!frontmatter) continue
|
|
24
|
+
|
|
25
|
+
let data: unknown
|
|
26
|
+
try {
|
|
27
|
+
data = parseYaml(frontmatter)
|
|
28
|
+
} catch {
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
if (!data || typeof data !== 'object' || Array.isArray(data) || (data as Record<string, unknown>).noindex !== true) {
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const docPath = relative(docsRoot, filePath).replace(/\\/g, '/')
|
|
36
|
+
const route = stripMarkdownExtension(docPath).replace(/\/index$/i, '').toLowerCase()
|
|
37
|
+
const encodedRoute = toSitemapRoute(route)
|
|
38
|
+
routes.add(route === homepage ? '/' : `/${encodedRoute}`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
visitDirectory(docsRoot)
|
|
44
|
+
} catch {
|
|
45
|
+
return routes
|
|
46
|
+
}
|
|
47
|
+
return routes
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toSitemapRoute(route: string): string {
|
|
51
|
+
const path = route
|
|
52
|
+
.replace(/%/g, '%25')
|
|
53
|
+
.replace(/[?#]/g, character => character === '?' ? '%3F' : '%23')
|
|
54
|
+
return new URL(`https://upcontent.invalid/${path}`).pathname.replace(/^\//, '')
|
|
55
|
+
}
|
package/src/lib/sidebar.test.ts
CHANGED
|
@@ -38,7 +38,10 @@ function mountFs(root: string, tree: Tree) {
|
|
|
38
38
|
|
|
39
39
|
vi.mocked(fs.statSync).mockImplementation((path: unknown) => {
|
|
40
40
|
const node = lookup(String(path))
|
|
41
|
-
return {
|
|
41
|
+
return {
|
|
42
|
+
isDirectory: () => node !== null && typeof node === 'object',
|
|
43
|
+
isFile: () => node === null,
|
|
44
|
+
} as never
|
|
42
45
|
})
|
|
43
46
|
}
|
|
44
47
|
|
|
@@ -89,6 +92,15 @@ describe('buildSidebar', () => {
|
|
|
89
92
|
expect(sidebar.map(entry => entry.label)).toEqual(['Docs'])
|
|
90
93
|
})
|
|
91
94
|
|
|
95
|
+
it('mantém a homepage mesmo quando roots não a lista', () => {
|
|
96
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
97
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ navigation: { roots: ['guides'] } }))
|
|
98
|
+
mountFs(ROOT, { 'index.md': null, guides: { 'guide.md': null } })
|
|
99
|
+
|
|
100
|
+
const sidebar = buildSidebar(ROOT)
|
|
101
|
+
expect(sidebar[0]).toEqual({ slug: 'index', label: 'Home' })
|
|
102
|
+
})
|
|
103
|
+
|
|
92
104
|
it('ignora dotfiles e dot-directories', () => {
|
|
93
105
|
mountFs(ROOT, { '.claude': { 'x.md': null }, domains: { historico: { 'a.md': null } } })
|
|
94
106
|
const sidebar = buildSidebar(ROOT) as { label: string }[]
|
|
@@ -98,16 +110,33 @@ describe('buildSidebar', () => {
|
|
|
98
110
|
it('ignora arquivos bloqueados (floor hardcoded)', () => {
|
|
99
111
|
mountFs(ROOT, { 'CLAUDE.md': null, 'README.md': null })
|
|
100
112
|
const sidebar = buildSidebar(ROOT)
|
|
101
|
-
expect(sidebar).toEqual([{ slug: '
|
|
113
|
+
expect(sidebar).toEqual([{ slug: 'index', label: 'Home' }])
|
|
102
114
|
})
|
|
103
115
|
|
|
104
116
|
it('fixa o README em primeiro, relabelado como Home, na frente de tudo', () => {
|
|
105
117
|
mountFs(ROOT, { 'README.md': null, domains: { historico: { 'a.md': null } } })
|
|
106
118
|
const sidebar = buildSidebar(ROOT) as { label: string }[]
|
|
107
|
-
expect(sidebar[0]).toEqual({ slug: '
|
|
119
|
+
expect(sidebar[0]).toEqual({ slug: 'index', label: 'Home' })
|
|
108
120
|
expect(sidebar[1].label).toBe('Historico')
|
|
109
121
|
})
|
|
110
122
|
|
|
123
|
+
it('permite sobrescrever o label do README', () => {
|
|
124
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
125
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
126
|
+
JSON.stringify({ navigation: { labelOverrides: { readme: 'Docs' } } }),
|
|
127
|
+
)
|
|
128
|
+
mountFs(ROOT, { 'README.md': null, domains: { historico: { 'a.md': null } } })
|
|
129
|
+
const sidebar = buildSidebar(ROOT) as { label: string }[]
|
|
130
|
+
expect(sidebar[0]).toEqual({ slug: 'index', label: 'Docs' })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('mantém README separado quando index.md é a homepage', () => {
|
|
134
|
+
mountFs(ROOT, { 'README.md': null, 'index.markdown': null, domains: { historico: { 'a.md': null } } })
|
|
135
|
+
const sidebar = buildSidebar(ROOT) as { slug?: string; label?: string }[]
|
|
136
|
+
expect(sidebar[0]).toEqual({ slug: 'index', label: 'Home' })
|
|
137
|
+
expect(sidebar).toContainEqual({ slug: 'readme', label: 'Readme' })
|
|
138
|
+
})
|
|
139
|
+
|
|
111
140
|
it('aplica labelOverrides do .upcontent/config.json em cima do Title Case', () => {
|
|
112
141
|
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
113
142
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
package/src/lib/sidebar.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { readdirSync, statSync } from 'node:fs'
|
|
2
2
|
import { isBlocked, resolveLabel } from './content-blocklist'
|
|
3
|
+
import { hasRootIndex } from './homepage'
|
|
4
|
+
import { MARKDOWN_EXTENSION, stripMarkdownExtension } from './markdown'
|
|
3
5
|
import { getPortalConfig } from './portal-config'
|
|
4
6
|
|
|
5
7
|
// Diretórios de topo que existem só como agrupamento estrutural do
|
|
@@ -33,11 +35,18 @@ function sortEntries(entries: SidebarEntry[]): SidebarEntry[] {
|
|
|
33
35
|
return [...entries].sort((a, b) => labelOf(a).localeCompare(labelOf(b), 'pt-BR'))
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
function toSidebarSlug(relativePath: string): string {
|
|
37
|
-
const slug = relativePath
|
|
38
|
+
function toSidebarSlug(relativePath: string, homepage: 'index' | 'readme'): string {
|
|
39
|
+
const slug = stripMarkdownExtension(relativePath).toLowerCase()
|
|
40
|
+
if (slug === homepage) return 'index'
|
|
41
|
+
if (slug === 'readme') return 'readme'
|
|
38
42
|
return slug.endsWith('/index') ? slug.slice(0, -'/index'.length) : slug
|
|
39
43
|
}
|
|
40
44
|
|
|
45
|
+
function sidebarFileEntry(relativePath: string, homepage: 'index' | 'readme'): SidebarLink {
|
|
46
|
+
const slug = toSidebarSlug(relativePath, homepage)
|
|
47
|
+
return slug === 'readme' ? { slug, label: resolveLabel('readme') } : { slug }
|
|
48
|
+
}
|
|
49
|
+
|
|
41
50
|
// Lista uma pasta ignorando dotfiles/dot-dirs e caminhos bloqueados
|
|
42
51
|
// (ver content-blocklist.ts) — relPath é relativo à raiz do content, sem
|
|
43
52
|
// barra inicial (ex: "domains/historico").
|
|
@@ -58,17 +67,17 @@ function listVisible(absDir: string, relPath: string): { name: string; isDir: bo
|
|
|
58
67
|
// (não só o de topo) recebe label em Title Case, porque o autogenerate
|
|
59
68
|
// nativo do Starlight usa o nome literal da pasta em todo nível abaixo do
|
|
60
69
|
// primeiro e não expõe nenhum jeito de sobrescrever isso via config.
|
|
61
|
-
function buildDir(absDir: string, relPath: string): SidebarEntry[] {
|
|
70
|
+
function buildDir(absDir: string, relPath: string, homepage: 'index' | 'readme'): SidebarEntry[] {
|
|
62
71
|
const entries: SidebarEntry[] = []
|
|
63
72
|
for (const { name, isDir } of listVisible(absDir, relPath)) {
|
|
64
73
|
const rel = relPath ? `${relPath}/${name}` : name
|
|
65
74
|
if (isDir) {
|
|
66
|
-
const items = buildDir(`${absDir}/${name}`, rel)
|
|
75
|
+
const items = buildDir(`${absDir}/${name}`, rel, homepage)
|
|
67
76
|
if (items.length > 0) entries.push({ label: resolveLabel(name), items })
|
|
68
|
-
} else if (
|
|
77
|
+
} else if (MARKDOWN_EXTENSION.test(name)) {
|
|
69
78
|
// Slug do Starlight = path relativo ao content root, sem extensão,
|
|
70
79
|
// minúsculo (ver ADR/nota em Footer.astro — mesmo mecanismo).
|
|
71
|
-
entries.push(
|
|
80
|
+
entries.push(sidebarFileEntry(rel, homepage))
|
|
72
81
|
}
|
|
73
82
|
}
|
|
74
83
|
return sortEntries(entries)
|
|
@@ -86,28 +95,28 @@ export function buildSidebar(docsRoot: string): SidebarEntry[] {
|
|
|
86
95
|
return []
|
|
87
96
|
}
|
|
88
97
|
|
|
98
|
+
const homepage = hasRootIndex(docsRoot) ? 'index' : 'readme'
|
|
89
99
|
const configuredRoots = getPortalConfig().navigation?.roots
|
|
90
100
|
const visibleTopLevel = configuredRoots
|
|
91
|
-
? topLevel.filter(({ name }) => configuredRoots.includes(name))
|
|
101
|
+
? topLevel.filter(({ name, isDir }) => configuredRoots.includes(name) || (!isDir && ['index', 'readme'].includes(toSidebarSlug(name, homepage))))
|
|
92
102
|
: topLevel
|
|
93
103
|
|
|
94
104
|
const entries: SidebarEntry[] = []
|
|
95
105
|
for (const { name, isDir } of visibleTopLevel) {
|
|
96
106
|
if (isDir && FLATTEN_TOP_LEVEL_DIRS.has(name)) {
|
|
97
|
-
entries.push(...buildDir(`${docsRoot}/${name}`, name))
|
|
107
|
+
entries.push(...buildDir(`${docsRoot}/${name}`, name, homepage))
|
|
98
108
|
} else if (isDir) {
|
|
99
|
-
const items = buildDir(`${docsRoot}/${name}`, name)
|
|
109
|
+
const items = buildDir(`${docsRoot}/${name}`, name, homepage)
|
|
100
110
|
if (items.length > 0) entries.push({ label: resolveLabel(name), items })
|
|
101
|
-
} else if (
|
|
102
|
-
entries.push(
|
|
111
|
+
} else if (MARKDOWN_EXTENSION.test(name)) {
|
|
112
|
+
entries.push(sidebarFileEntry(name, homepage))
|
|
103
113
|
}
|
|
104
114
|
}
|
|
105
115
|
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
const readme = readmeIndex >= 0 ? entries.splice(readmeIndex, 1)[0] : undefined
|
|
116
|
+
// A homepage fica fixa em primeiro, com o label configurável "Home" por
|
|
117
|
+
// padrão, sem competir alfabeticamente com as outras páginas.
|
|
118
|
+
const homepageIndex = entries.findIndex(e => !isSidebarGroup(e) && e.slug === 'index')
|
|
119
|
+
const homepageEntry = homepageIndex >= 0 ? entries.splice(homepageIndex, 1)[0] : undefined
|
|
111
120
|
const sorted = sortEntries(entries)
|
|
112
|
-
return
|
|
121
|
+
return homepageEntry ? [{ slug: 'index', label: resolveLabel(homepage, 'Home') }, ...sorted] : sorted
|
|
113
122
|
}
|
|
@@ -23,12 +23,10 @@ const editUrl = buildGitHubUrl(repoUrl, filePath, 'edit')
|
|
|
23
23
|
const related = entry.data.related?.length
|
|
24
24
|
? resolveRelated(
|
|
25
25
|
entry.data.related,
|
|
26
|
-
|
|
27
|
-
...doc,
|
|
28
|
-
id: doc.filePath ? toRelativeDocPath(doc.filePath) : doc.id,
|
|
29
|
-
})),
|
|
26
|
+
await getCollection('docs'),
|
|
30
27
|
)
|
|
31
28
|
: []
|
|
29
|
+
const baseUrl = import.meta.env.BASE_URL
|
|
32
30
|
---
|
|
33
31
|
|
|
34
32
|
<Default><slot /></Default>
|
|
@@ -56,7 +54,7 @@ const related = entry.data.related?.length
|
|
|
56
54
|
<h2>Related documents</h2>
|
|
57
55
|
<ul>
|
|
58
56
|
{related.map(doc => (
|
|
59
|
-
<li><a href={
|
|
57
|
+
<li><a href={`${baseUrl}${doc.slug}`}>{doc.title}</a></li>
|
|
60
58
|
))}
|
|
61
59
|
</ul>
|
|
62
60
|
</section>
|
package/src/overrides/Head.astro
CHANGED
|
@@ -8,13 +8,96 @@ import { getPortalConfig } from '../lib/portal-config'
|
|
|
8
8
|
import { PRODUCT_NAME } from '../lib/product-identity'
|
|
9
9
|
|
|
10
10
|
const { head } = Astro.locals.starlightRoute
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
11
|
+
const { entry } = Astro.locals.starlightRoute
|
|
12
|
+
const normalizedBasePath = import.meta.env.BASE_URL.replace(/\/+$/, '')
|
|
13
|
+
const isHome = Astro.url.pathname.replace(/\/+$/, '') === normalizedBasePath
|
|
14
|
+
const baseHead = isHome ? head.filter(({ tag }) => tag !== 'title') : head
|
|
15
|
+
const config = getPortalConfig()
|
|
16
|
+
const seoEnabled = config.seo?.enabled === true && Boolean(Astro.site)
|
|
17
|
+
const siteTitle = config.site?.title ?? PRODUCT_NAME
|
|
18
|
+
const pageData = entry?.data ?? {}
|
|
19
|
+
const seoHead = seoEnabled
|
|
20
|
+
? baseHead
|
|
21
|
+
: baseHead.filter(({ tag, attrs }) => {
|
|
22
|
+
const property = typeof attrs?.property === 'string' ? attrs.property : undefined
|
|
23
|
+
const name = typeof attrs?.name === 'string' ? attrs.name : undefined
|
|
24
|
+
return !(tag === 'meta' && (property?.startsWith('og:') || name?.startsWith('twitter:')))
|
|
25
|
+
})
|
|
26
|
+
const canonicalTag = seoHead.find(({ tag, attrs }) => tag === 'link' && attrs?.rel === 'canonical')
|
|
27
|
+
const canonicalUrl = seoEnabled
|
|
28
|
+
? typeof pageData.canonical === 'string'
|
|
29
|
+
? pageData.canonical
|
|
30
|
+
: typeof canonicalTag?.attrs?.href === 'string'
|
|
31
|
+
? canonicalTag.attrs.href
|
|
32
|
+
: undefined
|
|
33
|
+
: undefined
|
|
34
|
+
const pageHead = pageData.canonical || config.site?.locale
|
|
35
|
+
? seoHead.filter(({ tag, attrs }) =>
|
|
36
|
+
!(pageData.canonical && tag === 'link' && attrs?.rel === 'canonical')
|
|
37
|
+
&& !(pageData.canonical && tag === 'meta' && attrs?.property === 'og:url')
|
|
38
|
+
&& !(config.site?.locale && tag === 'meta' && attrs?.property === 'og:locale'),
|
|
39
|
+
)
|
|
40
|
+
: seoHead
|
|
41
|
+
const socialImage = seoEnabled && (typeof pageData.image === 'string' ? pageData.image : config.site?.socialImage)
|
|
42
|
+
const robots = !seoEnabled || pageData.noindex === true ? 'noindex, nofollow' : undefined
|
|
43
|
+
const pageTitle = typeof pageData.title === 'string' ? pageData.title : siteTitle
|
|
44
|
+
const pageDescription = typeof pageData.description === 'string' ? pageData.description : config.site?.description
|
|
45
|
+
const absoluteImage = resolveOptionalUrl(socialImage, canonicalUrl)
|
|
46
|
+
const siteRoot = canonicalUrl && Astro.site ? new URL(import.meta.env.BASE_URL, Astro.site).href : undefined
|
|
47
|
+
const breadcrumbItems = canonicalUrl
|
|
48
|
+
? [{ '@type': 'ListItem', position: 1, name: siteTitle, item: siteRoot },
|
|
49
|
+
...(isHome ? [] : [{ '@type': 'ListItem', position: 2, name: pageTitle, item: canonicalUrl }])]
|
|
50
|
+
: []
|
|
51
|
+
const structuredData = canonicalUrl
|
|
52
|
+
? {
|
|
53
|
+
'@context': 'https://schema.org',
|
|
54
|
+
'@graph': [
|
|
55
|
+
{
|
|
56
|
+
'@type': 'WebSite',
|
|
57
|
+
'@id': `${siteRoot}#website`,
|
|
58
|
+
url: siteRoot,
|
|
59
|
+
name: siteTitle,
|
|
60
|
+
description: config.site?.description,
|
|
61
|
+
inLanguage: config.site?.locale,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
'@type': 'TechArticle',
|
|
65
|
+
'@id': `${canonicalUrl}#article`,
|
|
66
|
+
url: canonicalUrl,
|
|
67
|
+
headline: pageTitle,
|
|
68
|
+
description: pageDescription,
|
|
69
|
+
isPartOf: { '@id': `${siteRoot}#website` },
|
|
70
|
+
image: absoluteImage,
|
|
71
|
+
dateModified: pageData.updated,
|
|
72
|
+
breadcrumb: { '@type': 'BreadcrumbList', itemListElement: breadcrumbItems },
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
}
|
|
76
|
+
: undefined;
|
|
77
|
+
const structuredDataJson = structuredData
|
|
78
|
+
? JSON.stringify(structuredData).replace(/[<>&]/g, character => ({ '<': '\\u003c', '>': '\\u003e', '&': '\\u0026' })[character]!)
|
|
79
|
+
: undefined
|
|
80
|
+
|
|
81
|
+
function resolveOptionalUrl(value: unknown, baseUrl: string | undefined): string | undefined {
|
|
82
|
+
if (typeof value !== 'string' || value.length === 0) return undefined
|
|
83
|
+
try {
|
|
84
|
+
const url = new URL(value, baseUrl)
|
|
85
|
+
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : undefined
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
}
|
|
14
90
|
---
|
|
15
91
|
|
|
16
92
|
{pageHead.map(({ tag: Tag, attrs, content }) => <Tag {...attrs} set:html={content} />)}
|
|
17
93
|
<slot />
|
|
18
94
|
{isHome && <title>{siteTitle}</title>}
|
|
95
|
+
{seoEnabled && pageData.canonical && <link rel="canonical" href={canonicalUrl} />}
|
|
96
|
+
{seoEnabled && pageData.canonical && <meta property="og:url" content={canonicalUrl} />}
|
|
97
|
+
{seoEnabled && config.site?.locale && <meta property="og:locale" content={config.site.locale} />}
|
|
98
|
+
{absoluteImage && <meta property="og:image" content={absoluteImage} />}
|
|
99
|
+
{absoluteImage && <meta name="twitter:image" content={absoluteImage} />}
|
|
100
|
+
{robots && <meta name="robots" content={robots} />}
|
|
101
|
+
{structuredDataJson && <script type="application/ld+json" set:html={structuredDataJson} />}
|
|
19
102
|
<MermaidLoader />
|
|
20
103
|
<StructuredDataCopy />
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { getPortalConfig } from '../lib/portal-config'
|
|
2
|
+
|
|
3
|
+
export const prerender = true
|
|
4
|
+
|
|
5
|
+
export function GET() {
|
|
6
|
+
const config = getPortalConfig()
|
|
7
|
+
const configuredUrl = process.env.SITE_URL || config.site?.url
|
|
8
|
+
const seoEnabled = config.seo?.enabled === true && Boolean(configuredUrl)
|
|
9
|
+
if (!seoEnabled) {
|
|
10
|
+
return disallowRobots()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const basePath = process.env.BASE_PATH || ''
|
|
14
|
+
let siteUrl
|
|
15
|
+
try {
|
|
16
|
+
siteUrl = configuredUrl ? addBasePath(configuredUrl, basePath) : undefined
|
|
17
|
+
} catch {
|
|
18
|
+
return disallowRobots()
|
|
19
|
+
}
|
|
20
|
+
const sitemap = siteUrl ? `${siteUrl}/sitemap-index.xml` : undefined
|
|
21
|
+
const body = ['User-agent: *', 'Allow: /', sitemap && `Sitemap: ${sitemap}`].filter(Boolean).join('\n') + '\n'
|
|
22
|
+
|
|
23
|
+
return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function addBasePath(siteUrl: string, basePath: string): string {
|
|
27
|
+
const url = new URL(siteUrl)
|
|
28
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('unsupported protocol')
|
|
29
|
+
if (url.username || url.password) throw new Error('userinfo is not allowed')
|
|
30
|
+
url.search = ''
|
|
31
|
+
url.hash = ''
|
|
32
|
+
const normalizedBasePath = basePath.replace(/^\/+|\/+$/g, '')
|
|
33
|
+
|
|
34
|
+
if (normalizedBasePath) {
|
|
35
|
+
url.pathname = `/${normalizedBasePath}`
|
|
36
|
+
return url.toString().replace(/\/+$/, '')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return url.toString().replace(/\/+$/, '')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function disallowRobots(): Response {
|
|
43
|
+
return new Response('User-agent: *\nDisallow: /\n', {
|
|
44
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
45
|
+
})
|
|
46
|
+
}
|