upcontent 0.0.0-stage → 0.1.0

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 (70) hide show
  1. package/.github/workflows/ci.yml +61 -0
  2. package/.github/workflows/deploy.yml +59 -0
  3. package/.github/workflows/reusable-pages.yml +74 -0
  4. package/.upcontent/config.json +82 -0
  5. package/.upcontent/favicon.svg +4 -0
  6. package/.upcontent/logo.svg +6 -0
  7. package/.upcontent/portal.css +139 -0
  8. package/Makefile +29 -0
  9. package/README.md +114 -2
  10. package/SHOWCASE.mdx +163 -0
  11. package/astro.config.mjs +125 -0
  12. package/customization/config-json.md +92 -0
  13. package/customization/content.md +48 -0
  14. package/customization/environment.md +43 -0
  15. package/customization/index.md +33 -0
  16. package/customization/navigation.md +57 -0
  17. package/customization/site-identity.md +43 -0
  18. package/customization/theme.md +47 -0
  19. package/deployment/github-pages.md +46 -0
  20. package/deployment/index.md +24 -0
  21. package/deployment/static-hosts.md +34 -0
  22. package/getting-started/consumer-repository.md +156 -0
  23. package/getting-started/first-build.md +78 -0
  24. package/guides/authoring-content.md +105 -0
  25. package/guides/validate-your-site.md +53 -0
  26. package/package.json +33 -3
  27. package/scripts/upcontent-cli.mjs +113 -0
  28. package/scripts/verify-external-build.mjs +24 -0
  29. package/src/components/MermaidLoader.astro +290 -0
  30. package/src/components/PaletteShowcase.astro +122 -0
  31. package/src/components/StructuredDataCopy.astro +22 -0
  32. package/src/content/__mocks__/astro-content.ts +7 -0
  33. package/src/content/__mocks__/astro-loaders.ts +3 -0
  34. package/src/content.config.test.ts +163 -0
  35. package/src/content.config.ts +90 -0
  36. package/src/lib/content-blocklist.test.ts +47 -0
  37. package/src/lib/content-blocklist.ts +55 -0
  38. package/src/lib/doc-links.test.ts +62 -0
  39. package/src/lib/doc-links.ts +31 -0
  40. package/src/lib/mermaid-render.test.ts +42 -0
  41. package/src/lib/mermaid-render.ts +24 -0
  42. package/src/lib/portal-config.test.ts +143 -0
  43. package/src/lib/portal-config.ts +176 -0
  44. package/src/lib/product-identity.ts +2 -0
  45. package/src/lib/rehype-callouts.test.ts +69 -0
  46. package/src/lib/rehype-callouts.ts +61 -0
  47. package/src/lib/remark-strip-duplicate-title.test.ts +73 -0
  48. package/src/lib/remark-strip-duplicate-title.ts +37 -0
  49. package/src/lib/remark-structured-data-preview.test.ts +104 -0
  50. package/src/lib/remark-structured-data-preview.ts +66 -0
  51. package/src/lib/remark-wiki-links.test.ts +102 -0
  52. package/src/lib/remark-wiki-links.ts +110 -0
  53. package/src/lib/sidebar.test.ts +142 -0
  54. package/src/lib/sidebar.ts +113 -0
  55. package/src/lib/structured-data-tree.test.ts +75 -0
  56. package/src/lib/structured-data-tree.ts +55 -0
  57. package/src/overrides/Footer.astro +114 -0
  58. package/src/overrides/Head.astro +20 -0
  59. package/src/pages/index.astro +20 -0
  60. package/src/styles/callouts.css +29 -0
  61. package/src/styles/structured-data-preview.css +95 -0
  62. package/test-fixtures/external-consumer/.upcontent/config.json +26 -0
  63. package/test-fixtures/external-consumer/.upcontent/favicon.svg +4 -0
  64. package/test-fixtures/external-consumer/.upcontent/logo.svg +4 -0
  65. package/test-fixtures/external-consumer/.upcontent/theme.css +4 -0
  66. package/test-fixtures/external-consumer/.upcontent-renderer/README.md +3 -0
  67. package/test-fixtures/external-consumer/README.md +6 -0
  68. package/test-fixtures/external-consumer/forbidden.md +5 -0
  69. package/tsconfig.json +7 -0
  70. package/vitest.config.ts +18 -0
