@levino/shipyard-base 0.9.2 → 0.9.3-rc-20260926183647

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levino/shipyard-base",
3
- "version": "0.9.2",
3
+ "version": "0.9.3-rc-20260926183647",
4
4
  "description": "Core layouts, components, and configuration for shipyard - a composable page builder for Astro",
5
5
  "keywords": [
6
6
  "astro",
@@ -60,20 +60,21 @@
60
60
  "remark-html": "^16.0.1",
61
61
  "tailwindcss": "^4",
62
62
  "typescript": "^7.0.2",
63
- "vite": "^8.2.2",
64
- "vitest": "^4.1.11"
63
+ "vite": "^8.3.1",
64
+ "vitest": "^5.0.2"
65
65
  },
66
66
  "dependencies": {
67
- "@astrojs/markdown-remark": "^7.2.4",
68
- "clsx": "^2.1.0",
69
- "mdast-util-directive": "^3.0.0",
67
+ "@astrojs/markdown-remark": "^7.3.1",
68
+ "@astrojs/markdown-satteri": "^0.4.2",
69
+ "clsx": "^2.1.1",
70
+ "mdast-util-directive": "^3.1.0",
70
71
  "mdast-util-to-hast": "^13.2.1",
71
- "micromark-extension-directive": "^3.0.0",
72
+ "micromark-extension-directive": "^4.0.0",
72
73
  "ramda": "^0.32.0",
73
- "remark-directive": "^3.0.0",
74
+ "remark-directive": "^4.0.0",
74
75
  "remark-parse": "^11.0.0",
75
- "tailwind-merge": "^2.2.0",
76
- "unified": "^11.0.0",
77
- "unist-util-visit": "^5.0.0"
76
+ "tailwind-merge": "^3.7.0",
77
+ "unified": "^11.0.5",
78
+ "unist-util-visit": "^5.1.0"
78
79
  }
79
80
  }
package/src/index.ts CHANGED
@@ -1,9 +1,6 @@
1
1
  import { fileURLToPath } from 'node:url'
2
- import { isUnifiedProcessor, unified } from '@astrojs/markdown-remark'
3
2
  import type { AstroIntegration } from 'astro'
4
- import { remarkAdmonitions } from './remark/remarkAdmonitions'
5
- import { remarkBlockDirective } from './remark/remarkBlockDirective'
6
- import { remarkNpm2Yarn } from './remark/remarkNpm2Yarn'
3
+ import { shipyardMarkdownProcessor } from './markdownProcessor'
7
4
  import type { Config } from './schemas/config'
8
5
  import { checkLinks, reportBrokenLinks } from './tools/linkChecker'
9
6
 
@@ -46,38 +43,13 @@ export default (config: Config): AstroIntegration => {
46
43
  [shipyardCssId]: config.css ? `import '${config.css}';` : '',
47
44
  } as Record<string, string | undefined>
48
45
 
49
- // Astro 7 renders Markdown with Sätteri by default, which does not run
50
- // unified plugins. shipyard's admonitions, npm2yarn tabs and block
51
- // directives are all remark plugins, so we pin the unified processor
52
- // from @astrojs/markdown-remark. Any plugins the user already put on
53
- // their own `unified()` processor are carried over so both sets run;
54
- // plugins they pass via the deprecated `markdown.remarkPlugins` option
55
- // are appended by Astro afterwards.
56
- const userProcessor = astroConfig.markdown?.processor
57
- const inherited =
58
- userProcessor && isUnifiedProcessor(userProcessor)
59
- ? userProcessor.options
60
- : undefined
61
-
62
- if (userProcessor && !inherited) {
63
- logger.warn(
64
- `\`markdown.processor\` is set to \`${userProcessor.name}\`, which does not run remark plugins. ` +
65
- 'Overriding it with `unified()` so shipyard admonitions, npm2yarn tabs and block directives keep working.',
66
- )
67
- }
46
+ const processor = shipyardMarkdownProcessor(
47
+ astroConfig.markdown.processor,
48
+ logger,
49
+ )
68
50
 
