@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.
@@ -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
  */
@@ -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?: string
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?: string
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
- {props.image && (
135
+ {ogImageData && ogImageUrl && (
89
136
  <>
90
- <meta property="og:image" content={props.image} />
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={props.image} />
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.0",
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.0.0",
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: [remarkDirective, remarkAdmonitions, remarkNpm2Yarn],
50
+ remarkPlugins: [
51
+ remarkBlockDirective,
52
+ remarkAdmonitions,
53
+ remarkNpm2Yarn,
54
+ ],
51
55
  },
52
56
  vite: {
53
57
  plugins: [
@@ -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
@@ -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.