@levino/shipyard-docs 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/astro/Layout.astro +73 -23
- package/package.json +16 -6
- package/src/fallbacks.test.ts +136 -0
- package/src/fallbacks.ts +82 -0
- package/src/index.ts +177 -50
- package/src/pagination.test.ts +115 -15
- package/src/pagination.ts +44 -16
- package/src/schema.test.ts +467 -0
- package/src/sidebarEntries.test.ts +145 -7
- package/src/sidebarEntries.ts +30 -3
package/astro/Layout.astro
CHANGED
|
@@ -14,6 +14,12 @@ import DocMetadata from './DocMetadata.astro'
|
|
|
14
14
|
import DocPagination from './DocPagination.astro'
|
|
15
15
|
import LlmsTxtSidebarLabel from './LlmsTxtSidebarLabel.astro'
|
|
16
16
|
|
|
17
|
+
type CustomMetaTag = {
|
|
18
|
+
name?: string
|
|
19
|
+
property?: string
|
|
20
|
+
content: string
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
interface Props {
|
|
18
24
|
headings?: { depth: number; text: string; slug: string }[]
|
|
19
25
|
/**
|
|
@@ -39,6 +45,36 @@ interface Props {
|
|
|
39
45
|
* The name of the author who last updated this page.
|
|
40
46
|
*/
|
|
41
47
|
lastAuthor?: string
|
|
48
|
+
/**
|
|
49
|
+
* Whether to hide the table of contents on this page.
|
|
50
|
+
* @default false
|
|
51
|
+
*/
|
|
52
|
+
hideTableOfContents?: boolean
|
|
53
|
+
/**
|
|
54
|
+
* SEO keywords for the page.
|
|
55
|
+
*/
|
|
56
|
+
keywords?: string[]
|
|
57
|
+
/**
|
|
58
|
+
* Social preview image URL (og:image).
|
|
59
|
+
*/
|
|
60
|
+
image?: string
|
|
61
|
+
/**
|
|
62
|
+
* Custom canonical URL for the page.
|
|
63
|
+
*/
|
|
64
|
+
canonicalUrl?: string
|
|
65
|
+
/**
|
|
66
|
+
* Custom meta tags to add to the page head.
|
|
67
|
+
*/
|
|
68
|
+
customMetaTags?: CustomMetaTag[]
|
|
69
|
+
/**
|
|
70
|
+
* Whether to hide the H1 title heading on this page.
|
|
71
|
+
* @default false
|
|
72
|
+
*/
|
|
73
|
+
hideTitle?: boolean
|
|
74
|
+
/**
|
|
75
|
+
* Override title for SEO/browser tab (overrides the regular title in <title> tag).
|
|
76
|
+
*/
|
|
77
|
+
titleMeta?: string
|
|
42
78
|
}
|
|
43
79
|
|
|
44
80
|
const {
|
|
@@ -48,6 +84,13 @@ const {
|
|
|
48
84
|
editUrl,
|
|
49
85
|
lastUpdated,
|
|
50
86
|
lastAuthor,
|
|
87
|
+
hideTableOfContents = false,
|
|
88
|
+
hideTitle = false,
|
|
89
|
+
keywords,
|
|
90
|
+
image,
|
|
91
|
+
canonicalUrl,
|
|
92
|
+
customMetaTags,
|
|
93
|
+
titleMeta,
|
|
51
94
|
} = Astro.props
|
|
52
95
|
|
|
53
96
|
// Normalize the route base path
|
|
@@ -82,21 +125,22 @@ const docs =
|
|
|
82
125
|
const {
|
|
83
126
|
id,
|
|
84
127
|
data: {
|
|
128
|
+
id: customId,
|
|
85
129
|
title,
|
|
86
|
-
sidebar
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
pagination_prev,
|
|
130
|
+
sidebar,
|
|
131
|
+
render: shouldRender,
|
|
132
|
+
unlisted,
|
|
133
|
+
paginationLabel,
|
|
134
|
+
paginationNext,
|
|
135
|
+
paginationPrev,
|
|
93
136
|
},
|
|
94
137
|
} = doc
|
|
95
138
|
return {
|
|
96
139
|
id,
|
|
140
|
+
customId,
|
|
97
141
|
path: getPath(id),
|
|
98
142
|
title:
|
|
99
|
-
label ??
|
|
143
|
+
sidebar.label ??
|
|
100
144
|
title ??
|
|
101
145
|
Option.getOrUndefined(
|
|
102
146
|
EffectArray.findFirst(
|
|
@@ -105,13 +149,17 @@ const docs =
|
|
|
105
149
|
),
|
|
106
150
|
)?.text ??
|
|
107
151
|
id,
|
|
108
|
-
link:
|
|
109
|
-
sidebarPosition:
|
|
110
|
-
sidebarLabel:
|
|
111
|
-
sidebarClassName:
|
|
112
|
-
sidebarCustomProps:
|
|
113
|
-
|
|
114
|
-
|
|
152
|
+
link: shouldRender,
|
|
153
|
+
sidebarPosition: sidebar.position,
|
|
154
|
+
sidebarLabel: sidebar.label,
|
|
155
|
+
sidebarClassName: sidebar.className,
|
|
156
|
+
sidebarCustomProps: sidebar.customProps,
|
|
157
|
+
collapsible: sidebar.collapsible,
|
|
158
|
+
collapsed: sidebar.collapsed,
|
|
159
|
+
unlisted,
|
|
160
|
+
paginationLabel,
|
|
161
|
+
paginationNext,
|
|
162
|
+
paginationPrev,
|
|
115
163
|
}
|
|
116
164
|
}),
|
|
117
165
|
)
|
|
@@ -144,21 +192,23 @@ if (docsConfig?.llmsTxtEnabled) {
|
|
|
144
192
|
}
|
|
145
193
|
---
|
|
146
194
|
|
|
147
|
-
<BaseLayout sidebarNavigation={entries}>
|
|
195
|
+
<BaseLayout sidebarNavigation={entries} keywords={keywords} image={image} canonicalUrl={canonicalUrl} customMetaTags={customMetaTags} title={titleMeta}>
|
|
148
196
|
<div class="grid grid-cols-12 gap-6 max-w-7xl mx-auto">
|
|
149
|
-
<div class=
|
|
150
|
-
<div class=
|
|
197
|
+
<div class:list={['col-span-12', { 'xl:col-span-9': !hideTableOfContents }]}>
|
|
198
|
+
<div class:list={['prose', 'max-w-none', { 'hide-title': hideTitle }]}>
|
|
151
199
|
<Breadcrumbs navigation={entries} />
|
|
152
|
-
<TableOfContents links={headings} class="xl:hidden" />
|
|
200
|
+
{!hideTableOfContents && <TableOfContents links={headings} class="xl:hidden" />}
|
|
153
201
|
<slot />
|
|
154
202
|
<DocMetadata editUrl={editUrl} lastUpdated={lastUpdated} lastAuthor={lastAuthor} />
|
|
155
203
|
<DocPagination prev={pagination.prev} next={pagination.next} />
|
|
156
204
|
</div>
|
|
157
205
|
</div>
|
|
158
|
-
|
|
159
|
-
<div class="
|
|
160
|
-
<
|
|
206
|
+
{!hideTableOfContents && (
|
|
207
|
+
<div class="hidden xl:block col-span-3">
|
|
208
|
+
<div class="sticky top-20 max-h-[calc(100vh-6rem)] overflow-y-auto">
|
|
209
|
+
<TableOfContents links={headings} desktopOnly />
|
|
210
|
+
</div>
|
|
161
211
|
</div>
|
|
162
|
-
|
|
212
|
+
)}
|
|
163
213
|
</div>
|
|
164
214
|
</BaseLayout>
|
package/package.json
CHANGED
|
@@ -1,22 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@levino/shipyard-docs",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Documentation plugin for shipyard with automatic sidebar, pagination, and git metadata",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test:unit": "vitest run"
|
|
9
9
|
},
|
|
10
|
-
"keywords": [
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
"keywords": [
|
|
11
|
+
"astro",
|
|
12
|
+
"astro-integration",
|
|
13
|
+
"withastro",
|
|
14
|
+
"frameworks",
|
|
15
|
+
"documentation",
|
|
16
|
+
"docs",
|
|
17
|
+
"sidebar",
|
|
18
|
+
"markdown"
|
|
19
|
+
],
|
|
20
|
+
"author": "Levin Keller",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"homepage": "https://shipyard.levinkeller.de",
|
|
13
23
|
"peerDependencies": {
|
|
14
24
|
"astro": "^5.15"
|
|
15
25
|
},
|
|
16
26
|
"dependencies": {
|
|
17
27
|
"effect": "^3.12.5",
|
|
18
28
|
"ramda": "^0.31",
|
|
19
|
-
"@levino/shipyard-base": "^0.
|
|
29
|
+
"@levino/shipyard-base": "^0.6.0"
|
|
20
30
|
},
|
|
21
31
|
"devDependencies": {
|
|
22
32
|
"@tailwindcss/typography": "^0.5.16",
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { extractFirstParagraph } from './fallbacks'
|
|
3
|
+
|
|
4
|
+
describe('extractFirstParagraph', () => {
|
|
5
|
+
it('should extract a simple paragraph', () => {
|
|
6
|
+
const body = 'This is the first paragraph.'
|
|
7
|
+
expect(extractFirstParagraph(body)).toBe('This is the first paragraph.')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('should extract first paragraph and ignore subsequent content', () => {
|
|
11
|
+
const body = `This is the first paragraph.
|
|
12
|
+
|
|
13
|
+
This is the second paragraph.`
|
|
14
|
+
expect(extractFirstParagraph(body)).toBe('This is the first paragraph.')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('should skip headings and extract first paragraph', () => {
|
|
18
|
+
const body = `# Main Heading
|
|
19
|
+
|
|
20
|
+
This is the first paragraph.
|
|
21
|
+
|
|
22
|
+
More content here.`
|
|
23
|
+
expect(extractFirstParagraph(body)).toBe('This is the first paragraph.')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('should skip multiple headings', () => {
|
|
27
|
+
const body = `# Heading 1
|
|
28
|
+
## Heading 2
|
|
29
|
+
### Heading 3
|
|
30
|
+
|
|
31
|
+
First paragraph content.`
|
|
32
|
+
expect(extractFirstParagraph(body)).toBe('First paragraph content.')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('should combine multi-line paragraphs', () => {
|
|
36
|
+
const body = `First line of paragraph.
|
|
37
|
+
Second line of same paragraph.
|
|
38
|
+
Third line.
|
|
39
|
+
|
|
40
|
+
Next paragraph.`
|
|
41
|
+
expect(extractFirstParagraph(body)).toBe(
|
|
42
|
+
'First line of paragraph. Second line of same paragraph. Third line.',
|
|
43
|
+
)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('should skip code blocks', () => {
|
|
47
|
+
const body = `\`\`\`javascript
|
|
48
|
+
const code = 'example'
|
|
49
|
+
\`\`\`
|
|
50
|
+
|
|
51
|
+
Actual first paragraph.`
|
|
52
|
+
expect(extractFirstParagraph(body)).toBe('Actual first paragraph.')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should skip images', () => {
|
|
56
|
+
const body = `
|
|
57
|
+
|
|
58
|
+
First paragraph after image.`
|
|
59
|
+
expect(extractFirstParagraph(body)).toBe('First paragraph after image.')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should skip list items', () => {
|
|
63
|
+
const body = `- Item 1
|
|
64
|
+
- Item 2
|
|
65
|
+
* Item 3
|
|
66
|
+
1. Numbered item
|
|
67
|
+
|
|
68
|
+
First actual paragraph.`
|
|
69
|
+
expect(extractFirstParagraph(body)).toBe('First actual paragraph.')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('should skip blockquotes', () => {
|
|
73
|
+
const body = `> This is a quote
|
|
74
|
+
> More quote
|
|
75
|
+
|
|
76
|
+
First paragraph.`
|
|
77
|
+
expect(extractFirstParagraph(body)).toBe('First paragraph.')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should skip horizontal rules', () => {
|
|
81
|
+
const body = `---
|
|
82
|
+
|
|
83
|
+
First paragraph.`
|
|
84
|
+
expect(extractFirstParagraph(body)).toBe('First paragraph.')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('should skip HTML comments', () => {
|
|
88
|
+
const body = `<!-- This is a comment -->
|
|
89
|
+
|
|
90
|
+
First paragraph.`
|
|
91
|
+
expect(extractFirstParagraph(body)).toBe('First paragraph.')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('should return undefined for empty content', () => {
|
|
95
|
+
expect(extractFirstParagraph('')).toBeUndefined()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('should return undefined for content with only headings', () => {
|
|
99
|
+
const body = `# Heading 1
|
|
100
|
+
## Heading 2`
|
|
101
|
+
expect(extractFirstParagraph(body)).toBeUndefined()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('should return undefined for content with only non-paragraph elements', () => {
|
|
105
|
+
const body = `- List item
|
|
106
|
+
- Another item
|
|
107
|
+
> Quote
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
\`\`\`code\`\`\``
|
|
112
|
+
expect(extractFirstParagraph(body)).toBeUndefined()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('should handle content starting with paragraph directly', () => {
|
|
116
|
+
const body = `Direct paragraph without preceding newlines.
|
|
117
|
+
|
|
118
|
+
Second paragraph.`
|
|
119
|
+
expect(extractFirstParagraph(body)).toBe(
|
|
120
|
+
'Direct paragraph without preceding newlines.',
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('should handle mixed markdown elements before paragraph', () => {
|
|
125
|
+
const body = `# Title
|
|
126
|
+
|
|
127
|
+

|
|
128
|
+
|
|
129
|
+
> Quote
|
|
130
|
+
|
|
131
|
+
- List
|
|
132
|
+
|
|
133
|
+
Finally, the first paragraph.`
|
|
134
|
+
expect(extractFirstParagraph(body)).toBe('Finally, the first paragraph.')
|
|
135
|
+
})
|
|
136
|
+
})
|
package/src/fallbacks.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for fallback values in documentation pages.
|
|
3
|
+
* Used when frontmatter fields are not explicitly provided.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Extracts the first paragraph from markdown content.
|
|
8
|
+
* Used as a fallback for the description field when not provided in frontmatter.
|
|
9
|
+
*
|
|
10
|
+
* @param body - The markdown content body (without frontmatter)
|
|
11
|
+
* @returns The first paragraph text, or undefined if no paragraph found
|
|
12
|
+
*/
|
|
13
|
+
export const extractFirstParagraph = (body: string): string | undefined => {
|
|
14
|
+
// Skip headings (lines starting with `#`)
|
|
15
|
+
// Return first non-empty paragraph text
|
|
16
|
+
const lines = body.split('\n')
|
|
17
|
+
const paragraphLines: string[] = []
|
|
18
|
+
let inCodeBlock = false
|
|
19
|
+
|
|
20
|
+
for (const line of lines) {
|
|
21
|
+
const trimmed = line.trim()
|
|
22
|
+
|
|
23
|
+
// Track code block state
|
|
24
|
+
if (trimmed.startsWith('```')) {
|
|
25
|
+
inCodeBlock = !inCodeBlock
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Skip content inside code blocks
|
|
30
|
+
if (inCodeBlock) {
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Skip empty lines before we've started collecting
|
|
35
|
+
if (!trimmed) {
|
|
36
|
+
if (paragraphLines.length > 0) {
|
|
37
|
+
// End of first paragraph
|
|
38
|
+
break
|
|
39
|
+
}
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Skip headings
|
|
44
|
+
if (trimmed.startsWith('#')) {
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Skip images
|
|
49
|
+
if (trimmed.startsWith('![')) {
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Skip HTML comments
|
|
54
|
+
if (trimmed.startsWith('<!--')) {
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Skip list items (they're not paragraphs)
|
|
59
|
+
if (
|
|
60
|
+
trimmed.startsWith('-') ||
|
|
61
|
+
trimmed.startsWith('*') ||
|
|
62
|
+
/^\d+\./.test(trimmed)
|
|
63
|
+
) {
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Skip blockquotes
|
|
68
|
+
if (trimmed.startsWith('>')) {
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Skip horizontal rules
|
|
73
|
+
if (/^[-*_]{3,}$/.test(trimmed)) {
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// This is part of a paragraph
|
|
78
|
+
paragraphLines.push(trimmed)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return paragraphLines.length > 0 ? paragraphLines.join(' ') : undefined
|
|
82
|
+
}
|