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.
Files changed (41) hide show
  1. package/.github/workflows/ci.yml +3 -3
  2. package/.github/workflows/publish.yml +75 -0
  3. package/.upcontent/config.json +3 -0
  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 +60 -18
  10. package/customization/config-json.md +9 -1
  11. package/customization/content.md +14 -0
  12. package/deployment/index.md +1 -0
  13. package/deployment/npm.md +39 -0
  14. package/guides/validate-your-site.md +5 -0
  15. package/package.json +15 -9
  16. package/scripts/upcontent-cli.mjs +2 -4
  17. package/scripts/verify-external-build.mjs +9 -1
  18. package/scripts/verify-golden-build.mjs +9 -0
  19. package/src/components/MermaidLoader.astro +17 -3
  20. package/src/content/i18n/en.json +1 -0
  21. package/src/content.config.test.ts +47 -1
  22. package/src/content.config.ts +26 -3
  23. package/src/lib/portal-config.test.ts +11 -1
  24. package/src/lib/portal-config.ts +11 -0
  25. package/src/lib/portal-routes.test.ts +62 -0
  26. package/src/lib/portal-routes.ts +59 -0
  27. package/src/lib/remark-doc-links.test.ts +50 -0
  28. package/src/lib/remark-doc-links.ts +21 -0
  29. package/src/lib/remark-wiki-links.test.ts +8 -8
  30. package/src/lib/remark-wiki-links.ts +6 -4
  31. package/src/lib/seo-sitemap.test.ts +37 -0
  32. package/src/lib/seo-sitemap.ts +54 -0
  33. package/src/lib/sidebar.test.ts +2 -2
  34. package/src/lib/sidebar.ts +3 -2
  35. package/src/overrides/Head.astro +86 -3
  36. package/src/pages/robots.txt.ts +46 -0
  37. package/src/upcontent-cli.test.ts +36 -0
  38. package/test-fixtures/external-consumer/.upcontent/config.json +4 -0
  39. package/test-fixtures/external-consumer/noindex.md +7 -0
  40. package/test-fixtures/external-consumer/public.md +6 -0
  41. 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
+ }
@@ -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: 'readme', label: 'Home' }])
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: 'readme', label: 'Home' })
107
+ expect(sidebar[0]).toEqual({ slug: 'index', label: 'Home' })
108
108
  expect(sidebar[1].label).toBe('Historico')
109
109
  })
110
110
 
@@ -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 === 'readme')
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: 'readme', label: 'Home' }, ...sorted] : sorted
113
+ return readme ? [{ slug: 'index', label: 'Home' }, ...sorted] : sorted
113
114
  }
@@ -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
+ }
@@ -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
  },
@@ -0,0 +1,7 @@
1
+ ---
2
+ title: Internal reference
3
+ description: This page should not appear in search results.
4
+ noindex: true
5
+ ---
6
+
7
+ This page is intentionally excluded from the sitemap.
@@ -0,0 +1,6 @@
1
+ ---
2
+ title: Public guide
3
+ description: This page remains discoverable when SEO is enabled.
4
+ ---
5
+
6
+ This page is included in the sitemap.
@@ -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
- )}