blume 1.6.0 → 1.6.1

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.
Files changed (83) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/cli/index.js +434 -150
  3. package/dist/cli/index.js.map +59 -56
  4. package/dist/types/core/data.d.ts +10 -1
  5. package/dist/types/core/i18n-ui.d.ts +2 -0
  6. package/dist/types/core/schema.d.ts +5 -0
  7. package/dist/types/core/types.d.ts +6 -0
  8. package/dist/types/openapi/references.d.ts +5 -0
  9. package/docs/07-faq.mdx +9 -9
  10. package/docs/advanced/api-reference.mdx +10 -1
  11. package/docs/advanced/custom-pages.mdx +3 -1
  12. package/docs/advanced/graphql.mdx +1 -1
  13. package/docs/configuration/ai.mdx +4 -0
  14. package/docs/configuration/seo.mdx +3 -3
  15. package/docs/configuration/theming.mdx +6 -0
  16. package/docs/content/components.mdx +8 -1
  17. package/package.json +53 -53
  18. package/src/astro/examples.ts +29 -2
  19. package/src/astro/generate.ts +99 -61
  20. package/src/astro/index.ts +7 -0
  21. package/src/astro/markdown-negotiation.ts +1 -1
  22. package/src/astro/runtime-modules.ts +196 -0
  23. package/src/astro/templates.ts +241 -38
  24. package/src/cli/commands/build.ts +7 -1
  25. package/src/cli/commands/dev.ts +6 -3
  26. package/src/cli/host-args.ts +18 -0
  27. package/src/cli/index.ts +2 -1
  28. package/src/components/copy-feedback.ts +93 -9
  29. package/src/components/islands/ask-ai.tsx +4 -1
  30. package/src/components/islands/hooks.ts +3 -1
  31. package/src/components/layout/PageActions.astro +25 -14
  32. package/src/core/data.ts +10 -1
  33. package/src/core/define-components.ts +2 -0
  34. package/src/core/i18n-ui.ts +1 -0
  35. package/src/core/includes.ts +2 -1
  36. package/src/core/manifest.ts +10 -0
  37. package/src/core/schema.ts +13 -5
  38. package/src/core/types.ts +6 -0
  39. package/src/core/ui-packs/ar.ts +1 -0
  40. package/src/core/ui-packs/bg.ts +1 -0
  41. package/src/core/ui-packs/bn.ts +1 -0
  42. package/src/core/ui-packs/ca.ts +1 -0
  43. package/src/core/ui-packs/cs.ts +1 -0
  44. package/src/core/ui-packs/da.ts +1 -0
  45. package/src/core/ui-packs/de.ts +1 -0
  46. package/src/core/ui-packs/el.ts +1 -0
  47. package/src/core/ui-packs/es.ts +1 -0
  48. package/src/core/ui-packs/fa.ts +1 -0
  49. package/src/core/ui-packs/fi.ts +1 -0
  50. package/src/core/ui-packs/fr.ts +1 -0
  51. package/src/core/ui-packs/he.ts +1 -0
  52. package/src/core/ui-packs/hi.ts +1 -0
  53. package/src/core/ui-packs/hr.ts +1 -0
  54. package/src/core/ui-packs/hu.ts +1 -0
  55. package/src/core/ui-packs/id.ts +1 -0
  56. package/src/core/ui-packs/it.ts +1 -0
  57. package/src/core/ui-packs/ja.ts +1 -0
  58. package/src/core/ui-packs/ko.ts +1 -0
  59. package/src/core/ui-packs/nl.ts +1 -0
  60. package/src/core/ui-packs/no.ts +1 -0
  61. package/src/core/ui-packs/pl.ts +1 -0
  62. package/src/core/ui-packs/pt-br.ts +1 -0
  63. package/src/core/ui-packs/pt.ts +1 -0
  64. package/src/core/ui-packs/ro.ts +1 -0
  65. package/src/core/ui-packs/ru.ts +1 -0
  66. package/src/core/ui-packs/sk.ts +1 -0
  67. package/src/core/ui-packs/sr.ts +1 -0
  68. package/src/core/ui-packs/sv.ts +1 -0
  69. package/src/core/ui-packs/th.ts +1 -0
  70. package/src/core/ui-packs/tr.ts +1 -0
  71. package/src/core/ui-packs/uk.ts +1 -0
  72. package/src/core/ui-packs/vi.ts +1 -0
  73. package/src/core/ui-packs/zh-tw.ts +1 -0
  74. package/src/core/ui-packs/zh.ts +1 -0
  75. package/src/core/version-cut.ts +5 -3
  76. package/src/deploy/vercel-negotiation.ts +49 -6
  77. package/src/og/card.ts +1 -1
  78. package/src/openapi/references.ts +8 -0
  79. package/src/openapi/render-mdx.ts +18 -4
  80. package/src/openapi/scalar.ts +0 -4
  81. package/src/registry/eject.ts +36 -17
  82. package/src/theme/entry.ts +2 -2
  83. package/src/theme/sources.ts +49 -0
