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,290 @@
|
|
|
1
|
+
---
|
|
2
|
+
---
|
|
3
|
+
|
|
4
|
+
<script>
|
|
5
|
+
import mermaid from 'mermaid'
|
|
6
|
+
import { renderMermaidDiagram } from '../lib/mermaid-render'
|
|
7
|
+
|
|
8
|
+
function mermaidTheme() {
|
|
9
|
+
return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'default'
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
mermaid.initialize({ startOnLoad: false, theme: mermaidTheme() })
|
|
13
|
+
|
|
14
|
+
let scale = 1
|
|
15
|
+
let translateX = 0
|
|
16
|
+
let translateY = 0
|
|
17
|
+
let dialogEl: HTMLDialogElement | null = null
|
|
18
|
+
let contentEl: HTMLDivElement | null = null
|
|
19
|
+
|
|
20
|
+
function applyTransform() {
|
|
21
|
+
if (contentEl) contentEl.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resetTransform() {
|
|
25
|
+
scale = 1
|
|
26
|
+
translateX = 0
|
|
27
|
+
translateY = 0
|
|
28
|
+
applyTransform()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ensureDialog() {
|
|
32
|
+
if (dialogEl) return
|
|
33
|
+
|
|
34
|
+
dialogEl = document.createElement('dialog')
|
|
35
|
+
dialogEl.className = 'mermaid-fullscreen-dialog'
|
|
36
|
+
|
|
37
|
+
const closeBtn = document.createElement('button')
|
|
38
|
+
closeBtn.type = 'button'
|
|
39
|
+
closeBtn.className = 'mermaid-fullscreen-close'
|
|
40
|
+
closeBtn.setAttribute('aria-label', 'Fechar')
|
|
41
|
+
closeBtn.textContent = '✕'
|
|
42
|
+
closeBtn.addEventListener('click', () => dialogEl?.close())
|
|
43
|
+
|
|
44
|
+
const viewport = document.createElement('div')
|
|
45
|
+
viewport.className = 'mermaid-fullscreen-viewport'
|
|
46
|
+
|
|
47
|
+
contentEl = document.createElement('div')
|
|
48
|
+
contentEl.className = 'mermaid-fullscreen-content'
|
|
49
|
+
viewport.appendChild(contentEl)
|
|
50
|
+
|
|
51
|
+
dialogEl.append(closeBtn, viewport)
|
|
52
|
+
document.body.appendChild(dialogEl)
|
|
53
|
+
|
|
54
|
+
dialogEl.addEventListener('click', e => {
|
|
55
|
+
if (e.target === dialogEl) dialogEl?.close()
|
|
56
|
+
})
|
|
57
|
+
dialogEl.addEventListener('close', resetTransform)
|
|
58
|
+
|
|
59
|
+
viewport.addEventListener(
|
|
60
|
+
'wheel',
|
|
61
|
+
e => {
|
|
62
|
+
e.preventDefault()
|
|
63
|
+
const delta = e.deltaY > 0 ? -0.1 : 0.1
|
|
64
|
+
scale = Math.min(Math.max(scale + delta, 0.2), 5)
|
|
65
|
+
applyTransform()
|
|
66
|
+
},
|
|
67
|
+
{ passive: false },
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
let dragging = false
|
|
71
|
+
let lastX = 0
|
|
72
|
+
let lastY = 0
|
|
73
|
+
viewport.addEventListener('pointerdown', e => {
|
|
74
|
+
dragging = true
|
|
75
|
+
lastX = e.clientX
|
|
76
|
+
lastY = e.clientY
|
|
77
|
+
viewport.setPointerCapture(e.pointerId)
|
|
78
|
+
})
|
|
79
|
+
viewport.addEventListener('pointermove', e => {
|
|
80
|
+
if (!dragging) return
|
|
81
|
+
translateX += e.clientX - lastX
|
|
82
|
+
translateY += e.clientY - lastY
|
|
83
|
+
lastX = e.clientX
|
|
84
|
+
lastY = e.clientY
|
|
85
|
+
applyTransform()
|
|
86
|
+
})
|
|
87
|
+
viewport.addEventListener('pointerup', () => { dragging = false })
|
|
88
|
+
viewport.addEventListener('pointerleave', () => { dragging = false })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function openFullscreen(svg: string) {
|
|
92
|
+
ensureDialog()
|
|
93
|
+
if (!dialogEl || !contentEl) return
|
|
94
|
+
contentEl.innerHTML = svg
|
|
95
|
+
resetTransform()
|
|
96
|
+
dialogEl.showModal()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function copyToClipboard(button: HTMLButtonElement, text: string) {
|
|
100
|
+
await navigator.clipboard.writeText(text)
|
|
101
|
+
const original = button.textContent
|
|
102
|
+
button.textContent = 'Copiado!'
|
|
103
|
+
setTimeout(() => { button.textContent = original }, 2000)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function processDiagrams() {
|
|
107
|
+
const nodes = Array.from(document.querySelectorAll<HTMLElement>('div.mermaid'))
|
|
108
|
+
for (const node of nodes) {
|
|
109
|
+
// <br/> inside a div becomes a DOM element before textContent reads it — não deveria
|
|
110
|
+
// acontecer (remarkMermaid já normaliza), mas o replace aqui é defensivo.
|
|
111
|
+
const source = (node.textContent ?? '').replace(/ /g, ' ')
|
|
112
|
+
const result = await renderMermaidDiagram(mermaid, source)
|
|
113
|
+
|
|
114
|
+
if (result.status === 'ok') {
|
|
115
|
+
const container = document.createElement('div')
|
|
116
|
+
container.className = 'mermaid-container'
|
|
117
|
+
container.dataset.mermaidSource = source
|
|
118
|
+
|
|
119
|
+
const copyBtn = document.createElement('button')
|
|
120
|
+
copyBtn.type = 'button'
|
|
121
|
+
copyBtn.className = 'mermaid-copy-btn'
|
|
122
|
+
copyBtn.textContent = 'Copiar'
|
|
123
|
+
copyBtn.setAttribute('aria-label', 'Copiar código-fonte do diagrama')
|
|
124
|
+
copyBtn.addEventListener('click', e => {
|
|
125
|
+
e.stopPropagation()
|
|
126
|
+
void copyToClipboard(copyBtn, source)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const diagram = document.createElement('div')
|
|
130
|
+
diagram.className = 'mermaid-diagram'
|
|
131
|
+
diagram.innerHTML = result.svg
|
|
132
|
+
diagram.setAttribute('role', 'button')
|
|
133
|
+
diagram.setAttribute('tabindex', '0')
|
|
134
|
+
diagram.setAttribute('aria-label', 'Abrir diagrama em tela cheia')
|
|
135
|
+
diagram.addEventListener('click', () => openFullscreen(diagram.innerHTML))
|
|
136
|
+
diagram.addEventListener('keydown', e => {
|
|
137
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
138
|
+
e.preventDefault()
|
|
139
|
+
openFullscreen(diagram.innerHTML)
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
container.append(copyBtn, diagram)
|
|
144
|
+
node.replaceWith(container)
|
|
145
|
+
} else {
|
|
146
|
+
const errorBox = document.createElement('div')
|
|
147
|
+
errorBox.className = 'mermaid-error'
|
|
148
|
+
errorBox.dataset.mermaidSource = source
|
|
149
|
+
|
|
150
|
+
const message = document.createElement('p')
|
|
151
|
+
message.className = 'mermaid-error-message'
|
|
152
|
+
message.textContent = `Erro ao renderizar diagrama Mermaid: ${result.message}`
|
|
153
|
+
|
|
154
|
+
const pre = document.createElement('pre')
|
|
155
|
+
pre.className = 'mermaid-error-source'
|
|
156
|
+
pre.textContent = result.source
|
|
157
|
+
|
|
158
|
+
errorBox.append(message, pre)
|
|
159
|
+
node.replaceWith(errorBox)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function rerenderExistingDiagrams() {
|
|
165
|
+
mermaid.initialize({ startOnLoad: false, theme: mermaidTheme() })
|
|
166
|
+
for (const container of document.querySelectorAll<HTMLElement>('[data-mermaid-source]')) {
|
|
167
|
+
const source = container.dataset.mermaidSource
|
|
168
|
+
if (!source) continue
|
|
169
|
+
const result = await renderMermaidDiagram(mermaid, source)
|
|
170
|
+
if (result.status === 'ok') {
|
|
171
|
+
const diagram = container.querySelector('.mermaid-diagram')
|
|
172
|
+
if (diagram) diagram.innerHTML = result.svg
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (document.readyState === 'loading') {
|
|
178
|
+
document.addEventListener('DOMContentLoaded', () => void processDiagrams())
|
|
179
|
+
} else {
|
|
180
|
+
void processDiagrams()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
new MutationObserver(() => void rerenderExistingDiagrams()).observe(document.documentElement, {
|
|
184
|
+
attributes: true,
|
|
185
|
+
attributeFilter: ['data-theme'],
|
|
186
|
+
})
|
|
187
|
+
</script>
|
|
188
|
+
|
|
189
|
+
<style is:global>
|
|
190
|
+
.mermaid-container {
|
|
191
|
+
position: relative;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.mermaid-diagram {
|
|
195
|
+
cursor: zoom-in;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.mermaid-diagram:focus-visible {
|
|
199
|
+
outline: 2px solid var(--sl-color-accent);
|
|
200
|
+
outline-offset: 0.25rem;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.mermaid-copy-btn {
|
|
204
|
+
position: absolute;
|
|
205
|
+
top: 0.5rem;
|
|
206
|
+
right: 0.5rem;
|
|
207
|
+
padding: 0.25rem 0.5rem;
|
|
208
|
+
font-size: var(--sl-text-2xs, 0.75rem);
|
|
209
|
+
border: 1px solid var(--sl-color-gray-5);
|
|
210
|
+
border-radius: 0.25rem;
|
|
211
|
+
background: var(--sl-color-gray-6);
|
|
212
|
+
color: var(--sl-color-gray-2);
|
|
213
|
+
cursor: pointer;
|
|
214
|
+
opacity: 0;
|
|
215
|
+
transition: opacity 0.15s;
|
|
216
|
+
}
|
|
217
|
+
.mermaid-container:hover .mermaid-copy-btn,
|
|
218
|
+
.mermaid-copy-btn:focus-visible {
|
|
219
|
+
opacity: 1;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
@media (prefers-reduced-motion: reduce) {
|
|
223
|
+
.mermaid-copy-btn {
|
|
224
|
+
transition: none;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.mermaid-error {
|
|
229
|
+
border: 1px solid var(--sl-color-red, #c53434);
|
|
230
|
+
border-radius: 0.5rem;
|
|
231
|
+
padding: 1rem;
|
|
232
|
+
}
|
|
233
|
+
.mermaid-error-message {
|
|
234
|
+
color: var(--sl-color-red, #c53434);
|
|
235
|
+
font-weight: 600;
|
|
236
|
+
margin: 0 0 0.5rem;
|
|
237
|
+
}
|
|
238
|
+
.mermaid-error-source {
|
|
239
|
+
white-space: pre-wrap;
|
|
240
|
+
overflow-x: auto;
|
|
241
|
+
margin: 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.mermaid-fullscreen-dialog {
|
|
245
|
+
padding: 0;
|
|
246
|
+
border: none;
|
|
247
|
+
width: 100vw;
|
|
248
|
+
height: 100vh;
|
|
249
|
+
max-width: 100vw;
|
|
250
|
+
max-height: 100vh;
|
|
251
|
+
background: var(--sl-color-bg, var(--sl-color-black));
|
|
252
|
+
}
|
|
253
|
+
.mermaid-fullscreen-dialog::backdrop {
|
|
254
|
+
background-color: var(--sl-color-backdrop-overlay, rgba(0, 0, 0, 0.6));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.mermaid-fullscreen-close {
|
|
258
|
+
position: absolute;
|
|
259
|
+
top: 1rem;
|
|
260
|
+
right: 1rem;
|
|
261
|
+
z-index: 1;
|
|
262
|
+
border: 1px solid var(--sl-color-gray-5);
|
|
263
|
+
border-radius: 0.375rem;
|
|
264
|
+
background: var(--sl-color-gray-6);
|
|
265
|
+
color: var(--sl-color-gray-2);
|
|
266
|
+
width: 2.5rem;
|
|
267
|
+
height: 2.5rem;
|
|
268
|
+
cursor: pointer;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.mermaid-fullscreen-viewport {
|
|
272
|
+
width: 100%;
|
|
273
|
+
height: 100%;
|
|
274
|
+
overflow: hidden;
|
|
275
|
+
touch-action: none;
|
|
276
|
+
cursor: grab;
|
|
277
|
+
}
|
|
278
|
+
.mermaid-fullscreen-viewport:active {
|
|
279
|
+
cursor: grabbing;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.mermaid-fullscreen-content {
|
|
283
|
+
width: 100%;
|
|
284
|
+
height: 100%;
|
|
285
|
+
display: flex;
|
|
286
|
+
align-items: center;
|
|
287
|
+
justify-content: center;
|
|
288
|
+
transform-origin: center center;
|
|
289
|
+
}
|
|
290
|
+
</style>
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
const palettes = {
|
|
3
|
+
quiet: { label: 'Quiet / default', accent: '#334155', surface: '#f8fafc', ink: '#0f172a' },
|
|
4
|
+
river: { label: 'River', accent: '#0f766e', surface: '#f0fdfa', ink: '#134e4a' },
|
|
5
|
+
signal: { label: 'Signal', accent: '#be123c', surface: '#fff1f2', ink: '#4c0519' },
|
|
6
|
+
}
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<div class="palette-showcase" data-palette="quiet" data-palettes={JSON.stringify(palettes)}>
|
|
10
|
+
<div class="palette-controls" role="group" aria-label="Preview palettes">
|
|
11
|
+
{Object.entries(palettes).map(([id, palette], index) => (
|
|
12
|
+
<button
|
|
13
|
+
type="button"
|
|
14
|
+
class="palette-button"
|
|
15
|
+
data-palette-button={id}
|
|
16
|
+
aria-pressed={index === 0 ? 'true' : 'false'}
|
|
17
|
+
>
|
|
18
|
+
{palette.label}
|
|
19
|
+
</button>
|
|
20
|
+
))}
|
|
21
|
+
</div>
|
|
22
|
+
<div class="palette-preview" aria-live="polite">
|
|
23
|
+
<div class="palette-swatch" data-swatch="accent"></div>
|
|
24
|
+
<div class="palette-swatch" data-swatch="surface"></div>
|
|
25
|
+
<div class="palette-swatch" data-swatch="ink"></div>
|
|
26
|
+
<div class="palette-preview-copy">
|
|
27
|
+
<strong data-palette-label>{palettes.quiet.label}</strong>
|
|
28
|
+
<span>One configuration seam, many possible identities.</span>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<script>
|
|
34
|
+
document.querySelectorAll<HTMLElement>('.palette-showcase').forEach(showcase => {
|
|
35
|
+
const palettes: Record<string, { accent: string; surface: string; ink: string; label: string }> = JSON.parse(
|
|
36
|
+
showcase.dataset.palettes ?? '{}',
|
|
37
|
+
)
|
|
38
|
+
const label = showcase.querySelector('[data-palette-label]')
|
|
39
|
+
|
|
40
|
+
showcase.querySelectorAll<HTMLButtonElement>('[data-palette-button]').forEach(button => {
|
|
41
|
+
button.addEventListener('click', () => {
|
|
42
|
+
const paletteId = button.dataset.paletteButton
|
|
43
|
+
const palette = paletteId ? palettes[paletteId] : undefined
|
|
44
|
+
if (!palette) return
|
|
45
|
+
showcase.dataset.palette = paletteId
|
|
46
|
+
showcase.style.setProperty('--palette-accent', palette.accent)
|
|
47
|
+
showcase.style.setProperty('--palette-surface', palette.surface)
|
|
48
|
+
showcase.style.setProperty('--palette-ink', palette.ink)
|
|
49
|
+
if (label) label.textContent = palette.label
|
|
50
|
+
showcase.querySelectorAll('[data-palette-button]').forEach(control => {
|
|
51
|
+
control.setAttribute('aria-pressed', String(control === button))
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
showcase.querySelector('[data-palette-button]')?.dispatchEvent(new Event('click'))
|
|
57
|
+
})
|
|
58
|
+
</script>
|
|
59
|
+
|
|
60
|
+
<style>
|
|
61
|
+
.palette-showcase {
|
|
62
|
+
--palette-accent: #334155;
|
|
63
|
+
--palette-surface: #f8fafc;
|
|
64
|
+
--palette-ink: #0f172a;
|
|
65
|
+
margin: 2rem 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.palette-controls {
|
|
69
|
+
display: flex;
|
|
70
|
+
flex-wrap: wrap;
|
|
71
|
+
gap: 0.5rem;
|
|
72
|
+
margin-bottom: 1rem;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.palette-button {
|
|
76
|
+
border: 1px solid var(--sl-color-gray-5);
|
|
77
|
+
border-radius: 999px;
|
|
78
|
+
background: transparent;
|
|
79
|
+
color: var(--sl-color-text);
|
|
80
|
+
cursor: pointer;
|
|
81
|
+
padding: 0.45rem 0.75rem;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.palette-button[aria-pressed='true'] {
|
|
85
|
+
border-color: var(--sl-color-accent);
|
|
86
|
+
background: var(--sl-color-accent);
|
|
87
|
+
color: var(--sl-color-bg);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.palette-preview {
|
|
91
|
+
display: grid;
|
|
92
|
+
grid-template-columns: repeat(3, 3rem) 1fr;
|
|
93
|
+
gap: 0.75rem;
|
|
94
|
+
align-items: center;
|
|
95
|
+
border: 1px solid var(--sl-color-gray-5);
|
|
96
|
+
border-radius: 0.5rem;
|
|
97
|
+
padding: 1rem;
|
|
98
|
+
background: var(--palette-surface);
|
|
99
|
+
color: var(--palette-ink);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.palette-swatch {
|
|
103
|
+
width: 3rem;
|
|
104
|
+
height: 3rem;
|
|
105
|
+
border: 1px solid color-mix(in srgb, var(--palette-ink), transparent 70%);
|
|
106
|
+
border-radius: 0.35rem;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.palette-swatch[data-swatch='accent'] { background: var(--palette-accent); }
|
|
110
|
+
.palette-swatch[data-swatch='surface'] { background: var(--palette-surface); }
|
|
111
|
+
.palette-swatch[data-swatch='ink'] { background: var(--palette-ink); }
|
|
112
|
+
|
|
113
|
+
.palette-preview-copy {
|
|
114
|
+
display: grid;
|
|
115
|
+
gap: 0.25rem;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@media (max-width: 560px) {
|
|
119
|
+
.palette-preview { grid-template-columns: repeat(3, 2.25rem) 1fr; }
|
|
120
|
+
.palette-swatch { width: 2.25rem; height: 2.25rem; }
|
|
121
|
+
}
|
|
122
|
+
</style>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
const buttons = document.querySelectorAll<HTMLButtonElement>('[data-sdp-copy]')
|
|
3
|
+
|
|
4
|
+
for (const button of buttons) {
|
|
5
|
+
button.addEventListener('click', async () => {
|
|
6
|
+
const source = button.dataset.sdpCopy
|
|
7
|
+
const label = button.querySelector('.sdp-copy-label')
|
|
8
|
+
if (!source || !label) return
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
await navigator.clipboard.writeText(decodeURIComponent(source))
|
|
12
|
+
label.textContent = 'Copied'
|
|
13
|
+
} catch {
|
|
14
|
+
label.textContent = 'Copy failed'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
window.setTimeout(() => {
|
|
18
|
+
label.textContent = 'Copy'
|
|
19
|
+
}, 1500)
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
vi.mock('node:fs', () => ({
|
|
4
|
+
existsSync: vi.fn(),
|
|
5
|
+
readFileSync: vi.fn(),
|
|
6
|
+
}))
|
|
7
|
+
|
|
8
|
+
import * as fs from 'node:fs'
|
|
9
|
+
import { domainFieldsSchema, isBlocked, resolveTitle, toRelativeDocPath } from './content.config'
|
|
10
|
+
import { _resetPortalConfigCache } from './lib/portal-config'
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
_resetPortalConfigCache()
|
|
14
|
+
vi.mocked(fs.existsSync).mockReturnValue(false)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
// domainFieldsSchema cobre só os campos de domínio deste portal — o título
|
|
18
|
+
// obrigatório do Starlight é resolvido por resolveTitle() antes da
|
|
19
|
+
// validação (ver describe('resolveTitle') abaixo), não faz parte deste schema.
|
|
20
|
+
describe('domainFieldsSchema', () => {
|
|
21
|
+
it('aceita frontmatter completamente vazio', () => {
|
|
22
|
+
const result = domainFieldsSchema.safeParse({})
|
|
23
|
+
expect(result.success).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('aceita status outdated', () => {
|
|
27
|
+
const result = domainFieldsSchema.safeParse({ status: 'outdated' })
|
|
28
|
+
expect(result.success).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('aceita created/updated como objeto Date (YAML parseia datas automaticamente)', () => {
|
|
32
|
+
const result = domainFieldsSchema.safeParse({
|
|
33
|
+
created: new Date('2026-08-07'),
|
|
34
|
+
updated: new Date('2026-08-07'),
|
|
35
|
+
})
|
|
36
|
+
expect(result.success).toBe(true)
|
|
37
|
+
if (result.success) {
|
|
38
|
+
expect(result.data.created).toBe('2026-08-07')
|
|
39
|
+
expect(result.data.updated).toBe('2026-08-07')
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('aceita todos os campos de domínio preenchidos', () => {
|
|
44
|
+
const result = domainFieldsSchema.safeParse({
|
|
45
|
+
type: 'architecture',
|
|
46
|
+
status: 'active',
|
|
47
|
+
created: '2026-08-07',
|
|
48
|
+
updated: '2026-08-07',
|
|
49
|
+
teams: ['my-team'],
|
|
50
|
+
impacted_teams: [],
|
|
51
|
+
domains: [],
|
|
52
|
+
repos: ['my-frontend-repo'],
|
|
53
|
+
areas: [],
|
|
54
|
+
tags: ['sdd/prd'],
|
|
55
|
+
aliases: [],
|
|
56
|
+
related: [],
|
|
57
|
+
})
|
|
58
|
+
expect(result.success).toBe(true)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('resolveTitle', () => {
|
|
63
|
+
it('mantém o title do frontmatter quando presente', () => {
|
|
64
|
+
const data: Record<string, unknown> = { title: 'Meu Título' }
|
|
65
|
+
resolveTitle('docs/algo.md', data)
|
|
66
|
+
expect(data.title).toBe('Meu Título')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('deriva do filename em Title Case quando title ausente', () => {
|
|
70
|
+
const data: Record<string, unknown> = {}
|
|
71
|
+
resolveTitle('domains/trd-backend.md', data)
|
|
72
|
+
expect(data.title).toBe('Trd Backend')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('remove prefixo numérico do filename antes de converter', () => {
|
|
76
|
+
const data: Record<string, unknown> = {}
|
|
77
|
+
resolveTitle('docs/adr/0001-self-hosted.md', data)
|
|
78
|
+
expect(data.title).toBe('Self Hosted')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('usa apenas o nome do arquivo, ignorando o diretório', () => {
|
|
82
|
+
const data: Record<string, unknown> = {}
|
|
83
|
+
resolveTitle('domains/licencas/gestao-acessos.md', data)
|
|
84
|
+
expect(data.title).toBe('Gestao Acessos')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('usa o campo configurado em content.titleField quando presente', () => {
|
|
88
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
89
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
90
|
+
JSON.stringify({ content: { titleField: 'heading' } })
|
|
91
|
+
)
|
|
92
|
+
const data: Record<string, unknown> = { heading: 'Título Alternativo' }
|
|
93
|
+
resolveTitle('domains/algo.md', data)
|
|
94
|
+
expect(data.title).toBe('Título Alternativo')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('cai no fallback de filename quando content.titleField está configurado mas ausente no frontmatter', () => {
|
|
98
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
99
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
100
|
+
JSON.stringify({ content: { titleField: 'heading' } })
|
|
101
|
+
)
|
|
102
|
+
const data: Record<string, unknown> = {}
|
|
103
|
+
resolveTitle('domains/algo-especifico.md', data)
|
|
104
|
+
expect(data.title).toBe('Algo Especifico')
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('isBlocked', () => {
|
|
109
|
+
it('bloqueia CLAUDE.md (case-insensitive)', () => {
|
|
110
|
+
expect(isBlocked('CLAUDE.md')).toBe(true)
|
|
111
|
+
expect(isBlocked('claude.md')).toBe(true)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('bloqueia RULES.md (case-insensitive)', () => {
|
|
115
|
+
expect(isBlocked('RULES.md')).toBe(true)
|
|
116
|
+
expect(isBlocked('rules.md')).toBe(true)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('bloqueia qualquer arquivo dentro de .claude/', () => {
|
|
120
|
+
expect(isBlocked('.claude/settings.json')).toBe(true)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('bloqueia qualquer arquivo dentro de .github/', () => {
|
|
124
|
+
expect(isBlocked('.github/workflows/ci.yml')).toBe(true)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('bloqueia qualquer arquivo dentro de .upcontent/', () => {
|
|
128
|
+
expect(isBlocked('.upcontent/config.json')).toBe(true)
|
|
129
|
+
expect(isBlocked('.upcontent/assets/logo.svg')).toBe(true)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// adversarial: arquivo com nome parecido mas que NÃO deve ser bloqueado
|
|
133
|
+
it('não bloqueia domains/README.md', () => {
|
|
134
|
+
expect(isBlocked('domains/README.md')).toBe(false)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('bloqueia shared/INDEX.md (case-insensitive — arquivo real usa maiúsculo)', () => {
|
|
138
|
+
expect(isBlocked('shared/INDEX.md')).toBe(true)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('não bloqueia docs/prd/prd.md', () => {
|
|
142
|
+
expect(isBlocked('docs/prd/prd.md')).toBe(false)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// adversarial: arquivo com prefixo ".cl" que NÃO é ".claude/"
|
|
146
|
+
it('não bloqueia um arquivo hipotético .clue/something.md', () => {
|
|
147
|
+
expect(isBlocked('.clue/something.md')).toBe(false)
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('toRelativeDocPath', () => {
|
|
152
|
+
// o loader glob() do Astro gera entry.filePath relativo à raiz do projeto
|
|
153
|
+
// (ex: "src/content/docs/RULES.md"), não relativo ao content base — isBlocked()
|
|
154
|
+
// espera o path relativo ao content base (ex: "RULES.md")
|
|
155
|
+
it('remove o prefixo src/content/docs/', () => {
|
|
156
|
+
expect(toRelativeDocPath('src/content/docs/RULES.md')).toBe('RULES.md')
|
|
157
|
+
expect(toRelativeDocPath('src/content/docs/shared/index.md')).toBe('shared/index.md')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('mantém o path como está se o prefixo não estiver presente', () => {
|
|
161
|
+
expect(toRelativeDocPath('RULES.md')).toBe('RULES.md')
|
|
162
|
+
})
|
|
163
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { defineCollection } from 'astro:content'
|
|
4
|
+
import { z } from 'astro/zod'
|
|
5
|
+
import { docsSchema } from '@astrojs/starlight/schema'
|
|
6
|
+
import { glob } from 'astro/loaders'
|
|
7
|
+
import type { Loader, LoaderContext } from 'astro/loaders'
|
|
8
|
+
import { getBlocklist, isBlocked, toRelativeDocPath, toTitleCase } from './lib/content-blocklist'
|
|
9
|
+
import { getPortalConfig } from './lib/portal-config'
|
|
10
|
+
|
|
11
|
+
export { getBlocklist, isBlocked, toRelativeDocPath }
|
|
12
|
+
|
|
13
|
+
// Campos de domínio específicos deste portal, além do schema padrão do
|
|
14
|
+
// Starlight (title, description, sidebar, etc). Mantido isolado do
|
|
15
|
+
// docsSchema() do Starlight pra ser testável sem precisar de um
|
|
16
|
+
// SchemaContext (o título obrigatório do Starlight é resolvido pelo loader,
|
|
17
|
+
// não pelo schema — ver resolveTitle()).
|
|
18
|
+
export const domainFieldsSchema = z.object({
|
|
19
|
+
type: z.string().optional(),
|
|
20
|
+
status: z.string().optional(),
|
|
21
|
+
created: z.string().or(z.date().transform(d => d.toISOString().split('T')[0])).optional(),
|
|
22
|
+
updated: z.string().or(z.date().transform(d => d.toISOString().split('T')[0])).optional(),
|
|
23
|
+
teams: z.array(z.string()).optional(),
|
|
24
|
+
impacted_teams: z.array(z.string()).optional(),
|
|
25
|
+
domains: z.array(z.string()).optional(),
|
|
26
|
+
repos: z.array(z.string()).optional(),
|
|
27
|
+
areas: z.array(z.string()).optional(),
|
|
28
|
+
tags: z.array(z.string()).optional(),
|
|
29
|
+
aliases: z.array(z.string()).optional(),
|
|
30
|
+
related: z.array(z.string()).optional(),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// Resolve o título obrigatório do Starlight ANTES da validação do schema:
|
|
34
|
+
// usa content.titleField do .upcontent/config.json se presente no frontmatter,
|
|
35
|
+
// senão deriva do nome do arquivo em Title Case (prefixo numérico removido).
|
|
36
|
+
// Muda `data` in-place — é chamado de dentro do generateId/parseData do
|
|
37
|
+
// loader, no ponto em que o frontmatter ainda não foi validado.
|
|
38
|
+
export function resolveTitle(relativeFilePath: string, data: Record<string, unknown>): void {
|
|
39
|
+
if (typeof data.title === 'string' && data.title.trim().length > 0) return
|
|
40
|
+
|
|
41
|
+
const titleField = getPortalConfig().content?.titleField ?? 'title'
|
|
42
|
+
if (titleField !== 'title') {
|
|
43
|
+
const custom = data[titleField]
|
|
44
|
+
if (typeof custom === 'string' && custom.trim().length > 0) {
|
|
45
|
+
data.title = custom
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const filename = relativeFilePath.split('/').pop() ?? relativeFilePath
|
|
51
|
+
const withoutExt = filename.replace(/\.mdx?$/i, '')
|
|
52
|
+
data.title = toTitleCase(withoutExt)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function toCaseInsensitiveGlob(value: string): string {
|
|
56
|
+
return value.replace(/[A-Za-z]/g, character => `[${character.toLowerCase()}${character.toUpperCase()}]`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Carrega Markdown com glob(): injeta o fallback de título antes da validação
|
|
60
|
+
// e aplica a blocklist como exclusões de glob antes do parse dos documentos.
|
|
61
|
+
function portalDocsLoader(): Loader {
|
|
62
|
+
return {
|
|
63
|
+
name: 'portal-docs-loader',
|
|
64
|
+
async load(context: LoaderContext) {
|
|
65
|
+
const docsBasePath = fileURLToPath(new URL('src/content/docs/', context.config.root))
|
|
66
|
+
const patterns = [
|
|
67
|
+
'**/[^_]*.{markdown,mdown,mkdn,mkd,mdwn,md,mdx}',
|
|
68
|
+
...getBlocklist().map(blocked => `!${toCaseInsensitiveGlob(blocked)}${blocked.endsWith('/') ? '**' : ''}`),
|
|
69
|
+
]
|
|
70
|
+
const wrappedContext: LoaderContext = {
|
|
71
|
+
...context,
|
|
72
|
+
parseData: async (props) => {
|
|
73
|
+
if (props.filePath) {
|
|
74
|
+
const relative = path.relative(docsBasePath, props.filePath).split(path.sep).join('/')
|
|
75
|
+
resolveTitle(relative, props.data)
|
|
76
|
+
}
|
|
77
|
+
return context.parseData(props)
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
await glob({ base: docsBasePath, pattern: patterns }).load(wrappedContext)
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const collections = {
|
|
86
|
+
docs: defineCollection({
|
|
87
|
+
loader: portalDocsLoader(),
|
|
88
|
+
schema: docsSchema({ extend: domainFieldsSchema }),
|
|
89
|
+
}),
|
|
90
|
+
}
|