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.
- package/.github/workflows/ci.yml +61 -0
- package/.github/workflows/deploy.yml +59 -0
- package/.github/workflows/reusable-pages.yml +74 -0
- package/.upcontent/config.json +82 -0
- package/.upcontent/favicon.svg +4 -0
- package/.upcontent/logo.svg +6 -0
- package/.upcontent/portal.css +139 -0
- package/Makefile +29 -0
- package/README.md +114 -2
- package/SHOWCASE.mdx +163 -0
- package/astro.config.mjs +125 -0
- package/customization/config-json.md +92 -0
- package/customization/content.md +48 -0
- package/customization/environment.md +43 -0
- package/customization/index.md +33 -0
- package/customization/navigation.md +57 -0
- package/customization/site-identity.md +43 -0
- package/customization/theme.md +47 -0
- package/deployment/github-pages.md +46 -0
- package/deployment/index.md +24 -0
- package/deployment/static-hosts.md +34 -0
- package/getting-started/consumer-repository.md +156 -0
- package/getting-started/first-build.md +78 -0
- package/guides/authoring-content.md +105 -0
- package/guides/validate-your-site.md +53 -0
- package/package.json +33 -3
- package/scripts/upcontent-cli.mjs +113 -0
- package/scripts/verify-external-build.mjs +24 -0
- package/src/components/MermaidLoader.astro +290 -0
- package/src/components/PaletteShowcase.astro +122 -0
- package/src/components/StructuredDataCopy.astro +22 -0
- package/src/content/__mocks__/astro-content.ts +7 -0
- package/src/content/__mocks__/astro-loaders.ts +3 -0
- package/src/content.config.test.ts +163 -0
- package/src/content.config.ts +90 -0
- package/src/lib/content-blocklist.test.ts +47 -0
- package/src/lib/content-blocklist.ts +55 -0
- package/src/lib/doc-links.test.ts +62 -0
- package/src/lib/doc-links.ts +31 -0
- package/src/lib/mermaid-render.test.ts +42 -0
- package/src/lib/mermaid-render.ts +24 -0
- package/src/lib/portal-config.test.ts +143 -0
- package/src/lib/portal-config.ts +176 -0
- package/src/lib/product-identity.ts +2 -0
- package/src/lib/rehype-callouts.test.ts +69 -0
- package/src/lib/rehype-callouts.ts +61 -0
- package/src/lib/remark-strip-duplicate-title.test.ts +73 -0
- package/src/lib/remark-strip-duplicate-title.ts +37 -0
- package/src/lib/remark-structured-data-preview.test.ts +104 -0
- package/src/lib/remark-structured-data-preview.ts +66 -0
- package/src/lib/remark-wiki-links.test.ts +102 -0
- package/src/lib/remark-wiki-links.ts +110 -0
- package/src/lib/sidebar.test.ts +142 -0
- package/src/lib/sidebar.ts +113 -0
- package/src/lib/structured-data-tree.test.ts +75 -0
- package/src/lib/structured-data-tree.ts +55 -0
- package/src/overrides/Footer.astro +114 -0
- package/src/overrides/Head.astro +20 -0
- package/src/pages/index.astro +20 -0
- package/src/styles/callouts.css +29 -0
- package/src/styles/structured-data-preview.css +95 -0
- package/test-fixtures/external-consumer/.upcontent/config.json +26 -0
- package/test-fixtures/external-consumer/.upcontent/favicon.svg +4 -0
- package/test-fixtures/external-consumer/.upcontent/logo.svg +4 -0
- package/test-fixtures/external-consumer/.upcontent/theme.css +4 -0
- package/test-fixtures/external-consumer/.upcontent-renderer/README.md +3 -0
- package/test-fixtures/external-consumer/README.md +6 -0
- package/test-fixtures/external-consumer/forbidden.md +5 -0
- package/tsconfig.json +7 -0
- 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
|
+
}
|