@meistrari/tela-build 1.66.0 → 1.67.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/components/tela/rfc/rfc-flowchart.vue +79 -0
- package/components/tela/rfc/rfc-heading.vue +35 -0
- package/components/tela/rfc/rfc-markdown.test.ts +95 -0
- package/components/tela/rfc/rfc-markdown.ts +92 -0
- package/components/tela/rfc/rfc-pre.vue +46 -0
- package/components/tela/rfc/rfc.mdx +131 -0
- package/components/tela/rfc/rfc.vue +329 -0
- package/components/tela/table-of-contents/table-of-contents.mdx +81 -0
- package/components/tela/table-of-contents/table-of-contents.vue +235 -0
- package/docs/interfaces.md +3 -0
- package/package.json +8 -4
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { renderMermaidSVG } from 'beautiful-mermaid'
|
|
3
|
+
|
|
4
|
+
const props = withDefaults(defineProps<{
|
|
5
|
+
content: string
|
|
6
|
+
label?: string
|
|
7
|
+
errorLabel?: string
|
|
8
|
+
}>(), {
|
|
9
|
+
label: 'Flowchart',
|
|
10
|
+
errorLabel: 'Flowchart could not be rendered',
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const result = computed(() => {
|
|
14
|
+
try {
|
|
15
|
+
const svg = renderMermaidSVG(props.content, {
|
|
16
|
+
bg: DT.colors.background.DEFAULT,
|
|
17
|
+
fg: DT.colors.text.primary,
|
|
18
|
+
line: DT.colors.border.strong,
|
|
19
|
+
accent: DT.colors.text.secondary,
|
|
20
|
+
muted: DT.colors.text.secondary,
|
|
21
|
+
surface: DT.colors.background.subtle,
|
|
22
|
+
border: DT.colors.border.DEFAULT,
|
|
23
|
+
font: 'Inter Variable',
|
|
24
|
+
padding: 32,
|
|
25
|
+
transparent: true,
|
|
26
|
+
}).replace(/\s*@import\s+url\([^)]*\);\s*/u, '')
|
|
27
|
+
|
|
28
|
+
return { svg, error: '' }
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
return {
|
|
32
|
+
svg: '',
|
|
33
|
+
error: error instanceof Error ? error.message : props.errorLabel,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<figure
|
|
41
|
+
flex="~ col" gap-12px
|
|
42
|
+
my-8px p-24px overflow-hidden
|
|
43
|
+
rounded-10px border-0.5px border bg
|
|
44
|
+
>
|
|
45
|
+
<div
|
|
46
|
+
v-if="result.svg"
|
|
47
|
+
role="img"
|
|
48
|
+
:aria-label="label"
|
|
49
|
+
w-full overflow-x-auto
|
|
50
|
+
data-rfc-flowchart
|
|
51
|
+
v-html="result.svg"
|
|
52
|
+
/>
|
|
53
|
+
|
|
54
|
+
<div v-else flex="~ col" gap-12px>
|
|
55
|
+
<p body-12-medium text-secondary>
|
|
56
|
+
{{ errorLabel }}
|
|
57
|
+
</p>
|
|
58
|
+
<pre overflow-x-auto rounded-8px bg p-16px body-12-regular text-primary><code>{{ content }}</code></pre>
|
|
59
|
+
<p body-12-regular text-secondary>
|
|
60
|
+
{{ result.error }}
|
|
61
|
+
</p>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<figcaption body-12-medium text-primary>
|
|
65
|
+
{{ label }}
|
|
66
|
+
</figcaption>
|
|
67
|
+
</figure>
|
|
68
|
+
</template>
|
|
69
|
+
|
|
70
|
+
<style scoped>
|
|
71
|
+
[data-rfc-flowchart] :deep(svg) {
|
|
72
|
+
display: block;
|
|
73
|
+
width: auto;
|
|
74
|
+
min-width: 100%;
|
|
75
|
+
max-width: none;
|
|
76
|
+
height: auto;
|
|
77
|
+
margin: 0 auto;
|
|
78
|
+
}
|
|
79
|
+
</style>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const props = withDefaults(defineProps<{
|
|
3
|
+
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
|
4
|
+
id?: string
|
|
5
|
+
}>(), {
|
|
6
|
+
as: 'h2',
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
const isAnchored = computed(() => !!props.id && props.as !== 'h1')
|
|
10
|
+
|
|
11
|
+
function scrollToSelf(event: MouseEvent) {
|
|
12
|
+
const target = event.currentTarget instanceof HTMLElement ? event.currentTarget.closest(props.as) : null
|
|
13
|
+
|
|
14
|
+
if (!target)
|
|
15
|
+
return
|
|
16
|
+
|
|
17
|
+
event.preventDefault()
|
|
18
|
+
target.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
19
|
+
history.replaceState(history.state, '', `#${props.id}`)
|
|
20
|
+
}
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<component :is="as" :id="id">
|
|
25
|
+
<a
|
|
26
|
+
v-if="isAnchored"
|
|
27
|
+
:href="`#${id}`"
|
|
28
|
+
class="tela-rfc-heading-anchor"
|
|
29
|
+
@click="scrollToSelf"
|
|
30
|
+
>
|
|
31
|
+
<slot />
|
|
32
|
+
</a>
|
|
33
|
+
<slot v-else />
|
|
34
|
+
</component>
|
|
35
|
+
</template>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { flattenRfcToc, parseRfcMarkdown } from './rfc-markdown'
|
|
4
|
+
|
|
5
|
+
describe('parseRfcMarkdown', () => {
|
|
6
|
+
it('extracts the title and a nested table of contents', async () => {
|
|
7
|
+
const document = await parseRfcMarkdown(`# Stateful workflow runs
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
Introductory copy.
|
|
12
|
+
|
|
13
|
+
### Current behavior
|
|
14
|
+
|
|
15
|
+
More detail.
|
|
16
|
+
|
|
17
|
+
## Proposal
|
|
18
|
+
`)
|
|
19
|
+
|
|
20
|
+
expect(document.title).toBe('Stateful workflow runs')
|
|
21
|
+
expect(document.toc).toEqual([
|
|
22
|
+
{
|
|
23
|
+
id: 'context',
|
|
24
|
+
depth: 2,
|
|
25
|
+
text: 'Context',
|
|
26
|
+
children: [
|
|
27
|
+
{
|
|
28
|
+
id: 'context-current-behavior',
|
|
29
|
+
depth: 3,
|
|
30
|
+
text: 'Current behavior',
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'proposal',
|
|
36
|
+
depth: 2,
|
|
37
|
+
text: 'Proposal',
|
|
38
|
+
},
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
expect(flattenRfcToc(document.toc).map(link => link.text)).toEqual([
|
|
42
|
+
'Context',
|
|
43
|
+
'Current behavior',
|
|
44
|
+
'Proposal',
|
|
45
|
+
])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('keeps ASCII illustrations and promotes Mermaid fences to flowcharts', async () => {
|
|
49
|
+
const document = await parseRfcMarkdown(`# Diagrams
|
|
50
|
+
|
|
51
|
+
\`\`\`ascii
|
|
52
|
+
+-------+ +--------+
|
|
53
|
+
| Draft | --> | Review |
|
|
54
|
+
+-------+ +--------+
|
|
55
|
+
\`\`\`
|
|
56
|
+
|
|
57
|
+
\`\`\`mermaid
|
|
58
|
+
flowchart LR
|
|
59
|
+
Draft --> Review
|
|
60
|
+
\`\`\`
|
|
61
|
+
`)
|
|
62
|
+
|
|
63
|
+
expect(document.tree.nodes).toContainEqual([
|
|
64
|
+
'pre',
|
|
65
|
+
{ language: 'ascii' },
|
|
66
|
+
[
|
|
67
|
+
'code',
|
|
68
|
+
{ class: 'language-ascii' },
|
|
69
|
+
'+-------+ +--------+\n| Draft | --> | Review |\n+-------+ +--------+',
|
|
70
|
+
],
|
|
71
|
+
])
|
|
72
|
+
expect(document.tree.nodes).toContainEqual([
|
|
73
|
+
'mermaid',
|
|
74
|
+
{ content: 'flowchart LR\n Draft --> Review\n' },
|
|
75
|
+
])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('treats raw HTML as text and removes custom component tags', async () => {
|
|
79
|
+
const document = await parseRfcMarkdown(`# Safe input
|
|
80
|
+
|
|
81
|
+
<script>alert('nope')</script>
|
|
82
|
+
|
|
83
|
+
::unsafe-component{onload="alert('nope')"}
|
|
84
|
+
Hidden content
|
|
85
|
+
::
|
|
86
|
+
`)
|
|
87
|
+
|
|
88
|
+
expect(document.tree.nodes).toContainEqual([
|
|
89
|
+
'p',
|
|
90
|
+
{},
|
|
91
|
+
"<script>alert('nope')</script>",
|
|
92
|
+
])
|
|
93
|
+
expect(document.tree.nodes.some(node => Array.isArray(node) && node[0] === 'unsafe-component')).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { ComarkTree } from 'comark'
|
|
2
|
+
import type { TocLink } from 'comark/plugins/toc'
|
|
3
|
+
|
|
4
|
+
import { parse } from 'comark'
|
|
5
|
+
import mermaid from 'comark/plugins/mermaid'
|
|
6
|
+
import security from 'comark/plugins/security'
|
|
7
|
+
import toc from 'comark/plugins/toc'
|
|
8
|
+
import { textContent } from 'comark/utils'
|
|
9
|
+
|
|
10
|
+
const allowedTags = [
|
|
11
|
+
'a',
|
|
12
|
+
'blockquote',
|
|
13
|
+
'br',
|
|
14
|
+
'code',
|
|
15
|
+
'del',
|
|
16
|
+
'em',
|
|
17
|
+
'h1',
|
|
18
|
+
'h2',
|
|
19
|
+
'h3',
|
|
20
|
+
'h4',
|
|
21
|
+
'h5',
|
|
22
|
+
'h6',
|
|
23
|
+
'hr',
|
|
24
|
+
'img',
|
|
25
|
+
'input',
|
|
26
|
+
'li',
|
|
27
|
+
'mermaid',
|
|
28
|
+
'ol',
|
|
29
|
+
'p',
|
|
30
|
+
'pre',
|
|
31
|
+
's',
|
|
32
|
+
'strong',
|
|
33
|
+
'table',
|
|
34
|
+
'tbody',
|
|
35
|
+
'td',
|
|
36
|
+
'th',
|
|
37
|
+
'thead',
|
|
38
|
+
'tr',
|
|
39
|
+
'ul',
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
const rfcPlugins = [
|
|
43
|
+
mermaid(),
|
|
44
|
+
toc({ depth: 3, searchDepth: 3 }),
|
|
45
|
+
security({
|
|
46
|
+
allowedTags,
|
|
47
|
+
allowedProtocols: ['http', 'https', 'mailto'],
|
|
48
|
+
allowDataImages: false,
|
|
49
|
+
}),
|
|
50
|
+
] as const
|
|
51
|
+
|
|
52
|
+
export interface ParsedRfcDocument {
|
|
53
|
+
tree: ComarkTree
|
|
54
|
+
title: string
|
|
55
|
+
toc: TocLink[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface FlatRfcTocLink extends Omit<TocLink, 'children'> {
|
|
59
|
+
children?: TocLink[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function documentTitle(tree: ComarkTree): string {
|
|
63
|
+
const titleHeading = tree.nodes.find(node => Array.isArray(node) && node[0] === 'h1')
|
|
64
|
+
const headingText = titleHeading ? textContent(titleHeading).trim() : ''
|
|
65
|
+
|
|
66
|
+
if (headingText)
|
|
67
|
+
return headingText
|
|
68
|
+
|
|
69
|
+
const frontmatterTitle = tree.frontmatter.title
|
|
70
|
+
|
|
71
|
+
return typeof frontmatterTitle === 'string' ? frontmatterTitle.trim() : ''
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function parseRfcMarkdown(markdown: string): Promise<ParsedRfcDocument> {
|
|
75
|
+
const tree = await parse(markdown, {
|
|
76
|
+
html: false,
|
|
77
|
+
plugins: rfcPlugins,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
tree,
|
|
82
|
+
title: documentTitle(tree),
|
|
83
|
+
toc: tree.meta.toc.links,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function flattenRfcToc(links: TocLink[]): FlatRfcTocLink[] {
|
|
88
|
+
return links.flatMap(link => [
|
|
89
|
+
link,
|
|
90
|
+
...flattenRfcToc(link.children ?? []),
|
|
91
|
+
])
|
|
92
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ComarkElement } from 'comark'
|
|
3
|
+
|
|
4
|
+
const props = withDefaults(defineProps<{
|
|
5
|
+
code?: string
|
|
6
|
+
language?: string
|
|
7
|
+
// eslint-disable-next-line vue/prop-name-casing -- Comark passes the element node as `__node`
|
|
8
|
+
__node?: ComarkElement
|
|
9
|
+
}>(), {
|
|
10
|
+
code: '',
|
|
11
|
+
language: '',
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
function nodeText(node: unknown): string {
|
|
15
|
+
if (typeof node === 'string')
|
|
16
|
+
return node
|
|
17
|
+
|
|
18
|
+
if (!Array.isArray(node))
|
|
19
|
+
return ''
|
|
20
|
+
|
|
21
|
+
return node.slice(2).map(nodeText).join('')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function nodeLanguage(node: ComarkElement | undefined): string {
|
|
25
|
+
if (!node)
|
|
26
|
+
return ''
|
|
27
|
+
|
|
28
|
+
const [, nodeProps, ...children] = node
|
|
29
|
+
|
|
30
|
+
if (typeof nodeProps?.language === 'string')
|
|
31
|
+
return nodeProps.language
|
|
32
|
+
|
|
33
|
+
const codeChild = children.find((child): child is ComarkElement => Array.isArray(child) && child[0] === 'code')
|
|
34
|
+
const codeClass = codeChild?.[1]?.class
|
|
35
|
+
const match = typeof codeClass === 'string' ? codeClass.match(/language-(\S+)/) : null
|
|
36
|
+
|
|
37
|
+
return match?.[1] ?? ''
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const code = computed(() => (props.code || nodeText(props.__node)).trimEnd())
|
|
41
|
+
const language = computed(() => props.language || nodeLanguage(props.__node) || 'text')
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<template>
|
|
45
|
+
<TelaCodeBlock :code="code" :language="language" class="my-16px" />
|
|
46
|
+
</template>
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Meta } from '@storybook/blocks';
|
|
2
|
+
|
|
3
|
+
<Meta title="Layout/RFC" tags={['new']} />
|
|
4
|
+
|
|
5
|
+
# TelaRfc
|
|
6
|
+
|
|
7
|
+
A full-page template for reading technical RFCs. Pass one plain Markdown string and the component renders a centered document column with a fixed table of contents generated from the document headings.
|
|
8
|
+
|
|
9
|
+
Fenced code blocks render through `TelaCodeBlock` (line numbers and copy action); `ascii` blocks stay plain text so illustrations are preserved exactly. Fenced `mermaid` blocks render as flowcharts and fall back to their source when the diagram is invalid.
|
|
10
|
+
|
|
11
|
+
## Intent
|
|
12
|
+
|
|
13
|
+
`TelaRfc` is for an engineer or reviewer who needs to understand a proposal, keep their place in a long document, and inspect architecture diagrams without leaving the reading flow. It should feel precise and calm, like a well-typeset technical memo.
|
|
14
|
+
|
|
15
|
+
- **Signature:** the table of contents stays fixed at the right of the viewport, tracks the active heading, and surfaces the document title once it scrolls away.
|
|
16
|
+
- **Depth:** borders-only, matching a technical document rather than a card dashboard.
|
|
17
|
+
- **Typography:** the same prose scale as the Tela Build docs site — `heading-h2/h3/h4-semibold` titles, `body-16-regular` secondary prose — on a centered 760px reading column.
|
|
18
|
+
- **Spacing:** 32px before each H2, 28px before H3, and 12px between paragraphs and list groups.
|
|
19
|
+
|
|
20
|
+
## Basic usage
|
|
21
|
+
|
|
22
|
+
```vue
|
|
23
|
+
<script setup lang="ts">
|
|
24
|
+
const markdown = `# Stateful workflow runs
|
|
25
|
+
|
|
26
|
+
This RFC proposes durable state between workflow steps.
|
|
27
|
+
|
|
28
|
+
## Context
|
|
29
|
+
|
|
30
|
+
Today every step reconstructs the state it needs.
|
|
31
|
+
|
|
32
|
+
### Current flow
|
|
33
|
+
|
|
34
|
+
\`\`\`ascii
|
|
35
|
+
+---------+ +---------+ +--------+
|
|
36
|
+
| Trigger | --> | Resolve | --> | Action |
|
|
37
|
+
+---------+ +---------+ +--------+
|
|
38
|
+
\`\`\`
|
|
39
|
+
|
|
40
|
+
## Proposal
|
|
41
|
+
|
|
42
|
+
Persist a versioned state envelope after each successful step.
|
|
43
|
+
|
|
44
|
+
\`\`\`mermaid
|
|
45
|
+
flowchart LR
|
|
46
|
+
Trigger --> Resolve
|
|
47
|
+
Resolve --> Persist
|
|
48
|
+
Persist --> Action
|
|
49
|
+
\`\`\`
|
|
50
|
+
|
|
51
|
+
## Alternatives considered
|
|
52
|
+
|
|
53
|
+
- Rebuild state for every step
|
|
54
|
+
- Keep state only in worker memory
|
|
55
|
+
|
|
56
|
+
## Rollout
|
|
57
|
+
|
|
58
|
+
1. Add the state envelope behind a flag
|
|
59
|
+
2. Migrate internal workflows
|
|
60
|
+
3. Enable it for new workflows
|
|
61
|
+
`
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<template>
|
|
65
|
+
<TelaRfc :markdown="markdown" />
|
|
66
|
+
</template>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Markdown contract
|
|
70
|
+
|
|
71
|
+
Use one H1 for the RFC title. H2, H3, and H4 headings populate the table of contents; deeper headings remain in the document but do not make the navigation unnecessarily dense. Consumers do not render `TelaTableOfContents` separately: `TelaRfc` owns it and derives its items from the Markdown.
|
|
72
|
+
|
|
73
|
+
Standard CommonMark and GFM content is supported, including:
|
|
74
|
+
|
|
75
|
+
- paragraphs, emphasis, links, images, and blockquotes
|
|
76
|
+
- ordered, unordered, nested, and task lists
|
|
77
|
+
- tables and horizontal rules
|
|
78
|
+
- inline code and fenced code blocks
|
|
79
|
+
- `ascii` fenced blocks for monospaced illustrations
|
|
80
|
+
- `mermaid` fenced blocks for flowcharts
|
|
81
|
+
- optional YAML frontmatter, which is parsed as document metadata and not rendered as prose
|
|
82
|
+
|
|
83
|
+
Raw HTML is displayed as text. Custom Markdown component tags are removed. Links are limited to relative URLs plus HTTP, HTTPS, and mailto protocols, and data-URL images are disabled.
|
|
84
|
+
|
|
85
|
+
## Anatomy
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
TelaRfc ← full-viewport scroll container
|
|
89
|
+
└─ centered reading column ← 760px, mx-auto
|
|
90
|
+
├─ TelaTableOfContents ← fixed at the right, built in
|
|
91
|
+
└─ article
|
|
92
|
+
└─ parsed Markdown
|
|
93
|
+
├─ headings (H2+ wrapped in a `#` anchor link)
|
|
94
|
+
├─ prose, lists, tables
|
|
95
|
+
├─ code blocks → TelaCodeBlock
|
|
96
|
+
└─ TelaRfcFlowchart
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Below 1511px the table of contents is hidden and the document keeps the full reading column; headings remain addressable through their native fragment anchors.
|
|
100
|
+
|
|
101
|
+
## Props
|
|
102
|
+
|
|
103
|
+
| Prop | Type | Default | Description |
|
|
104
|
+
| --- | --- | --- | --- |
|
|
105
|
+
| `markdown` | `string` | required | Plain Markdown source for the RFC. |
|
|
106
|
+
| `tocLabel` | `string` | `'On this page'` | Accessible label for the built-in table of contents. |
|
|
107
|
+
| `emptyLabel` | `string` | `'No RFC content'` | Empty-state copy. |
|
|
108
|
+
| `errorLabel` | `string` | `'Unable to render this RFC'` | Parser-error fallback copy. |
|
|
109
|
+
|
|
110
|
+
## Slots
|
|
111
|
+
|
|
112
|
+
| Slot | Scope | Description |
|
|
113
|
+
| --- | --- | --- |
|
|
114
|
+
| `empty` | — | Replaces the default empty state. |
|
|
115
|
+
|
|
116
|
+
## Behavior and accessibility
|
|
117
|
+
|
|
118
|
+
- The RFC shell is the only vertical scroll container and occupies `100dvh`.
|
|
119
|
+
- TOC links are native fragment links and heading targets have a scroll margin.
|
|
120
|
+
- H2–H6 headings wrap their text in a fragment link that reveals a `#` on hover and smooth-scrolls within the RFC container.
|
|
121
|
+
- The active H2–H4 section is exposed with `aria-current="location"`.
|
|
122
|
+
- Flowcharts expose `role="img"` with an accessible label and always retain a readable source fallback.
|
|
123
|
+
- Images and wide code blocks stay within the reading column and scroll horizontally when necessary.
|
|
124
|
+
|
|
125
|
+
## Rules
|
|
126
|
+
|
|
127
|
+
1. Pass the source Markdown unchanged; do not pre-render it to HTML.
|
|
128
|
+
2. Use exactly one H1 when the document has a visible title.
|
|
129
|
+
3. Use H2 for major RFC sections and H3/H4 for subsections so the generated TOC remains meaningful.
|
|
130
|
+
4. Tag text diagrams as `ascii` and rendered flowcharts as `mermaid`.
|
|
131
|
+
5. Keep primary RFC content in Markdown; the table of contents is derived automatically.
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ComarkRenderer } from '@comark/vue'
|
|
3
|
+
|
|
4
|
+
import RfcFlowchart from './rfc-flowchart.vue'
|
|
5
|
+
import RfcHeading from './rfc-heading.vue'
|
|
6
|
+
import RfcPre from './rfc-pre.vue'
|
|
7
|
+
import { flattenRfcToc, parseRfcMarkdown } from './rfc-markdown'
|
|
8
|
+
import type { ParsedRfcDocument } from './rfc-markdown'
|
|
9
|
+
|
|
10
|
+
const props = withDefaults(defineProps<{
|
|
11
|
+
markdown: string
|
|
12
|
+
tocLabel?: string
|
|
13
|
+
emptyLabel?: string
|
|
14
|
+
errorLabel?: string
|
|
15
|
+
}>(), {
|
|
16
|
+
tocLabel: 'On this page',
|
|
17
|
+
emptyLabel: 'No RFC content',
|
|
18
|
+
errorLabel: 'Unable to render this RFC',
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
defineSlots<{
|
|
22
|
+
empty: () => unknown
|
|
23
|
+
}>()
|
|
24
|
+
|
|
25
|
+
type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
|
26
|
+
|
|
27
|
+
function heading(as: HeadingTag) {
|
|
28
|
+
return defineComponent({
|
|
29
|
+
inheritAttrs: false,
|
|
30
|
+
setup: (_, { attrs, slots }) => () => h(RfcHeading, { ...attrs, as }, slots),
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const markdownComponents = {
|
|
35
|
+
a: 'a',
|
|
36
|
+
blockquote: 'blockquote',
|
|
37
|
+
br: 'br',
|
|
38
|
+
code: 'code',
|
|
39
|
+
del: 'del',
|
|
40
|
+
em: 'em',
|
|
41
|
+
h1: heading('h1'),
|
|
42
|
+
h2: heading('h2'),
|
|
43
|
+
h3: heading('h3'),
|
|
44
|
+
h4: heading('h4'),
|
|
45
|
+
h5: heading('h5'),
|
|
46
|
+
h6: heading('h6'),
|
|
47
|
+
hr: 'hr',
|
|
48
|
+
img: 'img',
|
|
49
|
+
input: 'input',
|
|
50
|
+
li: 'li',
|
|
51
|
+
mermaid: RfcFlowchart,
|
|
52
|
+
ol: 'ol',
|
|
53
|
+
p: 'p',
|
|
54
|
+
pre: RfcPre,
|
|
55
|
+
s: 's',
|
|
56
|
+
strong: 'strong',
|
|
57
|
+
table: 'table',
|
|
58
|
+
tbody: 'tbody',
|
|
59
|
+
td: 'td',
|
|
60
|
+
th: 'th',
|
|
61
|
+
thead: 'thead',
|
|
62
|
+
tr: 'tr',
|
|
63
|
+
ul: 'ul',
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const scrollContainerRef = ref<HTMLElement | null>(null)
|
|
67
|
+
const articleRef = ref<HTMLElement | null>(null)
|
|
68
|
+
const parsedDocument = shallowRef<ParsedRfcDocument | null>(null)
|
|
69
|
+
const parseError = ref('')
|
|
70
|
+
const isParsing = ref(true)
|
|
71
|
+
|
|
72
|
+
const tocLinks = computed(() => parsedDocument.value?.toc ?? [])
|
|
73
|
+
const tocItems = computed(() => flattenRfcToc(tocLinks.value).map(link => ({
|
|
74
|
+
id: link.id,
|
|
75
|
+
text: link.text,
|
|
76
|
+
level: link.depth,
|
|
77
|
+
})))
|
|
78
|
+
const isEmpty = computed(() => !props.markdown.trim())
|
|
79
|
+
|
|
80
|
+
let parseRevision = 0
|
|
81
|
+
|
|
82
|
+
async function updateMarkdown(markdown: string) {
|
|
83
|
+
const revision = ++parseRevision
|
|
84
|
+
|
|
85
|
+
isParsing.value = parsedDocument.value === null
|
|
86
|
+
parseError.value = ''
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const document = await parseRfcMarkdown(markdown)
|
|
90
|
+
|
|
91
|
+
if (revision !== parseRevision)
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
parsedDocument.value = document
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (revision !== parseRevision)
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
parseError.value = error instanceof Error ? error.message : props.errorLabel
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
if (revision === parseRevision)
|
|
104
|
+
isParsing.value = false
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
watch(() => props.markdown, updateMarkdown, { immediate: true })
|
|
109
|
+
</script>
|
|
110
|
+
|
|
111
|
+
<template>
|
|
112
|
+
<main
|
|
113
|
+
ref="scrollContainerRef"
|
|
114
|
+
data-tela-rfc
|
|
115
|
+
h-dvh w-full overflow-y-auto bg
|
|
116
|
+
>
|
|
117
|
+
<div
|
|
118
|
+
:aria-busy="isParsing"
|
|
119
|
+
class="mx-auto w-full max-w-760px px-20px pb-64px pt-24px min-[821px]:px-40px min-[821px]:pb-80px min-[821px]:pt-80px"
|
|
120
|
+
>
|
|
121
|
+
<TelaTableOfContents
|
|
122
|
+
v-if="tocItems.length"
|
|
123
|
+
:items="tocItems"
|
|
124
|
+
:label="tocLabel"
|
|
125
|
+
:content-root="articleRef"
|
|
126
|
+
:scroll-root="scrollContainerRef"
|
|
127
|
+
/>
|
|
128
|
+
|
|
129
|
+
<article ref="articleRef" data-rfc-document min-w-0>
|
|
130
|
+
<div v-if="isParsing" role="status" flex="~ col" gap-16px aria-live="polite">
|
|
131
|
+
<TelaSkeleton h-36px w-72% rounded-8px />
|
|
132
|
+
<TelaSkeleton h-20px w-full rounded-4px />
|
|
133
|
+
<TelaSkeleton h-20px w-88% rounded-4px />
|
|
134
|
+
<TelaSkeleton h-220px w-full rounded-12px />
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<slot v-else-if="isEmpty" name="empty">
|
|
138
|
+
<div py-80px text-center>
|
|
139
|
+
<p body-14-regular text-secondary>
|
|
140
|
+
{{ emptyLabel }}
|
|
141
|
+
</p>
|
|
142
|
+
</div>
|
|
143
|
+
</slot>
|
|
144
|
+
|
|
145
|
+
<div v-else-if="parseError" flex="~ col" gap-16px>
|
|
146
|
+
<p body-14-medium text-primary>
|
|
147
|
+
{{ errorLabel }}
|
|
148
|
+
</p>
|
|
149
|
+
<pre overflow-x-auto rounded-10px border-0.5px border bg p-20px body-14-regular text-primary><code>{{ markdown }}</code></pre>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<ComarkRenderer
|
|
153
|
+
v-else-if="parsedDocument"
|
|
154
|
+
:tree="parsedDocument.tree"
|
|
155
|
+
:components="markdownComponents"
|
|
156
|
+
/>
|
|
157
|
+
</article>
|
|
158
|
+
</div>
|
|
159
|
+
</main>
|
|
160
|
+
</template>
|
|
161
|
+
|
|
162
|
+
<style scoped>
|
|
163
|
+
[data-rfc-document] :deep(h1),
|
|
164
|
+
[data-rfc-document] :deep(h2),
|
|
165
|
+
[data-rfc-document] :deep(h3),
|
|
166
|
+
[data-rfc-document] :deep(h4),
|
|
167
|
+
[data-rfc-document] :deep(h5),
|
|
168
|
+
[data-rfc-document] :deep(h6) {
|
|
169
|
+
--at-apply: 'text-primary';
|
|
170
|
+
scroll-margin-top: 24px;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
[data-rfc-document] :deep(h1) {
|
|
174
|
+
--at-apply: 'heading-h2-semibold';
|
|
175
|
+
margin: 0 0 8px;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
[data-rfc-document] :deep(h2) {
|
|
179
|
+
--at-apply: 'heading-h3-semibold';
|
|
180
|
+
margin: 32px 0 8px;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
[data-rfc-document] :deep(h3) {
|
|
184
|
+
--at-apply: 'heading-h4-semibold';
|
|
185
|
+
margin: 28px 0 8px;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
[data-rfc-document] :deep(h4),
|
|
189
|
+
[data-rfc-document] :deep(h5),
|
|
190
|
+
[data-rfc-document] :deep(h6) {
|
|
191
|
+
--at-apply: 'heading-h5-semibold';
|
|
192
|
+
margin: 24px 0 8px;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
[data-rfc-document] :deep(.tela-rfc-heading-anchor) {
|
|
196
|
+
color: inherit;
|
|
197
|
+
font: inherit;
|
|
198
|
+
text-decoration: none;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
[data-rfc-document] :deep(.tela-rfc-heading-anchor::after) {
|
|
202
|
+
--at-apply: 'text-secondary';
|
|
203
|
+
content: '#';
|
|
204
|
+
margin-left: 6px;
|
|
205
|
+
opacity: 0;
|
|
206
|
+
transition: opacity 150ms ease;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
[data-rfc-document] :deep(.tela-rfc-heading-anchor:hover::after),
|
|
210
|
+
[data-rfc-document] :deep(.tela-rfc-heading-anchor:focus-visible::after) {
|
|
211
|
+
opacity: 1;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
[data-rfc-document] :deep(p) {
|
|
215
|
+
--at-apply: 'body-16-regular leading-24px text-secondary';
|
|
216
|
+
margin: 0 0 12px;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
[data-rfc-document] :deep(strong) {
|
|
220
|
+
--at-apply: 'font-580';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
[data-rfc-document] :deep(a) {
|
|
224
|
+
--at-apply: 'text-primary';
|
|
225
|
+
text-decoration: underline;
|
|
226
|
+
text-underline-offset: 2px;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
[data-rfc-document] :deep(a:focus-visible) {
|
|
230
|
+
outline: 2px solid currentColor;
|
|
231
|
+
outline-offset: 2px;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
[data-rfc-document] :deep(ul),
|
|
235
|
+
[data-rfc-document] :deep(ol) {
|
|
236
|
+
margin: 12px 0 12px;
|
|
237
|
+
padding-left: 20px;
|
|
238
|
+
list-style: disc;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
[data-rfc-document] :deep(p + ul),
|
|
242
|
+
[data-rfc-document] :deep(p + ol) {
|
|
243
|
+
margin-top: 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
[data-rfc-document] :deep(p:has(+ ul)),
|
|
247
|
+
[data-rfc-document] :deep(p:has(+ ol)) {
|
|
248
|
+
margin-bottom: 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
[data-rfc-document] :deep(ol) {
|
|
252
|
+
list-style: decimal;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
[data-rfc-document] :deep(li) {
|
|
256
|
+
--at-apply: 'body-16-regular text-secondary';
|
|
257
|
+
margin: 8px 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
[data-rfc-document] :deep(li > ul),
|
|
261
|
+
[data-rfc-document] :deep(li > ol) {
|
|
262
|
+
margin-top: 8px;
|
|
263
|
+
margin-bottom: 0;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
[data-rfc-document] :deep(blockquote) {
|
|
267
|
+
--at-apply: 'text-secondary border';
|
|
268
|
+
margin: 24px 0;
|
|
269
|
+
padding-left: 16px;
|
|
270
|
+
border-left: 0.5px solid;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
[data-rfc-document] :deep(blockquote p:last-child) {
|
|
274
|
+
margin-bottom: 0;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
[data-rfc-document] :deep(code) {
|
|
278
|
+
--at-apply: 'bg-muted rounded-4px text-secondary';
|
|
279
|
+
font-family: 'Geist Mono Variable', monospace;
|
|
280
|
+
font-size: 12.5px;
|
|
281
|
+
padding: 0 4px;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
[data-rfc-document] :deep(pre code) {
|
|
285
|
+
background: transparent;
|
|
286
|
+
border-radius: 0;
|
|
287
|
+
padding: 0;
|
|
288
|
+
font-size: inherit;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
[data-rfc-document] :deep(hr) {
|
|
292
|
+
--at-apply: 'bg-border';
|
|
293
|
+
border: none;
|
|
294
|
+
height: 0.5px;
|
|
295
|
+
margin: 32px 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
[data-rfc-document] :deep(table) {
|
|
299
|
+
width: 100%;
|
|
300
|
+
border-collapse: collapse;
|
|
301
|
+
margin: 16px 0;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
[data-rfc-document] :deep(th) {
|
|
305
|
+
--at-apply: 'body-12-medium text-secondary border-b-0.5px border';
|
|
306
|
+
text-align: left;
|
|
307
|
+
padding: 8px;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
[data-rfc-document] :deep(td) {
|
|
311
|
+
--at-apply: 'body-14-regular text-primary border-b-0.5px border';
|
|
312
|
+
padding: 8px;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
[data-rfc-document] :deep(img) {
|
|
316
|
+
--at-apply: 'border';
|
|
317
|
+
display: block;
|
|
318
|
+
max-width: 100%;
|
|
319
|
+
height: auto;
|
|
320
|
+
margin: 24px auto;
|
|
321
|
+
border-width: 0.5px;
|
|
322
|
+
border-style: solid;
|
|
323
|
+
border-radius: 10px;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
[data-rfc-document] :deep(input[type='checkbox']) {
|
|
327
|
+
margin-right: 8px;
|
|
328
|
+
}
|
|
329
|
+
</style>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Meta } from '@storybook/blocks';
|
|
2
|
+
|
|
3
|
+
<Meta title="Components/Table of Contents" tags={['new']} />
|
|
4
|
+
|
|
5
|
+
# TelaTableOfContents
|
|
6
|
+
|
|
7
|
+
A viewport-fixed in-page table of contents (the same one used by the Tela Build docs site) that tracks the current heading and scrolls to native fragment targets. It can discover headings from an article or receive an explicit item list, and it hides itself below 1511px.
|
|
8
|
+
|
|
9
|
+
## Basic usage
|
|
10
|
+
|
|
11
|
+
By default it discovers H2 and H3 elements in the first `article`. Remount it when a client-side route changes so it discovers the new document.
|
|
12
|
+
|
|
13
|
+
```vue
|
|
14
|
+
<article>
|
|
15
|
+
<h1 id="proposal">Proposal</h1>
|
|
16
|
+
<h2 id="context">Context</h2>
|
|
17
|
+
<h3 id="constraints">Constraints</h3>
|
|
18
|
+
</article>
|
|
19
|
+
|
|
20
|
+
<TelaTableOfContents :key="$route.path" />
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Supplied items and contained scrolling
|
|
24
|
+
|
|
25
|
+
Pass `items` when the headings are already known (for example from a parsed Markdown tree), `contentRoot` when the headings live in a specific element, and `scrollRoot` when that element scrolls inside the viewport.
|
|
26
|
+
|
|
27
|
+
```vue
|
|
28
|
+
<script setup lang="ts">
|
|
29
|
+
const scrollRoot = ref<HTMLElement | null>(null)
|
|
30
|
+
const contentRoot = ref<HTMLElement | null>(null)
|
|
31
|
+
|
|
32
|
+
const items = [
|
|
33
|
+
{ id: 'context', text: 'Context', level: 2 },
|
|
34
|
+
{ id: 'constraints', text: 'Constraints', level: 3 },
|
|
35
|
+
{ id: 'proposal', text: 'Proposal', level: 2 },
|
|
36
|
+
]
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<main ref="scrollRoot" h-dvh overflow-y-auto>
|
|
41
|
+
<div mx-auto max-w-760px>
|
|
42
|
+
<TelaTableOfContents
|
|
43
|
+
:items="items"
|
|
44
|
+
:content-root="contentRoot"
|
|
45
|
+
:scroll-root="scrollRoot"
|
|
46
|
+
/>
|
|
47
|
+
|
|
48
|
+
<article ref="contentRoot">
|
|
49
|
+
<h2 id="context">Context</h2>
|
|
50
|
+
<h3 id="constraints">Constraints</h3>
|
|
51
|
+
<h2 id="proposal">Proposal</h2>
|
|
52
|
+
</article>
|
|
53
|
+
</div>
|
|
54
|
+
</main>
|
|
55
|
+
</template>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Props
|
|
59
|
+
|
|
60
|
+
| Prop | Type | Default | Description |
|
|
61
|
+
| --- | --- | --- | --- |
|
|
62
|
+
| `items` | `{ id: string, text: string, level: number }[]` | discovered | Explicit ordered heading list. |
|
|
63
|
+
| `label` | `string` | `undefined` | Accessible navigation label. Falls back to the document title, then “Table of contents”. |
|
|
64
|
+
| `contentRoot` | `HTMLElement \| null` | first matching article | Element containing the target headings. |
|
|
65
|
+
| `scrollRoot` | `HTMLElement \| null` | viewport | Scroll container used for active-heading observation and scroll-to-top behavior. |
|
|
66
|
+
| `articleSelector` | `string` | `'article'` | Fallback selector used when `contentRoot` is not supplied. |
|
|
67
|
+
| `headingSelector` | `string` | `'h2[id], h3[id]'` | Headings discovered when `items` is not supplied. |
|
|
68
|
+
| `titleSelector` | `string` | `'h1[id]'` | Title shown above the list after the document title scrolls away. |
|
|
69
|
+
|
|
70
|
+
## Events
|
|
71
|
+
|
|
72
|
+
| Event | Payload | Description |
|
|
73
|
+
| --- | --- | --- |
|
|
74
|
+
| `select` | `{ id, text, level }` | Emitted after a TOC item is selected. |
|
|
75
|
+
|
|
76
|
+
## Behavior and accessibility
|
|
77
|
+
|
|
78
|
+
- Links retain native fragment URLs and expose the tracked section with `aria-current="location"`.
|
|
79
|
+
- Selecting a link scrolls smoothly and updates the URL hash without adding history entries.
|
|
80
|
+
- The title control leaves the tab order while hidden and returns to the top of the current scroll root.
|
|
81
|
+
- The navigation is hidden below 1511px; the document keeps its native heading anchors.
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
interface TableOfContentsItem {
|
|
3
|
+
id: string
|
|
4
|
+
text: string
|
|
5
|
+
level: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const props = withDefaults(defineProps<{
|
|
9
|
+
items?: TableOfContentsItem[]
|
|
10
|
+
label?: string
|
|
11
|
+
contentRoot?: HTMLElement | null
|
|
12
|
+
scrollRoot?: HTMLElement | null
|
|
13
|
+
articleSelector?: string
|
|
14
|
+
headingSelector?: string
|
|
15
|
+
titleSelector?: string
|
|
16
|
+
}>(), {
|
|
17
|
+
articleSelector: 'article',
|
|
18
|
+
headingSelector: 'h2[id], h3[id]',
|
|
19
|
+
titleSelector: 'h1[id]',
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const emit = defineEmits<{
|
|
23
|
+
select: [item: TableOfContentsItem]
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
const discoveredItems = ref<TableOfContentsItem[]>([])
|
|
27
|
+
const activeId = ref('')
|
|
28
|
+
const pageTitle = ref('')
|
|
29
|
+
const showPageTitle = ref(false)
|
|
30
|
+
|
|
31
|
+
const displayedItems = computed(() => props.items ?? discoveredItems.value)
|
|
32
|
+
const ariaLabel = computed(() => props.label || pageTitle.value || 'Table of contents')
|
|
33
|
+
const minimumLevel = computed(() => Math.min(...displayedItems.value.map(item => item.level)))
|
|
34
|
+
|
|
35
|
+
const indentClasses = ['', 'pl-12px', 'pl-24px', 'pl-36px']
|
|
36
|
+
|
|
37
|
+
let headingObserver: IntersectionObserver | undefined
|
|
38
|
+
let titleObserver: IntersectionObserver | undefined
|
|
39
|
+
let manualClickId: string | undefined
|
|
40
|
+
let manualClickTimeout: ReturnType<typeof setTimeout> | undefined
|
|
41
|
+
let retryTimeout: ReturnType<typeof setTimeout> | undefined
|
|
42
|
+
|
|
43
|
+
function getContentRoot() {
|
|
44
|
+
return props.contentRoot ?? document.querySelector<HTMLElement>(props.articleSelector)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function extractItems(contentRoot: HTMLElement) {
|
|
48
|
+
return [...contentRoot.querySelectorAll<HTMLElement>(props.headingSelector)]
|
|
49
|
+
.map(element => ({
|
|
50
|
+
id: element.id,
|
|
51
|
+
text: element.textContent?.trim() ?? '',
|
|
52
|
+
level: Number(element.tagName[1]),
|
|
53
|
+
}))
|
|
54
|
+
.filter(item => item.id && item.text)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getHeadingElement(contentRoot: HTMLElement, id: string) {
|
|
58
|
+
return [...contentRoot.querySelectorAll<HTMLElement>('[id]')].find(element => element.id === id)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function updateActiveHeading(contentRoot: HTMLElement) {
|
|
62
|
+
if (manualClickId)
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
const rootTop = props.scrollRoot?.getBoundingClientRect().top ?? 0
|
|
66
|
+
const activationTop = rootTop + window.innerHeight * 0.2
|
|
67
|
+
const headingElements = displayedItems.value
|
|
68
|
+
.map(item => getHeadingElement(contentRoot, item.id))
|
|
69
|
+
.filter((element): element is HTMLElement => !!element)
|
|
70
|
+
const current = headingElements
|
|
71
|
+
.filter(element => element.getBoundingClientRect().top <= activationTop)
|
|
72
|
+
.at(-1)
|
|
73
|
+
|
|
74
|
+
activeId.value = current?.id || headingElements[0]?.id || ''
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function disconnectObservers() {
|
|
78
|
+
headingObserver?.disconnect()
|
|
79
|
+
titleObserver?.disconnect()
|
|
80
|
+
headingObserver = undefined
|
|
81
|
+
titleObserver = undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function observe(attempts = 0) {
|
|
85
|
+
clearTimeout(retryTimeout)
|
|
86
|
+
disconnectObservers()
|
|
87
|
+
|
|
88
|
+
const contentRoot = getContentRoot()
|
|
89
|
+
|
|
90
|
+
if (!contentRoot) {
|
|
91
|
+
if (attempts < 20)
|
|
92
|
+
retryTimeout = setTimeout(() => observe(attempts + 1), 100)
|
|
93
|
+
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const nextItems = props.items ?? extractItems(contentRoot)
|
|
98
|
+
|
|
99
|
+
if (props.items === undefined)
|
|
100
|
+
discoveredItems.value = nextItems
|
|
101
|
+
|
|
102
|
+
const headingElements = nextItems
|
|
103
|
+
.map(item => getHeadingElement(contentRoot, item.id))
|
|
104
|
+
.filter((element): element is HTMLElement => !!element)
|
|
105
|
+
|
|
106
|
+
if (!nextItems.length || headingElements.length < nextItems.length) {
|
|
107
|
+
if (attempts < 20)
|
|
108
|
+
retryTimeout = setTimeout(() => observe(attempts + 1), 100)
|
|
109
|
+
|
|
110
|
+
if (!headingElements.length)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
activeId.value = nextItems[0]?.id ?? ''
|
|
115
|
+
|
|
116
|
+
if (typeof IntersectionObserver === 'undefined')
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
headingObserver = new IntersectionObserver(() => updateActiveHeading(contentRoot), {
|
|
120
|
+
root: props.scrollRoot ?? null,
|
|
121
|
+
rootMargin: '-20% 0px -70% 0px',
|
|
122
|
+
threshold: [0, 1],
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
for (const element of headingElements)
|
|
126
|
+
headingObserver.observe(element)
|
|
127
|
+
|
|
128
|
+
updateActiveHeading(contentRoot)
|
|
129
|
+
|
|
130
|
+
const title = contentRoot.querySelector<HTMLElement>(props.titleSelector)
|
|
131
|
+
|
|
132
|
+
pageTitle.value = title?.textContent?.trim() ?? ''
|
|
133
|
+
showPageTitle.value = false
|
|
134
|
+
|
|
135
|
+
if (title) {
|
|
136
|
+
titleObserver = new IntersectionObserver(([entry]) => {
|
|
137
|
+
const rootTop = props.scrollRoot?.getBoundingClientRect().top ?? 0
|
|
138
|
+
|
|
139
|
+
showPageTitle.value = !!entry && !entry.isIntersecting && entry.boundingClientRect.top < rootTop
|
|
140
|
+
}, {
|
|
141
|
+
root: props.scrollRoot ?? null,
|
|
142
|
+
threshold: 0,
|
|
143
|
+
})
|
|
144
|
+
titleObserver.observe(title)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function itemClass(item: TableOfContentsItem) {
|
|
149
|
+
const depth = Math.max(0, item.level - minimumLevel.value)
|
|
150
|
+
|
|
151
|
+
return [
|
|
152
|
+
indentClasses[Math.min(depth, indentClasses.length - 1)],
|
|
153
|
+
activeId.value === item.id ? 'text-primary' : 'text-tertiary hover:text-secondary',
|
|
154
|
+
]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function scrollToHeading(item: TableOfContentsItem) {
|
|
158
|
+
const contentRoot = getContentRoot()
|
|
159
|
+
const element = contentRoot ? getHeadingElement(contentRoot, item.id) : undefined
|
|
160
|
+
|
|
161
|
+
if (!element)
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
clearTimeout(manualClickTimeout)
|
|
165
|
+
manualClickId = item.id
|
|
166
|
+
activeId.value = item.id
|
|
167
|
+
|
|
168
|
+
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
169
|
+
history.replaceState(history.state, '', `#${item.id}`)
|
|
170
|
+
emit('select', item)
|
|
171
|
+
|
|
172
|
+
manualClickTimeout = setTimeout(() => {
|
|
173
|
+
manualClickId = undefined
|
|
174
|
+
}, 1000)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function scrollToTop() {
|
|
178
|
+
(props.scrollRoot ?? window).scrollTo({ top: 0, behavior: 'smooth' })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function cleanup() {
|
|
182
|
+
disconnectObservers()
|
|
183
|
+
clearTimeout(manualClickTimeout)
|
|
184
|
+
clearTimeout(retryTimeout)
|
|
185
|
+
manualClickId = undefined
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
onMounted(() => observe())
|
|
189
|
+
onBeforeUnmount(cleanup)
|
|
190
|
+
|
|
191
|
+
watch([
|
|
192
|
+
() => props.items,
|
|
193
|
+
() => props.contentRoot,
|
|
194
|
+
() => props.scrollRoot,
|
|
195
|
+
], async () => {
|
|
196
|
+
await nextTick()
|
|
197
|
+
observe()
|
|
198
|
+
}, { flush: 'post' })
|
|
199
|
+
</script>
|
|
200
|
+
|
|
201
|
+
<template>
|
|
202
|
+
<aside
|
|
203
|
+
v-if="displayedItems.length"
|
|
204
|
+
class="fixed top-0 w-340px h-screen overflow-y-auto no-scrollbar py-80px pr-48px pl-16px flex flex-col gap-12px select-none [@media(max-width:1510px)]:hidden right-[calc(60px+var(--scrollbar-width,0px))]"
|
|
205
|
+
>
|
|
206
|
+
<Motion
|
|
207
|
+
as="button"
|
|
208
|
+
type="button"
|
|
209
|
+
class="text-left body-14-medium text-primary truncate shrink-0 hover:text-secondary"
|
|
210
|
+
:initial="false"
|
|
211
|
+
:animate="{ opacity: showPageTitle ? 1 : 0 }"
|
|
212
|
+
:transition="{ ease: 'easeOut', duration: 0.15 }"
|
|
213
|
+
:style="{ pointerEvents: showPageTitle ? 'auto' : 'none' }"
|
|
214
|
+
:tabindex="showPageTitle ? 0 : -1"
|
|
215
|
+
:aria-hidden="!showPageTitle"
|
|
216
|
+
@click="scrollToTop"
|
|
217
|
+
>
|
|
218
|
+
{{ pageTitle }}
|
|
219
|
+
</Motion>
|
|
220
|
+
|
|
221
|
+
<nav :aria-label="ariaLabel" class="flex flex-col gap-6px">
|
|
222
|
+
<a
|
|
223
|
+
v-for="item in displayedItems"
|
|
224
|
+
:key="item.id"
|
|
225
|
+
:href="`#${item.id}`"
|
|
226
|
+
class="body-14-regular break-words transition-colors duration-150"
|
|
227
|
+
:class="itemClass(item)"
|
|
228
|
+
:aria-current="activeId === item.id ? 'location' : undefined"
|
|
229
|
+
@click.prevent="scrollToHeading(item)"
|
|
230
|
+
>
|
|
231
|
+
{{ item.text }}
|
|
232
|
+
</a>
|
|
233
|
+
</nav>
|
|
234
|
+
</aside>
|
|
235
|
+
</template>
|
package/docs/interfaces.md
CHANGED
|
@@ -187,11 +187,14 @@ Before building a new page from scratch, check for an existing **layout template
|
|
|
187
187
|
| --- | --- | --- |
|
|
188
188
|
| `TelaHome` (`home.vue`) | Root index / list / dashboard pages with a sidebar rail. | `components/tela/home/home.mdx` |
|
|
189
189
|
| `TelaDetails` (`details.vue`) | Full-screen detail / record pages and fullscreen modals. | `components/tela/details/details.mdx` |
|
|
190
|
+
| `TelaRfc` (`rfc.vue`) | Full-screen technical proposals rendered from Markdown, with generated section navigation and diagram support. | `components/tela/rfc/rfc.mdx` |
|
|
190
191
|
|
|
191
192
|
**`TelaHome`** — a flex-row shell: a sticky `TelaSidebar` beside a scrolling `TelaHomeContent` column that stacks a page title, a metric-card row (`TelaHomeMetrics`), a filter toolbar (`TelaHomeToolbar`), and a data table. It does **not** own scroll — the sidebar pins itself (`sticky top-0 h-screen`) and the page scrolls as one, no `overflow` container or height hack. Expandable detail rows are an opt-in enhancement.
|
|
192
193
|
|
|
193
194
|
**`TelaDetails`** — a sticky `TelaHeader` + a single scroll container + a primary content column beside a sticky context column, with an optional confirm footer. Two documented variants — a two-column body, and a hero header above the body.
|
|
194
195
|
|
|
196
|
+
**`TelaRfc`** — a centered long-form reading column with a built-in fixed `TelaTableOfContents` (H2–H4) at the right. It accepts plain Markdown directly, renders fenced code through `TelaCodeBlock`, renders fenced Mermaid flowcharts, and hides the TOC below 1511px.
|
|
197
|
+
|
|
195
198
|
This table is the source of truth for layout templates. Rules:
|
|
196
199
|
|
|
197
200
|
- If a layout fits the surface, use it. Don't hand-roll an equivalent.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meistrari/tela-build",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.67.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"app.config.ts",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"generate-docs": "bun scripts/generate-docs.ts"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
+
"@comark/vue": "0.5.1",
|
|
28
29
|
"@floating-ui/vue": "1.1.5",
|
|
29
30
|
"@fontsource-variable/geist-mono": "^5.2.7",
|
|
30
31
|
"@fontsource-variable/inter": "^5.2.8",
|
|
@@ -41,8 +42,10 @@
|
|
|
41
42
|
"@vueuse/components": "12.8.2",
|
|
42
43
|
"@vueuse/core": "12.8.2",
|
|
43
44
|
"@vueuse/nuxt": "12.8.2",
|
|
45
|
+
"beautiful-mermaid": "1.1.3",
|
|
44
46
|
"class-variance-authority": "0.7.0",
|
|
45
47
|
"clsx": "2.1.1",
|
|
48
|
+
"comark": "0.5.1",
|
|
46
49
|
"glob": "11.0.0",
|
|
47
50
|
"gsap": "3.12.5",
|
|
48
51
|
"import-in-the-middle": "1.11.2",
|
|
@@ -70,20 +73,21 @@
|
|
|
70
73
|
"typescript": "5.8.2",
|
|
71
74
|
"unified": "11.0.5",
|
|
72
75
|
"unocss": "66.5.12",
|
|
73
|
-
"vue": "3.5.17",
|
|
74
76
|
"vue-component-meta": "3.0.8",
|
|
75
77
|
"vue-docgen-api": "4.78.0",
|
|
76
78
|
"vue-input-otp": "0.3.2",
|
|
77
79
|
"vue-router": "4.5.0"
|
|
78
80
|
},
|
|
79
81
|
"peerDependencies": {
|
|
80
|
-
"nuxt": "^3.17.0 || ^4.0.0"
|
|
82
|
+
"nuxt": "^3.17.0 || ^4.0.0",
|
|
83
|
+
"vue": "^3.5.13"
|
|
81
84
|
},
|
|
82
85
|
"devDependencies": {
|
|
83
86
|
"@nuxt/kit": "3.17.7",
|
|
84
87
|
"@types/node": "18.19.79",
|
|
85
88
|
"fs-extra": "11.2.0",
|
|
86
89
|
"gray-matter": "4.0.3",
|
|
87
|
-
"nuxt": "3.17.7"
|
|
90
|
+
"nuxt": "3.17.7",
|
|
91
|
+
"vue": "3.5.17"
|
|
88
92
|
}
|
|
89
93
|
}
|