upcontent 0.1.1 → 0.1.3

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.
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "navigation": {
43
43
  "roots": [
44
- "README.md",
44
+ "index.md",
45
45
  "getting-started",
46
46
  "guides",
47
47
  "customization",
@@ -72,11 +72,13 @@
72
72
  "AGENTS.md",
73
73
  "CLAUDE.md",
74
74
  "CONTEXT.md",
75
- "PRODUCT.md"
75
+ "PRODUCT.md",
76
+ "README.md"
76
77
  ]
77
78
  },
78
79
  "labelOverrides": {
79
- "docs": "Project Docs"
80
+ "docs": "Project Docs",
81
+ "readme": "Repository README"
80
82
  }
81
83
  },
82
84
  "content": {
package/README.md CHANGED
@@ -1,8 +1,3 @@
1
- ---
2
- heading: Upcontent
3
- description: Turn a documentation repository into a fast, searchable, customizable static portal.
4
- ---
5
-
6
1
  <div align="center">
7
2
 
8
3
  # Upcontent
package/astro.config.mjs CHANGED
@@ -14,6 +14,7 @@ import { remarkDocumentLinks } from './src/lib/remark-doc-links.ts'
14
14
  import { getPortalConfig } from './src/lib/portal-config.ts'
15
15
  import { buildSidebar } from './src/lib/sidebar.ts'
16
16
  import { getNoindexRoutes } from './src/lib/seo-sitemap.ts'
17
+ import { hasRootIndex } from './src/lib/homepage.ts'
17
18
  import { PRODUCT_NAME, PRODUCT_TAGLINE } from './src/lib/product-identity.ts'
18
19
 
19
20
  const docsRoot = fileURLToPath(new URL('./src/content/docs', import.meta.url))
@@ -22,14 +23,20 @@ const portalConfig = getPortalConfig()
22
23
  function copyContentAssetDirectory(assetPath) {
23
24
  const source = resolve(docsRoot, assetPath)
24
25
  const target = resolve(process.cwd(), 'public', assetPath)
26
+ const readmeTarget = resolve(process.cwd(), 'public', 'readme', assetPath)
27
+ const hasHomepage = hasRootIndex(docsRoot)
28
+ const targets = hasHomepage ? [target, readmeTarget] : [target]
29
+ if (!hasHomepage) rmSync(readmeTarget, { force: true, recursive: true })
25
30
  if (!existsSync(source) || !statSync(source).isDirectory()) {
26
- rmSync(target, { force: true, recursive: true })
31
+ for (const target of targets) rmSync(target, { force: true, recursive: true })
27
32
  return
28
33
  }
29
34
 
30
- rmSync(target, { force: true, recursive: true })
31
- mkdirSync(resolve(target, '..'), { recursive: true })
32
- cpSync(source, target, { recursive: true })
35
+ for (const target of targets) {
36
+ rmSync(target, { force: true, recursive: true })
37
+ mkdirSync(resolve(target, '..'), { recursive: true })
38
+ cpSync(source, target, { recursive: true })
39
+ }
33
40
  }
34
41
 
35
42
  copyContentAssetDirectory('assets/readme')
@@ -156,8 +163,8 @@ export default defineConfig({
156
163
  processor: unified({
157
164
  remarkPlugins: [
158
165
  remarkStripDuplicateTitle,
159
- [remarkWikiLinks, { contentRoot: docsRoot, basePath: import.meta.env.BASE_URL, failOnBrokenLinks: true }],
160
- [remarkDocumentLinks, { contentRoot: docsRoot, basePath: import.meta.env.BASE_URL }],
166
+ [remarkWikiLinks, { contentRoot: docsRoot, basePath: base || '/', failOnBrokenLinks: true }],
167
+ [remarkDocumentLinks, { contentRoot: docsRoot, basePath: base || '/' }],
161
168
  remarkMermaid,
162
169
  remarkStructuredDataPreview,
163
170
  ],
@@ -7,6 +7,8 @@ sidebar:
7
7
 
8
8
  The sidebar is generated from the consumer file structure. Use configuration when the default filesystem order is not the right experience for readers.
9
9
 
10
+ If the repository has a root `index.md`, it becomes the website homepage and a root `README.md` remains available at `/readme/`. Repositories without `index.md` keep the backwards-compatible behavior where `README.md` is the homepage.
11
+
10
12
  ## Select top-level roots
11
13
 
12
14
  ```json
package/index.md ADDED
@@ -0,0 +1,19 @@
1
+ ---
2
+ heading: Upcontent
3
+ description: Turn a documentation repository into a fast, searchable, customizable static portal.
4
+ sidebar:
5
+ order: 1
6
+ ---
7
+
8
+ Upcontent turns a Markdown repository into a documentation site that is ready to share.
9
+
10
+ It adds clear navigation, full-text search, technical content rendering, consumer-owned branding, and a repeatable static build while keeping the source repository as the source of truth.
11
+
12
+ ## Start here
13
+
14
+ - [Set up a consumer repository](getting-started/consumer-repository/)
15
+ - [Run your first build](getting-started/first-build/)
16
+ - [Customize the portal](customization/)
17
+ - [Validate before publishing](guides/validate-your-site/)
18
+
19
+ The [repository README](readme/) contains the project and development details for Upcontent itself.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "upcontent",
3
3
  "type": "module",
4
- "version": "0.1.1",
4
+ "version": "0.1.3",
5
5
  "packageManager": "pnpm@10.20.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -1,9 +1,12 @@
1
- import { existsSync, readFileSync } from 'node:fs'
1
+ import { existsSync } from 'node:fs'
2
2
 
3
- const html = readFileSync('dist/index.html', 'utf8')
3
+ const htmlPath = 'dist/index.html'
4
4
  const requiredAssets = ['assets/readme/portal-home.png', 'assets/readme/portal-showcase.png']
5
5
 
6
+ if (!existsSync(htmlPath)) throw new Error(`Golden build is missing: ${htmlPath}`)
7
+
6
8
  for (const asset of requiredAssets) {
7
9
  if (!existsSync(`dist/${asset}`)) throw new Error(`Golden build is missing: dist/${asset}`)
8
- if (!html.includes(asset)) throw new Error(`Golden build does not reference: ${asset}`)
9
10
  }
11
+
12
+ if (existsSync('dist/readme/index.html')) throw new Error('Golden build unexpectedly published README as a portal route')
@@ -195,6 +195,11 @@ describe('toCollectionId', () => {
195
195
  expect(toCollectionId('README.md')).toBe('index')
196
196
  })
197
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
+
198
203
  it('normalizes document ids while preserving nested routes', () => {
199
204
  expect(toCollectionId('Guides/Getting-Started.mdx')).toBe('guides/getting-started')
200
205
  })
@@ -7,12 +7,12 @@ import { i18nLoader } from '@astrojs/starlight/loaders'
7
7
  import { glob } from 'astro/loaders'
8
8
  import type { Loader, LoaderContext } from 'astro/loaders'
9
9
  import { getBlocklist, isBlocked, toRelativeDocPath, toTitleCase } from './lib/content-blocklist'
10
+ import { hasRootIndex } from './lib/homepage'
11
+ import { MARKDOWN_EXTENSION, stripMarkdownExtension } from './lib/markdown'
10
12
  import { getPortalConfig } from './lib/portal-config'
11
13
 
12
14
  export { getBlocklist, isBlocked, toRelativeDocPath }
13
15
 
14
- const MARKDOWN_EXTENSION = /\.(?:markdown|mdown|mkdn|mkd|mdwn|md|mdx)$/i
15
-
16
16
  // Campos de domínio específicos deste portal, além do schema padrão do
17
17
  // Starlight (title, description, sidebar, etc). Mantido isolado do
18
18
  // docsSchema() do Starlight pra ser testável sem precisar de um
@@ -65,9 +65,10 @@ export function resolveTitle(relativeFilePath: string, data: Record<string, unkn
65
65
  data.title = toTitleCase(withoutExt)
66
66
  }
67
67
 
68
- export function toCollectionId(relativeFilePath: string): string {
69
- const normalized = relativeFilePath.split(path.sep).join('/').replace(MARKDOWN_EXTENSION, '').toLowerCase()
70
- if (normalized === 'readme') return 'index'
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'
71
72
  return normalized.endsWith('/index') ? normalized.slice(0, -'/index'.length) : normalized
72
73
  }
73
74
 
@@ -82,6 +83,7 @@ function portalDocsLoader(): Loader {
82
83
  name: 'portal-docs-loader',
83
84
  async load(context: LoaderContext) {
84
85
  const docsBasePath = fileURLToPath(new URL('src/content/docs/', context.config.root))
86
+ const homepage = hasRootIndex(docsBasePath) ? 'index' : 'readme'
85
87
  const patterns = [
86
88
  '**/[^_]*.{markdown,mdown,mkdn,mkd,mdwn,md,mdx}',
87
89
  ...getBlocklist().map(blocked => `!${toCaseInsensitiveGlob(blocked)}${blocked.endsWith('/') ? '**' : ''}`),
@@ -96,14 +98,14 @@ function portalDocsLoader(): Loader {
96
98
  return context.parseData(props)
97
99
  },
98
100
  }
99
- await glob({ base: docsBasePath, pattern: patterns, generateId: ({ entry }) => toCollectionId(entry) }).load(wrappedContext)
101
+ await glob({ base: docsBasePath, pattern: patterns, generateId: ({ entry }) => toCollectionId(entry, homepage) }).load(wrappedContext)
100
102
  },
101
103
  }
102
104
  }
103
105
 
104
106
  export const collections = {
105
107
  docs: defineCollection({
106
- loader: portalDocsLoader(),
108
+ loader: portalDocsLoader(),
107
109
  schema: docsSchema({ extend: domainFieldsSchema }),
108
110
  }),
109
111
  i18n: defineCollection({
@@ -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 ?? toTitleCase(name)
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
  })
@@ -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.endsWith('.md') ? ref : `${ref}.md`
25
- const entry = allDocs.find(d => d.id === normalized)
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 slug = normalized.replace(/\.md$/, '')
28
- const title = (entry.data as Record<string, unknown>).title as string | undefined ?? slug
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
+ }
@@ -36,6 +36,11 @@ describe('resolveMarkdownLink', () => {
36
36
  expect(resolveMarkdownLink('index.md#Overview', join(root, 'guides/current.md'), root)).toBe('/guides/#Overview')
37
37
  })
38
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
+
39
44
  it('normalizes route casing to match content collection ids', () => {
40
45
  const root = fixture(['README.md', 'Guides/Getting-Started.md'])
41
46
  expect(resolveMarkdownLink('Guides/Getting-Started.md', join(root, 'README.md'), root)).toBe('/guides/getting-started/')
@@ -47,6 +52,12 @@ describe('resolveMarkdownLink', () => {
47
52
  expect(resolveMarkdownLink('README#start', join(root, 'README.md'), root)).toBe('/#start')
48
53
  })
49
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
+
50
61
  it('prefixes generated routes with the configured base path', () => {
51
62
  const root = fixture(['README.md', 'guide.md'])
52
63
  expect(resolveMarkdownLink('guide.md', join(root, 'README.md'), root, '/recursos/')).toBe('/recursos/guide/')
@@ -1,5 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { dirname, extname, relative, resolve, sep } from 'node:path'
2
+ import { dirname, relative, resolve, sep } from 'node:path'
3
+ import { hasRootIndex } from './homepage'
4
+ import { MARKDOWN_EXTENSION, MARKDOWN_EXTENSIONS, stripMarkdownExtension } from './markdown'
3
5
 
4
6
  function splitHref(href: string): { path: string; suffix: string } {
5
7
  const match = href.match(/^([^?#]*)([?#].*)?$/)
@@ -16,21 +18,24 @@ function isExternalHref(href: string): boolean {
16
18
  }
17
19
 
18
20
  function documentPath(path: string): string | undefined {
19
- if (extname(path).toLowerCase() === '.md' || extname(path).toLowerCase() === '.mdx') {
21
+ if (MARKDOWN_EXTENSION.test(path)) {
20
22
  return existsSync(path) ? path : undefined
21
23
  }
22
- if (existsSync(`${path}.md`)) return `${path}.md`
23
- if (existsSync(`${path}.mdx`)) return `${path}.mdx`
24
- if (path.endsWith('/') && existsSync(`${path}index.md`)) return `${path}index.md`
25
- if (path.endsWith('/') && existsSync(`${path}index.mdx`)) return `${path}index.mdx`
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
+ }
26
29
  return undefined
27
30
  }
28
31
 
29
- export function toPortalRoute(path: string, basePath = '/'): string {
30
- const withoutExtension = path.replace(/\.mdx?$/i, '')
32
+ export function toPortalRoute(path: string, basePath = '/', homepage: 'index' | 'readme' = 'readme'): string {
33
+ const withoutExtension = stripMarkdownExtension(path)
31
34
  const normalizedPath = withoutExtension.toLowerCase()
32
- const route = normalizedPath === 'readme'
35
+ const route = normalizedPath === homepage
33
36
  ? ''
37
+ : normalizedPath === 'readme'
38
+ ? 'readme'
34
39
  : normalizedPath.endsWith('/index')
35
40
  ? normalizedPath.slice(0, -'/index'.length)
36
41
  : normalizedPath
@@ -55,5 +60,6 @@ export function resolveMarkdownLink(
55
60
  const markdownPath = documentPath(candidatePath)
56
61
  if (!markdownPath) return href
57
62
  const relativePath = relative(root, markdownPath).split(sep).join('/')
58
- return `${toPortalRoute(relativePath, basePath)}${suffix}`
63
+ const homepage = hasRootIndex(contentRoot) ? 'index' : 'readme'
64
+ return `${toPortalRoute(relativePath, basePath, homepage)}${suffix}`
59
65
  }
@@ -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="/"')
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="/#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,8 @@ 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'
5
7
  import { toPortalRoute } from './portal-routes'
6
8
 
7
9
  interface MdastText {
@@ -35,18 +37,23 @@ function slugifyHeading(heading: string): string {
35
37
  return heading.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '')
36
38
  }
37
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
+
38
45
  // Constrói a URL a partir da referência crua entre colchetes: [[#heading]] vira
39
46
  // anchor local; [[page]] vira path; [[page#heading]] combina os dois — path e
40
47
  // heading são fatiados (slugify) separadamente pra não perder o separador "#".
41
- function buildUrl(ref: string, basePath = '/'): string {
48
+ function buildUrl(ref: string, basePath = '/', homepage: 'index' | 'readme' = 'readme'): string {
42
49
  if (ref.startsWith('#')) return '#' + slugifyHeading(ref.slice(1))
43
50
  const hashIndex = ref.indexOf('#')
44
51
  if (hashIndex >= 0) {
45
52
  const pagePart = ref.slice(0, hashIndex)
46
53
  const headingPart = ref.slice(hashIndex + 1)
47
- return `${toPortalRoute(`${slugifyPath(pagePart)}.md`, basePath)}#${slugifyHeading(headingPart)}`
54
+ return `${pageRoute(pagePart, basePath, homepage)}#${slugifyHeading(headingPart)}`
48
55
  }
49
- return toPortalRoute(`${slugifyPath(ref)}.md`, basePath)
56
+ return pageRoute(ref, basePath, homepage)
50
57
  }
51
58
 
52
59
  function pagePartOf(ref: string): string {
@@ -59,16 +66,16 @@ function wikiLinkResolves(ref: string, contentRoot: string): boolean {
59
66
  if (!pagePart) return true
60
67
  const target = resolve(contentRoot, pagePart)
61
68
  if (!target.startsWith(`${resolve(contentRoot)}${sep}`)) return false
62
- if (/\.(?!mdx?$)[^/]+$/i.test(pagePart)) return false
63
- const withoutExtension = target.replace(/\.mdx?$/i, '')
64
- return [`${withoutExtension}.md`, `${withoutExtension}.mdx`, resolve(target, 'index.md'), resolve(target, 'index.mdx')]
65
- .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}`)))
66
72
  }
67
73
 
68
74
  // Remark plugin: converts [[page]] / [[#heading]] / [[page#heading]] / [[page|label]]
69
75
  // Obsidian wiki links pra links markdown normais.
70
76
  export function remarkWikiLinks(options: WikiLinkOptions = {}) {
71
77
  return (tree: MdastParent, file: { path?: string }) => {
78
+ const homepage = options.contentRoot && hasRootIndex(options.contentRoot) ? 'index' : 'readme'
72
79
  visit(tree, 'text', (node: MdastText, index: number | undefined, parent: MdastParent | undefined) => {
73
80
  if (!node.value.includes('[[')) return
74
81
  const parts: (MdastText | MdastLink)[] = []
@@ -91,7 +98,7 @@ export function remarkWikiLinks(options: WikiLinkOptions = {}) {
91
98
  const source = file.path ? ` in ${file.path}` : ''
92
99
  throw new Error(`[${PRODUCT_NAME}] Broken wiki link [[${inner}]]${source}`)
93
100
  }
94
- const url = buildUrl(ref, options.basePath)
101
+ const url = buildUrl(ref, options.basePath, homepage)
95
102
  parts.push({ type: 'link', url, title: null, children: [{ type: 'text', value: label }] })
96
103
  lastIndex = match.index + match[0].length
97
104
  }
@@ -34,4 +34,13 @@ describe('getNoindexRoutes', () => {
34
34
  '/guides/guide%2523notes',
35
35
  ]))
36
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
+ })
37
46
  })
@@ -1,11 +1,12 @@
1
1
  import { readdirSync, readFileSync, statSync } from 'node:fs'
2
2
  import { join, relative } from 'node:path'
3
3
  import { load as parseYaml } from 'js-yaml'
4
-
5
- const MARKDOWN_EXTENSION = /\.(?:markdown|mdown|mkdn|mkd|mdwn|md|mdx)$/i
4
+ import { hasRootIndex } from './homepage'
5
+ import { MARKDOWN_EXTENSION, stripMarkdownExtension } from './markdown'
6
6
 
7
7
  export function getNoindexRoutes(docsRoot: string): Set<string> {
8
8
  const routes = new Set<string>()
9
+ const homepage = hasRootIndex(docsRoot) ? 'index' : 'readme'
9
10
 
10
11
  function visitDirectory(directory: string): void {
11
12
  for (const name of readdirSync(directory)) {
@@ -32,9 +33,9 @@ export function getNoindexRoutes(docsRoot: string): Set<string> {
32
33
  }
33
34
 
34
35
  const docPath = relative(docsRoot, filePath).replace(/\\/g, '/')
35
- const route = docPath.replace(MARKDOWN_EXTENSION, '').replace(/\/index$/i, '').toLowerCase()
36
+ const route = stripMarkdownExtension(docPath).replace(/\/index$/i, '').toLowerCase()
36
37
  const encodedRoute = toSitemapRoute(route)
37
- routes.add(route === 'readme' ? '/' : `/${encodedRoute}`)
38
+ routes.add(route === homepage ? '/' : `/${encodedRoute}`)
38
39
  }
39
40
  }
40
41
 
@@ -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 }[]
@@ -108,6 +120,23 @@ describe('buildSidebar', () => {
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,12 +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
- if (slug === 'readme') return 'index'
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'
39
42
  return slug.endsWith('/index') ? slug.slice(0, -'/index'.length) : slug
40
43
  }
41
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
+
42
50
  // Lista uma pasta ignorando dotfiles/dot-dirs e caminhos bloqueados
43
51
  // (ver content-blocklist.ts) — relPath é relativo à raiz do content, sem
44
52
  // barra inicial (ex: "domains/historico").
@@ -59,17 +67,17 @@ function listVisible(absDir: string, relPath: string): { name: string; isDir: bo
59
67
  // (não só o de topo) recebe label em Title Case, porque o autogenerate
60
68
  // nativo do Starlight usa o nome literal da pasta em todo nível abaixo do
61
69
  // primeiro e não expõe nenhum jeito de sobrescrever isso via config.
62
- function buildDir(absDir: string, relPath: string): SidebarEntry[] {
70
+ function buildDir(absDir: string, relPath: string, homepage: 'index' | 'readme'): SidebarEntry[] {
63
71
  const entries: SidebarEntry[] = []
64
72
  for (const { name, isDir } of listVisible(absDir, relPath)) {
65
73
  const rel = relPath ? `${relPath}/${name}` : name
66
74
  if (isDir) {
67
- const items = buildDir(`${absDir}/${name}`, rel)
75
+ const items = buildDir(`${absDir}/${name}`, rel, homepage)
68
76
  if (items.length > 0) entries.push({ label: resolveLabel(name), items })
69
- } else if (/\.mdx?$/i.test(name)) {
77
+ } else if (MARKDOWN_EXTENSION.test(name)) {
70
78
  // Slug do Starlight = path relativo ao content root, sem extensão,
71
79
  // minúsculo (ver ADR/nota em Footer.astro — mesmo mecanismo).
72
- entries.push({ slug: toSidebarSlug(rel) })
80
+ entries.push(sidebarFileEntry(rel, homepage))
73
81
  }
74
82
  }
75
83
  return sortEntries(entries)
@@ -87,28 +95,28 @@ export function buildSidebar(docsRoot: string): SidebarEntry[] {
87
95
  return []
88
96
  }
89
97
 
98
+ const homepage = hasRootIndex(docsRoot) ? 'index' : 'readme'
90
99
  const configuredRoots = getPortalConfig().navigation?.roots
91
100
  const visibleTopLevel = configuredRoots
92
- ? topLevel.filter(({ name }) => configuredRoots.includes(name))
101
+ ? topLevel.filter(({ name, isDir }) => configuredRoots.includes(name) || (!isDir && ['index', 'readme'].includes(toSidebarSlug(name, homepage))))
93
102
  : topLevel
94
103
 
95
104
  const entries: SidebarEntry[] = []
96
105
  for (const { name, isDir } of visibleTopLevel) {
97
106
  if (isDir && FLATTEN_TOP_LEVEL_DIRS.has(name)) {
98
- entries.push(...buildDir(`${docsRoot}/${name}`, name))
107
+ entries.push(...buildDir(`${docsRoot}/${name}`, name, homepage))
99
108
  } else if (isDir) {
100
- const items = buildDir(`${docsRoot}/${name}`, name)
109
+ const items = buildDir(`${docsRoot}/${name}`, name, homepage)
101
110
  if (items.length > 0) entries.push({ label: resolveLabel(name), items })
102
- } else if (/\.mdx?$/i.test(name)) {
103
- entries.push({ slug: toSidebarSlug(name) })
111
+ } else if (MARKDOWN_EXTENSION.test(name)) {
112
+ entries.push(sidebarFileEntry(name, homepage))
104
113
  }
105
114
  }
106
115
 
107
- // README fica fixo em primeiro, relabelado como "Home" — é a landing
108
- // page do portal, não deveria competir alfabeticamente nem aparecer com
109
- // o nome literal do arquivo.
110
- const readmeIndex = entries.findIndex(e => !isSidebarGroup(e) && e.slug === 'index')
111
- 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
112
120
  const sorted = sortEntries(entries)
113
- return readme ? [{ slug: 'index', label: 'Home' }, ...sorted] : sorted
121
+ return homepageEntry ? [{ slug: 'index', label: resolveLabel(homepage, 'Home') }, ...sorted] : sorted
114
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>