upcontent 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +61 -0
- package/.github/workflows/deploy.yml +59 -0
- package/.github/workflows/publish.yml +75 -0
- package/.github/workflows/reusable-pages.yml +74 -0
- package/.upcontent/config.json +85 -0
- package/.upcontent/favicon.svg +4 -0
- package/.upcontent/logo.svg +6 -0
- package/.upcontent/portal.css +139 -0
- package/CONTEXT.md +25 -0
- package/Makefile +33 -0
- package/README.md +132 -2
- package/SHOWCASE.mdx +163 -0
- package/assets/readme/portal-home.png +0 -0
- package/assets/readme/portal-showcase.png +0 -0
- package/astro.config.mjs +167 -0
- package/customization/config-json.md +100 -0
- package/customization/content.md +62 -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 +25 -0
- package/deployment/npm.md +39 -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 +58 -0
- package/package.json +40 -4
- package/scripts/upcontent-cli.mjs +111 -0
- package/scripts/verify-external-build.mjs +32 -0
- package/scripts/verify-golden-build.mjs +9 -0
- package/src/components/MermaidLoader.astro +304 -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/i18n/en.json +1 -0
- package/src/content.config.test.ts +209 -0
- package/src/content.config.ts +113 -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 +153 -0
- package/src/lib/portal-config.ts +187 -0
- package/src/lib/portal-routes.test.ts +62 -0
- package/src/lib/portal-routes.ts +59 -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-doc-links.test.ts +50 -0
- package/src/lib/remark-doc-links.ts +21 -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 +112 -0
- package/src/lib/seo-sitemap.test.ts +37 -0
- package/src/lib/seo-sitemap.ts +54 -0
- package/src/lib/sidebar.test.ts +142 -0
- package/src/lib/sidebar.ts +114 -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 +103 -0
- package/src/pages/robots.txt.ts +46 -0
- package/src/styles/callouts.css +29 -0
- package/src/styles/structured-data-preview.css +95 -0
- package/src/upcontent-cli.test.ts +36 -0
- package/test-fixtures/external-consumer/.upcontent/config.json +30 -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/test-fixtures/external-consumer/noindex.md +7 -0
- package/test-fixtures/external-consumer/public.md +6 -0
- package/tsconfig.json +7 -0
- package/vitest.config.ts +18 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Validate your site
|
|
3
|
+
description: Check content, configuration, generated output, and browser behavior before publishing.
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 2
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Use this checklist before opening a pull request or publishing a consumer repository.
|
|
9
|
+
|
|
10
|
+
## Run the build contract
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
pnpm test
|
|
14
|
+
pnpm check
|
|
15
|
+
make build CONTENT_PATH=.
|
|
16
|
+
make check-golden
|
|
17
|
+
make check-external
|
|
18
|
+
git diff --check
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The tests cover content loading, title fallback, navigation, blocklists, wiki links, callouts, Mermaid transformation, and structured previews. The external build confirms that a separate consumer repository works too.
|
|
22
|
+
|
|
23
|
+
## Check the browser
|
|
24
|
+
|
|
25
|
+
Start the portal locally:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
make dev CONTENT_PATH=.
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Open the pages listed in the [capability showcase](../showcase/) and check:
|
|
32
|
+
|
|
33
|
+
- The logo appears once and the site name is clear.
|
|
34
|
+
- The sidebar groups are understandable and the active page is easy to find.
|
|
35
|
+
- Search, internal links, and the table of contents work.
|
|
36
|
+
- Light and dark themes remain readable.
|
|
37
|
+
- Callouts, Mermaid, JSON, YAML, and CSV examples render correctly.
|
|
38
|
+
- The layout works at desktop and mobile widths.
|
|
39
|
+
- The browser console has no required-asset errors.
|
|
40
|
+
- Each indexable page has a useful title, description, canonical URL, and one top-level heading.
|
|
41
|
+
- `dist/robots.txt` points to the generated sitemap.
|
|
42
|
+
- The page source contains JSON-LD and uses the intended `SITE_URL` and `BASE_PATH`.
|
|
43
|
+
- `seo.enabled` matches the intended portal policy; disabled SEO must produce `noindex, nofollow` and `Disallow: /`.
|
|
44
|
+
|
|
45
|
+
## Check content boundaries
|
|
46
|
+
|
|
47
|
+
The build should not publish:
|
|
48
|
+
|
|
49
|
+
- `.upcontent/` files
|
|
50
|
+
- Protected repository internals
|
|
51
|
+
- Consumer blocklisted paths
|
|
52
|
+
- Documents with malformed or traversal wiki links
|
|
53
|
+
|
|
54
|
+
When a change affects blocklists or navigation roots, verify both the generated route list and the sidebar. Hiding a link is not enough; excluded content must be absent before parsing.
|
|
55
|
+
|
|
56
|
+
## Record the result
|
|
57
|
+
|
|
58
|
+
If a check fails, record the source page, route, and browser or build output in the relevant engineering ticket. Do not mark a documentation change complete based only on a successful local page load.
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "upcontent",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.1",
|
|
5
|
+
"packageManager": "pnpm@10.20.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/lumamontes/upcontent.git"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"upcontent": "scripts/upcontent-cli.mjs"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"dev": "astro dev",
|
|
15
|
+
"build": "astro build",
|
|
16
|
+
"check": "astro check",
|
|
17
|
+
"test": "vitest run"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@astrojs/markdown-remark": "^7.3.1",
|
|
21
|
+
"@astrojs/sitemap": "^3.7.4",
|
|
22
|
+
"@astrojs/starlight": "^0.42.1",
|
|
23
|
+
"@fontsource/poppins": "^5.3.0",
|
|
24
|
+
"astro": "^7.3.3",
|
|
25
|
+
"js-yaml": "^4.3.2",
|
|
26
|
+
"mermaid": "^11.0.0",
|
|
27
|
+
"papaparse": "^5.7.0",
|
|
28
|
+
"unified": "^11.0.5",
|
|
29
|
+
"unist-util-visit": "^5.0.0",
|
|
30
|
+
"zod": "^3.0.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@astrojs/check": "^0.9.10",
|
|
34
|
+
"@types/js-yaml": "^4.0.9",
|
|
35
|
+
"@types/papaparse": "^5.5.2",
|
|
36
|
+
"rehype-stringify": "^10.0.1",
|
|
37
|
+
"remark-parse": "^11.0.0",
|
|
38
|
+
"remark-rehype": "^11.1.2",
|
|
39
|
+
"typescript": "^5.0.0",
|
|
40
|
+
"vitest": "^2.0.0"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { spawnSync } from 'node:child_process'
|
|
7
|
+
|
|
8
|
+
const command = process.argv[2] ?? 'help'
|
|
9
|
+
const force = process.argv.includes('--force')
|
|
10
|
+
const root = process.cwd()
|
|
11
|
+
const rendererRoot = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
12
|
+
|
|
13
|
+
const config = `{
|
|
14
|
+
"site": {
|
|
15
|
+
"title": "Documentation",
|
|
16
|
+
"description": "Project documentation.",
|
|
17
|
+
"logo": {
|
|
18
|
+
"src": ".upcontent/logo.svg",
|
|
19
|
+
"alt": "Documentation"
|
|
20
|
+
},
|
|
21
|
+
"favicon": ".upcontent/favicon.svg"
|
|
22
|
+
},
|
|
23
|
+
"repo": {
|
|
24
|
+
"url": "https://github.com/ORG/REPOSITORY"
|
|
25
|
+
},
|
|
26
|
+
"theme": {
|
|
27
|
+
"customCss": [".upcontent/theme.css"]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
`
|
|
31
|
+
|
|
32
|
+
const theme = `:root {
|
|
33
|
+
--sl-color-accent: #0f766e;
|
|
34
|
+
--sl-color-accent-high: #115e59;
|
|
35
|
+
}
|
|
36
|
+
`
|
|
37
|
+
|
|
38
|
+
const logo = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72">
|
|
39
|
+
<rect width="56" height="56" x="8" y="8" rx="16" fill="#0f766e"/>
|
|
40
|
+
<path d="M20 24h32v8H20zm0 16h24v8H20z" fill="#fff"/>
|
|
41
|
+
</svg>
|
|
42
|
+
`
|
|
43
|
+
|
|
44
|
+
const favicon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
45
|
+
<rect width="64" height="64" rx="16" fill="#0f766e"/>
|
|
46
|
+
<path d="M18 20h28v8H18zm0 16h21v8H18z" fill="#fff"/>
|
|
47
|
+
</svg>
|
|
48
|
+
`
|
|
49
|
+
|
|
50
|
+
const workflow = `name: Publish documentation
|
|
51
|
+
|
|
52
|
+
on:
|
|
53
|
+
push:
|
|
54
|
+
branches: [main]
|
|
55
|
+
workflow_dispatch:
|
|
56
|
+
|
|
57
|
+
jobs:
|
|
58
|
+
publish:
|
|
59
|
+
uses: lumamontes/upcontent/.github/workflows/reusable-pages.yml@main
|
|
60
|
+
permissions:
|
|
61
|
+
contents: read
|
|
62
|
+
pages: write
|
|
63
|
+
id-token: write
|
|
64
|
+
`
|
|
65
|
+
|
|
66
|
+
function init() {
|
|
67
|
+
const files = [
|
|
68
|
+
['.upcontent/config.json', config],
|
|
69
|
+
['.upcontent/theme.css', theme],
|
|
70
|
+
['.upcontent/logo.svg', logo],
|
|
71
|
+
['.upcontent/favicon.svg', favicon],
|
|
72
|
+
['.github/workflows/deploy-docs.yml', workflow],
|
|
73
|
+
]
|
|
74
|
+
const collisions = files.filter(([relativePath]) => existsSync(join(root, relativePath)))
|
|
75
|
+
if (collisions.length > 0 && !force) {
|
|
76
|
+
for (const [relativePath] of collisions) {
|
|
77
|
+
console.error(`Refusing to overwrite ${relativePath}. Use --force to replace generated files.`)
|
|
78
|
+
}
|
|
79
|
+
process.exitCode = 1
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
for (const [relativePath, content] of files) {
|
|
83
|
+
const absolutePath = join(root, relativePath)
|
|
84
|
+
mkdirSync(dirname(absolutePath), { recursive: true })
|
|
85
|
+
writeFileSync(absolutePath, content)
|
|
86
|
+
console.log(`created ${relativePath}`)
|
|
87
|
+
}
|
|
88
|
+
console.log('\nNext steps:')
|
|
89
|
+
console.log('1. Replace ORG/REPOSITORY in .upcontent/config.json.')
|
|
90
|
+
console.log('2. Review the generated identity and theme files.')
|
|
91
|
+
console.log('3. Run `pnpm dlx upcontent dev` to preview locally.')
|
|
92
|
+
console.log('4. Push to main to publish through GitHub Actions.')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function run(name, args) {
|
|
96
|
+
const result = spawnSync(name, args, { cwd: rendererRoot, stdio: 'inherit' })
|
|
97
|
+
if (result.error) throw result.error
|
|
98
|
+
return result.status === 0
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (command === 'init') init()
|
|
102
|
+
else if (command === 'dev') {
|
|
103
|
+
process.exitCode = run('make', ['dev', `CONTENT_PATH=${root}`]) ? 0 : 1
|
|
104
|
+
}
|
|
105
|
+
else if (command === 'check') {
|
|
106
|
+
const passed = run('make', ['build', `CONTENT_PATH=${root}`])
|
|
107
|
+
process.exitCode = passed ? 0 : 1
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
console.log('Usage: upcontent <init|dev|check> [--force]')
|
|
111
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
const html = readFileSync('dist/index.html', 'utf8')
|
|
4
|
+
const required = [
|
|
5
|
+
'External Consumer',
|
|
6
|
+
'https://github.com/example/external-consumer',
|
|
7
|
+
'/upcontent-assets/favicon.svg',
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
for (const value of required) {
|
|
11
|
+
if (!html.includes(value)) throw new Error(`External consumer artifact is missing: ${value}`)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (!existsSync('dist/upcontent-assets/favicon.svg')) throw new Error('External consumer favicon was not copied')
|
|
15
|
+
if (!html.includes('alt="External Consumer"')) throw new Error('External consumer logo was not rendered')
|
|
16
|
+
if (!html.includes('application/ld+json')) throw new Error('External consumer JSON-LD metadata is missing')
|
|
17
|
+
if (!existsSync('dist/robots.txt')) throw new Error('External consumer robots.txt is missing')
|
|
18
|
+
if (!existsSync('dist/sitemap-index.xml')) throw new Error('External consumer sitemap is missing')
|
|
19
|
+
if (!readFileSync('dist/noindex/index.html', 'utf8').includes('noindex, nofollow')) throw new Error('External consumer noindex page is not marked noindex')
|
|
20
|
+
const sitemap = readFileSync('dist/sitemap-0.xml', 'utf8')
|
|
21
|
+
if (!sitemap.includes('<loc>https://docs.example.com/</loc>')) throw new Error('External consumer root route is missing from sitemap')
|
|
22
|
+
if (sitemap.includes('/noindex/')) throw new Error('External consumer noindex page leaked into sitemap')
|
|
23
|
+
if (existsSync('dist/forbidden/index.html')) throw new Error('External consumer blocklist leaked forbidden.md')
|
|
24
|
+
if (existsSync('dist/assets/readme')) throw new Error('Golden README assets leaked into external consumer build')
|
|
25
|
+
if (existsSync('dist/upcontent-renderer/readme/index.html')) {
|
|
26
|
+
throw new Error('External consumer build leaked renderer checkout content')
|
|
27
|
+
}
|
|
28
|
+
if (!existsSync('dist/pagefind/pagefind.js')) throw new Error('External consumer Pagefind output is missing')
|
|
29
|
+
|
|
30
|
+
const cssFiles = readdirSync('dist/_astro').filter(file => file.endsWith('.css'))
|
|
31
|
+
const hasConsumerCss = cssFiles.some(file => readFileSync(`dist/_astro/${file}`, 'utf8').includes('#0f766e'))
|
|
32
|
+
if (!hasConsumerCss) throw new Error('External consumer CSS was not included')
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
const html = readFileSync('dist/index.html', 'utf8')
|
|
4
|
+
const requiredAssets = ['assets/readme/portal-home.png', 'assets/readme/portal-showcase.png']
|
|
5
|
+
|
|
6
|
+
for (const asset of requiredAssets) {
|
|
7
|
+
if (!existsSync(`dist/${asset}`)) throw new Error(`Golden build is missing: dist/${asset}`)
|
|
8
|
+
if (!html.includes(asset)) throw new Error(`Golden build does not reference: ${asset}`)
|
|
9
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
---
|
|
2
|
+
---
|
|
3
|
+
|
|
4
|
+
<script>
|
|
5
|
+
import { renderMermaidDiagram } from '../lib/mermaid-render'
|
|
6
|
+
|
|
7
|
+
function mermaidTheme() {
|
|
8
|
+
return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'default'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let mermaidPromise: Promise<typeof import('mermaid').default> | undefined
|
|
12
|
+
|
|
13
|
+
function loadMermaid() {
|
|
14
|
+
mermaidPromise ??= import('mermaid').then(({ default: mermaid }) => {
|
|
15
|
+
mermaid.initialize({ startOnLoad: false, theme: mermaidTheme() })
|
|
16
|
+
return mermaid
|
|
17
|
+
})
|
|
18
|
+
return mermaidPromise
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let scale = 1
|
|
22
|
+
let translateX = 0
|
|
23
|
+
let translateY = 0
|
|
24
|
+
let dialogEl: HTMLDialogElement | null = null
|
|
25
|
+
let contentEl: HTMLDivElement | null = null
|
|
26
|
+
|
|
27
|
+
function applyTransform() {
|
|
28
|
+
if (contentEl) contentEl.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function resetTransform() {
|
|
32
|
+
scale = 1
|
|
33
|
+
translateX = 0
|
|
34
|
+
translateY = 0
|
|
35
|
+
applyTransform()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ensureDialog() {
|
|
39
|
+
if (dialogEl) return
|
|
40
|
+
|
|
41
|
+
dialogEl = document.createElement('dialog')
|
|
42
|
+
dialogEl.className = 'mermaid-fullscreen-dialog'
|
|
43
|
+
|
|
44
|
+
const closeBtn = document.createElement('button')
|
|
45
|
+
closeBtn.type = 'button'
|
|
46
|
+
closeBtn.className = 'mermaid-fullscreen-close'
|
|
47
|
+
closeBtn.setAttribute('aria-label', 'Fechar')
|
|
48
|
+
closeBtn.textContent = '✕'
|
|
49
|
+
closeBtn.addEventListener('click', () => dialogEl?.close())
|
|
50
|
+
|
|
51
|
+
const viewport = document.createElement('div')
|
|
52
|
+
viewport.className = 'mermaid-fullscreen-viewport'
|
|
53
|
+
|
|
54
|
+
contentEl = document.createElement('div')
|
|
55
|
+
contentEl.className = 'mermaid-fullscreen-content'
|
|
56
|
+
viewport.appendChild(contentEl)
|
|
57
|
+
|
|
58
|
+
dialogEl.append(closeBtn, viewport)
|
|
59
|
+
document.body.appendChild(dialogEl)
|
|
60
|
+
|
|
61
|
+
dialogEl.addEventListener('click', e => {
|
|
62
|
+
if (e.target === dialogEl) dialogEl?.close()
|
|
63
|
+
})
|
|
64
|
+
dialogEl.addEventListener('close', resetTransform)
|
|
65
|
+
|
|
66
|
+
viewport.addEventListener(
|
|
67
|
+
'wheel',
|
|
68
|
+
e => {
|
|
69
|
+
e.preventDefault()
|
|
70
|
+
const delta = e.deltaY > 0 ? -0.1 : 0.1
|
|
71
|
+
scale = Math.min(Math.max(scale + delta, 0.2), 5)
|
|
72
|
+
applyTransform()
|
|
73
|
+
},
|
|
74
|
+
{ passive: false },
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
let dragging = false
|
|
78
|
+
let lastX = 0
|
|
79
|
+
let lastY = 0
|
|
80
|
+
viewport.addEventListener('pointerdown', e => {
|
|
81
|
+
dragging = true
|
|
82
|
+
lastX = e.clientX
|
|
83
|
+
lastY = e.clientY
|
|
84
|
+
viewport.setPointerCapture(e.pointerId)
|
|
85
|
+
})
|
|
86
|
+
viewport.addEventListener('pointermove', e => {
|
|
87
|
+
if (!dragging) return
|
|
88
|
+
translateX += e.clientX - lastX
|
|
89
|
+
translateY += e.clientY - lastY
|
|
90
|
+
lastX = e.clientX
|
|
91
|
+
lastY = e.clientY
|
|
92
|
+
applyTransform()
|
|
93
|
+
})
|
|
94
|
+
viewport.addEventListener('pointerup', () => { dragging = false })
|
|
95
|
+
viewport.addEventListener('pointerleave', () => { dragging = false })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function openFullscreen(svg: string) {
|
|
99
|
+
ensureDialog()
|
|
100
|
+
if (!dialogEl || !contentEl) return
|
|
101
|
+
contentEl.innerHTML = svg
|
|
102
|
+
resetTransform()
|
|
103
|
+
dialogEl.showModal()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function copyToClipboard(button: HTMLButtonElement, text: string) {
|
|
107
|
+
await navigator.clipboard.writeText(text)
|
|
108
|
+
const original = button.textContent
|
|
109
|
+
button.textContent = 'Copiado!'
|
|
110
|
+
setTimeout(() => { button.textContent = original }, 2000)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function processDiagrams() {
|
|
114
|
+
const nodes = Array.from(document.querySelectorAll<HTMLElement>('div.mermaid'))
|
|
115
|
+
if (nodes.length === 0) return
|
|
116
|
+
|
|
117
|
+
const mermaid = await loadMermaid()
|
|
118
|
+
for (const node of nodes) {
|
|
119
|
+
// <br/> inside a div becomes a DOM element before textContent reads it — não deveria
|
|
120
|
+
// acontecer (remarkMermaid já normaliza), mas o replace aqui é defensivo.
|
|
121
|
+
const source = (node.textContent ?? '').replace(/ /g, ' ')
|
|
122
|
+
const result = await renderMermaidDiagram(mermaid, source)
|
|
123
|
+
|
|
124
|
+
if (result.status === 'ok') {
|
|
125
|
+
const container = document.createElement('div')
|
|
126
|
+
container.className = 'mermaid-container'
|
|
127
|
+
container.dataset.mermaidSource = source
|
|
128
|
+
|
|
129
|
+
const copyBtn = document.createElement('button')
|
|
130
|
+
copyBtn.type = 'button'
|
|
131
|
+
copyBtn.className = 'mermaid-copy-btn'
|
|
132
|
+
copyBtn.textContent = 'Copiar'
|
|
133
|
+
copyBtn.setAttribute('aria-label', 'Copiar código-fonte do diagrama')
|
|
134
|
+
copyBtn.addEventListener('click', e => {
|
|
135
|
+
e.stopPropagation()
|
|
136
|
+
void copyToClipboard(copyBtn, source)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
const diagram = document.createElement('div')
|
|
140
|
+
diagram.className = 'mermaid-diagram'
|
|
141
|
+
diagram.innerHTML = result.svg
|
|
142
|
+
diagram.setAttribute('role', 'button')
|
|
143
|
+
diagram.setAttribute('tabindex', '0')
|
|
144
|
+
diagram.setAttribute('aria-label', 'Abrir diagrama em tela cheia')
|
|
145
|
+
diagram.addEventListener('click', () => openFullscreen(diagram.innerHTML))
|
|
146
|
+
diagram.addEventListener('keydown', e => {
|
|
147
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
148
|
+
e.preventDefault()
|
|
149
|
+
openFullscreen(diagram.innerHTML)
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
container.append(copyBtn, diagram)
|
|
154
|
+
node.replaceWith(container)
|
|
155
|
+
} else {
|
|
156
|
+
const errorBox = document.createElement('div')
|
|
157
|
+
errorBox.className = 'mermaid-error'
|
|
158
|
+
errorBox.dataset.mermaidSource = source
|
|
159
|
+
|
|
160
|
+
const message = document.createElement('p')
|
|
161
|
+
message.className = 'mermaid-error-message'
|
|
162
|
+
message.textContent = `Erro ao renderizar diagrama Mermaid: ${result.message}`
|
|
163
|
+
|
|
164
|
+
const pre = document.createElement('pre')
|
|
165
|
+
pre.className = 'mermaid-error-source'
|
|
166
|
+
pre.textContent = result.source
|
|
167
|
+
|
|
168
|
+
errorBox.append(message, pre)
|
|
169
|
+
node.replaceWith(errorBox)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function rerenderExistingDiagrams() {
|
|
175
|
+
const containers = Array.from(document.querySelectorAll<HTMLElement>('[data-mermaid-source]'))
|
|
176
|
+
if (containers.length === 0) return
|
|
177
|
+
|
|
178
|
+
const mermaid = await loadMermaid()
|
|
179
|
+
mermaid.initialize({ startOnLoad: false, theme: mermaidTheme() })
|
|
180
|
+
for (const container of containers) {
|
|
181
|
+
const source = container.dataset.mermaidSource
|
|
182
|
+
if (!source) continue
|
|
183
|
+
const result = await renderMermaidDiagram(mermaid, source)
|
|
184
|
+
if (result.status === 'ok') {
|
|
185
|
+
const diagram = container.querySelector('.mermaid-diagram')
|
|
186
|
+
if (diagram) diagram.innerHTML = result.svg
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (document.readyState === 'loading') {
|
|
192
|
+
document.addEventListener('DOMContentLoaded', () => void processDiagrams())
|
|
193
|
+
} else {
|
|
194
|
+
void processDiagrams()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
new MutationObserver(() => void rerenderExistingDiagrams()).observe(document.documentElement, {
|
|
198
|
+
attributes: true,
|
|
199
|
+
attributeFilter: ['data-theme'],
|
|
200
|
+
})
|
|
201
|
+
</script>
|
|
202
|
+
|
|
203
|
+
<style is:global>
|
|
204
|
+
.mermaid-container {
|
|
205
|
+
position: relative;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.mermaid-diagram {
|
|
209
|
+
cursor: zoom-in;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.mermaid-diagram:focus-visible {
|
|
213
|
+
outline: 2px solid var(--sl-color-accent);
|
|
214
|
+
outline-offset: 0.25rem;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.mermaid-copy-btn {
|
|
218
|
+
position: absolute;
|
|
219
|
+
top: 0.5rem;
|
|
220
|
+
right: 0.5rem;
|
|
221
|
+
padding: 0.25rem 0.5rem;
|
|
222
|
+
font-size: var(--sl-text-2xs, 0.75rem);
|
|
223
|
+
border: 1px solid var(--sl-color-gray-5);
|
|
224
|
+
border-radius: 0.25rem;
|
|
225
|
+
background: var(--sl-color-gray-6);
|
|
226
|
+
color: var(--sl-color-gray-2);
|
|
227
|
+
cursor: pointer;
|
|
228
|
+
opacity: 0;
|
|
229
|
+
transition: opacity 0.15s;
|
|
230
|
+
}
|
|
231
|
+
.mermaid-container:hover .mermaid-copy-btn,
|
|
232
|
+
.mermaid-copy-btn:focus-visible {
|
|
233
|
+
opacity: 1;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
@media (prefers-reduced-motion: reduce) {
|
|
237
|
+
.mermaid-copy-btn {
|
|
238
|
+
transition: none;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.mermaid-error {
|
|
243
|
+
border: 1px solid var(--sl-color-red, #c53434);
|
|
244
|
+
border-radius: 0.5rem;
|
|
245
|
+
padding: 1rem;
|
|
246
|
+
}
|
|
247
|
+
.mermaid-error-message {
|
|
248
|
+
color: var(--sl-color-red, #c53434);
|
|
249
|
+
font-weight: 600;
|
|
250
|
+
margin: 0 0 0.5rem;
|
|
251
|
+
}
|
|
252
|
+
.mermaid-error-source {
|
|
253
|
+
white-space: pre-wrap;
|
|
254
|
+
overflow-x: auto;
|
|
255
|
+
margin: 0;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.mermaid-fullscreen-dialog {
|
|
259
|
+
padding: 0;
|
|
260
|
+
border: none;
|
|
261
|
+
width: 100vw;
|
|
262
|
+
height: 100vh;
|
|
263
|
+
max-width: 100vw;
|
|
264
|
+
max-height: 100vh;
|
|
265
|
+
background: var(--sl-color-bg, var(--sl-color-black));
|
|
266
|
+
}
|
|
267
|
+
.mermaid-fullscreen-dialog::backdrop {
|
|
268
|
+
background-color: var(--sl-color-backdrop-overlay, rgba(0, 0, 0, 0.6));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.mermaid-fullscreen-close {
|
|
272
|
+
position: absolute;
|
|
273
|
+
top: 1rem;
|
|
274
|
+
right: 1rem;
|
|
275
|
+
z-index: 1;
|
|
276
|
+
border: 1px solid var(--sl-color-gray-5);
|
|
277
|
+
border-radius: 0.375rem;
|
|
278
|
+
background: var(--sl-color-gray-6);
|
|
279
|
+
color: var(--sl-color-gray-2);
|
|
280
|
+
width: 2.5rem;
|
|
281
|
+
height: 2.5rem;
|
|
282
|
+
cursor: pointer;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.mermaid-fullscreen-viewport {
|
|
286
|
+
width: 100%;
|
|
287
|
+
height: 100%;
|
|
288
|
+
overflow: hidden;
|
|
289
|
+
touch-action: none;
|
|
290
|
+
cursor: grab;
|
|
291
|
+
}
|
|
292
|
+
.mermaid-fullscreen-viewport:active {
|
|
293
|
+
cursor: grabbing;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.mermaid-fullscreen-content {
|
|
297
|
+
width: 100%;
|
|
298
|
+
height: 100%;
|
|
299
|
+
display: flex;
|
|
300
|
+
align-items: center;
|
|
301
|
+
justify-content: center;
|
|
302
|
+
transform-origin: center center;
|
|
303
|
+
}
|
|
304
|
+
</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>
|