upcontent 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/.github/workflows/ci.yml +3 -3
  2. package/.github/workflows/publish.yml +75 -0
  3. package/.upcontent/config.json +6 -1
  4. package/CONTEXT.md +25 -0
  5. package/Makefile +6 -2
  6. package/README.md +57 -39
  7. package/assets/readme/portal-home.png +0 -0
  8. package/assets/readme/portal-showcase.png +0 -0
  9. package/astro.config.mjs +67 -18
  10. package/customization/config-json.md +9 -1
  11. package/customization/content.md +14 -0
  12. package/customization/navigation.md +2 -0
  13. package/deployment/index.md +1 -0
  14. package/deployment/npm.md +39 -0
  15. package/guides/validate-your-site.md +5 -0
  16. package/index.md +19 -0
  17. package/package.json +15 -9
  18. package/scripts/upcontent-cli.mjs +2 -4
  19. package/scripts/verify-external-build.mjs +9 -1
  20. package/scripts/verify-golden-build.mjs +13 -0
  21. package/src/components/MermaidLoader.astro +17 -3
  22. package/src/content/i18n/en.json +1 -0
  23. package/src/content.config.test.ts +52 -1
  24. package/src/content.config.ts +29 -4
  25. package/src/lib/content-blocklist.ts +2 -2
  26. package/src/lib/doc-links.test.ts +21 -1
  27. package/src/lib/doc-links.ts +9 -4
  28. package/src/lib/homepage.ts +16 -0
  29. package/src/lib/markdown.ts +6 -0
  30. package/src/lib/portal-config.test.ts +11 -1
  31. package/src/lib/portal-config.ts +11 -0
  32. package/src/lib/portal-routes.test.ts +73 -0
  33. package/src/lib/portal-routes.ts +65 -0
  34. package/src/lib/remark-doc-links.test.ts +50 -0
  35. package/src/lib/remark-doc-links.ts +21 -0
  36. package/src/lib/remark-wiki-links.test.ts +10 -8
  37. package/src/lib/remark-wiki-links.ts +17 -8
  38. package/src/lib/seo-sitemap.test.ts +46 -0
  39. package/src/lib/seo-sitemap.ts +55 -0
  40. package/src/lib/sidebar.test.ts +32 -3
  41. package/src/lib/sidebar.ts +26 -17
  42. package/src/overrides/Footer.astro +3 -5
  43. package/src/overrides/Head.astro +86 -3
  44. package/src/pages/robots.txt.ts +46 -0
  45. package/src/upcontent-cli.test.ts +36 -0
  46. package/test-fixtures/external-consumer/.upcontent/config.json +4 -0
  47. package/test-fixtures/external-consumer/noindex.md +7 -0
  48. package/test-fixtures/external-consumer/public.md +6 -0
  49. package/src/pages/index.astro +0 -20
