astro-lilypond 0.12.0 → 0.13.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.
package/dist/index.d.ts CHANGED
@@ -14,7 +14,7 @@ export interface LilypondContent {
14
14
  }
15
15
  export interface LilypondOptions extends PluginOptions {
16
16
  /**
17
- * Output format to be written to a file under `outputDir`.
17
+ * Output format.
18
18
  * @default "svg"
19
19
  */
20
20
  format?: "svg" | "png";
@@ -29,15 +29,5 @@ export interface LilypondOptions extends PluginOptions {
29
29
  * @default 60000
30
30
  */
31
31
  timeout?: number;
32
- /**
33
- * Directory name, relative to Astro's `publicDir`, that rendered assets
34
- * are written into by both `astro dev` and `astro build`. Filenames are
35
- * content-addressed, so unchanged scores are reused instead of being
36
- * re-rendered, and it's safe to commit this directory if you'd like
37
- * faster rebuilds.
38
- *
39
- * @default "_lilypond"
40
- */
41
- outputDir?: string;
42
32
  }
43
33
  export default function lilypond(options?: LilypondOptions): AstroIntegration;
package/dist/index.js CHANGED
@@ -1,12 +1,9 @@
1
1
  import { execFile } from "node:child_process";
2
- import { join } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
2
  import { promisify } from "node:util";
5
- import { pruneOrphanedAssets, pruneStaleAssets } from "./deleteAssets.js";
3
+ import emitAssetIntegration from "astro-emit-asset";
6
4
  import { rehypePlugin, remarkPlugin, satteriPlugin, } from "./plugins/index.js";
7
5
  import { defaultOptions, render, resolveCrop, } from "./render.js";
8
- import { altTextFor, assetsUrlBaseFor, contentHashFor, includePathsFor, lyTypeDeclarationsFor, parseLyHeader, parseLyImportQuery, prependVersion, RECOGNIZED_QUERY_PARAMS, resolveDefaults, sourceNameFor, titleFor, } from "./utils/index.js";
9
- import { writeAssets } from "./writeAsset.js";
6
+ import { altTextFor, emitLilypondAsset, includePathsFor, lyTypeDeclarationsFor, parseLyHeader, parseLyImportQuery, prependVersion, RECOGNIZED_QUERY_PARAMS, resolveDefaults, sourceNameFor, titleFor, } from "./utils/index.js";
10
7
  const execFileAsync = promisify(execFile);
11
8
  export const LY_EXTENSIONS = [".ly", ".lilypond", ".ily"];
12
9
  function lyFilePlugin(options) {
@@ -27,17 +24,15 @@ function lyFilePlugin(options) {
27
24
  const includePaths = includePathsFor(pathname);
28
25
  const sourceName = sourceNameFor(pathname);
29
26
  const title = titleFor(sourceName);
30
- const hash = contentHashFor({ source: src, format, resolution, crop });
31
27
  const alt = altTextFor(parseLyHeader(source));
32
- const assets = await writeAssets({
33
- hash,
28
+ const pages = await emitLilypondAsset({
34
29
  title,
35
30
  format,
36
- outputDir: options.assetsDir,
37
- urlBase: options.assetsUrlBase,
31
+ source: src,
32
+ resolution,
33
+ crop,
38
34
  sizeScale: crop ? cropScale : 1,
39
- trackAsset: options.trackAsset,
40
- getBuffers: () => render(src, {
35
+ render: () => render(src, {
41
36
  format,
42
37
  crop,
43
38
  defaults: options.defaults,
@@ -46,15 +41,8 @@ function lyFilePlugin(options) {
46
41
  sourceName,
47
42
  }),
48
43
  });
49
- // Keyed by the full id (including query) so `./score.ly` and
50
- // `./score.ly?crop` are tracked as independent sources.
51
- await options.pruneStaleAssets(id, assets.map((asset) => asset.fileName));
52
44
  const content = {
53
- pages: assets.map((asset) => ({
54
- src: asset.url,
55
- width: asset.width,
56
- height: asset.height,
57
- })),
45
+ pages,
58
46
  alt,
59
47
  };
60
48
  return {
@@ -64,9 +52,6 @@ function lyFilePlugin(options) {
64
52
  };
65
53
  }
66
54
  export default function lilypond(options = {}) {
67
- const referencedAssets = new Set();
68
- const assetsBySource = new Map();
69
- let assetsDir;
70
55
  return {
71
56
  name: "astro-lilypond",
72
57
  hooks: {
@@ -76,25 +61,9 @@ export default function lilypond(options = {}) {
76
61
  logger?.warn("astro-lilypond: `lilypond` binary not found — LilyPond blocks will render as errors. Install LilyPond and ensure it is on PATH.");
77
62
  }
78
63
  });
79
- const outputDirName = options.outputDir ?? "_lilypond";
80
- const assetsUrlBase = assetsUrlBaseFor(config.base, outputDirName);
81
- const resolvedAssetsDir = join(fileURLToPath(config.publicDir), outputDirName);
82
- assetsDir = resolvedAssetsDir;
83
- const resolvedOptions = {
84
- ...options,
85
- assetsDir,
86
- assetsUrlBase,
87
- trackAsset: (fileName) => referencedAssets.add(fileName),
88
- pruneStaleAssets: (sourceKey, fileNames) => pruneStaleAssets({
89
- assetsBySource,
90
- sourceKey,
91
- fileNames,
92
- outputDir: resolvedAssetsDir,
93
- logger,
94
- }),
95
- };
96
64
  updateConfig({
97
- vite: { plugins: [lyFilePlugin(resolvedOptions)] },
65
+ integrations: [emitAssetIntegration()],
66
+ vite: { plugins: [lyFilePlugin(options)] },
98
67
  });
99
68
  const existingProcessor = config.markdown?.processor;
100
69
  if (existingProcessor?.name === "satteri") {
@@ -110,7 +79,7 @@ export default function lilypond(options = {}) {
110
79
  ...existingOptions,
111
80
  mdastPlugins: [
112
81
  ...(existingOptions.mdastPlugins ?? []),
113
- satteriPlugin(resolvedOptions),
82
+ satteriPlugin(options),
114
83
  ],
115
84
  }),
116
85
  },
@@ -131,11 +100,11 @@ export default function lilypond(options = {}) {
131
100
  ...existingOptions,
132
101
  remarkPlugins: [
133
102
  ...(existingOptions.remarkPlugins ?? []),
134
- [remarkPlugin, resolvedOptions],
103
+ [remarkPlugin, options],
135
104
  ],
136
105
  rehypePlugins: [
137
106
  ...(existingOptions.rehypePlugins ?? []),
138
- [rehypePlugin, resolvedOptions],
107
+ [rehypePlugin, options],
139
108
  ],
140
109
  }),
141
110
  },
@@ -154,15 +123,6 @@ export default function lilypond(options = {}) {
154
123
  content: lyTypeDeclarationsFor(LY_EXTENSIONS, RECOGNIZED_QUERY_PARAMS),
155
124
  });
156
125
  },
157
- "astro:build:done": async ({ logger }) => {
158
- if (!assetsDir)
159
- return;
160
- await pruneOrphanedAssets({
161
- dir: assetsDir,
162
- referenced: referencedAssets,
163
- logger,
164
- });
165
- },
166
126
  },
167
127
  };
168
128
  }
package/dist/loader.d.ts CHANGED
@@ -27,15 +27,6 @@ export interface LilypondLoaderOptions extends PluginOptions {
27
27
  * @default the file's path relative to `base`, POSIX-separated, with its extension stripped.
28
28
  */
29
29
  generateId?: (options: GenerateIdOptions) => string;
30
- /**
31
- * Directory name, relative to Astro's `publicDir`, that rendered assets
32
- * are written into. Exclusive to this collection — the loader prunes
33
- * anything under it that isn't currently referenced by one of its own
34
- * entries, so don't point two different collections (or the `lilypond()`
35
- * integration's own `outputDir`) at the same directory.
36
- * @default `"_lilypond/<collection name>"`
37
- */
38
- outputDir?: string;
39
30
  }
40
31
  export interface LilypondHeaderData extends KnownLyHeaderFields {
41
32
  /** Header fields outside LilyPond's standard set (e.g. `mutopiacomposer`). */
package/dist/loader.js CHANGED
@@ -1,12 +1,10 @@
1
1
  import { glob, readFile } from "node:fs/promises";
2
- import { basename, join, posix, relative, sep } from "node:path";
2
+ import { join, relative, sep } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { z } from "astro/zod";
5
- import { pruneOrphanedAssets } from "./deleteAssets.js";
6
5
  import { LY_EXTENSIONS } from "./index.js";
7
6
  import { defaultOptions, render, resolveCrop } from "./render.js";
8
- import { altTextFor, assetsUrlBaseFor, contentHashFor, includePathsFor, parseLyHeaderFields, prependVersion, resolveDefaults, sourceNameFor, splitHeaderFields, titleFor, } from "./utils/index.js";
9
- import { writeAssets } from "./writeAsset.js";
7
+ import { altTextFor, emitLilypondAsset, includePathsFor, parseLyHeaderFields, prependVersion, resolveDefaults, sourceNameFor, splitHeaderFields, titleFor, } from "./utils/index.js";
10
8
  const DEFAULT_PATTERN = `**/*.{${LY_EXTENSIONS.map((ext) => ext.slice(1)).join(",")}}`;
11
9
  export const lilypondEntrySchema = z.object({
12
10
  pages: z.array(z.object({
@@ -56,7 +54,7 @@ function posixRelative(from, to) {
56
54
  * Content Loader to turn a directory of `.ly` files into a content collection.
57
55
  */
58
56
  export function lilypondLoader(options) {
59
- const { pattern = DEFAULT_PATTERN, base, generateId = defaultGenerateId, format, defaults, timeout, outputDir, } = options;
57
+ const { pattern = DEFAULT_PATTERN, base, generateId = defaultGenerateId, format, defaults, timeout, } = options;
60
58
  const resolved = resolveDefaults(defaults);
61
59
  const crop = resolveCrop(resolved.crop, "component");
62
60
  const resolvedFormat = format ?? defaultOptions.format;
@@ -64,10 +62,7 @@ export function lilypondLoader(options) {
64
62
  name: "astro-lilypond-loader",
65
63
  schema: lilypondEntrySchema,
66
64
  async load(context) {
67
- const { config, collection, store, logger, watcher, generateDigest, parseData, } = context;
68
- const outputDirName = outputDir ?? posix.join("_lilypond", collection);
69
- const assetsDir = join(fileURLToPath(config.publicDir), outputDirName);
70
- const assetsUrlBase = assetsUrlBaseFor(config.base, outputDirName);
65
+ const { config, store, logger, watcher, generateDigest, parseData } = context;
71
66
  const rootDir = fileURLToPath(config.root);
72
67
  const baseUrl = resolveBaseUrl(base, config.root);
73
68
  const baseDir = fileURLToPath(baseUrl);
@@ -83,28 +78,19 @@ export function lilypondLoader(options) {
83
78
  }
84
79
  const headerFields = parseLyHeaderFields(source);
85
80
  const id = generateId({ entry, base: baseUrl, header: headerFields });
86
- // Not short-circuited on a matching digest: that only proves the
87
- // source is unchanged, not that the asset file still exists on
88
- // disk. `writeAssets()` does the real disk-backed cache check.
89
81
  const digest = generateDigest(source);
90
82
  const src = prependVersion(source, resolved.version);
91
83
  const includePaths = includePathsFor(filePath);
92
84
  const sourceName = sourceNameFor(filePath);
93
85
  const title = titleFor(sourceName);
94
- const hash = contentHashFor({
95
- source: src,
86
+ const pages = await emitLilypondAsset({
87
+ title,
96
88
  format: resolvedFormat,
89
+ source: src,
97
90
  resolution: resolved.resolution,
98
91
  crop,
99
- });
100
- const assets = await writeAssets({
101
- hash,
102
- title,
103
- format: resolvedFormat,
104
- outputDir: assetsDir,
105
- urlBase: assetsUrlBase,
106
92
  sizeScale: crop ? resolved.cropScale : 1,
107
- getBuffers: () => render(src, {
93
+ render: () => render(src, {
108
94
  format: resolvedFormat,
109
95
  crop,
110
96
  defaults,
@@ -116,11 +102,7 @@ export function lilypondLoader(options) {
116
102
  const data = await parseData({
117
103
  id,
118
104
  data: {
119
- pages: assets.map((asset) => ({
120
- src: asset.url,
121
- width: asset.width,
122
- height: asset.height,
123
- })),
105
+ pages,
124
106
  alt: altTextFor({
125
107
  title: headerFields.title,
126
108
  composer: headerFields.composer,
@@ -167,17 +149,6 @@ export function lilypondLoader(options) {
167
149
  }
168
150
  for (const id of untouched)
169
151
  store.delete(id);
170
- // Sweep the directory against every currently-stored entry's pages
171
- // (not just the ones touched this pass), so a removed/renamed
172
- // source's leftover files get cleaned up without needing separate
173
- // per-entry bookkeeping.
174
- const referenced = new Set();
175
- for (const stored of store.values()) {
176
- const pages = stored.data.pages;
177
- for (const page of pages ?? [])
178
- referenced.add(basename(page.src));
179
- }
180
- await pruneOrphanedAssets({ dir: assetsDir, referenced, logger });
181
152
  }
182
153
  await runSync();
183
154
  if (!watcher)
@@ -1,4 +1,4 @@
1
1
  export { rehypePlugin } from "./rehype.js";
2
2
  export { remarkPlugin } from "./remark.js";
3
3
  export { satteriPlugin } from "./satteri.js";
4
- export type { PluginOptions, ResolvedPluginOptions } from "./types.js";
4
+ export type { PluginOptions } from "./types.js";
@@ -1,5 +1,4 @@
1
- import type { ResolvedPluginOptions } from "./types.js";
2
- export type RehypePluginOptions = ResolvedPluginOptions;
3
- export declare function rehypePlugin(options: RehypePluginOptions): (tree: any, file?: {
1
+ import type { PluginOptions } from "./types.js";
2
+ export declare function rehypePlugin(options: PluginOptions): (tree: any, file?: {
4
3
  path?: string;
5
4
  }) => Promise<void>;
@@ -1,13 +1,11 @@
1
1
  import { visit } from "unist-util-visit";
2
2
  import { defaultOptions, render, resolveCrop } from "../render.js";
3
- import { altTextForBlock, contentHashFor, includePathsFor, isLilypondLang, prependVersion, renderedHtml, resolveDefaults, sourceNameFor, titleFor, } from "../utils/index.js";
4
- import { writeAssets } from "../writeAsset.js";
3
+ import { altTextForBlock, emitLilypondAsset, includePathsFor, isLilypondLang, prependVersion, renderedHtml, resolveDefaults, sourceNameFor, titleFor, } from "../utils/index.js";
5
4
  // Typed loosely so it's assignable to both RehypePlugin and the unified
6
5
  // Plugin generic regardless of which @types/hast version the host project pins.
7
6
  export function rehypePlugin(options) {
8
7
  return async (tree, file) => {
9
8
  const promises = [];
10
- const fileNames = [];
11
9
  const includePaths = includePathsFor(file?.path);
12
10
  const sourceName = sourceNameFor(file?.path);
13
11
  const title = titleFor(sourceName);
@@ -36,17 +34,15 @@ export function rehypePlugin(options) {
36
34
  const source = version ? prependVersion(raw, version) : raw;
37
35
  const format = options.format ?? defaultOptions.format;
38
36
  const crop = resolveCrop(cropSetting, "markdown");
39
- const hash = contentHashFor({ source, format, resolution, crop });
40
37
  const alt = altTextForBlock(codeNode.data?.meta, raw);
41
- const promise = writeAssets({
42
- hash,
38
+ const promise = emitLilypondAsset({
43
39
  title,
44
40
  format,
45
- outputDir: options.assetsDir,
46
- urlBase: options.assetsUrlBase,
47
- trackAsset: options.trackAsset,
41
+ source,
42
+ resolution,
43
+ crop,
48
44
  sizeScale: crop ? cropScale : 1,
49
- getBuffers: () => render(source, {
45
+ render: () => render(source, {
50
46
  format,
51
47
  crop,
52
48
  defaults: options.defaults,
@@ -54,19 +50,15 @@ export function rehypePlugin(options) {
54
50
  includePaths,
55
51
  sourceName,
56
52
  }),
57
- }).then((assets) => {
58
- fileNames.push(...assets.map((asset) => asset.fileName));
53
+ }).then((pages) => {
59
54
  const rawNode = {
60
55
  type: "raw",
61
- value: renderedHtml(assets, alt),
56
+ value: renderedHtml(pages, alt),
62
57
  };
63
58
  parent.children[index] = rawNode;
64
59
  });
65
60
  promises.push(promise);
66
61
  });
67
62
  await Promise.all(promises);
68
- if (file?.path) {
69
- await options.pruneStaleAssets(file.path, fileNames);
70
- }
71
63
  };
72
64
  }
@@ -1,5 +1,4 @@
1
1
  import type { Root } from "mdast";
2
2
  import type { Plugin } from "unified";
3
- import type { ResolvedPluginOptions } from "./types.js";
4
- export type RemarkPluginOptions = ResolvedPluginOptions;
5
- export declare const remarkPlugin: Plugin<[RemarkPluginOptions], Root>;
3
+ import type { PluginOptions } from "./types.js";
4
+ export declare const remarkPlugin: Plugin<[PluginOptions], Root>;
@@ -1,11 +1,9 @@
1
1
  import { visit } from "unist-util-visit";
2
2
  import { defaultOptions, render, resolveCrop } from "../render.js";
3
- import { altTextForBlock, contentHashFor, includePathsFor, isLilypondLang, prependVersion, renderedHtml, resolveDefaults, sourceNameFor, titleFor, } from "../utils/index.js";
4
- import { writeAssets } from "../writeAsset.js";
3
+ import { altTextForBlock, emitLilypondAsset, includePathsFor, isLilypondLang, prependVersion, renderedHtml, resolveDefaults, sourceNameFor, titleFor, } from "../utils/index.js";
5
4
  export const remarkPlugin = (options) => {
6
5
  return async (tree, file) => {
7
6
  const promises = [];
8
- const fileNames = [];
9
7
  const includePaths = includePathsFor(file?.path);
10
8
  const sourceName = sourceNameFor(file?.path);
11
9
  const title = titleFor(sourceName);
@@ -16,17 +14,15 @@ export const remarkPlugin = (options) => {
16
14
  const source = version ? prependVersion(node.value, version) : node.value;
17
15
  const format = options.format ?? defaultOptions.format;
18
16
  const crop = resolveCrop(cropSetting, "markdown");
19
- const hash = contentHashFor({ source, format, resolution, crop });
20
17
  const alt = altTextForBlock(node.meta, node.value);
21
- const promise = writeAssets({
22
- hash,
18
+ const promise = emitLilypondAsset({
23
19
  title,
24
20
  format,
25
- outputDir: options.assetsDir,
26
- urlBase: options.assetsUrlBase,
27
- trackAsset: options.trackAsset,
21
+ source,
22
+ resolution,
23
+ crop,
28
24
  sizeScale: crop ? cropScale : 1,
29
- getBuffers: () => render(source, {
25
+ render: () => render(source, {
30
26
  format,
31
27
  crop,
32
28
  defaults: options.defaults,
@@ -34,19 +30,15 @@ export const remarkPlugin = (options) => {
34
30
  includePaths,
35
31
  sourceName,
36
32
  }),
37
- }).then((assets) => {
38
- fileNames.push(...assets.map((asset) => asset.fileName));
33
+ }).then((pages) => {
39
34
  const htmlNode = {
40
35
  type: "html",
41
- value: renderedHtml(assets, alt),
36
+ value: renderedHtml(pages, alt),
42
37
  };
43
38
  parent.children[index] = htmlNode;
44
39
  });
45
40
  promises.push(promise);
46
41
  });
47
42
  await Promise.all(promises);
48
- if (file?.path) {
49
- await options.pruneStaleAssets(file.path, fileNames);
50
- }
51
43
  };
52
44
  };
@@ -1,4 +1,3 @@
1
1
  import type { MdastPluginDefinition } from "satteri";
2
- import type { ResolvedPluginOptions } from "./types.js";
3
- export type SatteriPluginOptions = ResolvedPluginOptions;
4
- export declare function satteriPlugin(options: SatteriPluginOptions): MdastPluginDefinition;
2
+ import type { PluginOptions } from "./types.js";
3
+ export declare function satteriPlugin(options: PluginOptions): MdastPluginDefinition;
@@ -1,6 +1,5 @@
1
1
  import { defaultOptions, render, resolveCrop } from "../render.js";
2
- import { altTextForBlock, contentHashFor, includePathsFor, isLilypondLang, prependVersion, renderedHtml, resolveDefaults, sourceNameFor, titleFor, } from "../utils/index.js";
3
- import { writeAssets } from "../writeAsset.js";
2
+ import { altTextForBlock, emitLilypondAsset, includePathsFor, isLilypondLang, prependVersion, renderedHtml, resolveDefaults, sourceNameFor, titleFor, } from "../utils/index.js";
4
3
  export function satteriPlugin(options) {
5
4
  return {
6
5
  name: "astro-lilypond",
@@ -17,17 +16,15 @@ export function satteriPlugin(options) {
17
16
  const sourceName = sourceNameFor(ctx.fileURL);
18
17
  const title = titleFor(sourceName);
19
18
  const crop = resolveCrop(cropSetting, "markdown");
20
- const hash = contentHashFor({ source, format, resolution, crop });
21
19
  const alt = altTextForBlock(node.meta, node.value);
22
- const assets = await writeAssets({
23
- hash,
20
+ const pages = await emitLilypondAsset({
24
21
  title,
25
22
  format,
26
- outputDir: options.assetsDir,
27
- urlBase: options.assetsUrlBase,
28
- trackAsset: options.trackAsset,
23
+ source,
24
+ resolution,
25
+ crop,
29
26
  sizeScale: crop ? cropScale : 1,
30
- getBuffers: () => render(source, {
27
+ render: () => render(source, {
31
28
  format,
32
29
  crop,
33
30
  defaults: options.defaults,
@@ -36,14 +33,9 @@ export function satteriPlugin(options) {
36
33
  sourceName,
37
34
  }),
38
35
  });
39
- // Sätteri has no per-file "done" hook, so prune per block instead.
40
- if (ctx.fileURL) {
41
- const index = ctx.indexOf(node) ?? "root";
42
- await options.pruneStaleAssets(`${ctx.fileURL.href}#${index}`, assets.map((asset) => asset.fileName));
43
- }
44
36
  return {
45
37
  type: "html",
46
- value: renderedHtml(assets, alt),
38
+ value: renderedHtml(pages, alt),
47
39
  };
48
40
  },
49
41
  };
@@ -4,17 +4,3 @@ export interface PluginOptions {
4
4
  defaults?: LilypondDefaults;
5
5
  timeout?: number;
6
6
  }
7
- /**
8
- * `PluginOptions` plus the filesystem/URL details resolved once from Astro's
9
- * config in `astro:config:setup`. Internal only.
10
- */
11
- export interface ResolvedPluginOptions extends PluginOptions {
12
- /** Absolute filesystem path assets are written into. */
13
- assetsDir: string;
14
- /** URL prefix assets are referenced by, e.g. `"/_lilypond"`. */
15
- assetsUrlBase: string;
16
- /** Records that a given output filename was referenced during this build. */
17
- trackAsset: (fileName: string) => void;
18
- /** Deletes files this source previously produced but no longer does. */
19
- pruneStaleAssets: (sourceKey: string, fileNames: readonly string[]) => Promise<void>;
20
- }
@@ -0,0 +1,29 @@
1
+ import type { LilypondPage } from "../index.js";
2
+ import type { Format } from "../render.js";
3
+ export interface EmitLilypondAssetOptions {
4
+ /**
5
+ * Human-readable title — becomes the emitted file's name, with the hash
6
+ * `emitAsset()` derives inserted after it (`<title>.<hash>.<format>`).
7
+ */
8
+ title: string;
9
+ /** Output format — also used as the file extension. */
10
+ format: Format;
11
+ /** The exact LilyPond source rendered, so a change busts the cache. */
12
+ source: string;
13
+ /** Resolution in DPI (PNG only) — a render dependency, so part of the cache key. */
14
+ resolution: number;
15
+ /** Whether output is cropped — a render dependency, so part of the cache key. */
16
+ crop: boolean;
17
+ /**
18
+ * Multiplies the `width`/`height` reported on each page. The generated
19
+ * bytes are never touched — this only affects the dimensions handed back
20
+ * for sizing the `<img>` tag.
21
+ */
22
+ sizeScale: number;
23
+ /**
24
+ * Produces the bytes to render, one Buffer per page. Only invoked on a
25
+ * cache miss.
26
+ */
27
+ render: () => Promise<Buffer[]>;
28
+ }
29
+ export declare function emitLilypondAsset(options: EmitLilypondAssetOptions): Promise<LilypondPage[]>;
@@ -0,0 +1,10 @@
1
+ import type { vi } from "vitest";
2
+ import type { emitLilypondAsset } from "./emitLilypondAsset.js";
3
+ type EmitLilypondAssetMock = ReturnType<typeof vi.mocked<typeof emitLilypondAsset>>;
4
+ export declare function fakeEmitLilypondAsset(mock: EmitLilypondAssetMock, urlBase?: string): void;
5
+ /**
6
+ * Fake that just awaits `opts.render()` and discards the result — for tests
7
+ * asserting that a `render()` rejection propagates out of the caller.
8
+ */
9
+ export declare function fakeEmitLilypondAssetPropagatingRenderErrors(mock: EmitLilypondAssetMock): void;
10
+ export {};
@@ -0,0 +1,18 @@
1
+ export function fakeEmitLilypondAsset(mock, urlBase = "/_astro") {
2
+ mock.mockImplementation(async (opts) => {
3
+ const buffers = await opts.render();
4
+ return buffers.map((_, i) => ({
5
+ src: `${urlBase}/${opts.title}${i === 0 ? "" : `-p${i + 1}`}.${opts.format}`,
6
+ }));
7
+ });
8
+ }
9
+ /**
10
+ * Fake that just awaits `opts.render()` and discards the result — for tests
11
+ * asserting that a `render()` rejection propagates out of the caller.
12
+ */
13
+ export function fakeEmitLilypondAssetPropagatingRenderErrors(mock) {
14
+ mock.mockImplementation(async (opts) => {
15
+ await opts.render();
16
+ return [];
17
+ });
18
+ }
@@ -0,0 +1,37 @@
1
+ import { emitAsset } from "astro-emit-asset/emit";
2
+ import { imageDimensionsFor } from "./imageDimensions.js";
3
+ const EMIT_ASSET_GLOBAL_KEY = "astro-emit-asset";
4
+ function assertEmitAssetRegistered() {
5
+ if (globalThis[EMIT_ASSET_GLOBAL_KEY])
6
+ return;
7
+ throw new Error("astro-lilypond: please add the `lilypond()` integration to your Astro config.");
8
+ }
9
+ export async function emitLilypondAsset(options) {
10
+ assertEmitAssetRegistered();
11
+ const { title, format, source, resolution, crop, sizeScale, render } = options;
12
+ const result = await emitAsset(`${title}.[hash].${format}`, [source, format, resolution, crop, sizeScale], async () => {
13
+ const buffers = await render();
14
+ const pages = buffers.map((data) => {
15
+ const dimensions = imageDimensionsFor(format, data);
16
+ return {
17
+ data,
18
+ meta: {
19
+ width: dimensions ? dimensions.width * sizeScale : undefined,
20
+ height: dimensions ? dimensions.height * sizeScale : undefined,
21
+ },
22
+ };
23
+ });
24
+ // Returning a 1-element array (instead of the bare object) makes
25
+ // astro-emit-asset insert a page index into the filename even for a
26
+ // single-page score (`title.0.hash.ext`) — return the object
27
+ // directly for that (overwhelmingly common) case so the file is
28
+ // named `title.hash.ext` instead.
29
+ return pages.length === 1 ? pages[0] : pages;
30
+ });
31
+ const assets = Array.isArray(result) ? result : [result];
32
+ return assets.map((asset) => ({
33
+ src: asset.src,
34
+ width: asset.meta.width,
35
+ height: asset.meta.height,
36
+ }));
37
+ }
@@ -1,7 +1,6 @@
1
1
  export { altTextFor } from "./altTextFor.js";
2
2
  export { altTextForBlock } from "./altTextForBlock.js";
3
- export { assetsUrlBaseFor } from "./assetsUrlBaseFor.js";
4
- export { contentHashFor } from "./contentHashFor.js";
3
+ export { type EmitLilypondAssetOptions, emitLilypondAsset, } from "./emitLilypondAsset.js";
5
4
  export { escapeHtmlAttribute } from "./escapeHtmlAttribute.js";
6
5
  export type { ImageDimensions } from "./imageDimensions.js";
7
6
  export { imageDimensionsFor } from "./imageDimensions.js";
@@ -9,12 +8,10 @@ export { includePathsFor } from "./includePathsFor.js";
9
8
  export { isLilypondLang } from "./isLilypondLang.js";
10
9
  export { parseLyImportQuery, RECOGNIZED_QUERY_PARAMS, } from "./lyImportQuery.js";
11
10
  export { lyTypeDeclarationsFor } from "./lyTypeDeclarationsFor.js";
12
- export { contentTypeFor, isOwnAssetFileName } from "./ownAssetFileName.js";
13
11
  export { parseFenceMeta } from "./parseFenceMeta.js";
14
12
  export type { LyHeaderFields } from "./parseLyHeader.js";
15
13
  export { extractMarkupText, parseLyHeader, parseLyHeaderFields, } from "./parseLyHeader.js";
16
14
  export { prependVersion } from "./prependVersion.js";
17
- export type { RenderedPage } from "./renderedHtml.js";
18
15
  export { renderedHtml } from "./renderedHtml.js";
19
16
  export { resolveDefaults } from "./resolveDefaults.js";
20
17
  export { sourceNameFor } from "./sourceNameFor.js";
@@ -1,14 +1,12 @@
1
1
  export { altTextFor } from "./altTextFor.js";
2
2
  export { altTextForBlock } from "./altTextForBlock.js";
3
- export { assetsUrlBaseFor } from "./assetsUrlBaseFor.js";
4
- export { contentHashFor } from "./contentHashFor.js";
3
+ export { emitLilypondAsset, } from "./emitLilypondAsset.js";
5
4
  export { escapeHtmlAttribute } from "./escapeHtmlAttribute.js";
6
5
  export { imageDimensionsFor } from "./imageDimensions.js";
7
6
  export { includePathsFor } from "./includePathsFor.js";
8
7
  export { isLilypondLang } from "./isLilypondLang.js";
9
8
  export { parseLyImportQuery, RECOGNIZED_QUERY_PARAMS, } from "./lyImportQuery.js";
10
9
  export { lyTypeDeclarationsFor } from "./lyTypeDeclarationsFor.js";
11
- export { contentTypeFor, isOwnAssetFileName } from "./ownAssetFileName.js";
12
10
  export { parseFenceMeta } from "./parseFenceMeta.js";
13
11
  export { extractMarkupText, parseLyHeader, parseLyHeaderFields, } from "./parseLyHeader.js";
14
12
  export { prependVersion } from "./prependVersion.js";
@@ -1,6 +1,2 @@
1
- export interface RenderedPage {
2
- url: string;
3
- width?: number;
4
- height?: number;
5
- }
6
- export declare function renderedHtml(pages: RenderedPage[], alt: string): string;
1
+ import type { LilypondPage } from "../index.js";
2
+ export declare function renderedHtml(pages: LilypondPage[], alt: string): string;
@@ -3,7 +3,7 @@ function imgTag(page, escapedAlt) {
3
3
  const size = page.width !== undefined && page.height !== undefined
4
4
  ? ` width="${page.width}" height="${page.height}"`
5
5
  : "";
6
- return `<img data-lilypond-image src="${page.url}"${size} alt="${escapedAlt}">`;
6
+ return `<img data-lilypond-image src="${page.src}"${size} alt="${escapedAlt}">`;
7
7
  }
8
8
  export function renderedHtml(pages, alt) {
9
9
  const escapedAlt = escapeHtmlAttribute(alt);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-lilypond",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "An Astro integration for rendering Lilypond markup to music notation.",
5
5
  "homepage": "https://lilypond.ky.fyi",
6
6
  "author": "Ky Decker",
@@ -48,6 +48,7 @@
48
48
  "astro": ">=7.0.0"
49
49
  },
50
50
  "dependencies": {
51
+ "astro-emit-asset": "^0.1.0",
51
52
  "unist-util-visit": "^5.1.0"
52
53
  },
53
54
  "devDependencies": {
@@ -1,26 +0,0 @@
1
- export interface PruneOrphanedAssetsOptions {
2
- /** Absolute filesystem path to the assets output directory. */
3
- dir: string;
4
- /** Filenames (e.g. `"ab12…ef.svg"`) referenced during this build. */
5
- referenced: ReadonlySet<string>;
6
- logger?: {
7
- info: (message: string) => void;
8
- };
9
- }
10
- /** Deletes our content-addressed files under `dir` that weren't referenced this build. */
11
- export declare function pruneOrphanedAssets(options: PruneOrphanedAssetsOptions): Promise<void>;
12
- export interface PruneStaleAssetsOptions {
13
- /** Per-source history of output filenames, keyed by module id or file path. */
14
- assetsBySource: Map<string, Set<string>>;
15
- /** Stable identifier for the source these `fileNames` were rendered from. */
16
- sourceKey: string;
17
- /** Every output filename this source's current transform pass produced. */
18
- fileNames: readonly string[];
19
- /** Absolute filesystem path the files live in. */
20
- outputDir: string;
21
- logger?: {
22
- info: (message: string) => void;
23
- };
24
- }
25
- /** Deletes a source's own previously-rendered files that it no longer produces. */
26
- export declare function pruneStaleAssets(options: PruneStaleAssetsOptions): Promise<void>;
@@ -1,51 +0,0 @@
1
- import { readdir, unlink } from "node:fs/promises";
2
- import { join, relative } from "node:path";
3
- import { isOwnAssetFileName } from "./utils/index.js";
4
- /** Deletes `fileNames` from `dir` and logs a one-line summary. Ignores files that are already gone. */
5
- async function deleteAssets(options) {
6
- const { dir, fileNames, reason, context, logger } = options;
7
- if (fileNames.length === 0)
8
- return;
9
- await Promise.all(fileNames.map((name) => unlink(join(dir, name)).catch(() => { })));
10
- logger?.info(`astro-lilypond: pruned ${fileNames.length} ${reason} asset${fileNames.length === 1 ? "" : "s"} from ${context}`);
11
- }
12
- /** Relativizes `path` to the current working directory for log messages. */
13
- function relativeToCwd(path) {
14
- return relative(process.cwd(), path) || ".";
15
- }
16
- /** Deletes our content-addressed files under `dir` that weren't referenced this build. */
17
- export async function pruneOrphanedAssets(options) {
18
- const { dir, referenced, logger } = options;
19
- let entries;
20
- try {
21
- entries = await readdir(dir);
22
- }
23
- catch {
24
- return;
25
- }
26
- const orphaned = entries.filter((name) => isOwnAssetFileName(name) && !referenced.has(name));
27
- await deleteAssets({
28
- dir,
29
- fileNames: orphaned,
30
- reason: "orphaned",
31
- context: relativeToCwd(dir),
32
- logger,
33
- });
34
- }
35
- /** Deletes a source's own previously-rendered files that it no longer produces. */
36
- export async function pruneStaleAssets(options) {
37
- const { assetsBySource, sourceKey, fileNames, outputDir, logger } = options;
38
- const next = new Set(fileNames);
39
- const previous = assetsBySource.get(sourceKey);
40
- assetsBySource.set(sourceKey, next);
41
- if (!previous)
42
- return;
43
- const stale = [...previous].filter((name) => !next.has(name));
44
- await deleteAssets({
45
- dir: outputDir,
46
- fileNames: stale,
47
- reason: "stale",
48
- context: relativeToCwd(sourceKey),
49
- logger,
50
- });
51
- }
@@ -1,5 +0,0 @@
1
- /**
2
- * Joins Astro's `base` config with the configured output directory name
3
- * into a clean URL prefix.
4
- */
5
- export declare function assetsUrlBaseFor(base: string, outputDirName: string): string;
@@ -1,8 +0,0 @@
1
- import { posix } from "node:path";
2
- /**
3
- * Joins Astro's `base` config with the configured output directory name
4
- * into a clean URL prefix.
5
- */
6
- export function assetsUrlBaseFor(base, outputDirName) {
7
- return posix.join(base, outputDirName);
8
- }
@@ -1,19 +0,0 @@
1
- import type { Format } from "../render.js";
2
- export interface ContentHashInput {
3
- source: string;
4
- format: Format;
5
- resolution: number;
6
- crop: boolean;
7
- }
8
- /**
9
- * Derives a content-addressed hash for a rendered score (the first segment
10
- * of its `<hash>.<title>.<ext>` filename), so identical source/options reuse
11
- * the same output file (skipping a redundant `lilypond` invocation) and any
12
- * change produces a new, cache-safe filename.
13
- *
14
- * Deliberately does not cover `\include`d file contents or the `lilypond`
15
- * binary version — an included file changing, or a LilyPond upgrade,
16
- * won't bust the cache. Accepted v1 tradeoff; clear the output directory
17
- * to force a full re-render after either.
18
- */
19
- export declare function contentHashFor(input: ContentHashInput): string;
@@ -1,20 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- const HASH_LENGTH = 6;
3
- /**
4
- * Derives a content-addressed hash for a rendered score (the first segment
5
- * of its `<hash>.<title>.<ext>` filename), so identical source/options reuse
6
- * the same output file (skipping a redundant `lilypond` invocation) and any
7
- * change produces a new, cache-safe filename.
8
- *
9
- * Deliberately does not cover `\include`d file contents or the `lilypond`
10
- * binary version — an included file changing, or a LilyPond upgrade,
11
- * won't bust the cache. Accepted v1 tradeoff; clear the output directory
12
- * to force a full re-render after either.
13
- */
14
- export function contentHashFor(input) {
15
- const { source, format, resolution, crop } = input;
16
- return createHash("sha256")
17
- .update(JSON.stringify([source, format, resolution, crop]))
18
- .digest("hex")
19
- .slice(0, HASH_LENGTH);
20
- }
@@ -1,11 +0,0 @@
1
- /**
2
- * True for filenames matching our own `<hash>.<title>.<format>` naming —
3
- * the single source of truth for what counts as one of our own rendered
4
- * assets, shared by `pruneOrphanedAssets` and the dev asset endpoint.
5
- */
6
- export declare function isOwnAssetFileName(name: string): boolean;
7
- /**
8
- * The `Content-Type` for one of our own asset filenames, or `undefined` if
9
- * `fileName` isn't one (see `isOwnAssetFileName`).
10
- */
11
- export declare function contentTypeFor(fileName: string): string | undefined;
@@ -1,24 +0,0 @@
1
- import { FORMATS } from "../render.js";
2
- const CONTENT_TYPES = {
3
- svg: "image/svg+xml",
4
- png: "image/png",
5
- };
6
- const OWN_ASSET_NAME = new RegExp(`^[0-9a-f]+\\.[a-zA-Z0-9_-]+\\.(?:${FORMATS.join("|")})$`);
7
- /**
8
- * True for filenames matching our own `<hash>.<title>.<format>` naming —
9
- * the single source of truth for what counts as one of our own rendered
10
- * assets, shared by `pruneOrphanedAssets` and the dev asset endpoint.
11
- */
12
- export function isOwnAssetFileName(name) {
13
- return OWN_ASSET_NAME.test(name);
14
- }
15
- /**
16
- * The `Content-Type` for one of our own asset filenames, or `undefined` if
17
- * `fileName` isn't one (see `isOwnAssetFileName`).
18
- */
19
- export function contentTypeFor(fileName) {
20
- if (!isOwnAssetFileName(fileName))
21
- return undefined;
22
- const format = fileName.slice(fileName.lastIndexOf(".") + 1);
23
- return CONTENT_TYPES[format];
24
- }
@@ -1,51 +0,0 @@
1
- import type { Format } from "./render.js";
2
- export interface WriteAssetOptions {
3
- /** Content hash — the first segment of the filename. */
4
- hash: string;
5
- /**
6
- * Human-readable title — the middle segment of the filename, e.g. the
7
- * source file's basename. See `titleFor()`.
8
- */
9
- title: string;
10
- /** Output format — also used as the file extension. */
11
- format: Format;
12
- /** Absolute filesystem path to write the file into. */
13
- outputDir: string;
14
- /** URL prefix the returned URL is built from, e.g. `"/_lilypond"`. */
15
- urlBase: string;
16
- /** Called with each written/reused file's name, hit or miss. */
17
- trackAsset?: (fileName: string) => void;
18
- /**
19
- * Produces the bytes to write, one Buffer per page. Only invoked on a
20
- * cache miss, so a caller can defer an expensive render behind this and
21
- * skip it entirely when the content-addressed file(s) already exist.
22
- */
23
- getBuffers: () => Promise<Buffer[]>;
24
- /**
25
- * Multiplies the `width`/`height` reported on each `WrittenAsset`. The
26
- * written file's own bytes are never touched — this only affects the
27
- * dimensions handed back for sizing the `<img>` tag, so it's cheap to
28
- * change and applies even to a cache hit that skips `getBuffers`.
29
- * @default 1
30
- */
31
- sizeScale?: number;
32
- }
33
- export interface WrittenAsset {
34
- /** Bare filename under `outputDir`. */
35
- fileName: string;
36
- /** Public URL to reference the file by. */
37
- url: string;
38
- /**
39
- * Dimensions read back from the written file's own bytes, so `<img>`
40
- * tags can be sized upfront and avoid layout shift on load. Omitted if
41
- * they couldn't be determined (e.g. unrecognized/malformed content).
42
- */
43
- width?: number;
44
- height?: number;
45
- }
46
- /**
47
- * Persists rendered pages to content-addressed files under `outputDir`,
48
- * skipping the render entirely if page 1 already exists on disk, and returns
49
- * each page's filename and public URL, in page order.
50
- */
51
- export declare function writeAssets(options: WriteAssetOptions): Promise<WrittenAsset[]>;
@@ -1,80 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
4
- import { imageDimensionsFor } from "./utils/imageDimensions.js";
5
- /** Page 1 keeps the plain `<hash>.<title>.<format>` name; later pages get `-pN`. */
6
- function pageFileName(hash, title, format, page) {
7
- return page === 1
8
- ? `${hash}.${title}.${format}`
9
- : `${hash}.${title}-p${page}.${format}`;
10
- }
11
- async function exists(path) {
12
- return access(path).then(() => true, () => false);
13
- }
14
- /**
15
- * Finds every already-written sibling page after page 1, stopping at the
16
- * first missing page number — pages are written contiguously, so a gap
17
- * means the end. Costs one `access()` per actual page (typically 0-2)
18
- * rather than listing the whole (potentially large, shared) `outputDir`.
19
- */
20
- async function existingSiblingPages(outputDir, hash, title, format) {
21
- const siblings = [];
22
- for (let page = 2;; page++) {
23
- const fileName = pageFileName(hash, title, format, page);
24
- if (!(await exists(join(outputDir, fileName))))
25
- break;
26
- siblings.push(fileName);
27
- }
28
- return siblings;
29
- }
30
- /**
31
- * Persists rendered pages to content-addressed files under `outputDir`,
32
- * skipping the render entirely if page 1 already exists on disk, and returns
33
- * each page's filename and public URL, in page order.
34
- */
35
- export async function writeAssets(options) {
36
- const { hash, title, format, outputDir, urlBase, trackAsset, getBuffers, sizeScale = 1, } = options;
37
- const page1Name = pageFileName(hash, title, format, 1);
38
- const page1Path = join(outputDir, page1Name);
39
- const alreadyWritten = await exists(page1Path);
40
- let fileNames;
41
- // Populated on a cache miss, so dimensions can be read from the buffers
42
- // already in hand instead of reading each file back from disk.
43
- let buffersByFileName;
44
- if (alreadyWritten) {
45
- fileNames = [
46
- page1Name,
47
- ...(await existingSiblingPages(outputDir, hash, title, format)),
48
- ];
49
- }
50
- else {
51
- const buffers = await getBuffers();
52
- fileNames = buffers.map((_, i) => pageFileName(hash, title, format, i + 1));
53
- buffersByFileName = new Map(fileNames.map((name, i) => [name, buffers[i]]));
54
- await mkdir(outputDir, { recursive: true });
55
- await Promise.all(buffers.map(async (buf, i) => {
56
- const destPath = join(outputDir, fileNames[i]);
57
- // Write to a uniquely-suffixed temp file, then atomically rename
58
- // onto the final path — safe under concurrent writers racing to
59
- // produce the same hash, since identical hashes imply identical
60
- // bytes, so whichever rename lands "wins" without ever leaving a
61
- // corrupt/partial file.
62
- const tmpPath = `${destPath}.${randomUUID()}.tmp`;
63
- await writeFile(tmpPath, buf);
64
- await rename(tmpPath, destPath);
65
- }));
66
- }
67
- for (const fileName of fileNames)
68
- trackAsset?.(fileName);
69
- return Promise.all(fileNames.map(async (fileName) => {
70
- const buf = buffersByFileName?.get(fileName) ??
71
- (await readFile(join(outputDir, fileName)));
72
- const dimensions = imageDimensionsFor(format, buf);
73
- return {
74
- fileName,
75
- url: `${urlBase}/${fileName}`,
76
- width: dimensions ? dimensions.width * sizeScale : undefined,
77
- height: dimensions ? dimensions.height * sizeScale : undefined,
78
- };
79
- }));
80
- }