@@ -177,12 +177,14 @@ export const insertArchivedVersion = async (
177
177
  const indent = text.slice(lineStart).match(/^\s*/u)?.[0] ?? "";
178
178
  const rest = text.slice(insertAt);
179
179
  // Match the array's authored shape: empty stays bare, an inline array gets
180
- // an inline entry, a multiline array gets its own indented line.
180
+ // an inline entry, a multiline array gets its own indented line — on the
181
+ // file's own line ending, so a CRLF config doesn't gain a lone LF.
182
+ const eol = /^\r?\n/u.exec(rest)?.[0];
181
183
  let entry: string;
182
184
  if (rest.trimStart().startsWith("]")) {
183
185
  entry = `{ id: "${id}" }`;
184
- } else if (rest.startsWith("\n")) {
185
- entry = `\n${indent} { id: "${id}" },`;
186
+ } else if (eol) {
187
+ entry = `${eol}${indent} { id: "${id}" },`;
186
188
  } else {
187
189
  entry = `{ id: "${id}" }, `;
188
190
  }
@@ -9,6 +9,9 @@
9
9
  * into it so a content-page request that prefers `text/markdown` is rewritten
10
10
  * (not redirected) to the page's prerendered `.md` mirror — the deployed
11
11
  * counterpart of the dev-server rewrite in `astro/markdown-negotiation.ts`.
12
+ * The same routing config also answers a *missing* page: a request that
13
+ * prefers Markdown (or asks for a `.md` URL no page backs) gets the
14
+ * prerendered Markdown 404 body with the 404 status, instead of the HTML shell.
12
15
  */
13
16
 
14
17
  /**
@@ -49,6 +52,32 @@ const ACCEPT_MARKDOWN_CONDITION: VercelRoute["has"] = [
49
52
 
50
53
  const VARY_ACCEPT = { vary: "Accept" };
51
54
 
55
+ /** Where the prerendered Markdown 404 (`pages/404.md.ts`) lands. */
56
+ const NOT_FOUND_MARKDOWN_DEST = "/404.md";
57
+
58
+ /** The adapter's own not-found fallback — the anchor the Markdown 404 precedes. */
59
+ const NOT_FOUND_HTML_DEST = "/404.html";
60
+
61
+ /**
62
+ * Miss-phase routes that answer a missing page with the Markdown 404 body: any
63
+ * path when the client prefers Markdown, and any `.md`/`.mdx` URL (a request
64
+ * for a raw-Markdown mirror that has no page wants Markdown back, not the HTML
65
+ * shell). Both keep the 404 status. Spliced immediately before the adapter's
66
+ * `/404.html` fallback, so they run after every server route (the MCP
67
+ * endpoint, server islands, images) has had its turn and never hijack a
68
+ * request one of those would have answered.
69
+ */
70
+ const NOT_FOUND_MARKDOWN_ROUTES: readonly VercelRoute[] = [
71
+ {
72
+ dest: NOT_FOUND_MARKDOWN_DEST,
73
+ has: ACCEPT_MARKDOWN_CONDITION,
74
+ headers: VARY_ACCEPT,
75
+ src: "^/.*$",
76
+ status: 404,
77
+ },
78
+ { dest: NOT_FOUND_MARKDOWN_DEST, src: "^/.*\\.mdx?$", status: 404 },
79
+ ];
80
+
52
81
  /**
53
82
  * Vercel rejects route `src` patterns longer than 4096 characters, so route
54
83
  * alternations are split across as many route entries as needed. The budget
@@ -186,12 +215,14 @@ export const TRAILING_SLASH_REDIRECT: VercelRoute = {
186
215
  * user-authored route of that identical shape would be semantically equal to
187
216
  * the one re-added); the homepage `Link` route by its three-field
188
217
  * continue-with-link shape (the Build Output config is adapter-generated, so
189
- * no user-authored route competes in this file).
218
+ * no user-authored route competes in this file); the Markdown 404 routes by
219
+ * their `/404.md` destination.
190
220
  */
191
221
  const isNegotiationRoute = (route: VercelRoute): boolean =>
192
222
  route.has?.some(
193
223
  (condition) => condition.value === ACCEPT_MARKDOWN_HEADER_VALUE
194
224
  ) === true ||
225
+ (route.dest === NOT_FOUND_MARKDOWN_DEST && route.status === 404) ||
195
226
  (route.continue === true &&
196
227
  route.headers?.vary === "Accept" &&
197
228
  isString(route.src) &&
@@ -213,17 +244,21 @@ const isNegotiationRoute = (route: VercelRoute): boolean =>
213
244
  * platform's mechanism for extensionless static files (e.g. the Web Bot Auth
214
245
  * signature directory). The trailing-slash 308 redirect is always spliced in
215
246
  * alongside, so slashed duplicates of every page collapse onto the canonical
216
- * slashless URL. Returns the updated JSON text (tab-indented, like the
217
- * adapter's own output), or `null` when there is nowhere safe to splice: an
218
- * unparsable config, no `routes` array, or no `handle: "filesystem"` marker
219
- * to anchor the splice.
247
+ * slashless URL. With `notFoundMarkdown` (the build emitted `404.md`), the
248
+ * Markdown 404 routes go into the miss phase right before the adapter's
249
+ * `/404.html` fallback — and nowhere when that fallback is absent, since a
250
+ * `dest` with no file behind it would serve nothing. Returns the updated JSON
251
+ * text (tab-indented, like the adapter's own output), or `null` when there is
252
+ * nowhere safe to splice: an unparsable config, no `routes` array, or no
253
+ * `handle: "filesystem"` marker to anchor the splice.
220
254
  */
221
255
  export const injectNegotiationRoutes = (
222
256
  configText: string,
223
257
  routePaths: readonly string[],
224
258
  homeLinkHeader?: string | null,
225
259
  contentTypeOverrides?: Record<string, string>,
226
- homeTokens?: number
260
+ homeTokens?: number,
261
+ notFoundMarkdown = false
227
262
  ): string | null => {
228
263
  const overrideEntries = Object.entries(contentTypeOverrides ?? {});
229
264
  let config: {
@@ -272,6 +307,14 @@ export const injectNegotiationRoutes = (
272
307
  ...rewriteRoutes,
273
308
  TRAILING_SLASH_REDIRECT
274
309
  );
310
+ if (notFoundMarkdown) {
311
+ const fallbackIndex = routes.findIndex(
312
+ (route) => route.status === 404 && route.dest === NOT_FOUND_HTML_DEST
313
+ );
314
+ if (fallbackIndex !== -1) {
315
+ routes.splice(fallbackIndex, 0, ...NOT_FOUND_MARKDOWN_ROUTES);
316
+ }
317
+ }
275
318
  config.routes = routes;
276
319
  return `${JSON.stringify(config, null, "\t")}\n`;
277
320
  };
package/src/og/card.ts CHANGED
@@ -81,7 +81,7 @@ export interface OgCardOptions {
81
81
  accent?: string;
82
82
  /** Brand/site name shown in the top-left lockup. */
83
83
  brand?: string;
84
- /** Muted subtitle under the headline (usually the site description). */
84
+ /** Muted subtitle under the headline (the page description, else the site's). */
85
85
  description?: string;
86
86
  /**
87
87
  * Inlined SVG markup of the configured logo, painted into the brand
@@ -54,6 +54,11 @@ export interface ReferenceSource {
54
54
  includeInSearch: boolean;
55
55
  /** Whether generated pages emit noindex metadata and stay out of the sitemap. */
56
56
  noindex: boolean;
57
+ /**
58
+ * Whether operation meta descriptions end with the generated English
59
+ * "Reference for …" sentence, or carry the spec's own prose alone.
60
+ */
61
+ seoDescriptionSuffix: boolean;
57
62
  /** Local path or `http(s)` URL, verbatim from config. */
58
63
  spec: string;
59
64
  /**
@@ -132,6 +137,7 @@ interface Block {
132
137
  label?: string;
133
138
  noindex: boolean;
134
139
  route?: string;
140
+ seoDescriptionSuffix: boolean;
135
141
  spec: string;
136
142
  }[];
137
143
  spec?: string;
@@ -146,6 +152,7 @@ const sourcesOf = (block: Block): Block["sources"] => {
146
152
  includeInLlms: true,
147
153
  includeInSearch: true,
148
154
  noindex: false,
155
+ seoDescriptionSuffix: true,
149
156
  spec: block.spec,
150
157
  });
151
158
  }
@@ -192,6 +199,7 @@ const referencesFor = (
192
199
  renderer,
193
200
  route,
194
201
  scalar: block.scalar,
202
+ seoDescriptionSuffix: source.seoDescriptionSuffix,
195
203
  slug: routeSlug(route),
196
204
  spec: source.spec,
197
205
  theme: block.theme,
@@ -174,12 +174,23 @@ const GRAPHQL_MEMBER_PHRASES = {
174
174
  /**
175
175
  * The spec's own prose for the operation, followed by the endpoint it documents
176
176
  * — so every operation page carries a distinct, self-describing meta
177
- * description even when the spec's summaries are terse.
177
+ * description even when the spec's summaries are terse. With the suffix
178
+ * switched off (`seoDescriptionSuffix: false`, for sites whose prose isn't
179
+ * English) the description is the prose alone, or the page `title` — a
180
+ * language-neutral `GET /pets`, channel, or field name — when the operation
181
+ * has no prose at all, so no page ships an empty description.
178
182
  */
179
183
  const operationDescription = (
180
184
  spec: ApiSpecData,
181
- operation: ApiOperationRef
185
+ operation: ApiOperationRef,
186
+ options: { suffix: boolean; title: string }
182
187
  ): string => {
188
+ if (!options.suffix) {
189
+ return clip(
190
+ plainProse(operation.description || operation.summary) || options.title,
191
+ META_DESCRIPTION_MAX
192
+ );
193
+ }
183
194
  // AsyncAPI operations act on a channel, not an HTTP endpoint; GraphQL pages
184
195
  // document a root field or a named type.
185
196
  let suffix: string;
@@ -210,7 +221,7 @@ export const operationMdx = (
210
221
  operation: ApiOperationRef,
211
222
  reference?: Pick<
212
223
  ReferenceSource,
213
- "includeInLlms" | "includeInSearch" | "noindex"
224
+ "includeInLlms" | "includeInSearch" | "noindex" | "seoDescriptionSuffix"
214
225
  >
215
226
  ): RenderedPage => {
216
227
  const method = operation.method.toUpperCase();
@@ -242,7 +253,10 @@ export const operationMdx = (
242
253
  searchFlags.exclude = true;
243
254
  }
244
255
  const seo: RenderedPageData["seo"] = {
245
- description: operationDescription(spec, operation),
256
+ description: operationDescription(spec, operation, {
257
+ suffix: reference?.seoDescriptionSuffix !== false,
258
+ title,
259
+ }),
246
260
  };
247
261
  if (reference?.noindex) {
248
262
  seo.noindex = true;
@@ -144,9 +144,6 @@ export const buildReferenceFiles = async (options: {
144
144
  warnings.push(spec.warning);
145
145
  }
146
146
  const pagePath = referencePagePath(ref.route);
147
- // Relative path from the page back to src/generated/data.json: a page one
148
- // directory deep (api/events.astro) needs an extra "../".
149
- const depth = pagePath.split("/").length - 1;
150
147
  files.push({
151
148
  content: scalarReferenceTemplate({
152
149
  configuration: {
@@ -157,7 +154,6 @@ export const buildReferenceFiles = async (options: {
157
154
  // hideTestRequestButton, orderSchemaPropertiesBy, and the rest).
158
155
  ...ref.scalar,
159
156
  },
160
- dataImport: `${"../".repeat(depth + 1)}generated/data.json`,
161
157
  noindex: ref.noindex,
162
158
  route: ref.route,
163
159
  title: ref.label,
@@ -9,7 +9,12 @@ import { buildRawMarkdown } from "../ai/markdown.ts";
9
9
  import { buildMcpData } from "../ai/mcp/data.ts";
10
10
  import { buildMcpDiscovery, buildMcpServerCard } from "../ai/mcp/discovery.ts";
11
11
  import { planComponentSlots } from "../astro/component-slots.ts";
12
- import { discoverExamples, exampleMarkdownLookup } from "../astro/examples.ts";
12
+ import {
13
+ EXAMPLE_SCAN_GLOB,
14
+ discoverExamples,
15
+ exampleMarkdownLookup,
16
+ exampleScanRoots,
17
+ } from "../astro/examples.ts";
13
18
  import {
14
19
  buildRuntimeData,
15
20
  collectStaged,
@@ -61,6 +66,7 @@ import {
61
66
  tailwindEntryTemplate,
62
67
  } from "../theme/entry.ts";
63
68
  import { buildThemeCss } from "../theme/palette.ts";
69
+ import { rebaseSourceDirectives } from "../theme/sources.ts";
64
70
  import { twoslashCss } from "../theme/twoslash.ts";
65
71
 
66
72
  const toPosix = (path: string): string => path.split("\\").join("/");
@@ -203,7 +209,7 @@ const mcpFiles = async (
203
209
  path: join(genDir, "mcp-data.json"),
204
210
  },
205
211
  {
206
- content: mcpEndpointTemplate(route),
212
+ content: mcpEndpointTemplate(),
207
213
  path: join(srcDir, "pages", mcpPageFile(route)),
208
214
  },
209
215
  {
@@ -251,13 +257,21 @@ const changelogFiles = (
251
257
  };
252
258
 
253
259
  /** Contents of the configured `examples.css`, or `""` when unset/absent. */
254
- const readExamplesCss = (
255
- root: string,
256
- css: string | undefined
257
- ): Promise<string> =>
258
- css && existsSync(join(root, css))
259
- ? readFile(join(root, css), "utf-8")
260
- : Promise.resolve("");
260
+ /**
261
+ * Read a user stylesheet that eject inlines into a generated entry under
262
+ * `genDir`, re-rooting its relative `@source` paths from the user's file.
263
+ * Resolves to an empty string when the file is unset or absent.
264
+ */
265
+ const readUserCss = async (
266
+ file: string | null,
267
+ genDir: string
268
+ ): Promise<string> => {
269
+ if (!(file && existsSync(file))) {
270
+ return "";
271
+ }
272
+ const css = await readFile(file, "utf-8");
273
+ return rebaseSourceDirectives(css, { from: file, to: genDir });
274
+ };
261
275
 
262
276
  /**
263
277
  * The per-example preview route `<Component />` iframes embed, nested under
@@ -331,10 +345,11 @@ export const eject = async (
331
345
  context.pagesRoot ? discoverPages(context.pagesRoot) : Promise.resolve([]),
332
346
  detectNeedsReact(root),
333
347
  detectUsesMath(root),
334
- context.themeFile
335
- ? readFile(context.themeFile, "utf-8")
336
- : Promise.resolve(""),
337
- readExamplesCss(root, config.examples.css),
348
+ readUserCss(context.themeFile, genDir),
349
+ readUserCss(
350
+ config.examples.css ? join(root, config.examples.css) : null,
351
+ genDir
352
+ ),
338
353
  buildRawMarkdown(project),
339
354
  discoverIslands(root),
340
355
  ]);
@@ -387,9 +402,11 @@ export const eject = async (
387
402
  contentRoot: relContext.contentRoot,
388
403
  contentRoutes: project.manifest.routes.map((route) => route.path),
389
404
  context: relContext,
390
- dataPath: "./src/generated/data.json",
391
405
  examplesPath: "./src/generated/examples.ts",
392
406
  examplesThemePath: "./src/generated/examples.css",
407
+ // No CLI publishes the runtime data modules in memory after eject, so
408
+ // the config aliases each to the JSON snapshot written below.
409
+ generatedModulesDir: "./src/generated",
393
410
  integrationBridge: ejectIntegrationBridge(
394
411
  config,
395
412
  root,
@@ -398,7 +415,6 @@ export const eject = async (
398
415
  needsReact,
399
416
  needsSvelte,
400
417
  needsVue,
401
- openapiPath: "./src/generated/openapi.json",
402
418
  pages: relPages,
403
419
  searchClientPath: "./src/generated/search-client.ts",
404
420
  themePath: "./src/generated/app.css",
@@ -452,7 +468,9 @@ export const eject = async (
452
468
  // Relative sources keep the ejected app portable.
453
469
  content: examplesEntryTemplate({
454
470
  configTokens: buildThemeCss(config.theme),
455
- sources: ["../../**/*.{astro,jsx,svelte,ts,tsx,vue}"],
471
+ sources: exampleScanRoots(root, examples.dir).map(
472
+ (dir) => `${relative(genDir, dir)}/${EXAMPLE_SCAN_GLOB}`
473
+ ),
456
474
  userCss: userExamplesCss,
457
475
  }),
458
476
  path: join(genDir, "examples.css"),
@@ -513,7 +531,8 @@ export const eject = async (
513
531
  if (config.seo.og.enabled) {
514
532
  files.push({
515
533
  content: ogEndpointTemplate(
516
- customOgRoutes(pages, config.title, config.seo.og.titles)
534
+ customOgRoutes(pages, config.title, config.seo.og.titles),
535
+ { pageDescriptions: config.seo.og.description !== false }
517
536
  ),
518
537
  path: join(srcDir, "pages", "og", "[...slug].png.ts"),
519
538
  });
@@ -835,7 +835,7 @@ ${options.userTheme}
835
835
  interface ExamplesEntryOptions {
836
836
  /** Config-derived token overrides (`:root { --blume-accent: ... }`). */
837
837
  configTokens: string;
838
- /** Globs to scan for utility classes (example files and their imports). */
838
+ /** Globs to scan for utility classes (the project and examples directory). */
839
839
  sources: string[];
840
840
  /** Raw contents of the configured `examples.css`, if any. */
841
841
  userCss: string;
@@ -855,7 +855,7 @@ export const examplesEntryTemplate = (options: ExamplesEntryOptions): string =>
855
855
  `/* Generated by Blume. Do not edit. */
856
856
  @import "tailwindcss";
857
857
 
858
- /* Scan the example files and the project sources they import. */
858
+ /* Scan the project (and an out-of-root examples directory) for utility classes. */
859
859
  ${options.sources.map((source) => `@source "${source}";`).join("\n")}
860
860
 
861
861
  ${DARK_VARIANT}
@@ -0,0 +1,49 @@
1
+ import { dirname, isAbsolute, relative, resolve } from "pathe";
2
+
3
+ /**
4
+ * A Tailwind `@source "…"` / `@source not "…"` directive with a quoted path.
5
+ * `@source inline("…")` never matches: the quote must directly follow the
6
+ * keyword (or `not`), and `inline(` sits in between.
7
+ */
8
+ const SOURCE_DIRECTIVE =
9
+ /@source(?<not>\s+not)?\s+(?<quote>["'])(?<path>[^"']+)\k<quote>/gu;
10
+
11
+ /**
12
+ * Rewrite the relative `@source` paths in a user stylesheet so they still
13
+ * point where the author meant once the sheet is inlined into a generated
14
+ * Tailwind entry. Tailwind resolves `@source` relative to the stylesheet that
15
+ * declares it, and Blume splices `theme.css` / `examples.css` verbatim into
16
+ * `.blume/src/generated/*.css` (or `src/generated/*.css` after eject), which
17
+ * would silently re-root them there. Resolving each path against the user's
18
+ * file and re-expressing it relative to the generated directory keeps the
19
+ * standard contract — write `@source "../../packages/ui"` next to the file
20
+ * that says it — and lets a monorepo scan sibling workspace packages for
21
+ * utility classes without any knowledge of Blume's internal layout. Absolute
22
+ * paths pass through untouched.
23
+ */
24
+ export const rebaseSourceDirectives = (
25
+ css: string,
26
+ options: {
27
+ /** The user's stylesheet the CSS was read from. */
28
+ from: string;
29
+ /** The directory of the generated entry the CSS is inlined into. */
30
+ to: string;
31
+ }
32
+ ): string => {
33
+ const base = dirname(options.from);
34
+ return css.replace(
35
+ SOURCE_DIRECTIVE,
36
+ (
37
+ directive: string,
38
+ not: string | undefined,
39
+ quote: string,
40
+ path: string
41
+ ): string => {
42
+ if (isAbsolute(path)) {
43
+ return directive;
44
+ }
45
+ const rebased = relative(options.to, resolve(base, path));
46
+ return `@source${not ?? ""} ${quote}${rebased}${quote}`;
47
+ }
48
+ );
49
+ };