@levino/shipyard-base 0.8.0 → 0.8.1-rc-20260509120402
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/layouts/Markdown.astro +5 -2
- package/astro/layouts/Page.astro +67 -5
- package/package.json +4 -3
- package/src/index.ts +6 -2
- package/src/remark/index.ts +1 -0
- package/src/remark/remarkBlockDirective.test.ts +69 -0
- package/src/remark/remarkBlockDirective.ts +35 -0
- package/src/schemas/config.ts +16 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import type { ImageMetadata } from 'astro'
|
|
2
3
|
import type { NavigationTree } from '../../src/schemas/config'
|
|
3
4
|
import Base from './Page.astro'
|
|
4
5
|
|
|
@@ -17,9 +18,11 @@ type Props = {
|
|
|
17
18
|
*/
|
|
18
19
|
keywords?: string[]
|
|
19
20
|
/**
|
|
20
|
-
* Social card/preview image.
|
|
21
|
+
* Social card/preview image. Accepts an absolute URL or, in content
|
|
22
|
+
* collections, an `ImageMetadata` from a relative path. Local images are
|
|
23
|
+
* automatically optimized to 1200×630 JPEG for OG/Twitter cards.
|
|
21
24
|
*/
|
|
22
|
-
image?: string
|
|
25
|
+
image?: string | ImageMetadata
|
|
23
26
|
/**
|
|
24
27
|
* Custom canonical URL.
|
|
25
28
|
*/
|
package/astro/layouts/Page.astro
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
import 'virtual:shipyard/css'
|
|
3
|
+
import { getImage } from 'astro:assets'
|
|
3
4
|
import { i18n } from 'astro:config/server'
|
|
4
5
|
import config from 'virtual:shipyard/config'
|
|
6
|
+
import type { ImageMetadata } from 'astro'
|
|
5
7
|
import { mapObjIndexed } from 'ramda'
|
|
6
8
|
import type {
|
|
7
9
|
NavigationEntry,
|
|
@@ -24,12 +26,14 @@ type CustomMetaTag = {
|
|
|
24
26
|
content: string
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
type PageImage = string | ImageMetadata
|
|
30
|
+
|
|
27
31
|
type Props = {
|
|
28
32
|
frontmatter?: {
|
|
29
33
|
title?: string
|
|
30
34
|
description?: string
|
|
31
35
|
keywords?: string[]
|
|
32
|
-
image?:
|
|
36
|
+
image?: PageImage
|
|
33
37
|
canonicalUrl?: string
|
|
34
38
|
customMetaTags?: CustomMetaTag[]
|
|
35
39
|
sidebarNavigation?: NavigationTree
|
|
@@ -38,7 +42,7 @@ type Props = {
|
|
|
38
42
|
title?: string
|
|
39
43
|
description?: string
|
|
40
44
|
keywords?: string[]
|
|
41
|
-
image?:
|
|
45
|
+
image?: PageImage
|
|
42
46
|
canonicalUrl?: string
|
|
43
47
|
customMetaTags?: CustomMetaTag[]
|
|
44
48
|
sidebarNavigation?: NavigationTree
|
|
@@ -47,6 +51,49 @@ type Props = {
|
|
|
47
51
|
const currentPath = Astro.url.pathname
|
|
48
52
|
const props = Astro.props.frontmatter || Astro.props
|
|
49
53
|
|
|
54
|
+
const isImageMetadata = (value: PageImage): value is ImageMetadata =>
|
|
55
|
+
typeof value === 'object' && value !== null && 'src' in value
|
|
56
|
+
|
|
57
|
+
const toAbsoluteUrl = (url: string) => {
|
|
58
|
+
if (/^https?:\/\//i.test(url)) return url
|
|
59
|
+
if (!Astro.site) return url
|
|
60
|
+
return new URL(url, Astro.site).href
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type OgImageData = {
|
|
64
|
+
src: string
|
|
65
|
+
width?: number
|
|
66
|
+
height?: number
|
|
67
|
+
type?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const resolveOgImage = async (
|
|
71
|
+
value: PageImage | undefined,
|
|
72
|
+
): Promise<OgImageData | undefined> => {
|
|
73
|
+
if (!value) return undefined
|
|
74
|
+
if (typeof value === 'string') return { src: value }
|
|
75
|
+
if (!isImageMetadata(value)) return undefined
|
|
76
|
+
// 1200x630 JPEG keeps OG cards under ~200 KB so WhatsApp/Telegram fetch the
|
|
77
|
+
// preview; re-encoding via Sharp also strips EXIF.
|
|
78
|
+
const optimized = await getImage({
|
|
79
|
+
src: value,
|
|
80
|
+
width: 1200,
|
|
81
|
+
height: 630,
|
|
82
|
+
format: 'jpeg',
|
|
83
|
+
fit: 'cover',
|
|
84
|
+
})
|
|
85
|
+
return {
|
|
86
|
+
src: optimized.src,
|
|
87
|
+
width: 1200,
|
|
88
|
+
height: 630,
|
|
89
|
+
type: 'image/jpeg',
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const ogImageData = await resolveOgImage(props.image ?? config.defaultImage)
|
|
94
|
+
const ogImageUrl = ogImageData ? toAbsoluteUrl(ogImageData.src) : undefined
|
|
95
|
+
const ogImageAlt = props.description
|
|
96
|
+
|
|
50
97
|
const withLocale = (path: string) =>
|
|
51
98
|
i18n ? `/${Astro.currentLocale}${path}` : path
|
|
52
99
|
const applyLocaleAndSetActive: (nav: NavigationTree) => NavigationTree =
|
|
@@ -85,11 +132,26 @@ const renderScriptAttributes = (script: Script) => {
|
|
|
85
132
|
)}
|
|
86
133
|
<meta property="og:title" content={title} />
|
|
87
134
|
<meta property="og:description" content={props.description} />
|
|
88
|
-
{
|
|
135
|
+
{ogImageData && ogImageUrl && (
|
|
89
136
|
<>
|
|
90
|
-
<meta property="og:image" content={
|
|
137
|
+
<meta property="og:image" content={ogImageUrl} />
|
|
138
|
+
{ogImageData.width && (
|
|
139
|
+
<meta property="og:image:width" content={String(ogImageData.width)} />
|
|
140
|
+
)}
|
|
141
|
+
{ogImageData.height && (
|
|
142
|
+
<meta property="og:image:height" content={String(ogImageData.height)} />
|
|
143
|
+
)}
|
|
144
|
+
{ogImageData.type && (
|
|
145
|
+
<meta property="og:image:type" content={ogImageData.type} />
|
|
146
|
+
)}
|
|
147
|
+
{ogImageAlt && (
|
|
148
|
+
<meta property="og:image:alt" content={ogImageAlt} />
|
|
149
|
+
)}
|
|
91
150
|
<meta name="twitter:card" content="summary_large_image" />
|
|
92
|
-
<meta name="twitter:image" content={
|
|
151
|
+
<meta name="twitter:image" content={ogImageUrl} />
|
|
152
|
+
{ogImageAlt && (
|
|
153
|
+
<meta name="twitter:image:alt" content={ogImageAlt} />
|
|
154
|
+
)}
|
|
93
155
|
</>
|
|
94
156
|
)}
|
|
95
157
|
{props.canonicalUrl && (
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@levino/shipyard-base",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1-rc-20260509120402",
|
|
4
4
|
"description": "Core layouts, components, and configuration for shipyard - a composable page builder for Astro",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"@tailwindcss/typography": "^0.5.10",
|
|
42
|
-
"astro": "^6.
|
|
42
|
+
"astro": "^6.1.6",
|
|
43
43
|
"daisyui": "^5",
|
|
44
44
|
"tailwindcss": "^4"
|
|
45
45
|
},
|
|
@@ -60,12 +60,13 @@
|
|
|
60
60
|
"remark-html": "^16.0.1",
|
|
61
61
|
"tailwindcss": "^4",
|
|
62
62
|
"typescript": "^5.2.2",
|
|
63
|
-
"vite": "^7",
|
|
63
|
+
"vite": "^7.3.2",
|
|
64
64
|
"vitest": "^4.1.0"
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
67
|
"clsx": "^2.1.0",
|
|
68
68
|
"mdast-util-directive": "^3.0.0",
|
|
69
|
+
"micromark-extension-directive": "^3.0.0",
|
|
69
70
|
"ramda": "^0.31",
|
|
70
71
|
"remark-directive": "^3.0.0",
|
|
71
72
|
"tailwind-merge": "^2.2.0",
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { fileURLToPath } from 'node:url'
|
|
2
2
|
import type { AstroIntegration } from 'astro'
|
|
3
|
-
import remarkDirective from 'remark-directive'
|
|
4
3
|
import { remarkAdmonitions } from './remark/remarkAdmonitions'
|
|
4
|
+
import { remarkBlockDirective } from './remark/remarkBlockDirective'
|
|
5
5
|
import { remarkNpm2Yarn } from './remark/remarkNpm2Yarn'
|
|
6
6
|
import type { Config } from './schemas/config'
|
|
7
7
|
import { checkLinks, reportBrokenLinks } from './tools/linkChecker'
|
|
@@ -47,7 +47,11 @@ export default (config: Config): AstroIntegration => {
|
|
|
47
47
|
|
|
48
48
|
updateConfig({
|
|
49
49
|
markdown: {
|
|
50
|
-
remarkPlugins: [
|
|
50
|
+
remarkPlugins: [
|
|
51
|
+
remarkBlockDirective,
|
|
52
|
+
remarkAdmonitions,
|
|
53
|
+
remarkNpm2Yarn,
|
|
54
|
+
],
|
|
51
55
|
},
|
|
52
56
|
vite: {
|
|
53
57
|
plugins: [
|
package/src/remark/index.ts
CHANGED
|
@@ -9,4 +9,5 @@
|
|
|
9
9
|
// Re-export remark-directive for convenience
|
|
10
10
|
export { default as remarkDirective } from 'remark-directive'
|
|
11
11
|
export { type AdmonitionType, remarkAdmonitions } from './remarkAdmonitions'
|
|
12
|
+
export { remarkBlockDirective } from './remarkBlockDirective'
|
|
12
13
|
export { remarkNpm2Yarn } from './remarkNpm2Yarn'
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { remark } from 'remark'
|
|
2
|
+
import remarkHtml from 'remark-html'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { remarkAdmonitions } from './remarkAdmonitions'
|
|
5
|
+
import { remarkBlockDirective } from './remarkBlockDirective'
|
|
6
|
+
|
|
7
|
+
const process = async (markdown: string): Promise<string> => {
|
|
8
|
+
const result = await remark()
|
|
9
|
+
.use(remarkBlockDirective)
|
|
10
|
+
.use(remarkAdmonitions)
|
|
11
|
+
.use(remarkHtml, { sanitize: false })
|
|
12
|
+
.process(markdown)
|
|
13
|
+
return String(result)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('remarkBlockDirective', () => {
|
|
17
|
+
describe('German gender colon', () => {
|
|
18
|
+
it('does not split paragraphs containing :innen', async () => {
|
|
19
|
+
const input =
|
|
20
|
+
'herzlicher Dank an die Organisator:innen vom **ADFC** und an alle Ordner:innen'
|
|
21
|
+
const result = await process(input)
|
|
22
|
+
expect(result).toContain('Organisator:innen')
|
|
23
|
+
expect(result).toContain('Ordner:innen')
|
|
24
|
+
expect(result).not.toContain('<div></div>')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('preserves :innen inside a paragraph as plain text', async () => {
|
|
28
|
+
const input = 'Liebe Anwohner:innen, willkommen.'
|
|
29
|
+
const result = await process(input)
|
|
30
|
+
expect(result).toContain('Liebe Anwohner:innen, willkommen.')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('handles multiple gender colon variants in one paragraph', async () => {
|
|
34
|
+
const input = 'Mitarbeiter:innen, Lehrer:innen und Schüler:innen.'
|
|
35
|
+
const result = await process(input)
|
|
36
|
+
expect(result).toContain(
|
|
37
|
+
'Mitarbeiter:innen, Lehrer:innen und Schüler:innen.',
|
|
38
|
+
)
|
|
39
|
+
expect(result).not.toContain('<div></div>')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('container directives', () => {
|
|
44
|
+
it('still parses :::note admonitions', async () => {
|
|
45
|
+
const input = ':::note\nThis is a note\n:::'
|
|
46
|
+
const result = await process(input)
|
|
47
|
+
expect(result).toContain('admonition')
|
|
48
|
+
expect(result).toContain('admonition-note')
|
|
49
|
+
expect(result).toContain('This is a note')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('still parses :::warning with custom title', async () => {
|
|
53
|
+
const input = ':::warning[Be Careful]\nWatch out\n:::'
|
|
54
|
+
const result = await process(input)
|
|
55
|
+
expect(result).toContain('admonition-warning')
|
|
56
|
+
expect(result).toContain('Be Careful')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('inline text directives', () => {
|
|
61
|
+
it('does not consume :name as a directive', async () => {
|
|
62
|
+
const input = 'Use :foo as a marker.'
|
|
63
|
+
const result = await process(input)
|
|
64
|
+
// Without text-directive support `:foo` stays as plain text.
|
|
65
|
+
expect(result).toContain(':foo')
|
|
66
|
+
expect(result).not.toContain('<div></div>')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Root } from 'mdast'
|
|
2
|
+
import { directiveFromMarkdown } from 'mdast-util-directive'
|
|
3
|
+
import { directive } from 'micromark-extension-directive'
|
|
4
|
+
import type { Plugin, Processor } from 'unified'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Like `remark-directive`, but only enables block-level directives — container
|
|
8
|
+
* (`:::name`) and leaf (`::name`) — and disables inline text directives
|
|
9
|
+
* (`:name`).
|
|
10
|
+
*
|
|
11
|
+
* Why: the inline text directive syntax conflicts with the German gender
|
|
12
|
+
* colon (e.g. `Organisator:innen`, `Ordner:innen`), which would otherwise be
|
|
13
|
+
* tokenised as a directive and rendered as an empty `<div>`, tearing the
|
|
14
|
+
* surrounding paragraph apart.
|
|
15
|
+
*
|
|
16
|
+
* Block directives stay available so admonitions (`:::note`, `:::warning`)
|
|
17
|
+
* keep working.
|
|
18
|
+
*/
|
|
19
|
+
export const remarkBlockDirective: Plugin<[], Root> = function () {
|
|
20
|
+
const self = this as unknown as Processor<Root>
|
|
21
|
+
const data = self.data()
|
|
22
|
+
|
|
23
|
+
if (!data.micromarkExtensions) {
|
|
24
|
+
data.micromarkExtensions = []
|
|
25
|
+
}
|
|
26
|
+
if (!data.fromMarkdownExtensions) {
|
|
27
|
+
data.fromMarkdownExtensions = []
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { flow } = directive()
|
|
31
|
+
data.micromarkExtensions.push({ flow })
|
|
32
|
+
data.fromMarkdownExtensions.push(directiveFromMarkdown())
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default remarkBlockDirective
|
package/src/schemas/config.ts
CHANGED
|
@@ -201,6 +201,22 @@ export interface Config {
|
|
|
201
201
|
navigation: NavigationTree
|
|
202
202
|
title: string
|
|
203
203
|
tagline: string
|
|
204
|
+
/**
|
|
205
|
+
* Default social preview image URL used as a fallback for pages that don't
|
|
206
|
+
* specify their own `image` in frontmatter.
|
|
207
|
+
*
|
|
208
|
+
* Accepts an absolute URL (e.g. `https://example.com/og.jpg`) or a path
|
|
209
|
+
* relative to the site root (e.g. `/og-default.jpg` for a file in `public/`).
|
|
210
|
+
* The path is resolved against `site` from `astro.config.*` to produce an
|
|
211
|
+
* absolute URL in the emitted `og:image` / `twitter:image` meta tags.
|
|
212
|
+
*
|
|
213
|
+
* For best results across Facebook, LinkedIn, Twitter and chat clients
|
|
214
|
+
* (WhatsApp, Telegram), use a 1200×630 JPEG under ~200 KB.
|
|
215
|
+
*
|
|
216
|
+
* @example '/og-default.jpg'
|
|
217
|
+
* @example 'https://example.com/og.jpg'
|
|
218
|
+
*/
|
|
219
|
+
defaultImage?: string
|
|
204
220
|
/**
|
|
205
221
|
* Path to your app's CSS entry point.
|
|
206
222
|
* This file should set up Tailwind CSS with @source directives for shipyard packages.
|