@rokkit/stories 1.0.0-next.123
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/dist/shiki.d.ts +14 -0
- package/dist/stories.d.ts +117 -0
- package/dist/stories.spec.d.ts +1 -0
- package/package.json +36 -0
- package/src/Code.svelte +21 -0
- package/src/CodeViewer.svelte +11 -0
- package/src/CopyToClipboard.svelte +26 -0
- package/src/Notes.svelte +57 -0
- package/src/Preview.svelte +10 -0
- package/src/lib/StoryViewer.svelte +16 -0
- package/src/lib/shiki.js +75 -0
- package/src/lib/stories.js +193 -0
- package/src/lib/stories.spec.js +298 -0
- package/tsconfig.build.json +11 -0
package/dist/shiki.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Highlight code using Shiki
|
|
3
|
+
*
|
|
4
|
+
* @param {string} code - The code to highlight
|
|
5
|
+
* @param {object} options - Options for highlighting
|
|
6
|
+
* @param {string} options.lang - The language to highlight
|
|
7
|
+
* @param {string} options.theme - The theme to use for highlighting
|
|
8
|
+
* @returns {Promise<string>} - The highlighted code as HTML
|
|
9
|
+
*/
|
|
10
|
+
export function highlightCode(code: string, options?: {
|
|
11
|
+
lang: string;
|
|
12
|
+
theme: string;
|
|
13
|
+
}): Promise<string>;
|
|
14
|
+
export function preloadHighlighter(): Promise<void>;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetches the content of the sources.
|
|
3
|
+
*
|
|
4
|
+
* @param {Object} sources - The modules to fetch the content from.
|
|
5
|
+
* @returns {Promise<File[]>} - The content of the modules.
|
|
6
|
+
*/
|
|
7
|
+
export function fetchImports(sources: Object): Promise<File[]>;
|
|
8
|
+
/**
|
|
9
|
+
* Returns the slug of a file.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} file - The file to get the slug from.
|
|
12
|
+
* @returns {string} - The slug of the file.
|
|
13
|
+
*/
|
|
14
|
+
export function getSlug(file: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* Converts the input content into a group by catgeory
|
|
17
|
+
* @param {ModuleFile[]} metadata - The metadata to convert.
|
|
18
|
+
* @returns {Metadata[]} Array of section objects
|
|
19
|
+
*/
|
|
20
|
+
export function getSections(metadata: ModuleFile[]): Metadata[];
|
|
21
|
+
/**
|
|
22
|
+
*
|
|
23
|
+
* @param {File[]} files
|
|
24
|
+
* @returns
|
|
25
|
+
*/
|
|
26
|
+
export function groupFiles(files: File[]): {};
|
|
27
|
+
/**
|
|
28
|
+
* Fetches the stories.
|
|
29
|
+
*
|
|
30
|
+
* @param {Object} sources - The sources to fetch the content from.
|
|
31
|
+
* @param {Object} modules - The modules to fetch the content from.
|
|
32
|
+
* @returns {Promise<Object<string, Story>>} - The stories.
|
|
33
|
+
*/
|
|
34
|
+
export function fetchStories(sources: Object, modules: Object): Promise<{
|
|
35
|
+
[x: string]: Story;
|
|
36
|
+
}>;
|
|
37
|
+
/**
|
|
38
|
+
* Get all individual sections flattened from groups
|
|
39
|
+
* @returns {Array} Array of all tutorial sections
|
|
40
|
+
*/
|
|
41
|
+
export function getAllSections(): any[];
|
|
42
|
+
/**
|
|
43
|
+
* Find a section by its ID
|
|
44
|
+
* @param {string} slug - The section ID to find
|
|
45
|
+
* @returns {Object|null} The section object or null if not found
|
|
46
|
+
*/
|
|
47
|
+
export function findSection(sections: any, slug: string): Object | null;
|
|
48
|
+
/**
|
|
49
|
+
* Get the group that contains a specific section
|
|
50
|
+
* @param {string} sectionId - The section ID to find the group for
|
|
51
|
+
* @returns {Object|null} The group object or null if not found
|
|
52
|
+
*/
|
|
53
|
+
export function findGroupForSection(sections: any, sectionId: string): Object | null;
|
|
54
|
+
export type SourceFile = {
|
|
55
|
+
/**
|
|
56
|
+
* - The file path.
|
|
57
|
+
*/
|
|
58
|
+
file: string;
|
|
59
|
+
/**
|
|
60
|
+
* - The group name.
|
|
61
|
+
*/
|
|
62
|
+
group?: string | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* - The file name.
|
|
65
|
+
*/
|
|
66
|
+
name: string;
|
|
67
|
+
/**
|
|
68
|
+
* - The language of the file.
|
|
69
|
+
*/
|
|
70
|
+
language: string;
|
|
71
|
+
/**
|
|
72
|
+
* - The content of the file.
|
|
73
|
+
*/
|
|
74
|
+
content: string;
|
|
75
|
+
};
|
|
76
|
+
export type ModuleFile = {
|
|
77
|
+
/**
|
|
78
|
+
* - The file path.
|
|
79
|
+
*/
|
|
80
|
+
file: string;
|
|
81
|
+
/**
|
|
82
|
+
* - The group name.
|
|
83
|
+
*/
|
|
84
|
+
group?: string | undefined;
|
|
85
|
+
/**
|
|
86
|
+
* - The file name.
|
|
87
|
+
*/
|
|
88
|
+
name: string;
|
|
89
|
+
/**
|
|
90
|
+
* - The language of the file.
|
|
91
|
+
*/
|
|
92
|
+
language: string;
|
|
93
|
+
/**
|
|
94
|
+
* - The content of the file.
|
|
95
|
+
*/
|
|
96
|
+
content: Object;
|
|
97
|
+
};
|
|
98
|
+
export type File = SourceFile | ModuleFile;
|
|
99
|
+
export type Metadata = {
|
|
100
|
+
title: string;
|
|
101
|
+
description: string;
|
|
102
|
+
category: string;
|
|
103
|
+
tags: string[];
|
|
104
|
+
depth: number;
|
|
105
|
+
order: number;
|
|
106
|
+
children?: Metadata[] | undefined;
|
|
107
|
+
};
|
|
108
|
+
export type Story = {
|
|
109
|
+
/**
|
|
110
|
+
* - Array of files.
|
|
111
|
+
*/
|
|
112
|
+
files: File[];
|
|
113
|
+
/**
|
|
114
|
+
* - The preview component.
|
|
115
|
+
*/
|
|
116
|
+
App?: import("svelte").SvelteComponent<Record<string, any>, any, any> | undefined;
|
|
117
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rokkit/stories",
|
|
3
|
+
"version": "1.0.0-next.123",
|
|
4
|
+
"description": "Utilities for building tutorials.",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"keywords": [
|
|
9
|
+
"story",
|
|
10
|
+
"tutorial",
|
|
11
|
+
"markdown",
|
|
12
|
+
"svelte",
|
|
13
|
+
"svelte-kit"
|
|
14
|
+
],
|
|
15
|
+
"author": "Jerry Thomas<me@jerrythomas.name>",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"type": "module",
|
|
18
|
+
"exports": {
|
|
19
|
+
"./package.json": "./package.json",
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./src/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"prepublishOnly": "bun clean && bun tsc --project tsconfig.build.json",
|
|
27
|
+
"clean": "rm -rf dist",
|
|
28
|
+
"build": "bun prepublishOnly"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@vitest/expect": "^3.2.4",
|
|
32
|
+
"frontmatter": "^0.0.3",
|
|
33
|
+
"ramda": "^0.31.3",
|
|
34
|
+
"@rokkit/core": "latest"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/Code.svelte
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { vibe } from '@rokkit/states'
|
|
3
|
+
import { highlightCode } from './shiki.js'
|
|
4
|
+
import CopyToClipboard from './CopyToClipboard.svelte'
|
|
5
|
+
|
|
6
|
+
let { content, language } = $props()
|
|
7
|
+
let theme = $derived(vibe.mode == 'dark' ? 'github-dark' : 'github-light')
|
|
8
|
+
let highlightedCode = $derived(highlightCode(content, { lang: language, theme }))
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<div data-code-root>
|
|
12
|
+
<CopyToClipboard {content} floating={true} />
|
|
13
|
+
{#await highlightedCode}
|
|
14
|
+
<div class="text-surface-z7 p-4">Highlighting code...</div>
|
|
15
|
+
{:then code}
|
|
16
|
+
<!-- eslint-disable svelte/no-at-html-tags -->
|
|
17
|
+
{@html code}
|
|
18
|
+
{:catch error}
|
|
19
|
+
<div class="p-4 text-red-500">Error highlighting code: {error.message}</div>
|
|
20
|
+
{/await}
|
|
21
|
+
</div>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { Tabs } from '@rokkit/ui'
|
|
3
|
+
import Code from './Code.svelte'
|
|
4
|
+
let { files = [] } = $props()
|
|
5
|
+
let current = $state(files[0])
|
|
6
|
+
let fields = { text: 'name', icon: 'language' }
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<Tabs items={files} {fields} bind:value={current} class="no-padding">
|
|
10
|
+
<Code content={current?.content} language={current?.language} />
|
|
11
|
+
</Tabs>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { defaultStateIcons } from '@rokkit/core'
|
|
3
|
+
import { Icon, Button } from '@rokkit/ui'
|
|
4
|
+
let { content, class: className = 'absolute right-2 top-2 z-10', title = 'Copy code' } = $props()
|
|
5
|
+
let copySuccess = $state(false)
|
|
6
|
+
|
|
7
|
+
async function copyToClipboard() {
|
|
8
|
+
try {
|
|
9
|
+
await navigator.clipboard.writeText(content || '')
|
|
10
|
+
copySuccess = true
|
|
11
|
+
setTimeout(() => {
|
|
12
|
+
copySuccess = false
|
|
13
|
+
}, 2000)
|
|
14
|
+
} catch (err) {
|
|
15
|
+
console.error('Failed to copy code:', err)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<Button onclick={copyToClipboard} class={className} {title}>
|
|
21
|
+
{#if copySuccess}
|
|
22
|
+
<Icon name={defaultStateIcons.action.copysuccess} />
|
|
23
|
+
{:else}
|
|
24
|
+
<Icon name={defaultStateIcons.action.copy} />
|
|
25
|
+
{/if}
|
|
26
|
+
</Button>
|
package/src/Notes.svelte
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { Icon, BreadCrumbs } from '@rokkit/ui'
|
|
3
|
+
import { getContext } from 'svelte'
|
|
4
|
+
import { goto } from '$app/navigation'
|
|
5
|
+
import { page } from '$app/state'
|
|
6
|
+
const site = getContext('site')()
|
|
7
|
+
|
|
8
|
+
let { content, crumbs, previous, next } = $props()
|
|
9
|
+
/**
|
|
10
|
+
*
|
|
11
|
+
* @param {string} route
|
|
12
|
+
*/
|
|
13
|
+
async function gotoPage(route) {
|
|
14
|
+
if (route) {
|
|
15
|
+
const location = '/' + page.params.segment + '/' + route
|
|
16
|
+
await goto(location)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function toggle() {
|
|
20
|
+
site.sidebar = !site.sidebar
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<aside class="border-r-surface-z0 flex h-full w-full flex-col border-r">
|
|
25
|
+
{#if content}
|
|
26
|
+
<nav class="border-b-surface-z0 box-border flex h-10 items-center gap-1 border-b px-2 text-sm">
|
|
27
|
+
{#if !site.sidebar}
|
|
28
|
+
<Icon
|
|
29
|
+
name="i-rokkit:menu"
|
|
30
|
+
class="border-r-surface-z2 border-r"
|
|
31
|
+
role="button"
|
|
32
|
+
onclick={toggle}
|
|
33
|
+
/>
|
|
34
|
+
{/if}
|
|
35
|
+
<Icon
|
|
36
|
+
name="i-rokkit:arrow-left"
|
|
37
|
+
role="button"
|
|
38
|
+
onclick={() => gotoPage(previous)}
|
|
39
|
+
class="square"
|
|
40
|
+
/>
|
|
41
|
+
<h1 class="w-full">
|
|
42
|
+
<BreadCrumbs items={crumbs} class="text-xs" />
|
|
43
|
+
</h1>
|
|
44
|
+
<Icon
|
|
45
|
+
name="i-rokkit:arrow-right"
|
|
46
|
+
role="button"
|
|
47
|
+
onclick={() => gotoPage(next)}
|
|
48
|
+
class="square"
|
|
49
|
+
/>
|
|
50
|
+
</nav>
|
|
51
|
+
|
|
52
|
+
{@const SvelteComponent = content}
|
|
53
|
+
<notes class="markdown-body h-full w-full overflow-scroll p-8 font-thin">
|
|
54
|
+
<SvelteComponent />
|
|
55
|
+
</notes>
|
|
56
|
+
{/if}
|
|
57
|
+
</aside>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import CodeViewer from './CodeViewer.svelte'
|
|
3
|
+
|
|
4
|
+
let { App, files } = $props()
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<div data-story-root>
|
|
8
|
+
<div data-story-preview>
|
|
9
|
+
{#if App}
|
|
10
|
+
<App />
|
|
11
|
+
{:else}
|
|
12
|
+
<div>No preview available</div>
|
|
13
|
+
{/if}
|
|
14
|
+
</div>
|
|
15
|
+
<CodeViewer {files} />
|
|
16
|
+
</div>
|
package/src/lib/shiki.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Shiki syntax highlighting utility for CodeViewer
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createHighlighter } from 'shiki'
|
|
6
|
+
|
|
7
|
+
let highlighter = null
|
|
8
|
+
let isInitializing = false
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Initialize Shiki highlighter
|
|
12
|
+
*/
|
|
13
|
+
async function initializeHighlighter() {
|
|
14
|
+
if (highlighter) return highlighter
|
|
15
|
+
|
|
16
|
+
if (isInitializing) {
|
|
17
|
+
while (isInitializing) {
|
|
18
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
19
|
+
}
|
|
20
|
+
return highlighter
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
isInitializing = true
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
highlighter = await createHighlighter({
|
|
27
|
+
themes: ['github-light', 'github-dark'],
|
|
28
|
+
langs: ['svelte', 'javascript', 'typescript', 'css', 'html', 'json', 'bash', 'shell']
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
isInitializing = false
|
|
32
|
+
return highlighter
|
|
33
|
+
} catch (error) {
|
|
34
|
+
isInitializing = false
|
|
35
|
+
throw error
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Highlight code using Shiki
|
|
41
|
+
*
|
|
42
|
+
* @param {string} code - The code to highlight
|
|
43
|
+
* @param {object} options - Options for highlighting
|
|
44
|
+
* @param {string} options.lang - The language to highlight
|
|
45
|
+
* @param {string} options.theme - The theme to use for highlighting
|
|
46
|
+
* @returns {Promise<string>} - The highlighted code as HTML
|
|
47
|
+
*/
|
|
48
|
+
export async function highlightCode(code, options = {}) {
|
|
49
|
+
if (!code || typeof code !== 'string') {
|
|
50
|
+
throw new Error('Invalid code provided for highlighting')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const hl = await initializeHighlighter()
|
|
55
|
+
const lang = options.lang
|
|
56
|
+
const theme = options.theme
|
|
57
|
+
|
|
58
|
+
return hl
|
|
59
|
+
.codeToHtml(code, {
|
|
60
|
+
lang,
|
|
61
|
+
theme
|
|
62
|
+
})
|
|
63
|
+
.replace(/(<pre[^>]+) style=".*?"/, '$1') /* remove conflicting background color */
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw new Error(`Failed to highlight code: ${error.message}`)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function preloadHighlighter() {
|
|
70
|
+
try {
|
|
71
|
+
await initializeHighlighter()
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.warn('Failed to preload syntax highlighter:', error.message)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { omit } from 'ramda'
|
|
2
|
+
const LANGUAGE_MAP = {
|
|
3
|
+
js: 'javascript',
|
|
4
|
+
ts: 'typescript',
|
|
5
|
+
md: 'markdown',
|
|
6
|
+
sh: 'bash',
|
|
7
|
+
bash: 'bash',
|
|
8
|
+
shell: 'shell'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Object} SourceFile
|
|
13
|
+
* @property {string} file - The file path.
|
|
14
|
+
* @property {string} [group] - The group name.
|
|
15
|
+
* @property {string} name - The file name.
|
|
16
|
+
* @property {string} language - The language of the file.
|
|
17
|
+
* @property {string} content - The content of the file.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {Object} ModuleFile
|
|
22
|
+
* @property {string} file - The file path.
|
|
23
|
+
* @property {string} [group] - The group name.
|
|
24
|
+
* @property {string} name - The file name.
|
|
25
|
+
* @property {string} language - The language of the file.
|
|
26
|
+
* @property {Object} content - The content of the file.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** @typedef {SourceFile|ModuleFile} File */
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {Object} Metadata
|
|
33
|
+
* @property {string} title
|
|
34
|
+
* @property {string} description
|
|
35
|
+
* @property {string} category
|
|
36
|
+
* @property {string[]} tags
|
|
37
|
+
* @property {number} depth
|
|
38
|
+
* @property {number} order
|
|
39
|
+
* @property {Metadata[]} [children]
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {Object} Story
|
|
44
|
+
* @property {File[]} files - Array of files.
|
|
45
|
+
* @property {import('svelte').SvelteComponent} [App] - The preview component.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Returns the language of a file based on its extension.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} file - The file to get the language from.
|
|
52
|
+
* @returns {string} - The language of the file.
|
|
53
|
+
*/
|
|
54
|
+
function getLanguage(file) {
|
|
55
|
+
const ext = file.split('.').pop()
|
|
56
|
+
return LANGUAGE_MAP[ext] || ext
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Fetches the content of the sources.
|
|
60
|
+
*
|
|
61
|
+
* @param {Object} sources - The modules to fetch the content from.
|
|
62
|
+
* @returns {Promise<File[]>} - The content of the modules.
|
|
63
|
+
*/
|
|
64
|
+
export async function fetchImports(sources) {
|
|
65
|
+
const files = await Promise.all(
|
|
66
|
+
Object.entries(sources).map(async ([file, content]) => ({
|
|
67
|
+
file,
|
|
68
|
+
group: file.split('/').slice(-2)[0],
|
|
69
|
+
name: file.split('/').pop(),
|
|
70
|
+
language: getLanguage(file),
|
|
71
|
+
content: await content()
|
|
72
|
+
}))
|
|
73
|
+
)
|
|
74
|
+
return files
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Returns the slug of a file.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} file - The file to get the slug from.
|
|
81
|
+
* @returns {string} - The slug of the file.
|
|
82
|
+
*/
|
|
83
|
+
export function getSlug(file) {
|
|
84
|
+
const parts = file.split('/').slice(1).slice(0, -1)
|
|
85
|
+
return `/${parts.join('/')}`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Converts the input content into a group by catgeory
|
|
90
|
+
* @param {ModuleFile[]} metadata - The metadata to convert.
|
|
91
|
+
* @returns {Metadata[]} Array of section objects
|
|
92
|
+
*/
|
|
93
|
+
export function getSections(metadata) {
|
|
94
|
+
/** @type Object<string, Metadata> */
|
|
95
|
+
const sections = {}
|
|
96
|
+
|
|
97
|
+
metadata.forEach(({ content, file, group }) => {
|
|
98
|
+
const item = {
|
|
99
|
+
category: group ?? '',
|
|
100
|
+
order: 99,
|
|
101
|
+
...content,
|
|
102
|
+
slug: getSlug(file),
|
|
103
|
+
depth: file.split('/').length - 2
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const category = item.category.toLowerCase()
|
|
107
|
+
if (!sections[category]) {
|
|
108
|
+
sections[category] = { children: [] }
|
|
109
|
+
}
|
|
110
|
+
if (item.depth === 1) {
|
|
111
|
+
sections[category] = {
|
|
112
|
+
...sections[category],
|
|
113
|
+
...item
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
sections[category].children.push(item)
|
|
117
|
+
}
|
|
118
|
+
sections[category].children = sections[category].children.sort((a, b) => a.order - b.order)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
return Object.values(sections).sort((a, b) => a.order - b.order)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
*
|
|
126
|
+
* @param {File[]} files
|
|
127
|
+
* @returns
|
|
128
|
+
*/
|
|
129
|
+
export function groupFiles(files) {
|
|
130
|
+
const groups = files
|
|
131
|
+
.filter((file) => file.group !== '.')
|
|
132
|
+
.reduce(
|
|
133
|
+
(acc, file) => ({
|
|
134
|
+
...acc,
|
|
135
|
+
[file.group]: [...(acc[file.group] || []), omit(['group'], file)]
|
|
136
|
+
}),
|
|
137
|
+
{}
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return groups
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Fetches the stories.
|
|
145
|
+
*
|
|
146
|
+
* @param {Object} sources - The sources to fetch the content from.
|
|
147
|
+
* @param {Object} modules - The modules to fetch the content from.
|
|
148
|
+
* @returns {Promise<Object<string, Story>>} - The stories.
|
|
149
|
+
*/
|
|
150
|
+
export async function fetchStories(sources, modules) {
|
|
151
|
+
const components = groupFiles(await fetchImports(modules))
|
|
152
|
+
const files = groupFiles(await fetchImports(sources))
|
|
153
|
+
|
|
154
|
+
/** @type {Object<string, Story>} */
|
|
155
|
+
const stories = {}
|
|
156
|
+
|
|
157
|
+
Object.entries(files).forEach(([group, files]) => {
|
|
158
|
+
stories[group] = { files }
|
|
159
|
+
if (components[group]) {
|
|
160
|
+
stories[group].App = components[group][0].content
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
return stories
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Get all individual sections flattened from groups
|
|
167
|
+
* @returns {Array} Array of all tutorial sections
|
|
168
|
+
*/
|
|
169
|
+
export function getAllSections() {
|
|
170
|
+
return sections.flatMap((group) => group.children)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Find a section by its ID
|
|
175
|
+
* @param {string} slug - The section ID to find
|
|
176
|
+
* @returns {Object|null} The section object or null if not found
|
|
177
|
+
*/
|
|
178
|
+
export function findSection(sections, slug) {
|
|
179
|
+
for (const group of sections) {
|
|
180
|
+
const section = group.children.find((child) => child.slug === slug)
|
|
181
|
+
if (section) return section
|
|
182
|
+
}
|
|
183
|
+
return {}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Get the group that contains a specific section
|
|
188
|
+
* @param {string} sectionId - The section ID to find the group for
|
|
189
|
+
* @returns {Object|null} The group object or null if not found
|
|
190
|
+
*/
|
|
191
|
+
export function findGroupForSection(sections, sectionId) {
|
|
192
|
+
return sections.find((group) => group.children.some((child) => child.id === sectionId)) || null
|
|
193
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { getSections, getSlug, fetchImports, fetchStories, groupFiles } from './stories.js'
|
|
3
|
+
|
|
4
|
+
describe('stories.js', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
vi.clearAllMocks()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
describe('getSlug', () => {
|
|
10
|
+
it('should return the slug from a file path', () => {
|
|
11
|
+
const file = './welcome/introduction/meta.json'
|
|
12
|
+
const slug = getSlug(file)
|
|
13
|
+
expect(slug).toBe('/welcome/introduction')
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('getSections', () => {
|
|
18
|
+
it('should combine metadata from categories and components', () => {
|
|
19
|
+
const result = getSections([])
|
|
20
|
+
expect(result).toEqual([])
|
|
21
|
+
})
|
|
22
|
+
it('should group by categories', () => {
|
|
23
|
+
const metadata = [
|
|
24
|
+
,
|
|
25
|
+
{
|
|
26
|
+
content: { title: 'Elements', category: 'elements', order: 2 },
|
|
27
|
+
file: './elements/meta.json'
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
content: { title: 'List', order: 2 },
|
|
31
|
+
|
|
32
|
+
group: 'elements',
|
|
33
|
+
file: './elements/list/meta.json'
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
content: { title: 'Components', category: 'elements', order: 1 },
|
|
37
|
+
group: 'elements',
|
|
38
|
+
file: './elements/components/meta.json'
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
content: { title: 'Welcome', category: 'welcome', order: 1 },
|
|
42
|
+
file: './welcome/meta.json'
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
content: { title: 'Getting Started', category: 'welcome', order: 2 },
|
|
46
|
+
file: './welcome/get/meta.json'
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
content: { title: 'Introduction', category: 'welcome', order: 1 },
|
|
50
|
+
file: './welcome/introduction/meta.json'
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
const result = getSections(metadata)
|
|
54
|
+
expect(result).toEqual([
|
|
55
|
+
{
|
|
56
|
+
title: 'Welcome',
|
|
57
|
+
category: 'welcome',
|
|
58
|
+
order: 1,
|
|
59
|
+
depth: 1,
|
|
60
|
+
slug: '/welcome',
|
|
61
|
+
children: [
|
|
62
|
+
{
|
|
63
|
+
title: 'Introduction',
|
|
64
|
+
category: 'welcome',
|
|
65
|
+
order: 1,
|
|
66
|
+
depth: 2,
|
|
67
|
+
slug: '/welcome/introduction'
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
title: 'Getting Started',
|
|
71
|
+
category: 'welcome',
|
|
72
|
+
depth: 2,
|
|
73
|
+
slug: '/welcome/get',
|
|
74
|
+
order: 2
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
title: 'Elements',
|
|
80
|
+
category: 'elements',
|
|
81
|
+
order: 2,
|
|
82
|
+
depth: 1,
|
|
83
|
+
slug: '/elements',
|
|
84
|
+
children: [
|
|
85
|
+
{
|
|
86
|
+
title: 'Components',
|
|
87
|
+
order: 1,
|
|
88
|
+
depth: 2,
|
|
89
|
+
category: 'elements',
|
|
90
|
+
slug: '/elements/components'
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
title: 'List',
|
|
94
|
+
order: 2,
|
|
95
|
+
depth: 2,
|
|
96
|
+
category: 'elements',
|
|
97
|
+
slug: '/elements/list'
|
|
98
|
+
}
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
])
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('fetchImports', () => {
|
|
106
|
+
it('should process sources and group files correctly', async () => {
|
|
107
|
+
const mockSources = {
|
|
108
|
+
'folder1/file1.js': vi.fn().mockResolvedValue('const test = 1;'),
|
|
109
|
+
'folder1/file2.ts': vi.fn().mockResolvedValue('const test: number = 2;'),
|
|
110
|
+
'folder2/file3.svelte': vi.fn().mockResolvedValue('<script>let count = 0;</script>'),
|
|
111
|
+
'./root.js': vi.fn().mockResolvedValue('console.log("root");')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const result = await fetchImports(mockSources)
|
|
115
|
+
|
|
116
|
+
expect(result).toEqual([
|
|
117
|
+
{
|
|
118
|
+
file: 'folder1/file1.js',
|
|
119
|
+
name: 'file1.js',
|
|
120
|
+
group: 'folder1',
|
|
121
|
+
language: 'javascript',
|
|
122
|
+
content: 'const test = 1;'
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
file: 'folder1/file2.ts',
|
|
126
|
+
name: 'file2.ts',
|
|
127
|
+
group: 'folder1',
|
|
128
|
+
language: 'typescript',
|
|
129
|
+
content: 'const test: number = 2;'
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
{
|
|
133
|
+
file: 'folder2/file3.svelte',
|
|
134
|
+
name: 'file3.svelte',
|
|
135
|
+
group: 'folder2',
|
|
136
|
+
language: 'svelte',
|
|
137
|
+
content: '<script>let count = 0;</script>'
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
file: './root.js',
|
|
141
|
+
name: 'root.js',
|
|
142
|
+
group: '.',
|
|
143
|
+
language: 'javascript',
|
|
144
|
+
content: 'console.log("root");'
|
|
145
|
+
}
|
|
146
|
+
])
|
|
147
|
+
|
|
148
|
+
expect(mockSources['folder1/file1.js']).toHaveBeenCalled()
|
|
149
|
+
expect(mockSources['folder1/file2.ts']).toHaveBeenCalled()
|
|
150
|
+
expect(mockSources['folder2/file3.svelte']).toHaveBeenCalled()
|
|
151
|
+
expect(mockSources['./root.js']).toHaveBeenCalled()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('should handle empty sources', async () => {
|
|
155
|
+
const result = await fetchImports({})
|
|
156
|
+
expect(result).toEqual([])
|
|
157
|
+
})
|
|
158
|
+
it('should handle unknown file extensions', async () => {
|
|
159
|
+
const mockSources = {
|
|
160
|
+
'config/settings.toml': vi.fn().mockResolvedValue('[section]\nkey = "value"')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const result = await fetchImports(mockSources)
|
|
164
|
+
|
|
165
|
+
expect(result[0].language).toBe('toml')
|
|
166
|
+
})
|
|
167
|
+
it('should handle markdown files with correct language mapping', async () => {
|
|
168
|
+
const mockSources = {
|
|
169
|
+
'docs/readme.md': vi.fn().mockResolvedValue('# Title\n\nContent')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const result = await fetchImports(mockSources)
|
|
173
|
+
|
|
174
|
+
expect(result[0].language).toBe('markdown')
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
describe('groupFiles', () => {
|
|
178
|
+
it('should filter out root level files (group === ".")', () => {
|
|
179
|
+
const mockFiles = [
|
|
180
|
+
{
|
|
181
|
+
file: 'folder1/file1.js',
|
|
182
|
+
name: 'file1.js',
|
|
183
|
+
group: 'folder1',
|
|
184
|
+
language: 'javascript',
|
|
185
|
+
content: 'const test = 1;'
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
file: 'folder1/file2.ts',
|
|
189
|
+
name: 'file2.ts',
|
|
190
|
+
group: 'folder1',
|
|
191
|
+
language: 'typescript',
|
|
192
|
+
content: 'const test: number = 2;'
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
{
|
|
196
|
+
file: 'folder2/file3.svelte',
|
|
197
|
+
name: 'file3.svelte',
|
|
198
|
+
group: 'folder2',
|
|
199
|
+
language: 'svelte',
|
|
200
|
+
content: '<script>let count = 0;</script>'
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
file: './root.js',
|
|
204
|
+
name: 'root.js',
|
|
205
|
+
group: '.',
|
|
206
|
+
language: 'javascript',
|
|
207
|
+
content: 'console.log("root");'
|
|
208
|
+
}
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
const result = groupFiles(mockFiles)
|
|
212
|
+
|
|
213
|
+
expect(result).toEqual({
|
|
214
|
+
folder1: [
|
|
215
|
+
{
|
|
216
|
+
content: 'const test = 1;',
|
|
217
|
+
file: 'folder1/file1.js',
|
|
218
|
+
language: 'javascript',
|
|
219
|
+
name: 'file1.js'
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
content: 'const test: number = 2;',
|
|
223
|
+
file: 'folder1/file2.ts',
|
|
224
|
+
language: 'typescript',
|
|
225
|
+
name: 'file2.ts'
|
|
226
|
+
}
|
|
227
|
+
],
|
|
228
|
+
folder2: [
|
|
229
|
+
{
|
|
230
|
+
content: '<script>let count = 0;</script>',
|
|
231
|
+
file: 'folder2/file3.svelte',
|
|
232
|
+
language: 'svelte',
|
|
233
|
+
name: 'file3.svelte'
|
|
234
|
+
}
|
|
235
|
+
]
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
describe('fetchStories', () => {
|
|
241
|
+
it('should combine sources and modules into stories', async () => {
|
|
242
|
+
const mockSources = {
|
|
243
|
+
'story1/file1.js': vi.fn().mockResolvedValue('const test = 1;'),
|
|
244
|
+
'story1/file2.svelte': vi.fn().mockResolvedValue('<h1>Hello</h1>')
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const mockModules = {
|
|
248
|
+
'story1/App.svelte': vi.fn().mockResolvedValue({ default: 'MockComponent' })
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const result = await fetchStories(mockSources, mockModules)
|
|
252
|
+
|
|
253
|
+
expect(result).toEqual({
|
|
254
|
+
story1: {
|
|
255
|
+
files: [
|
|
256
|
+
{
|
|
257
|
+
file: 'story1/file1.js',
|
|
258
|
+
name: 'file1.js',
|
|
259
|
+
language: 'javascript',
|
|
260
|
+
content: 'const test = 1;'
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
file: 'story1/file2.svelte',
|
|
264
|
+
name: 'file2.svelte',
|
|
265
|
+
language: 'svelte',
|
|
266
|
+
content: '<h1>Hello</h1>'
|
|
267
|
+
}
|
|
268
|
+
],
|
|
269
|
+
App: { default: 'MockComponent' }
|
|
270
|
+
}
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('should handle multiple stories', async () => {
|
|
275
|
+
const mockSources = {
|
|
276
|
+
'story1/file1.js': vi.fn().mockResolvedValue('const a = 1;'),
|
|
277
|
+
'story2/file2.js': vi.fn().mockResolvedValue('const b = 2;')
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const mockModules = {
|
|
281
|
+
'story1/App.svelte': vi.fn().mockResolvedValue({ default: 'Component1' }),
|
|
282
|
+
'story2/App.svelte': vi.fn().mockResolvedValue({ default: 'Component2' })
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const result = await fetchStories(mockSources, mockModules)
|
|
286
|
+
|
|
287
|
+
expect(result).toHaveProperty('story1')
|
|
288
|
+
expect(result).toHaveProperty('story2')
|
|
289
|
+
expect(result.story1.App).toEqual({ default: 'Component1' })
|
|
290
|
+
expect(result.story2.App).toEqual({ default: 'Component2' })
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('should handle empty sources and modules', async () => {
|
|
294
|
+
const result = await fetchStories({}, {})
|
|
295
|
+
expect(result).toEqual({})
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
})
|