@@ -0,0 +1,50 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import { unified } from 'unified'
6
+ import remarkParse from 'remark-parse'
7
+ import remarkRehype from 'remark-rehype'
8
+ import rehypeStringify from 'rehype-stringify'
9
+ import { remarkDocumentLinks } from './remark-doc-links'
10
+
11
+ const roots: string[] = []
12
+
13
+ afterEach(() => {
14
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
15
+ })
16
+
17
+ function render(markdown: string, source: string, root: string, basePath?: string): string {
18
+ return unified()
19
+ .use(remarkParse)
20
+ .use(remarkDocumentLinks, { contentRoot: root, basePath })
21
+ .use(remarkRehype)
22
+ .use(rehypeStringify)
23
+ .processSync({ value: markdown, path: source })
24
+ .toString()
25
+ }
26
+
27
+ function fixture(files: string[]) {
28
+ const root = mkdtempSync(join(tmpdir(), 'upcontent-markdown-links-'))
29
+ roots.push(root)
30
+ for (const file of files) {
31
+ mkdirSync(dirname(join(root, file)), { recursive: true })
32
+ writeFileSync(join(root, file), '')
33
+ }
34
+ return root
35
+ }
36
+
37
+ describe('remarkDocumentLinks', () => {
38
+ it('converts relative Markdown links using the source file path', () => {
39
+ const root = fixture(['README.md', 'acervos/resource.md'])
40
+ const html = render('[Resource](acervos/resource.md)', join(root, 'README.md'), root)
41
+ expect(html).toContain('href="/acervos/resource/"')
42
+ })
43
+
44
+ it('preserves external links and applies the base path to document routes', () => {
45
+ const root = fixture(['README.md', 'guide.md'])
46
+ const html = render('[Guide](guide.md) [External](https://example.com/guide.md)', join(root, 'README.md'), root, '/recursos/')
47
+ expect(html).toContain('href="/recursos/guide/"')
48
+ expect(html).toContain('href="https://example.com/guide.md"')
49
+ })
50
+ })
@@ -0,0 +1,21 @@
1
+ import { visit } from 'unist-util-visit'
2
+ import { resolveMarkdownLink } from './portal-routes'
3
+
4
+ interface LinkNode {
5
+ type: 'link'
6
+ url: string
7
+ }
8
+
9
+ export interface DocumentLinkOptions {
10
+ contentRoot: string
11
+ basePath?: string
12
+ }
13
+
14
+ export function remarkDocumentLinks(options: DocumentLinkOptions) {
15
+ return (tree: any, file: { path?: string }) => {
16
+ if (!file.path) return
17
+ visit(tree, 'link', (node: LinkNode) => {
18
+ node.url = resolveMarkdownLink(node.url, file.path!, options.contentRoot, options.basePath)
19
+ })
20
+ }
21
+ }
@@ -28,13 +28,13 @@ function renderStrict(markdown: string): string {
28
28
  describe('remarkWikiLinks', () => {
29
29
  it('resolve [[page]] simples pra /page', () => {
30
30
  const html = render('Veja [[backend/plan]] pra mais detalhes.')
31
- expect(html).toContain('href="/backend/plan"')
31
+ expect(html).toContain('href="/backend/plan/"')
32
32
  expect(html).toContain('>backend/plan<')
33
33
  })
34
34
 
35
35
  it('resolve [[page#heading]] combinando path e anchor', () => {
36
36
  const html = render('Ver [[DESIGN-licencas#Impact Summary]].')
37
- expect(html).toContain('href="/design-licencas#impact-summary"')
37
+ expect(html).toContain('href="/design-licencas/#impact-summary"')
38
38
  })
39
39
 
40
40
  it('resolve [[#heading]] como anchor local, sem path', () => {
@@ -44,13 +44,13 @@ describe('remarkWikiLinks', () => {
44
44
 
45
45
  it('resolve [[page|label]] usando o label como texto do link', () => {
46
46
  const html = render('Veja [[backend/plan|o plano]] aqui.')
47
- expect(html).toContain('href="/backend/plan"')
47
+ expect(html).toContain('href="/backend/plan/"')
48
48
  expect(html).toContain('>o plano<')
49
49
  })
50
50
 
51
51
  it('normaliza espaços e maiúsculas no path', () => {
52
52
  const html = render('Ver [[Meu Documento]].')
53
- expect(html).toContain('href="/meu-documento"')
53
+ expect(html).toContain('href="/meu-documento/"')
54
54
  })
55
55
 
56
56
  it('não afeta texto sem wiki links', () => {
@@ -61,8 +61,8 @@ describe('remarkWikiLinks', () => {
61
61
 
62
62
  it('resolve múltiplos wiki links na mesma linha', () => {
63
63
  const html = render('Ver [[a]] e também [[b]].')
64
- expect(html).toContain('href="/a"')
65
- expect(html).toContain('href="/b"')
64
+ expect(html).toContain('href="/a/"')
65
+ expect(html).toContain('href="/b/"')
66
66
  })
67
67
 
68
68
  // não corrompe rótulos de diagrama Mermaid que usam [[texto]] (sintaxe de subroutine node) —
@@ -78,12 +78,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#product"')
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 '/' + slugifyPath(pagePart) + '#' + slugifyHeading(headingPart)
54
+ return `${pageRoute(pagePart, basePath, homepage)}#${slugifyHeading(headingPart)}`
46
55
  }
47
- return '/' + slugifyPath(ref)
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 (/\.(?!mdx?$)[^/]+$/i.test(pagePart)) return false
61
- const withoutExtension = target.replace(/\.mdx?$/i, '')
62
- return [`${withoutExtension}.md`, `${withoutExtension}.mdx`, resolve(target, 'index.md'), resolve(target, 'index.mdx')]
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
+ }
@@ -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 { isDirectory: () => node !== null && typeof node === 'object' } as never
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: 'readme', label: 'Home' }])
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: 'readme', label: 'Home' })
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(
@@ -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.replace(/\.mdx?$/i, '').toLowerCase()
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 (/\.mdx?$/i.test(name)) {
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({ slug: toSidebarSlug(rel) })
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 (/\.mdx?$/i.test(name)) {
102
- entries.push({ slug: toSidebarSlug(name) })
111
+ } else if (MARKDOWN_EXTENSION.test(name)) {
112
+ entries.push(sidebarFileEntry(name, homepage))
103
113
  }
104
114
  }
105
115
 
106
- // README fica fixo em primeiro, relabelado como "Home" — é a landing
107
- // page do portal, não deveria competir alfabeticamente nem aparecer com
108
- // o nome literal do arquivo.
109
- const readmeIndex = entries.findIndex(e => !isSidebarGroup(e) && e.slug === 'readme')
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 readme ? [{ slug: 'readme', label: 'Home' }, ...sorted] : sorted
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
- (await getCollection('docs')).map(doc => ({
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={`/${doc.slug}`}>{doc.title}</a></li>
57
+ <li><a href={`${baseUrl}${doc.slug}`}>{doc.title}</a></li>
60
58
  ))}
61
59
  </ul>
62
60
  </section>
@@ -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 isHome = Astro.locals.starlightRoute.id === 'readme'
12
- const pageHead = isHome ? head.filter(({ tag }) => tag !== 'title') : head
13
- const siteTitle = getPortalConfig().site?.title ?? PRODUCT_NAME
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
+ }