69
51
  updateConfig({
70
- markdown: {
71
- processor: unified({
72
- ...inherited,
73
- remarkPlugins: [
74
- ...(inherited?.remarkPlugins ?? []),
75
- remarkBlockDirective,
76
- remarkAdmonitions,
77
- remarkNpm2Yarn,
78
- ],
79
- }),
80
- },
52
+ ...(processor && { markdown: { processor } }),
81
53
  vite: {
82
54
  plugins: [
83
55
  {
@@ -1,6 +1,12 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
1
4
  import type { UnifiedResolvedOptions } from '@astrojs/markdown-remark'
2
5
  import { unified } from '@astrojs/markdown-remark'
3
- import { describe, expect, test, vi } from 'vitest'
6
+ import { satteri } from '@astrojs/markdown-satteri'
7
+ import type { AstroConfig, AstroInlineConfig } from 'astro'
8
+ import { sync } from 'astro'
9
+ import { afterEach, describe, expect, test, vi } from 'vitest'
4
10
  import shipyard from './index'
5
11
  import type { Config } from './schemas/config'
6
12
 
@@ -23,8 +29,11 @@ const baseConfig: Config = {
23
29
  /**
24
30
  * Runs the integration's `astro:config:setup` hook against a fake Astro config
25
31
  * and returns whatever it passed to `updateConfig`, plus the logger it used.
32
+ * Without a processor, the config gets Astro's default, as resolution does.
26
33
  */
27
- const runConfigSetup = (astroConfig: Record<string, unknown> = {}) => {
34
+ const runConfigSetup = (
35
+ processor: { name: string; options: object } = satteri(),
36
+ ) => {
28
37
  const updates: ConfigUpdate[] = []
29
38
  const logger = { warn: vi.fn(), info: vi.fn(), error: vi.fn() }
30
39
 
@@ -34,7 +43,7 @@ const runConfigSetup = (astroConfig: Record<string, unknown> = {}) => {
34
43
 
35
44
  hook({
36
45
  updateConfig: (update: ConfigUpdate) => updates.push(update),
37
- config: astroConfig,
46
+ config: { markdown: { processor } },
38
47
  logger,
39
48
  } as never)
40
49
 
@@ -42,13 +51,14 @@ const runConfigSetup = (astroConfig: Record<string, unknown> = {}) => {
42
51
  }
43
52
 
44
53
  describe('markdown processor', () => {
45
- test('pins the unified processor so remark plugins run under Astro 7', () => {
46
- const { update } = runConfigSetup()
54
+ test("replaces Astro's default processor with unified, without a warning", () => {
55
+ const { update, logger } = runConfigSetup()
47
56
 
48
57
  expect(update?.markdown?.processor?.name).toBe(unified().name)
49
58
  expect(update?.markdown?.processor?.options.remarkPlugins).toHaveLength(
50
59
  SHIPYARD_PLUGIN_COUNT,
51
60
  )
61
+ expect(logger.warn).not.toHaveBeenCalled()
52
62
  })
53
63
 
54
64
  test('does not use the deprecated markdown.remarkPlugins option', () => {
@@ -59,9 +69,9 @@ describe('markdown processor', () => {
59
69
 
60
70
  test("keeps the user's own plugins on an existing unified processor", () => {
61
71
  const userPlugin = () => undefined
62
- const { update, logger } = runConfigSetup({
63
- markdown: { processor: unified({ remarkPlugins: [userPlugin] }) },
64
- })
72
+ const { update, logger } = runConfigSetup(
73
+ unified({ remarkPlugins: [userPlugin] }),
74
+ )
65
75
 
66
76
  const plugins = update?.markdown?.processor?.options.remarkPlugins
67
77
  expect(plugins).toHaveLength(SHIPYARD_PLUGIN_COUNT + 1)
@@ -70,24 +80,90 @@ describe('markdown processor', () => {
70
80
  })
71
81
 
72
82
  test("carries over the user's other unified options", () => {
73
- const { update } = runConfigSetup({
74
- markdown: { processor: unified({ gfm: false, smartypants: false }) },
75
- })
83
+ const { update } = runConfigSetup(
84
+ unified({ gfm: false, smartypants: false }),
85
+ )
76
86
 
77
87
  expect(update?.markdown?.processor?.options.gfm).toBe(false)
78
88
  expect(update?.markdown?.processor?.options.smartypants).toBe(false)
79
89
  })
80
90
 
81
- test('warns and overrides when a non-unified processor is configured', () => {
82
- const { update, logger } = runConfigSetup({
83
- markdown: { processor: { name: 'satteri', options: {} } },
84
- })
91
+ test('leaves a configured satteri processor alone and warns', () => {
92
+ const { update, logger } = runConfigSetup(
93
+ satteri({ features: { directive: true } }),
94
+ )
85
95
 
96
+ expect(update).not.toHaveProperty('markdown')
86
97
  expect(logger.warn).toHaveBeenCalledOnce()
87
98
  expect(logger.warn.mock.calls[0]?.[0]).toContain('satteri')
88
- expect(update?.markdown?.processor?.name).toBe(unified().name)
89
- expect(update?.markdown?.processor?.options.remarkPlugins).toHaveLength(
90
- SHIPYARD_PLUGIN_COUNT,
99
+ })
100
+
101
+ test('leaves any other configured processor alone and warns', () => {
102
+ const { update, logger } = runConfigSetup({ name: 'custom', options: {} })
103
+
104
+ expect(update).not.toHaveProperty('markdown')
105
+ expect(logger.warn).toHaveBeenCalledOnce()
106
+ expect(logger.warn.mock.calls[0]?.[0]).toContain('custom')
107
+ })
108
+ })
109
+
110
+ describe('markdown processor with the real Astro config', () => {
111
+ let root: string | undefined
112
+
113
+ afterEach(async () => {
114
+ vi.restoreAllMocks()
115
+ if (root) await rm(root, { recursive: true, force: true })
116
+ })
117
+
118
+ const resolveWithAstro = async (
119
+ configured?: NonNullable<AstroInlineConfig['markdown']>['processor'],
120
+ ) => {
121
+ root = await mkdtemp(join(tmpdir(), 'shipyard-markdown-'))
122
+ // Astro's console logger sends warnings to `console.info`, errors to `console.error`.
123
+ const output = [
124
+ vi.spyOn(console, 'info').mockImplementation(() => {}),
125
+ vi.spyOn(console, 'error').mockImplementation(() => {}),
126
+ ]
127
+ let processor: AstroConfig['markdown']['processor'] | undefined
128
+
129
+ await sync({
130
+ root,
131
+ markdown: configured ? { processor: configured } : {},
132
+ logger: { entrypoint: 'astro/logger/console' },
133
+ integrations: [
134
+ shipyard(baseConfig),
135
+ {
136
+ name: 'processor-probe',
137
+ hooks: {
138
+ 'astro:config:done': ({ config }) => {
139
+ processor = config.markdown.processor
140
+ },
141
+ },
142
+ },
143
+ ],
144
+ })
145
+
146
+ const shipyardWarnings = output
147
+ .flatMap((spy) => spy.mock.calls)
148
+ .map(([message]) => String(message))
149
+ .filter((message) => /\[(WARN|ERROR)\] \[shipyard\]/.test(message))
150
+ return { processor, shipyardWarnings }
151
+ }
152
+
153
+ test('a site without markdown.processor builds warning-free on unified', async () => {
154
+ const { processor, shipyardWarnings } = await resolveWithAstro()
155
+
156
+ expect(shipyardWarnings).toEqual([])
157
+ expect(processor?.name).toBe(unified().name)
158
+ })
159
+
160
+ test('an explicitly configured satteri processor survives', async () => {
161
+ const { processor, shipyardWarnings } = await resolveWithAstro(
162
+ satteri({ features: { directive: true } }),
91
163
  )
164
+
165
+ expect(processor?.name).toBe(satteri().name)
166
+ expect(shipyardWarnings).toHaveLength(1)
167
+ expect(shipyardWarnings[0]).toContain('`satteri`')
92
168
  })
93
169
  })
@@ -0,0 +1,65 @@
1
+ import { isDeepStrictEqual } from 'node:util'
2
+ import { isUnifiedProcessor, unified } from '@astrojs/markdown-remark'
3
+ import { satteri } from '@astrojs/markdown-satteri'
4
+ import type { AstroConfig, AstroIntegrationLogger } from 'astro'
5
+ import { remarkAdmonitions } from './remark/remarkAdmonitions'
6
+ import { remarkBlockDirective } from './remark/remarkBlockDirective'
7
+ import { remarkNpm2Yarn } from './remark/remarkNpm2Yarn'
8
+
9
+ type MarkdownProcessor = AstroConfig['markdown']['processor']
10
+
11
+ const shipyardRemarkPlugins = [
12
+ remarkBlockDirective,
13
+ remarkAdmonitions,
14
+ remarkNpm2Yarn,
15
+ ]
16
+
17
+ // Astro fills in a fresh `satteri()` when `markdown.processor` is unset, so
18
+ // the resolved config cannot tell that default apart from an explicit bare
19
+ // `satteri()`. Both render identically, so both count as "not chosen".
20
+ const isAstroDefault = (processor: MarkdownProcessor) => {
21
+ const defaultProcessor = satteri()
22
+ return (
23
+ processor.name === defaultProcessor.name &&
24
+ isDeepStrictEqual(processor.options, defaultProcessor.options)
25
+ )
26
+ }
27
+
28
+ /**
29
+ * shipyard's admonitions, npm2yarn tabs and block directives are remark
30
+ * plugins, and only the `unified()` processor runs those.
31
+ *
32
+ * - Astro's default processor is replaced with `unified()` carrying the
33
+ * shipyard plugins.
34
+ * - A `unified()` processor the user configured keeps all its options; the
35
+ * shipyard plugins are appended to its own.
36
+ * - Any other processor the user configured is left untouched, with a warning
37
+ * that the shipyard Markdown features will not render.
38
+ *
39
+ * Returns the processor to set, or `undefined` to leave the config alone.
40
+ */
41
+ export const shipyardMarkdownProcessor = (
42
+ current: MarkdownProcessor,
43
+ logger: AstroIntegrationLogger,
44
+ ): MarkdownProcessor | undefined => {
45
+ if (isUnifiedProcessor(current)) {
46
+ return unified({
47
+ ...current.options,
48
+ remarkPlugins: [
49
+ ...current.options.remarkPlugins,
50
+ ...shipyardRemarkPlugins,
51
+ ],
52
+ })
53
+ }
54
+
55
+ if (isAstroDefault(current)) {
56
+ return unified({ remarkPlugins: shipyardRemarkPlugins })
57
+ }
58
+
59
+ logger.warn(
60
+ `\`markdown.processor\` is set to \`${current.name}\`, which does not run remark plugins, ` +
61
+ 'so shipyard admonitions, npm2yarn tabs and block directives will not render. ' +
62
+ 'Use `unified()` from `@astrojs/markdown-remark` to enable them.',
63
+ )
64
+ return undefined
65
+ }
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
+ import type { AstroIntegrationLogger } from 'astro'
4
5
  import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
5
6
  import type { LinkCheckResult } from './linkChecker'
6
7
  import { checkLinks, reportBrokenLinks } from './linkChecker'
@@ -168,15 +169,18 @@ describe('checkLinks', () => {
168
169
  })
169
170
 
170
171
  describe('reportBrokenLinks', () => {
171
- const createMockLogger = () => ({
172
- info: vi.fn(),
173
- warn: vi.fn(),
174
- error: vi.fn(),
175
- debug: vi.fn(),
176
- label: 'test',
177
- options: {},
178
- fork: vi.fn(),
179
- })
172
+ const createMockLogger = () => {
173
+ const mock = {
174
+ info: vi.fn(),
175
+ warn: vi.fn(),
176
+ error: vi.fn(),
177
+ debug: vi.fn(),
178
+ label: 'test',
179
+ options: {},
180
+ fork: vi.fn(),
181
+ }
182
+ return mock as typeof mock & AstroIntegrationLogger
183
+ }
180
184
 
181
185
  test('logs success message when no broken links and action is not ignore', () => {
182
186
  const logger = createMockLogger()
@@ -186,7 +190,7 @@ describe('reportBrokenLinks', () => {
186
190
  brokenLinks: [],
187
191
  }
188
192
 
189
- reportBrokenLinks(result, 'warn', logger as any)
193
+ reportBrokenLinks(result, 'warn', logger)
190
194
 
191
195
  expect(logger.info).toHaveBeenCalledWith(
192
196
  'Link check passed: 10 links verified',
@@ -204,7 +208,7 @@ describe('reportBrokenLinks', () => {
204
208
  ],
205
209
  }
206
210
 
207
- reportBrokenLinks(result, 'ignore', logger as any)
211
+ reportBrokenLinks(result, 'ignore', logger)
208
212
 
209
213
  expect(logger.info).not.toHaveBeenCalled()
210
214
  expect(logger.warn).not.toHaveBeenCalled()
@@ -218,7 +222,7 @@ describe('reportBrokenLinks', () => {
218
222
  brokenLinks: [{ sourceFile: 'index.html', href: '/broken', line: 1 }],
219
223
  }
220
224
 
221
- reportBrokenLinks(result, 'log', logger as any)
225
+ reportBrokenLinks(result, 'log', logger)
222
226
 
223
227
  expect(logger.info).toHaveBeenCalled()
224
228
  const message = logger.info.mock.calls[0]?.[0]
@@ -235,7 +239,7 @@ describe('reportBrokenLinks', () => {
235
239
  brokenLinks: [{ sourceFile: 'index.html', href: '/broken', line: 1 }],
236
240
  }
237
241
 
238
- reportBrokenLinks(result, 'warn', logger as any)
242
+ reportBrokenLinks(result, 'warn', logger)
239
243
 
240
244
  expect(logger.warn).toHaveBeenCalled()
241
245
  const message = logger.warn.mock.calls[0]?.[0]
@@ -250,7 +254,7 @@ describe('reportBrokenLinks', () => {
250
254
  brokenLinks: [{ sourceFile: 'index.html', href: '/broken', line: 1 }],
251
255
  }
252
256
 
253
- expect(() => reportBrokenLinks(result, 'throw', logger as any)).toThrow(
257
+ expect(() => reportBrokenLinks(result, 'throw', logger)).toThrow(
254
258
  'Broken links found',
255
259
  )
256
260
  })
@@ -267,7 +271,7 @@ describe('reportBrokenLinks', () => {
267
271
  ],
268
272
  }
269
273
 
270
- reportBrokenLinks(result, 'warn', logger as any)
274
+ reportBrokenLinks(result, 'warn', logger)
271
275
 
272
276
  const message = logger.warn.mock.calls[0]?.[0]
273
277
  expect(message).toContain('Found 3 broken links')
package/src/virtual.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  declare module 'virtual:shipyard/config' {
2
2
  import type { Config } from './schemas/config'
3
+
3
4
  const config: Config
4
5
  export default config
5
6
  }