@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.
@@ -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: { render: shouldBeRendered, label },
87
- sidebar_position,
88
- sidebar_label,
89
- sidebar_class_name,
90
- sidebar_custom_props,
91
- pagination_next,
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: shouldBeRendered,
109
- sidebarPosition: sidebar_position,
110
- sidebarLabel: sidebar_label,
111
- sidebarClassName: sidebar_class_name,
112
- sidebarCustomProps: sidebar_custom_props,
113
- pagination_next,
114
- pagination_prev,
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="col-span-12 xl:col-span-9">
150
- <div class="prose max-w-none">
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
- <div class="hidden xl:block col-span-3">
159
- <div class="sticky top-20 max-h-[calc(100vh-6rem)] overflow-y-auto">
160
- <TableOfContents links={headings} desktopOnly />
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
- </div>
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.5.2",
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
- "author": "",
12
- "license": "ISC",
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.5.11"
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 = `![Alt text](image.png)
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
+ ![Image](img.png)
128
+
129
+ > Quote
130
+
131
+ - List
132
+
133
+ Finally, the first paragraph.`
134
+ expect(extractFirstParagraph(body)).toBe('Finally, the first paragraph.')
135
+ })
136
+ })
@@ -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
+ }