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.
- package/.upcontent/config.json +5 -3
- package/README.md +0 -5
- package/astro.config.mjs +13 -6
- package/customization/navigation.md +2 -0
- package/index.md +19 -0
- package/package.json +1 -1
- package/scripts/verify-golden-build.mjs +6 -3
- package/src/content.config.test.ts +5 -0
- package/src/content.config.ts +9 -7
- 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-routes.test.ts +11 -0
- package/src/lib/portal-routes.ts +16 -10
- package/src/lib/remark-wiki-links.test.ts +4 -2
- package/src/lib/remark-wiki-links.ts +15 -8
- package/src/lib/seo-sitemap.test.ts +9 -0
- package/src/lib/seo-sitemap.ts +5 -4
- package/src/lib/sidebar.test.ts +30 -1
- package/src/lib/sidebar.ts +26 -18
- package/src/overrides/Footer.astro +3 -5
package/.upcontent/config.json
CHANGED
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"navigation": {
|
|
43
43
|
"roots": [
|
|
44
|
-
"
|
|
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
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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,9 +1,12 @@
|
|
|
1
|
-
import { existsSync
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
2
|
|
|
3
|
-
const
|
|
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
|
})
|
package/src/content.config.ts
CHANGED
|
@@ -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('/')
|
|
70
|
-
if (normalized ===
|
|
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
|
-
|
|
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 ??
|
|
54
|
+
return override ?? fallback
|
|
55
55
|
}
|
|
@@ -41,7 +41,7 @@ describe('resolveRelated', () => {
|
|
|
41
41
|
const result = resolveRelated(['domains/foo/prd'], allDocs as any)
|
|
42
42
|
expect(result).toHaveLength(1)
|
|
43
43
|
expect(result[0].title).toBe('Foo PRD')
|
|
44
|
-
expect(result[0].slug).toBe('domains/foo/prd')
|
|
44
|
+
expect(result[0].slug).toBe('domains/foo/prd/')
|
|
45
45
|
})
|
|
46
46
|
|
|
47
47
|
it('resolve entry referenciado com extensão .md', () => {
|
|
@@ -59,4 +59,24 @@ describe('resolveRelated', () => {
|
|
|
59
59
|
const result = resolveRelated(['domains/bar/trd'], allDocs as any)
|
|
60
60
|
expect(result[0].title).toBe('domains/bar/trd')
|
|
61
61
|
})
|
|
62
|
+
|
|
63
|
+
it('resolve extensões alternativas e as rotas distintas de index e README', () => {
|
|
64
|
+
const docs = [
|
|
65
|
+
{ id: 'index', data: { title: 'Home' } },
|
|
66
|
+
{ id: 'readme', data: { title: 'README' } },
|
|
67
|
+
{ id: 'guides/legacy', data: { title: 'Legacy' } },
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
expect(resolveRelated(['index.md', 'README.markdown', 'guides/legacy.mdx'], docs as any)).toEqual([
|
|
71
|
+
{ slug: '', title: 'Home' },
|
|
72
|
+
{ slug: 'readme/', title: 'README' },
|
|
73
|
+
{ slug: 'guides/legacy/', title: 'Legacy' },
|
|
74
|
+
])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('mantém links para README em consumers sem index dedicado', () => {
|
|
78
|
+
expect(resolveRelated(['README.markdown'], [{ id: 'index', data: { title: 'README' } }] as any)).toEqual([
|
|
79
|
+
{ slug: '', title: 'README' },
|
|
80
|
+
])
|
|
81
|
+
})
|
|
62
82
|
})
|
package/src/lib/doc-links.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CollectionEntry } from 'astro:content'
|
|
2
|
+
import { stripMarkdownExtension } from './markdown'
|
|
2
3
|
|
|
3
4
|
export function buildGitHubUrl(
|
|
4
5
|
repoUrl: string | undefined,
|
|
@@ -21,11 +22,15 @@ export function resolveRelated(
|
|
|
21
22
|
): RelatedDoc[] {
|
|
22
23
|
if (!related || related.length === 0) return []
|
|
23
24
|
return related.flatMap(ref => {
|
|
24
|
-
const normalized = ref.
|
|
25
|
-
const
|
|
25
|
+
const normalized = stripMarkdownExtension(ref).toLowerCase()
|
|
26
|
+
const candidates = normalized === 'readme' ? ['readme', 'index'] : [normalized]
|
|
27
|
+
const entry = candidates.flatMap(candidate => allDocs.filter(d => [d.id, d.filePath]
|
|
28
|
+
.filter((path): path is string => Boolean(path))
|
|
29
|
+
.some(path => stripMarkdownExtension(path).toLowerCase() === candidate)))[0]
|
|
26
30
|
if (!entry) return []
|
|
27
|
-
const
|
|
28
|
-
const
|
|
31
|
+
const entryId = stripMarkdownExtension(entry.id).toLowerCase()
|
|
32
|
+
const slug = entryId === 'index' ? '' : entryId === 'readme' ? 'readme/' : `${entryId}/`
|
|
33
|
+
const title = (entry.data as Record<string, unknown>).title as string | undefined ?? entryId
|
|
29
34
|
return [{ slug, title }]
|
|
30
35
|
})
|
|
31
36
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { isBlocked } from './content-blocklist'
|
|
3
|
+
import { MARKDOWN_EXTENSION, stripMarkdownExtension } from './markdown'
|
|
4
|
+
|
|
5
|
+
export function hasRootIndex(docsRoot: string): boolean {
|
|
6
|
+
try {
|
|
7
|
+
return readdirSync(docsRoot).some(name =>
|
|
8
|
+
MARKDOWN_EXTENSION.test(name)
|
|
9
|
+
&& statSync(`${docsRoot}/${name}`).isFile()
|
|
10
|
+
&& stripMarkdownExtension(name).toLowerCase() === 'index'
|
|
11
|
+
&& !isBlocked(name),
|
|
12
|
+
)
|
|
13
|
+
} catch {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const MARKDOWN_EXTENSIONS = ['.markdown', '.mdown', '.mkdn', '.mkd', '.mdwn', '.md', '.mdx'] as const
|
|
2
|
+
export const MARKDOWN_EXTENSION = /\.(?:markdown|mdown|mkdn|mkd|mdwn|md|mdx)$/i
|
|
3
|
+
|
|
4
|
+
export function stripMarkdownExtension(path: string): string {
|
|
5
|
+
return path.replace(MARKDOWN_EXTENSION, '')
|
|
6
|
+
}
|
|
@@ -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/')
|
package/src/lib/portal-routes.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
-
import { dirname,
|
|
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 (
|
|
21
|
+
if (MARKDOWN_EXTENSION.test(path)) {
|
|
20
22
|
return existsSync(path) ? path : undefined
|
|
21
23
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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 ===
|
|
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
|
-
|
|
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 `${
|
|
54
|
+
return `${pageRoute(pagePart, basePath, homepage)}#${slugifyHeading(headingPart)}`
|
|
48
55
|
}
|
|
49
|
-
return
|
|
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 (/\.
|
|
63
|
-
const withoutExtension = target
|
|
64
|
-
return
|
|
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
|
})
|
package/src/lib/seo-sitemap.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
36
|
+
const route = stripMarkdownExtension(docPath).replace(/\/index$/i, '').toLowerCase()
|
|
36
37
|
const encodedRoute = toSitemapRoute(route)
|
|
37
|
-
|
|
38
|
+
routes.add(route === homepage ? '/' : `/${encodedRoute}`)
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
|
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 }[]
|
|
@@ -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(
|
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,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
|
|
38
|
-
if (slug ===
|
|
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 (
|
|
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(
|
|
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 (
|
|
103
|
-
entries.push(
|
|
111
|
+
} else if (MARKDOWN_EXTENSION.test(name)) {
|
|
112
|
+
entries.push(sidebarFileEntry(name, homepage))
|
|
104
113
|
}
|
|
105
114
|
}
|
|
106
115
|
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
const
|
|
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
|
|
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
|
-
|
|
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>
|