upcontent 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +3 -3
- package/.github/workflows/publish.yml +75 -0
- package/.upcontent/config.json +3 -0
- package/CONTEXT.md +25 -0
- package/Makefile +6 -2
- package/README.md +57 -39
- package/assets/readme/portal-home.png +0 -0
- package/assets/readme/portal-showcase.png +0 -0
- package/astro.config.mjs +60 -18
- package/customization/config-json.md +9 -1
- package/customization/content.md +14 -0
- package/deployment/index.md +1 -0
- package/deployment/npm.md +39 -0
- package/guides/validate-your-site.md +5 -0
- package/package.json +15 -9
- package/scripts/upcontent-cli.mjs +2 -4
- package/scripts/verify-external-build.mjs +9 -1
- package/scripts/verify-golden-build.mjs +9 -0
- package/src/components/MermaidLoader.astro +17 -3
- package/src/content/i18n/en.json +1 -0
- package/src/content.config.test.ts +47 -1
- package/src/content.config.ts +26 -3
- package/src/lib/portal-config.test.ts +11 -1
- package/src/lib/portal-config.ts +11 -0
- package/src/lib/portal-routes.test.ts +62 -0
- package/src/lib/portal-routes.ts +59 -0
- package/src/lib/remark-doc-links.test.ts +50 -0
- package/src/lib/remark-doc-links.ts +21 -0
- package/src/lib/remark-wiki-links.test.ts +8 -8
- package/src/lib/remark-wiki-links.ts +6 -4
- package/src/lib/seo-sitemap.test.ts +37 -0
- package/src/lib/seo-sitemap.ts +54 -0
- package/src/lib/sidebar.test.ts +2 -2
- package/src/lib/sidebar.ts +3 -2
- package/src/overrides/Head.astro +86 -3
- package/src/pages/robots.txt.ts +46 -0
- package/src/upcontent-cli.test.ts +36 -0
- package/test-fixtures/external-consumer/.upcontent/config.json +4 -0
- package/test-fixtures/external-consumer/noindex.md +7 -0
- package/test-fixtures/external-consumer/public.md +6 -0
- package/src/pages/index.astro +0 -20
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { join, relative } from 'node:path'
|
|
3
|
+
import { load as parseYaml } from 'js-yaml'
|
|
4
|
+
|
|
5
|
+
const MARKDOWN_EXTENSION = /\.(?:markdown|mdown|mkdn|mkd|mdwn|md|mdx)$/i
|
|
6
|
+
|
|
7
|
+
export function getNoindexRoutes(docsRoot: string): Set<string> {
|
|
8
|
+
const routes = new Set<string>()
|
|
9
|
+
|
|
10
|
+
function visitDirectory(directory: string): void {
|
|
11
|
+
for (const name of readdirSync(directory)) {
|
|
12
|
+
if (name.startsWith('.')) continue
|
|
13
|
+
const filePath = join(directory, name)
|
|
14
|
+
if (statSync(filePath).isDirectory()) {
|
|
15
|
+
visitDirectory(filePath)
|
|
16
|
+
continue
|
|
17
|
+
}
|
|
18
|
+
if (!MARKDOWN_EXTENSION.test(name)) continue
|
|
19
|
+
|
|
20
|
+
const source = readFileSync(filePath, 'utf8')
|
|
21
|
+
const frontmatter = source.match(/^---\s*\n([\s\S]*?)\n---(?:\s|$)/)?.[1]
|
|
22
|
+
if (!frontmatter) continue
|
|
23
|
+
|
|
24
|
+
let data: unknown
|
|
25
|
+
try {
|
|
26
|
+
data = parseYaml(frontmatter)
|
|
27
|
+
} catch {
|
|
28
|
+
continue
|
|
29
|
+
}
|
|
30
|
+
if (!data || typeof data !== 'object' || Array.isArray(data) || (data as Record<string, unknown>).noindex !== true) {
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const docPath = relative(docsRoot, filePath).replace(/\\/g, '/')
|
|
35
|
+
const route = docPath.replace(MARKDOWN_EXTENSION, '').replace(/\/index$/i, '').toLowerCase()
|
|
36
|
+
const encodedRoute = toSitemapRoute(route)
|
|
37
|
+
routes.add(route === 'readme' ? '/' : `/${encodedRoute}`)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
visitDirectory(docsRoot)
|
|
43
|
+
} catch {
|
|
44
|
+
return routes
|
|
45
|
+
}
|
|
46
|
+
return routes
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toSitemapRoute(route: string): string {
|
|
50
|
+
const path = route
|
|
51
|
+
.replace(/%/g, '%25')
|
|
52
|
+
.replace(/[?#]/g, character => character === '?' ? '%3F' : '%23')
|
|
53
|
+
return new URL(`https://upcontent.invalid/${path}`).pathname.replace(/^\//, '')
|
|
54
|
+
}
|
package/src/lib/sidebar.test.ts
CHANGED
|
@@ -98,13 +98,13 @@ describe('buildSidebar', () => {
|
|
|
98
98
|
it('ignora arquivos bloqueados (floor hardcoded)', () => {
|
|
99
99
|
mountFs(ROOT, { 'CLAUDE.md': null, 'README.md': null })
|
|
100
100
|
const sidebar = buildSidebar(ROOT)
|
|
101
|
-
expect(sidebar).toEqual([{ slug: '
|
|
101
|
+
expect(sidebar).toEqual([{ slug: 'index', label: 'Home' }])
|
|
102
102
|
})
|
|
103
103
|
|
|
104
104
|
it('fixa o README em primeiro, relabelado como Home, na frente de tudo', () => {
|
|
105
105
|
mountFs(ROOT, { 'README.md': null, domains: { historico: { 'a.md': null } } })
|
|
106
106
|
const sidebar = buildSidebar(ROOT) as { label: string }[]
|
|
107
|
-
expect(sidebar[0]).toEqual({ slug: '
|
|
107
|
+
expect(sidebar[0]).toEqual({ slug: 'index', label: 'Home' })
|
|
108
108
|
expect(sidebar[1].label).toBe('Historico')
|
|
109
109
|
})
|
|
110
110
|
|
package/src/lib/sidebar.ts
CHANGED
|
@@ -35,6 +35,7 @@ function sortEntries(entries: SidebarEntry[]): SidebarEntry[] {
|
|
|
35
35
|
|
|
36
36
|
function toSidebarSlug(relativePath: string): string {
|
|
37
37
|
const slug = relativePath.replace(/\.mdx?$/i, '').toLowerCase()
|
|
38
|
+
if (slug === 'readme') return 'index'
|
|
38
39
|
return slug.endsWith('/index') ? slug.slice(0, -'/index'.length) : slug
|
|
39
40
|
}
|
|
40
41
|
|
|
@@ -106,8 +107,8 @@ export function buildSidebar(docsRoot: string): SidebarEntry[] {
|
|
|
106
107
|
// README fica fixo em primeiro, relabelado como "Home" — é a landing
|
|
107
108
|
// page do portal, não deveria competir alfabeticamente nem aparecer com
|
|
108
109
|
// o nome literal do arquivo.
|
|
109
|
-
const readmeIndex = entries.findIndex(e => !isSidebarGroup(e) && e.slug === '
|
|
110
|
+
const readmeIndex = entries.findIndex(e => !isSidebarGroup(e) && e.slug === 'index')
|
|
110
111
|
const readme = readmeIndex >= 0 ? entries.splice(readmeIndex, 1)[0] : undefined
|
|
111
112
|
const sorted = sortEntries(entries)
|
|
112
|
-
return readme ? [{ slug: '
|
|
113
|
+
return readme ? [{ slug: 'index', label: 'Home' }, ...sorted] : sorted
|
|
113
114
|
}
|
package/src/overrides/Head.astro
CHANGED
|
@@ -8,13 +8,96 @@ import { getPortalConfig } from '../lib/portal-config'
|
|
|
8
8
|
import { PRODUCT_NAME } from '../lib/product-identity'
|
|
9
9
|
|
|
10
10
|
const { head } = Astro.locals.starlightRoute
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
11
|
+
const { entry } = Astro.locals.starlightRoute
|
|
12
|
+
const normalizedBasePath = import.meta.env.BASE_URL.replace(/\/+$/, '')
|
|
13
|
+
const isHome = Astro.url.pathname.replace(/\/+$/, '') === normalizedBasePath
|
|
14
|
+
const baseHead = isHome ? head.filter(({ tag }) => tag !== 'title') : head
|
|
15
|
+
const config = getPortalConfig()
|
|
16
|
+
const seoEnabled = config.seo?.enabled === true && Boolean(Astro.site)
|
|
17
|
+
const siteTitle = config.site?.title ?? PRODUCT_NAME
|
|
18
|
+
const pageData = entry?.data ?? {}
|
|
19
|
+
const seoHead = seoEnabled
|
|
20
|
+
? baseHead
|
|
21
|
+
: baseHead.filter(({ tag, attrs }) => {
|
|
22
|
+
const property = typeof attrs?.property === 'string' ? attrs.property : undefined
|
|
23
|
+
const name = typeof attrs?.name === 'string' ? attrs.name : undefined
|
|
24
|
+
return !(tag === 'meta' && (property?.startsWith('og:') || name?.startsWith('twitter:')))
|
|
25
|
+
})
|
|
26
|
+
const canonicalTag = seoHead.find(({ tag, attrs }) => tag === 'link' && attrs?.rel === 'canonical')
|
|
27
|
+
const canonicalUrl = seoEnabled
|
|
28
|
+
? typeof pageData.canonical === 'string'
|
|
29
|
+
? pageData.canonical
|
|
30
|
+
: typeof canonicalTag?.attrs?.href === 'string'
|
|
31
|
+
? canonicalTag.attrs.href
|
|
32
|
+
: undefined
|
|
33
|
+
: undefined
|
|
34
|
+
const pageHead = pageData.canonical || config.site?.locale
|
|
35
|
+
? seoHead.filter(({ tag, attrs }) =>
|
|
36
|
+
!(pageData.canonical && tag === 'link' && attrs?.rel === 'canonical')
|
|
37
|
+
&& !(pageData.canonical && tag === 'meta' && attrs?.property === 'og:url')
|
|
38
|
+
&& !(config.site?.locale && tag === 'meta' && attrs?.property === 'og:locale'),
|
|
39
|
+
)
|
|
40
|
+
: seoHead
|
|
41
|
+
const socialImage = seoEnabled && (typeof pageData.image === 'string' ? pageData.image : config.site?.socialImage)
|
|
42
|
+
const robots = !seoEnabled || pageData.noindex === true ? 'noindex, nofollow' : undefined
|
|
43
|
+
const pageTitle = typeof pageData.title === 'string' ? pageData.title : siteTitle
|
|
44
|
+
const pageDescription = typeof pageData.description === 'string' ? pageData.description : config.site?.description
|
|
45
|
+
const absoluteImage = resolveOptionalUrl(socialImage, canonicalUrl)
|
|
46
|
+
const siteRoot = canonicalUrl && Astro.site ? new URL(import.meta.env.BASE_URL, Astro.site).href : undefined
|
|
47
|
+
const breadcrumbItems = canonicalUrl
|
|
48
|
+
? [{ '@type': 'ListItem', position: 1, name: siteTitle, item: siteRoot },
|
|
49
|
+
...(isHome ? [] : [{ '@type': 'ListItem', position: 2, name: pageTitle, item: canonicalUrl }])]
|
|
50
|
+
: []
|
|
51
|
+
const structuredData = canonicalUrl
|
|
52
|
+
? {
|
|
53
|
+
'@context': 'https://schema.org',
|
|
54
|
+
'@graph': [
|
|
55
|
+
{
|
|
56
|
+
'@type': 'WebSite',
|
|
57
|
+
'@id': `${siteRoot}#website`,
|
|
58
|
+
url: siteRoot,
|
|
59
|
+
name: siteTitle,
|
|
60
|
+
description: config.site?.description,
|
|
61
|
+
inLanguage: config.site?.locale,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
'@type': 'TechArticle',
|
|
65
|
+
'@id': `${canonicalUrl}#article`,
|
|
66
|
+
url: canonicalUrl,
|
|
67
|
+
headline: pageTitle,
|
|
68
|
+
description: pageDescription,
|
|
69
|
+
isPartOf: { '@id': `${siteRoot}#website` },
|
|
70
|
+
image: absoluteImage,
|
|
71
|
+
dateModified: pageData.updated,
|
|
72
|
+
breadcrumb: { '@type': 'BreadcrumbList', itemListElement: breadcrumbItems },
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
}
|
|
76
|
+
: undefined;
|
|
77
|
+
const structuredDataJson = structuredData
|
|
78
|
+
? JSON.stringify(structuredData).replace(/[<>&]/g, character => ({ '<': '\\u003c', '>': '\\u003e', '&': '\\u0026' })[character]!)
|
|
79
|
+
: undefined
|
|
80
|
+
|
|
81
|
+
function resolveOptionalUrl(value: unknown, baseUrl: string | undefined): string | undefined {
|
|
82
|
+
if (typeof value !== 'string' || value.length === 0) return undefined
|
|
83
|
+
try {
|
|
84
|
+
const url = new URL(value, baseUrl)
|
|
85
|
+
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : undefined
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
}
|
|
14
90
|
---
|
|
15
91
|
|
|
16
92
|
{pageHead.map(({ tag: Tag, attrs, content }) => <Tag {...attrs} set:html={content} />)}
|
|
17
93
|
<slot />
|
|
18
94
|
{isHome && <title>{siteTitle}</title>}
|
|
95
|
+
{seoEnabled && pageData.canonical && <link rel="canonical" href={canonicalUrl} />}
|
|
96
|
+
{seoEnabled && pageData.canonical && <meta property="og:url" content={canonicalUrl} />}
|
|
97
|
+
{seoEnabled && config.site?.locale && <meta property="og:locale" content={config.site.locale} />}
|
|
98
|
+
{absoluteImage && <meta property="og:image" content={absoluteImage} />}
|
|
99
|
+
{absoluteImage && <meta name="twitter:image" content={absoluteImage} />}
|
|
100
|
+
{robots && <meta name="robots" content={robots} />}
|
|
101
|
+
{structuredDataJson && <script type="application/ld+json" set:html={structuredDataJson} />}
|
|
19
102
|
<MermaidLoader />
|
|
20
103
|
<StructuredDataCopy />
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { getPortalConfig } from '../lib/portal-config'
|
|
2
|
+
|
|
3
|
+
export const prerender = true
|
|
4
|
+
|
|
5
|
+
export function GET() {
|
|
6
|
+
const config = getPortalConfig()
|
|
7
|
+
const configuredUrl = process.env.SITE_URL || config.site?.url
|
|
8
|
+
const seoEnabled = config.seo?.enabled === true && Boolean(configuredUrl)
|
|
9
|
+
if (!seoEnabled) {
|
|
10
|
+
return disallowRobots()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const basePath = process.env.BASE_PATH || ''
|
|
14
|
+
let siteUrl
|
|
15
|
+
try {
|
|
16
|
+
siteUrl = configuredUrl ? addBasePath(configuredUrl, basePath) : undefined
|
|
17
|
+
} catch {
|
|
18
|
+
return disallowRobots()
|
|
19
|
+
}
|
|
20
|
+
const sitemap = siteUrl ? `${siteUrl}/sitemap-index.xml` : undefined
|
|
21
|
+
const body = ['User-agent: *', 'Allow: /', sitemap && `Sitemap: ${sitemap}`].filter(Boolean).join('\n') + '\n'
|
|
22
|
+
|
|
23
|
+
return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function addBasePath(siteUrl: string, basePath: string): string {
|
|
27
|
+
const url = new URL(siteUrl)
|
|
28
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('unsupported protocol')
|
|
29
|
+
if (url.username || url.password) throw new Error('userinfo is not allowed')
|
|
30
|
+
url.search = ''
|
|
31
|
+
url.hash = ''
|
|
32
|
+
const normalizedBasePath = basePath.replace(/^\/+|\/+$/g, '')
|
|
33
|
+
|
|
34
|
+
if (normalizedBasePath) {
|
|
35
|
+
url.pathname = `/${normalizedBasePath}`
|
|
36
|
+
return url.toString().replace(/\/+$/, '')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return url.toString().replace(/\/+$/, '')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function disallowRobots(): Response {
|
|
43
|
+
return new Response('User-agent: *\nDisallow: /\n', {
|
|
44
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
45
|
+
})
|
|
46
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { spawnSync } from 'node:child_process'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { describe, expect, it } from 'vitest'
|
|
7
|
+
import yaml from 'js-yaml'
|
|
8
|
+
|
|
9
|
+
const cliPath = fileURLToPath(new URL('../scripts/upcontent-cli.mjs', import.meta.url))
|
|
10
|
+
|
|
11
|
+
describe('upcontent init', () => {
|
|
12
|
+
it('generates a valid GitHub Actions workflow', () => {
|
|
13
|
+
const root = mkdtempSync(join(tmpdir(), 'upcontent-cli-'))
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const result = spawnSync(process.execPath, [cliPath, 'init'], { cwd: root, encoding: 'utf8' })
|
|
17
|
+
expect(result.status).toBe(0)
|
|
18
|
+
|
|
19
|
+
const workflow = readFileSync(join(root, '.github/workflows/deploy-docs.yml'), 'utf8')
|
|
20
|
+
expect(yaml.load(workflow)).toMatchObject({
|
|
21
|
+
jobs: {
|
|
22
|
+
publish: {
|
|
23
|
+
uses: 'lumamontes/upcontent/.github/workflows/reusable-pages.yml@main',
|
|
24
|
+
permissions: {
|
|
25
|
+
contents: 'read',
|
|
26
|
+
pages: 'write',
|
|
27
|
+
'id-token': 'write',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
} finally {
|
|
33
|
+
rmSync(root, { recursive: true, force: true })
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -2,12 +2,16 @@
|
|
|
2
2
|
"site": {
|
|
3
3
|
"title": "External Consumer",
|
|
4
4
|
"description": "A fixture proving consumer-owned configuration.",
|
|
5
|
+
"url": "https://docs.example.com",
|
|
5
6
|
"logo": {
|
|
6
7
|
"src": ".upcontent/logo.svg",
|
|
7
8
|
"alt": "External Consumer"
|
|
8
9
|
},
|
|
9
10
|
"favicon": ".upcontent/favicon.svg"
|
|
10
11
|
},
|
|
12
|
+
"seo": {
|
|
13
|
+
"enabled": true
|
|
14
|
+
},
|
|
11
15
|
"repo": {
|
|
12
16
|
"url": "https://github.com/example/external-consumer"
|
|
13
17
|
},
|
package/src/pages/index.astro
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
// Redireciona pro README (raiz do conteúdo) — Starlight não tem homepage
|
|
3
|
-
// própria sem um index.md na collection.
|
|
4
|
-
import { getCollection } from 'astro:content'
|
|
5
|
-
import { PRODUCT_NAME } from '../lib/product-identity'
|
|
6
|
-
|
|
7
|
-
const all = await getCollection('docs')
|
|
8
|
-
const readme = all.find(e => e.id.toLowerCase() === 'readme')
|
|
9
|
-
---
|
|
10
|
-
|
|
11
|
-
{readme ? (
|
|
12
|
-
<meta http-equiv="refresh" content={`0;url=/${readme.id}/`} />
|
|
13
|
-
) : (
|
|
14
|
-
<html lang="pt-BR">
|
|
15
|
-
<body>
|
|
16
|
-
<h1>{PRODUCT_NAME}</h1>
|
|
17
|
-
<p>Rode <code>make dev</code> para visualizar os documentos.</p>
|
|
18
|
-
</body>
|
|
19
|
-
</html>
|
|
20
|
-
)}
|