@@ -0,0 +1,61 @@
1
+ import { visit } from 'unist-util-visit'
2
+
3
+ interface HastText {
4
+ type: 'text'
5
+ value: string
6
+ }
7
+
8
+ interface HastElement {
9
+ type: 'element'
10
+ tagName: string
11
+ properties?: Record<string, unknown>
12
+ children: (HastElement | HastText)[]
13
+ }
14
+
15
+ const ICONS: Record<string, string> = {
16
+ info: 'ℹ️',
17
+ note: '📝',
18
+ warning: '⚠️',
19
+ danger: '🚨',
20
+ tip: '💡',
21
+ important: '❗',
22
+ caution: '⚠️',
23
+ }
24
+
25
+ // Rehype plugin: converts > [!type] Title callouts (formato Obsidian) AFTER remark-to-rehype
26
+ export function rehypeCallouts() {
27
+ return (tree: HastElement) => {
28
+ visit(tree, 'element', (node: HastElement) => {
29
+ if (node.tagName !== 'blockquote') return
30
+ const firstP = node.children?.find((c): c is HastElement => c.type === 'element' && c.tagName === 'p')
31
+ if (!firstP) return
32
+ const firstText = firstP.children?.find((c): c is HastText => c.type === 'text')
33
+ if (!firstText) return
34
+ const match = firstText.value.match(/^\[!([\w]+)\](?:\s+(.+))?/)
35
+ if (!match) return
36
+
37
+ const type = match[1].toLowerCase()
38
+ const titleText = match[2]?.trim() ?? (type.charAt(0).toUpperCase() + type.slice(1))
39
+ const afterMarker = firstText.value.replace(/^\[![\w]+\][^\n]*\n?/, '').trimStart()
40
+
41
+ if (afterMarker) {
42
+ firstText.value = afterMarker
43
+ } else {
44
+ firstP.children = firstP.children.filter(c => c !== firstText)
45
+ if (firstP.children.length === 0) node.children = node.children.filter(c => c !== firstP)
46
+ }
47
+
48
+ node.tagName = 'div'
49
+ node.properties = { className: [`callout`, `callout-${type}`] }
50
+
51
+ node.children.unshift({
52
+ type: 'element', tagName: 'div',
53
+ properties: { className: ['callout-title'] },
54
+ children: [
55
+ { type: 'element', tagName: 'span', properties: {}, children: [{ type: 'text', value: ICONS[type] ?? 'ℹ️' }] },
56
+ { type: 'text', value: ' ' + titleText },
57
+ ],
58
+ })
59
+ })
60
+ }
61
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { unified } from 'unified'
3
+ import remarkParse from 'remark-parse'
4
+ import remarkRehype from 'remark-rehype'
5
+ import rehypeStringify from 'rehype-stringify'
6
+ import { remarkStripDuplicateTitle, stripDuplicateTitle } from './remark-strip-duplicate-title'
7
+
8
+ describe('stripDuplicateTitle', () => {
9
+ it('remove o primeiro H1 quando o texto bate com o title', () => {
10
+ const tree = {
11
+ children: [
12
+ { type: 'heading', depth: 1, children: [{ type: 'text', value: 'Meu Título' }] },
13
+ { type: 'paragraph', children: [{ type: 'text', value: 'corpo' }] },
14
+ ],
15
+ }
16
+ const removed = stripDuplicateTitle(tree, 'Meu Título')
17
+ expect(removed).toBe(true)
18
+ expect(tree.children).toHaveLength(1)
19
+ expect(tree.children[0].type).toBe('paragraph')
20
+ })
21
+
22
+ it('mantém o H1 quando o texto é diferente do title', () => {
23
+ const tree = {
24
+ children: [{ type: 'heading', depth: 1, children: [{ type: 'text', value: 'Outro título' }] }],
25
+ }
26
+ const removed = stripDuplicateTitle(tree, 'Meu Título')
27
+ expect(removed).toBe(false)
28
+ expect(tree.children).toHaveLength(1)
29
+ })
30
+
31
+ it('não remove H2 ou heading que não seja o primeiro nó', () => {
32
+ const tree = {
33
+ children: [
34
+ { type: 'paragraph', children: [{ type: 'text', value: 'intro' }] },
35
+ { type: 'heading', depth: 1, children: [{ type: 'text', value: 'Meu Título' }] },
36
+ ],
37
+ }
38
+ expect(stripDuplicateTitle(tree, 'Meu Título')).toBe(false)
39
+ expect(tree.children).toHaveLength(2)
40
+ })
41
+
42
+ it('sem title, não faz nada', () => {
43
+ const tree = {
44
+ children: [{ type: 'heading', depth: 1, children: [{ type: 'text', value: 'Meu Título' }] }],
45
+ }
46
+ expect(stripDuplicateTitle(tree, undefined)).toBe(false)
47
+ expect(tree.children).toHaveLength(1)
48
+ })
49
+ })
50
+
51
+ function render(markdown: string, title: string | undefined): string {
52
+ const file = { value: markdown, data: { astro: { frontmatter: { title } } } }
53
+ return unified()
54
+ .use(remarkParse)
55
+ .use(remarkStripDuplicateTitle)
56
+ .use(remarkRehype)
57
+ .use(rehypeStringify)
58
+ .processSync(file)
59
+ .toString()
60
+ }
61
+
62
+ describe('remarkStripDuplicateTitle (integração markdown → HTML)', () => {
63
+ it('remove o H1 duplicado, mantém o resto do corpo', () => {
64
+ const html = render('# Guia X\n\nConteúdo real.', 'Guia X')
65
+ expect(html).not.toContain('<h1>')
66
+ expect(html).toContain('Conteúdo real.')
67
+ })
68
+
69
+ it('sem duplicação, o H1 permanece intacto', () => {
70
+ const html = render('# Guia X\n\nConteúdo real.', 'Outro Título')
71
+ expect(html).toContain('<h1>Guia X</h1>')
72
+ })
73
+ })
@@ -0,0 +1,37 @@
1
+ interface MdastNode {
2
+ type: string
3
+ depth?: number
4
+ value?: string
5
+ children?: MdastNode[]
6
+ }
7
+
8
+ function flattenText(node: MdastNode): string {
9
+ if (typeof node.value === 'string') return node.value
10
+ return (node.children ?? []).map(flattenText).join('')
11
+ }
12
+
13
+ // Pura, testável separadamente do vfile do Astro.
14
+ export function stripDuplicateTitle(tree: { children: MdastNode[] }, title: string | undefined): boolean {
15
+ if (!title) return false
16
+ const first = tree.children[0]
17
+ if (!first || first.type !== 'heading' || first.depth !== 1) return false
18
+ if (flattenText(first).trim() !== title.trim()) return false
19
+ tree.children.splice(0, 1)
20
+ return true
21
+ }
22
+
23
+ // Muito conteúdo real tem title no frontmatter E repete o mesmo texto como
24
+ // primeiro `# H1` do corpo (hábito de markdown feito pra ser lido standalone,
25
+ // fora do Starlight) — o Starlight já renderiza o title do frontmatter no
26
+ // topo da página, então esse H1 duplicado só produz o título repetido duas
27
+ // vezes. Remove o H1 do corpo só quando o texto bate exatamente com o title.
28
+ export function remarkStripDuplicateTitle() {
29
+ // tree/file tipados como `any` — o tipo genérico `Node` do unified não
30
+ // garante `children`, então um parâmetro estreito quebra a resolução de
31
+ // overload do Plugin (mesmo problema não aparece nos outros plugins deste
32
+ // projeto porque eles vivem em astro.config.mjs, sem checagem de tipo).
33
+ return (tree: any, file: any) => {
34
+ const title = file?.data?.astro?.frontmatter?.title
35
+ stripDuplicateTitle(tree, typeof title === 'string' ? title : undefined)
36
+ }
37
+ }
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { unified } from 'unified'
3
+ import remarkParse from 'remark-parse'
4
+ import remarkRehype from 'remark-rehype'
5
+ import rehypeStringify from 'rehype-stringify'
6
+ import { remarkStructuredDataPreview, tryRenderStructuredData } from './remark-structured-data-preview'
7
+
8
+ function render(markdown: string): string {
9
+ return unified()
10
+ .use(remarkParse)
11
+ .use(remarkStructuredDataPreview)
12
+ .use(remarkRehype, { allowDangerousHtml: true })
13
+ .use(rehypeStringify, { allowDangerousHtml: true })
14
+ .processSync(markdown)
15
+ .toString()
16
+ }
17
+
18
+ describe('tryRenderStructuredData', () => {
19
+ it('JSON válido → retorna a árvore renderizada', () => {
20
+ const html = tryRenderStructuredData('json', '{"a": 1}')
21
+ expect(html).toContain('sdp-root')
22
+ })
23
+
24
+ it('YAML válido → retorna a árvore renderizada', () => {
25
+ const html = tryRenderStructuredData('yaml', 'a: 1\nb: 2')
26
+ expect(html).toContain('sdp-root')
27
+ })
28
+
29
+ it('JSON malformado → retorna null (fallback)', () => {
30
+ expect(tryRenderStructuredData('json', '{ invalido')).toBeNull()
31
+ })
32
+
33
+ it('YAML malformado → retorna null (fallback)', () => {
34
+ expect(tryRenderStructuredData('yaml', ':\n - broken: [')).toBeNull()
35
+ })
36
+
37
+ it('linguagem não suportada → retorna null, nunca tenta parsear', () => {
38
+ expect(tryRenderStructuredData('typescript', 'const x = 1')).toBeNull()
39
+ })
40
+
41
+ it('bloco vazio → retorna null (nada estrutural pra mostrar)', () => {
42
+ expect(tryRenderStructuredData('yaml', '')).toBeNull()
43
+ })
44
+
45
+ it('bloco de uma linha ainda recebe o tratamento (sem threshold de tamanho)', () => {
46
+ expect(tryRenderStructuredData('json', '{"x":1}')).toContain('sdp-root')
47
+ })
48
+
49
+ it('CSV válido → retorna a tabela renderizada', () => {
50
+ const html = tryRenderStructuredData('csv', 'nome,idade\nAna,30')
51
+ expect(html).toContain('sdp-csv-table')
52
+ })
53
+
54
+ it('CSV com campo entre aspas contendo vírgula é tratado corretamente', () => {
55
+ const html = tryRenderStructuredData('csv', 'nome,cidade\n"Ana, Silva",SP')
56
+ expect(html).toContain('<td>Ana, Silva</td>')
57
+ expect(html).toContain('<td>SP</td>')
58
+ })
59
+
60
+ it('CSV vazio → retorna null (fallback)', () => {
61
+ expect(tryRenderStructuredData('csv', '')).toBeNull()
62
+ })
63
+
64
+ it('CSV com linhas de tamanho inconsistente → retorna null (fallback)', () => {
65
+ expect(tryRenderStructuredData('csv', 'a,b,c\n1,2')).toBeNull()
66
+ })
67
+ })
68
+
69
+ describe('remarkStructuredDataPreview (integração markdown → HTML)', () => {
70
+ it('bloco ```json válido vira árvore, não bloco de código', () => {
71
+ const html = render('```json\n{"nome": "Ana"}\n```')
72
+ expect(html).toContain('sdp-root')
73
+ expect(html).not.toContain('<code')
74
+ })
75
+
76
+ it('bloco ```yaml válido vira árvore', () => {
77
+ const html = render('```yaml\nnome: Ana\nidade: 30\n```')
78
+ expect(html).toContain('sdp-root')
79
+ })
80
+
81
+ it('JSON malformado cai no bloco de código normal, sem quebrar o build', () => {
82
+ const html = render('```json\n{ isso não é json }\n```')
83
+ expect(html).not.toContain('sdp-root')
84
+ expect(html).toContain('<code')
85
+ })
86
+
87
+ it('outras linguagens (ts, kotlin) não são tocadas pelo plugin', () => {
88
+ const html = render('```ts\nconst x = 1\n```')
89
+ expect(html).not.toContain('sdp-root')
90
+ expect(html).toContain('<code')
91
+ })
92
+
93
+ it('bloco ```csv válido vira tabela, não bloco de código', () => {
94
+ const html = render('```csv\nnome,idade\nAna,30\n```')
95
+ expect(html).toContain('sdp-csv-table')
96
+ expect(html).not.toContain('<code')
97
+ })
98
+
99
+ it('CSV inconsistente cai no bloco de código normal, sem quebrar o build', () => {
100
+ const html = render('```csv\na,b,c\n1,2\n```')
101
+ expect(html).not.toContain('sdp-csv-table')
102
+ expect(html).toContain('<code')
103
+ })
104
+ })
@@ -0,0 +1,66 @@
1
+ import { JSON_SCHEMA, load as loadYaml } from 'js-yaml'
2
+ import Papa from 'papaparse'
3
+ import { visit } from 'unist-util-visit'
4
+ import { renderCsvTable, renderStructuredDataTree } from './structured-data-tree'
5
+
6
+ interface MdastCode {
7
+ type: 'code'
8
+ lang?: string | null
9
+ value: string
10
+ }
11
+
12
+ interface MdastParent {
13
+ type: string
14
+ children: unknown[]
15
+ }
16
+
17
+ const SUPPORTED_LANGS = new Set(['json', 'yaml', 'yml', 'csv'])
18
+
19
+ // CSV vazio, com colunas inconsistentes entre linhas, ou que o papaparse não
20
+ // consegue parsear cai no tratamento padrão de código (retorna null) — sem
21
+ // threshold de tamanho, mas exige uma estrutura tabular coerente.
22
+ function tryRenderCsv(source: string): string | null {
23
+ const { data, errors } = Papa.parse<string[]>(source.trim(), { skipEmptyLines: true })
24
+ if (errors.length > 0 || data.length === 0) return null
25
+
26
+ const width = data[0].length
27
+ if (width === 0 || data.some(row => row.length !== width)) return null
28
+
29
+ return renderCsvTable(data)
30
+ }
31
+
32
+ // Tenta parsear e renderizar a árvore — retorna null quando o conteúdo não é
33
+ // válido no formato (ou a linguagem não é suportada), sinalizando pro
34
+ // caller manter o bloco de código como está (fallback pro highlighting normal).
35
+ export function tryRenderStructuredData(lang: string | null | undefined, source: string): string | null {
36
+ const normalizedLang = lang?.toLowerCase()
37
+ if (!normalizedLang || !SUPPORTED_LANGS.has(normalizedLang)) return null
38
+
39
+ if (normalizedLang === 'csv') return tryRenderCsv(source)
40
+
41
+ try {
42
+ // JSON_SCHEMA: restringe a tipos equivalentes a JSON (sem !!timestamp virando
43
+ // Date, sem tags YAML exóticas) — js-yaml v4 já não expõe nenhum tipo
44
+ // inseguro (removido da lib), isso é só previsibilidade, não segurança.
45
+ const parsed = normalizedLang === 'json' ? JSON.parse(source) : loadYaml(source, { schema: JSON_SCHEMA })
46
+ if (parsed === undefined) return null // bloco vazio ou só comentários (YAML) — nada estrutural pra mostrar
47
+ return renderStructuredDataTree(parsed, source)
48
+ } catch {
49
+ return null
50
+ }
51
+ }
52
+
53
+ // Remark plugin: converte ```json / ```yaml em árvore estática (<details>
54
+ // aninhado), no mesmo esquema que remarkMermaid usa pra ```mermaid — troca
55
+ // o nó 'code' por um nó 'html' ANTES do remark-rehype/Expressive Code
56
+ // processarem o bloco, então eles nunca veem o bloco original.
57
+ export function remarkStructuredDataPreview() {
58
+ return (tree: MdastParent) => {
59
+ visit(tree, 'code', (node: MdastCode, index: number | undefined, parent: MdastParent | undefined) => {
60
+ const html = tryRenderStructuredData(node.lang, node.value)
61
+ if (html === null || !parent || index === undefined) return
62
+ parent.children.splice(index, 1, { type: 'html', value: html })
63
+ return index + 1
64
+ })
65
+ }
66
+ }
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { unified } from 'unified'
3
+ import remarkParse from 'remark-parse'
4
+ import remarkRehype from 'remark-rehype'
5
+ import rehypeStringify from 'rehype-stringify'
6
+ import { remarkWikiLinks } from './remark-wiki-links'
7
+
8
+ function render(markdown: string): string {
9
+ return unified()
10
+ .use(remarkParse)
11
+ .use(remarkWikiLinks)
12
+ .use(remarkRehype)
13
+ .use(rehypeStringify)
14
+ .processSync(markdown)
15
+ .toString()
16
+ }
17
+
18
+ function renderStrict(markdown: string): string {
19
+ return unified()
20
+ .use(remarkParse)
21
+ .use(remarkWikiLinks, { contentRoot: process.cwd(), failOnBrokenLinks: true })
22
+ .use(remarkRehype)
23
+ .use(rehypeStringify)
24
+ .processSync(markdown)
25
+ .toString()
26
+ }
27
+
28
+ describe('remarkWikiLinks', () => {
29
+ it('resolve [[page]] simples pra /page', () => {
30
+ const html = render('Veja [[backend/plan]] pra mais detalhes.')
31
+ expect(html).toContain('href="/backend/plan"')
32
+ expect(html).toContain('>backend/plan<')
33
+ })
34
+
35
+ it('resolve [[page#heading]] combinando path e anchor', () => {
36
+ const html = render('Ver [[DESIGN-licencas#Impact Summary]].')
37
+ expect(html).toContain('href="/design-licencas#impact-summary"')
38
+ })
39
+
40
+ it('resolve [[#heading]] como anchor local, sem path', () => {
41
+ const html = render('Ver [[#Contracts]] abaixo.')
42
+ expect(html).toContain('href="#contracts"')
43
+ })
44
+
45
+ it('resolve [[page|label]] usando o label como texto do link', () => {
46
+ const html = render('Veja [[backend/plan|o plano]] aqui.')
47
+ expect(html).toContain('href="/backend/plan"')
48
+ expect(html).toContain('>o plano<')
49
+ })
50
+
51
+ it('normaliza espaços e maiúsculas no path', () => {
52
+ const html = render('Ver [[Meu Documento]].')
53
+ expect(html).toContain('href="/meu-documento"')
54
+ })
55
+
56
+ it('não afeta texto sem wiki links', () => {
57
+ const html = render('Texto normal sem links especiais.')
58
+ expect(html).not.toContain('<a ')
59
+ expect(html).toContain('Texto normal sem links especiais.')
60
+ })
61
+
62
+ it('resolve múltiplos wiki links na mesma linha', () => {
63
+ const html = render('Ver [[a]] e também [[b]].')
64
+ expect(html).toContain('href="/a"')
65
+ expect(html).toContain('href="/b"')
66
+ })
67
+
68
+ // não corrompe rótulos de diagrama Mermaid que usam [[texto]] (sintaxe de subroutine node) —
69
+ // o conteúdo de um bloco de código é um nó 'code' opaco, nunca visitado como 'text'
70
+ it('não transforma [[texto]] dentro de um bloco de código', () => {
71
+ const html = render('```mermaid\nflowchart TD\n A[["algo<br/>outro"]]\n```')
72
+ expect(html).not.toContain('<a ')
73
+ expect(html).toContain('A[[')
74
+ })
75
+
76
+ it('falha quando um wiki link não aponta para um documento', () => {
77
+ expect(() => renderStrict('Veja [[arquivo-que-nao-existe]].')).toThrow('Broken wiki link')
78
+ })
79
+
80
+ it('aceita um wiki link que aponta para um documento do consumer', () => {
81
+ expect(renderStrict('Veja [[README]].')).toContain('href="/readme"')
82
+ })
83
+
84
+ it('mantém os formatos suportados sob validação estrita', () => {
85
+ expect(renderStrict('[[#Product]] [[README#Product]] [[README|início]]')).toContain('href="#product"')
86
+ expect(renderStrict('[[#Product]] [[README#Product]] [[README|início]]')).toContain('href="/readme#product"')
87
+ })
88
+
89
+ it('rejeita referências a arquivos que não são documentos', () => {
90
+ expect(() => renderStrict('Veja [[package.json]].')).toThrow('Broken wiki link')
91
+ })
92
+
93
+ it('rejeita referências que escapam da raiz do consumer', () => {
94
+ expect(() => renderStrict('Veja [[../package.json]].')).toThrow('Broken wiki link')
95
+ })
96
+
97
+ it('falha para uma referência wiki vazia ou sem fechamento', () => {
98
+ expect(() => renderStrict('Veja [[]].')).toThrow('Malformed wiki link')
99
+ expect(() => renderStrict('Veja [[sem fechamento.')).toThrow('Malformed wiki link')
100
+ expect(() => renderStrict('Veja [[README]] e [[sem fechamento.')).toThrow('Malformed wiki link')
101
+ })
102
+ })
@@ -0,0 +1,110 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { resolve, sep } from 'node:path'
3
+ import { visit } from 'unist-util-visit'
4
+ import { PRODUCT_NAME } from './product-identity'
5
+
6
+ interface MdastText {
7
+ type: 'text'
8
+ value: string
9
+ }
10
+
11
+ interface MdastLink {
12
+ type: 'link'
13
+ url: string
14
+ title: null
15
+ children: MdastText[]
16
+ }
17
+
18
+ interface MdastParent {
19
+ type: string
20
+ children: (MdastText | MdastLink | unknown)[]
21
+ }
22
+
23
+ interface WikiLinkOptions {
24
+ contentRoot?: string
25
+ failOnBrokenLinks?: boolean
26
+ }
27
+
28
+ function slugifyPath(ref: string): string {
29
+ return ref.toLowerCase().replace(/\s+/g, '-').replace(/[^\w/-]/g, '')
30
+ }
31
+
32
+ function slugifyHeading(heading: string): string {
33
+ return heading.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '')
34
+ }
35
+
36
+ // Constrói a URL a partir da referência crua entre colchetes: [[#heading]] vira
37
+ // anchor local; [[page]] vira path; [[page#heading]] combina os dois — path e
38
+ // heading são fatiados (slugify) separadamente pra não perder o separador "#".
39
+ function buildUrl(ref: string): string {
40
+ if (ref.startsWith('#')) return '#' + slugifyHeading(ref.slice(1))
41
+ const hashIndex = ref.indexOf('#')
42
+ if (hashIndex >= 0) {
43
+ const pagePart = ref.slice(0, hashIndex)
44
+ const headingPart = ref.slice(hashIndex + 1)
45
+ return '/' + slugifyPath(pagePart) + '#' + slugifyHeading(headingPart)
46
+ }
47
+ return '/' + slugifyPath(ref)
48
+ }
49
+
50
+ function pagePartOf(ref: string): string {
51
+ const hashIndex = ref.indexOf('#')
52
+ return (hashIndex >= 0 ? ref.slice(0, hashIndex) : ref).replace(/^\/+/, '')
53
+ }
54
+
55
+ function wikiLinkResolves(ref: string, contentRoot: string): boolean {
56
+ const pagePart = pagePartOf(ref)
57
+ if (!pagePart) return true
58
+ const target = resolve(contentRoot, pagePart)
59
+ if (!target.startsWith(`${resolve(contentRoot)}${sep}`)) return false
60
+ if (/\.(?!mdx?$)[^/]+$/i.test(pagePart)) return false
61
+ const withoutExtension = target.replace(/\.mdx?$/i, '')
62
+ return [`${withoutExtension}.md`, `${withoutExtension}.mdx`, resolve(target, 'index.md'), resolve(target, 'index.mdx')]
63
+ .some(candidate => existsSync(candidate))
64
+ }
65
+
66
+ // Remark plugin: converts [[page]] / [[#heading]] / [[page#heading]] / [[page|label]]
67
+ // Obsidian wiki links pra links markdown normais.
68
+ export function remarkWikiLinks(options: WikiLinkOptions = {}) {
69
+ return (tree: MdastParent, file: { path?: string }) => {
70
+ visit(tree, 'text', (node: MdastText, index: number | undefined, parent: MdastParent | undefined) => {
71
+ if (!node.value.includes('[[')) return
72
+ const parts: (MdastText | MdastLink)[] = []
73
+ const pattern = /\[\[([^\]]+)\]\]/g
74
+ const openingMarkers = node.value.match(/\[\[/g)?.length ?? 0
75
+ const closingMarkers = node.value.match(/\]\]/g)?.length ?? 0
76
+ if (options.failOnBrokenLinks && openingMarkers !== closingMarkers) {
77
+ const source = file.path ? ` in ${file.path}` : ''
78
+ throw new Error(`[${PRODUCT_NAME}] Malformed wiki link${source}`)
79
+ }
80
+ let lastIndex = 0
81
+ let match: RegExpExecArray | null
82
+ while ((match = pattern.exec(node.value)) !== null) {
83
+ if (match.index > lastIndex) parts.push({ type: 'text', value: node.value.slice(lastIndex, match.index) })
84
+ const inner = match[1]
85
+ const pipeIdx = inner.indexOf('|')
86
+ const ref = pipeIdx >= 0 ? inner.slice(0, pipeIdx) : inner
87
+ const label = pipeIdx >= 0 ? inner.slice(pipeIdx + 1) : ref.replace(/^#/, '')
88
+ if (options.failOnBrokenLinks && options.contentRoot && !wikiLinkResolves(ref, options.contentRoot)) {
89
+ const source = file.path ? ` in ${file.path}` : ''
90
+ throw new Error(`[${PRODUCT_NAME}] Broken wiki link [[${inner}]]${source}`)
91
+ }
92
+ const url = buildUrl(ref)
93
+ parts.push({ type: 'link', url, title: null, children: [{ type: 'text', value: label }] })
94
+ lastIndex = match.index + match[0].length
95
+ }
96
+ if (!parts.length) {
97
+ if (options.failOnBrokenLinks && node.value.includes('[[')) {
98
+ const source = file.path ? ` in ${file.path}` : ''
99
+ throw new Error(`[${PRODUCT_NAME}] Malformed wiki link${source}`)
100
+ }
101
+ return
102
+ }
103
+ if (lastIndex < node.value.length) parts.push({ type: 'text', value: node.value.slice(lastIndex) })
104
+ if (parent && index !== undefined) {
105
+ parent.children.splice(index, 1, ...parts)
106
+ return index + parts.length
107
+ }
108
+ })
109
+ }
110
+ }