@altano/astro-opengraph 0.0.1-alpha.1 → 0.1.0-alpha.3

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/README.md CHANGED
@@ -86,7 +86,7 @@ In order serve the component from the prior step as an image, you need a js/ts [
86
86
 
87
87
  ```ts
88
88
  import opengraphTemplate from "../components/opengraph.astro";
89
- import { makeOpengraphEndpoint } from "@altano/astro-opengraph";
89
+ import { makeOpengraphEndpoint } from "@altano/astro-opengraph/endpoint";
90
90
 
91
91
  export const GET = makeOpengraphEndpoint({
92
92
  template: opengraphTemplate,
@@ -142,6 +142,8 @@ import OpenGraphMeta from "@altano/astro-opengraph/components/meta.astro";
142
142
  </html>
143
143
  ```
144
144
 
145
+ You can also provide an `alt` prop which would set the `og:image:alt` meta tag, but my understanding is that it isn't well supported, so feel free to omit this.
146
+
145
147
  If you want the `OpenGraphMeta` component to use a different set of defaults for your whole site, you can provide the `componentMetaTagFallbacks` integration config, e.g. to provide a default title:
146
148
 
147
149
  ```ts
@@ -274,7 +276,7 @@ It doesn't matter where you put your Open Graph component or endpoints, as long
274
276
  ```ts
275
277
  // import your component, wherever it is
276
278
  import opengraphTemplate from "../components/my-custom-component.astro";
277
- import { makeOpengraphEndpoint } from "@altano/astro-opengraph";
279
+ import { makeOpengraphEndpoint } from "@altano/astro-opengraph/endpoint";
278
280
 
279
281
  export const GET = makeOpengraphEndpoint({
280
282
  template: opengraphTemplate,
@@ -399,7 +401,7 @@ This integration makes use of the [experimental container APIs](https://docs.ast
399
401
 
400
402
  While in `astro dev` mode, you can use the included Astro dev toolbar app installed by this integration. It include a preview of the opengraph image as well as the `og:` meta tags on the page:
401
403
 
402
- ![Screenshot of the Astro developer toolbar showing an Open Graph image](./readme-assets/dev-toolbar.png)
404
+ ![Screenshot of the Astro developer toolbar showing an Open Graph image](./assets/dev-toolbar.png)
403
405
 
404
406
  Once your site is live, you can use a tool like [opengraph.xyz](https://www.opengraph.xyz/) to preview the opengraph image/tags.
405
407
 
Binary file
@@ -21,6 +21,10 @@ export type Props = {
21
21
  * something else, provide the path to the resulting image here.
22
22
  */
23
23
  image?: string;
24
+ /**
25
+ * The specification says "A description of what is in the image (not a caption). If the page specifies an og:image it should specify og:image:alt." but people have found this to be poorly supported. Use if you want.
26
+ */
27
+ alt?: string;
24
28
  /**
25
29
  * Override the width, which otherwise defaults to:
26
30
  *
@@ -42,9 +46,9 @@ export type Props = {
42
46
  */
43
47
  height?: number;
44
48
  /**
45
- * Override the mimetype, which defaults to "image/png"
49
+ * Override `og:image:type` MIME type, which defaults to "image/png"
46
50
  */
47
- mimeType?: string;
51
+ type?: string;
48
52
  };
49
53
 
50
54
  const { imageOptions, componentMetaTagFallbacks } = await getResolvedConfig();
@@ -56,12 +60,13 @@ const {
56
60
  } = componentMetaTagFallbacks;
57
61
 
58
62
  const {
63
+ alt,
59
64
  title = titleFallback,
60
65
  description = descriptionFallback,
61
66
  image = imageUrlFallback,
62
67
  width = imageOptions.width,
63
68
  height = imageOptions.height,
64
- mimeType = "image/png",
69
+ type = "image/png",
65
70
  } = Astro.props;
66
71
 
67
72
  const urlStr = Astro.url.toString();
@@ -72,6 +77,7 @@ const opengraphImageUrl = new URL(image, urlWithTrailingSlash);
72
77
  {title && <meta property="og:title" content={title} />}
73
78
  {description && <meta property="og:description" content={description} />}
74
79
  <meta property="og:image" content={opengraphImageUrl.toString()} />
75
- <meta property="og:image:type" content={mimeType} />
80
+ {alt && <meta property="og:image:alt" content={alt} />}
81
+ <meta property="og:image:type" content={type} />
76
82
  <meta property="og:image:width" content={width.toString()} />
77
83
  <meta property="og:image:height" content={height.toString()} />
@@ -0,0 +1,4 @@
1
+ import type { OpengraphImageConfigDeserialized, OpengraphImageConfigResolved, OpengraphImageConfigSerializableMaybeMocked } from "./types.js";
2
+ export declare function deserializeVirtualConfig(configMaybeMocked: OpengraphImageConfigSerializableMaybeMocked): Promise<OpengraphImageConfigDeserialized>;
3
+ export declare function getResolvedConfig(): Promise<OpengraphImageConfigResolved>;
4
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1,14 @@
1
+ import type { APIRoute } from "astro";
2
+ import type { ImageOptions } from "./types.js";
3
+ type Options = {
4
+ template: unknown;
5
+ /**
6
+ * Custom "Content-Type" header, only used in SSR. Defaults to "image/png"
7
+ */
8
+ contentType?: string;
9
+ imageOptionOverrides?: Partial<ImageOptions>;
10
+ };
11
+ export declare function makeOpengraphDevEndpoint({ template, contentType, }: Options): APIRoute;
12
+ export declare function makeOpengraphEndpoint({ template, imageOptionOverrides, contentType, }: Options): APIRoute;
13
+ export {};
14
+ //# sourceMappingURL=endpoint.d.ts.map
@@ -0,0 +1,137 @@
1
+ // src/endpoint.ts
2
+ import { experimental_AstroContainer } from "astro/container";
3
+ import { isAstroComponentFactory } from "astro/runtime/server/render/astro/factory.js";
4
+
5
+ // src/config.ts
6
+ import { readFile } from "node:fs/promises";
7
+ async function deserializeVirtualConfig(configMaybeMocked) {
8
+ const config = typeof configMaybeMocked === "function" ? configMaybeMocked() : configMaybeMocked;
9
+ const fontsWithBuffers = await Promise.all(
10
+ config.imageOptions.fonts.map(async (font) => {
11
+ const data = await readFile(font.path);
12
+ return {
13
+ ...font,
14
+ data
15
+ };
16
+ })
17
+ );
18
+ return {
19
+ ...config,
20
+ imageOptions: {
21
+ ...config.imageOptions,
22
+ fonts: fontsWithBuffers
23
+ }
24
+ };
25
+ }
26
+ var imageOptionDefaults = {
27
+ width: 1200,
28
+ height: 630
29
+ };
30
+ var metaTagDefaults = {
31
+ "og:image": `/opengraph.png`
32
+ };
33
+ async function getResolvedConfig() {
34
+ const module = await import("virtual:astro-opengraph/user-config");
35
+ const lazyConfig = module.default;
36
+ const deserialized = await deserializeVirtualConfig(lazyConfig);
37
+ return {
38
+ ...deserialized,
39
+ imageOptions: {
40
+ ...imageOptionDefaults,
41
+ ...deserialized.imageOptions
42
+ },
43
+ componentMetaTagFallbacks: {
44
+ ...deserialized.componentMetaTagFallbacks,
45
+ ...metaTagDefaults
46
+ }
47
+ };
48
+ }
49
+
50
+ // src/image.ts
51
+ import { getConfiguredImageService, imageConfig } from "astro:assets";
52
+ import satori from "satori";
53
+ import { html as htmlToVNode } from "satori-html";
54
+ import he from "he";
55
+ async function htmlToPNG(html, satoriOptions) {
56
+ const responseTextWithDecodedHtmlEntities = he.decode(html);
57
+ const vnode = htmlToVNode(responseTextWithDecodedHtmlEntities);
58
+ const svg = await satori(vnode, satoriOptions);
59
+ const fileBuffer = Buffer.from(svg, "utf-8");
60
+ const uint8Array = new Uint8Array(
61
+ fileBuffer.buffer,
62
+ fileBuffer.byteOffset,
63
+ fileBuffer.byteLength
64
+ );
65
+ return bufferToTransformedPNG(uint8Array);
66
+ }
67
+ async function bufferToTransformedPNG(imageBuffer) {
68
+ const imageService = await getConfiguredImageService();
69
+ return imageService.transform(
70
+ imageBuffer,
71
+ { src: "", format: "png" },
72
+ imageConfig
73
+ );
74
+ }
75
+
76
+ // src/endpoint.ts
77
+ function makeOpengraphDevEndpoint({
78
+ template,
79
+ contentType = "text/html"
80
+ }) {
81
+ return async ({ locals, params, props, request }) => {
82
+ const config = await getResolvedConfig();
83
+ if (config.command !== "dev") {
84
+ return new Response(null, { status: 404 });
85
+ }
86
+ if (!isAstroComponentFactory(template)) {
87
+ throw new Error(`Given is not an Astro template.`);
88
+ }
89
+ const container = await experimental_AstroContainer.create();
90
+ const templateStr = await container.renderToString(template, {
91
+ partial: false,
92
+ request,
93
+ params,
94
+ locals,
95
+ props
96
+ });
97
+ return new Response(templateStr, {
98
+ headers: {
99
+ "Content-Type": contentType
100
+ }
101
+ });
102
+ };
103
+ }
104
+ function makeOpengraphEndpoint({
105
+ template,
106
+ imageOptionOverrides,
107
+ contentType = "image/png"
108
+ }) {
109
+ return async ({ locals, params, props, request }) => {
110
+ if (!isAstroComponentFactory(template)) {
111
+ throw new Error(`Given is not an Astro template.`);
112
+ }
113
+ const container = await experimental_AstroContainer.create();
114
+ const templateStr = await container.renderToString(template, {
115
+ partial: false,
116
+ request,
117
+ params,
118
+ locals,
119
+ props
120
+ });
121
+ const config = await getResolvedConfig();
122
+ const imageOptions = {
123
+ ...config.imageOptions,
124
+ ...imageOptionOverrides
125
+ };
126
+ const png = await htmlToPNG(templateStr, imageOptions);
127
+ return new Response(png.data, {
128
+ headers: {
129
+ "Content-Type": contentType
130
+ }
131
+ });
132
+ };
133
+ }
134
+ export {
135
+ makeOpengraphDevEndpoint,
136
+ makeOpengraphEndpoint
137
+ };
@@ -0,0 +1,7 @@
1
+ import { type SatoriOptions } from "satori";
2
+ import type { LocalImageService } from "astro";
3
+ /**
4
+ * Convert an html string to an image
5
+ */
6
+ export declare function htmlToPNG(html: string, satoriOptions: SatoriOptions): Promise<ReturnType<LocalImageService["transform"]>>;
7
+ //# sourceMappingURL=image.d.ts.map
@@ -0,0 +1,3 @@
1
+ export { default } from "./integration.js";
2
+ export type { OpengraphImageConfig, ImageOptionsWithFontPaths, ComponentMetaTagFallbacks, } from "./types.js";
3
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,65 @@
1
+ // src/virtual-user-config.ts
2
+ function resolveVirtualModuleId(id) {
3
+ return `\0${id}`;
4
+ }
5
+ function vitePluginOpengraphImageUserConfig(config) {
6
+ const modules = {
7
+ "virtual:astro-opengraph/user-config": `export default ${JSON.stringify(
8
+ config
9
+ )}`
10
+ };
11
+ const resolutionMap = Object.fromEntries(
12
+ Object.keys(modules).map((key) => [
13
+ resolveVirtualModuleId(key),
14
+ key
15
+ ])
16
+ );
17
+ return {
18
+ name: "vite-plugin-astro-opengraph-user-config",
19
+ resolveId(id) {
20
+ if (id in modules) return resolveVirtualModuleId(id);
21
+ },
22
+ load(id) {
23
+ const resolution = resolutionMap[id];
24
+ if (resolution) return modules[resolution];
25
+ }
26
+ };
27
+ }
28
+
29
+ // src/integration.ts
30
+ var integration_default = (config) => ({
31
+ name: "@altano/astro-opengraph",
32
+ hooks: {
33
+ async "astro:config:setup"({
34
+ updateConfig,
35
+ command,
36
+ addDevToolbarApp
37
+ }) {
38
+ const imageOptions = await config.getImageOptions();
39
+ const configSerializable = {
40
+ imageOptions,
41
+ command
42
+ };
43
+ updateConfig({
44
+ vite: {
45
+ // Shove the serializable config into the virtual module for later
46
+ // retrieval in the middleware
47
+ plugins: [vitePluginOpengraphImageUserConfig(configSerializable)]
48
+ }
49
+ });
50
+ addDevToolbarApp({
51
+ id: "@altano/astro-opengraph",
52
+ name: "Open Graph",
53
+ // icon: "🌠",
54
+ icon: "image",
55
+ // We don't use `@altano/astro-opengraph/toolbar` because that won't
56
+ // resolve when serving the test fixture for e2e runs (`pnpm
57
+ // test:e2e:server`)
58
+ entrypoint: new URL("./toolbar/index.js", import.meta.url)
59
+ });
60
+ }
61
+ }
62
+ });
63
+ export {
64
+ integration_default as default
65
+ };
@@ -0,0 +1,5 @@
1
+ import type { AstroIntegration } from "astro";
2
+ import type { OpengraphImageConfig } from "./types.js";
3
+ declare const _default: (config: OpengraphImageConfig) => AstroIntegration;
4
+ export default _default;
5
+ //# sourceMappingURL=integration.d.ts.map
@@ -0,0 +1,3 @@
1
+ import React from "react";
2
+ export declare function CopyImageUrlButton(): React.JSX.Element;
3
+ //# sourceMappingURL=CopyImageUrlButton.d.ts.map
@@ -0,0 +1,7 @@
1
+ import type React from "react";
2
+ export declare function ExternalLinkicon({ width, height, fill, }: {
3
+ width?: number;
4
+ height?: number;
5
+ fill?: string;
6
+ }): React.JSX.Element;
7
+ //# sourceMappingURL=ExternalLinkicon.d.ts.map
@@ -0,0 +1,5 @@
1
+ import React from "react";
2
+ export declare function Image({ imageSize }: {
3
+ imageSize: number;
4
+ }): React.JSX.Element;
5
+ //# sourceMappingURL=Image.d.ts.map
@@ -0,0 +1,4 @@
1
+ export declare function ImageSection({ isHovered, }: {
2
+ isHovered: boolean;
3
+ }): React.JSX.Element;
4
+ //# sourceMappingURL=ImageSection.d.ts.map
@@ -0,0 +1,7 @@
1
+ import type React from "react";
2
+ export declare function ImageZoomToolbar({ isHovered, imageSize, setImageSize, }: {
3
+ isHovered: boolean;
4
+ imageSize: number;
5
+ setImageSize: React.Dispatch<React.SetStateAction<number>>;
6
+ }): React.JSX.Element;
7
+ //# sourceMappingURL=ImageZoomToolbar.d.ts.map
@@ -0,0 +1,7 @@
1
+ import type React from "react";
2
+ type LinkOrTextType = React.JSX.Element | string | null;
3
+ export declare function LinkOrText({ maybeLinkText, }: {
4
+ maybeLinkText: string | null;
5
+ }): LinkOrTextType;
6
+ export {};
7
+ //# sourceMappingURL=LinkOrText.d.ts.map
@@ -0,0 +1,3 @@
1
+ import type React from "react";
2
+ export declare function MetaTags(): React.JSX.Element;
3
+ //# sourceMappingURL=MetaTags.d.ts.map
@@ -0,0 +1,5 @@
1
+ import type React from "react";
2
+ export declare function Summary({ children }: {
3
+ children: string;
4
+ }): React.JSX.Element;
5
+ //# sourceMappingURL=Summary.d.ts.map
@@ -0,0 +1,3 @@
1
+ import React from "react";
2
+ export declare function ToolbarApp(): React.JSX.Element;
3
+ //# sourceMappingURL=ToolbarApp.d.ts.map
@@ -0,0 +1,3 @@
1
+ import type React from "react";
2
+ export declare function ToolbarAppWindow(): React.JSX.Element;
3
+ //# sourceMappingURL=ToolbarAppWindow.d.ts.map
@@ -0,0 +1,6 @@
1
+ import type React from "react";
2
+ export declare function ToolbarSection({ icon, children, }: {
3
+ icon: React.ReactNode;
4
+ children: React.ReactNode;
5
+ }): React.JSX.Element;
6
+ //# sourceMappingURL=ToolbarSection.d.ts.map
@@ -0,0 +1,7 @@
1
+ import type React from "react";
2
+ export declare function ZoomButton({ value, imageSize, setImageSize, }: {
3
+ value: number;
4
+ imageSize: number;
5
+ setImageSize: React.Dispatch<React.SetStateAction<number>>;
6
+ }): React.JSX.Element;
7
+ //# sourceMappingURL=ZoomButton.d.ts.map
@@ -0,0 +1,4 @@
1
+ export declare function ZoomLabel({ imageSize }: {
2
+ imageSize: number;
3
+ }): string;
4
+ //# sourceMappingURL=ZoomLabel.d.ts.map
@@ -0,0 +1,3 @@
1
+ import React from "react";
2
+ export declare function useHoverState<TElement extends HTMLElement>(setHoverState: React.Dispatch<React.SetStateAction<boolean>>): React.RefObject<TElement | null>;
3
+ //# sourceMappingURL=useHoverState.d.ts.map
@@ -0,0 +1,3 @@
1
+ export declare function useImageURL(): string;
2
+ export declare function useImageURLIfAvailable(): string | null;
3
+ //# sourceMappingURL=useImageURL.d.ts.map
@@ -0,0 +1,3 @@
1
+ export type MetaTagInfo = [content: string | null, property: string | null];
2
+ export declare function useMetaTagInfo(): MetaTagInfo[];
3
+ //# sourceMappingURL=useMetaTagInfo.d.ts.map
@@ -0,0 +1,3 @@
1
+ declare const _default: import("astro").DevToolbarApp;
2
+ export default _default;
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,472 @@
1
+ // src/toolbar/index.tsx
2
+ import { createRoot } from "react-dom/client";
3
+ import { defineToolbarApp } from "astro/toolbar";
4
+
5
+ // src/toolbar/styles.tsx
6
+ var styles = (
7
+ /* css */
8
+ `
9
+ :host astro-dev-toolbar-window {
10
+ max-height: calc(100vh - 80px);
11
+ overflow-y: auto;
12
+ }
13
+
14
+ button {
15
+ background: none;
16
+ color: inherit;
17
+ border: none;
18
+ font: inherit;
19
+ cursor: pointer;
20
+ outline: inherit;
21
+ }
22
+
23
+ details {
24
+ margin-block-end: 10px;
25
+ }
26
+ details:last-of-type {
27
+ margin-block-end: 0;
28
+ }
29
+
30
+ details[open] summary, .image {
31
+ margin-block-end: 7px;
32
+ }
33
+
34
+ .image img {
35
+ transition: max-width 0.1s;
36
+ }
37
+ `
38
+ );
39
+
40
+ // src/toolbar/components/ToolbarApp.tsx
41
+ import { useState as useState4 } from "react";
42
+
43
+ // src/toolbar/components/Summary.tsx
44
+ import { jsx } from "react/jsx-runtime";
45
+ function Summary({ children }) {
46
+ return /* @__PURE__ */ jsx(
47
+ "summary",
48
+ {
49
+ style: {
50
+ cursor: "pointer",
51
+ fontWeight: 700,
52
+ color: "white"
53
+ },
54
+ children
55
+ }
56
+ );
57
+ }
58
+
59
+ // src/toolbar/components/ExternalLinkicon.tsx
60
+ import { jsx as jsx2, jsxs } from "react/jsx-runtime";
61
+ function ExternalLinkicon({
62
+ width = 27,
63
+ height = 27,
64
+ fill = "#020202"
65
+ }) {
66
+ return /* @__PURE__ */ jsx2(
67
+ "span",
68
+ {
69
+ style: {
70
+ display: "inline-block",
71
+ width,
72
+ height
73
+ },
74
+ children: /* @__PURE__ */ jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 120 120", children: [
75
+ /* @__PURE__ */ jsx2("path", { d: "M120.09 16.696H60.256V.096L120.09.09z", fill }),
76
+ /* @__PURE__ */ jsx2(
77
+ "path",
78
+ {
79
+ d: "M57.99 48.64l42.573-42.574 13.475 13.475-42.574 42.58z",
80
+ fill
81
+ }
82
+ ),
83
+ /* @__PURE__ */ jsx2(
84
+ "path",
85
+ {
86
+ d: "M119.98.107l.02 59.83-16.59.013-.02-59.846zM3 23.5h17v87H3zm83.49 52.56h17V113h-17z",
87
+ fill
88
+ }
89
+ ),
90
+ /* @__PURE__ */ jsx2("path", { d: "M3 16.692h40.655v17H3zM3 96h100.49v17H3z", fill })
91
+ ] })
92
+ }
93
+ );
94
+ }
95
+
96
+ // src/toolbar/components/LinkOrText.tsx
97
+ import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
98
+ function LinkOrText({
99
+ maybeLinkText
100
+ }) {
101
+ let parsedMaybeLink = null;
102
+ try {
103
+ if (maybeLinkText != null) {
104
+ const url = new URL(maybeLinkText);
105
+ parsedMaybeLink = /* @__PURE__ */ jsxs2("a", { href: url.toString(), target: "_blank", children: [
106
+ maybeLinkText,
107
+ " ",
108
+ /* @__PURE__ */ jsx3(ExternalLinkicon, { width: 10, height: 10, fill: "white" })
109
+ ] });
110
+ }
111
+ } catch (_) {
112
+ parsedMaybeLink = maybeLinkText;
113
+ }
114
+ return parsedMaybeLink;
115
+ }
116
+
117
+ // src/toolbar/hooks/useMetaTagInfo.tsx
118
+ function useMetaTagInfo() {
119
+ return Array.from(document.querySelectorAll("meta[property^='og:']")).map(
120
+ (tag) => [
121
+ tag.getAttribute("content"),
122
+ tag.getAttribute("property")
123
+ ]
124
+ );
125
+ }
126
+
127
+ // src/toolbar/components/MetaTags.tsx
128
+ import { Fragment, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
129
+ function MetaTags() {
130
+ const tags = useMetaTagInfo();
131
+ return /* @__PURE__ */ jsx4(
132
+ "dl",
133
+ {
134
+ "data-testid": "astro-opengraph-toolbar",
135
+ style: {
136
+ margin: 0,
137
+ display: "grid",
138
+ gridTemplateColumns: "max-content 1fr",
139
+ gap: 7,
140
+ columnGap: 13
141
+ },
142
+ children: tags.map((tag) => /* @__PURE__ */ jsx4(MetaTagContent, { tag }, tag[1]))
143
+ }
144
+ );
145
+ }
146
+ function MetaTagContent({ tag }) {
147
+ const [content, property] = tag;
148
+ return /* @__PURE__ */ jsxs3(Fragment, { children: [
149
+ /* @__PURE__ */ jsx4(
150
+ "dt",
151
+ {
152
+ style: {
153
+ color: "white",
154
+ textAlign: "right",
155
+ fontWeight: 500
156
+ },
157
+ children: property
158
+ }
159
+ ),
160
+ /* @__PURE__ */ jsx4("dd", { style: { margin: 0 }, children: /* @__PURE__ */ jsx4(LinkOrText, { maybeLinkText: content }) })
161
+ ] });
162
+ }
163
+
164
+ // src/toolbar/hooks/useHoverState.tsx
165
+ import { useRef, useEffect } from "react";
166
+ function useHoverState(setHoverState) {
167
+ const ref = useRef(null);
168
+ useEffect(() => {
169
+ const controller = new AbortController();
170
+ const { signal } = controller;
171
+ ref.current?.addEventListener("mouseenter", () => setHoverState(true), {
172
+ signal
173
+ });
174
+ ref.current?.addEventListener("mouseleave", () => setHoverState(false), {
175
+ signal
176
+ });
177
+ return () => controller.abort();
178
+ }, [setHoverState]);
179
+ return ref;
180
+ }
181
+
182
+ // src/toolbar/hooks/useImageURL.tsx
183
+ import { useState } from "react";
184
+ function useImageURL() {
185
+ const imageUrl = useImageURLIfAvailable();
186
+ if (imageUrl == null) {
187
+ throw new Error(
188
+ `Shouldn't be rendering this component without an image URL`
189
+ );
190
+ }
191
+ return imageUrl;
192
+ }
193
+ function useImageURLIfAvailable() {
194
+ const [imageUrlRef] = useState(() => {
195
+ const meta = document.querySelector('meta[property="og:image"]');
196
+ return meta?.getAttribute("content") ?? null;
197
+ });
198
+ return imageUrlRef;
199
+ }
200
+
201
+ // src/toolbar/components/Image.tsx
202
+ import { jsx as jsx5 } from "react/jsx-runtime";
203
+ function Image({ imageSize }) {
204
+ const imageUrl = useImageURL();
205
+ return /* @__PURE__ */ jsx5("div", { className: "image", children: /* @__PURE__ */ jsx5("a", { href: imageUrl, target: "_blank", title: "Open image in new tab", children: /* @__PURE__ */ jsx5(
206
+ "img",
207
+ {
208
+ alt: "Open Graph",
209
+ src: imageUrl,
210
+ style: {
211
+ maxHeight: "100%",
212
+ maxWidth: `${imageSize}%`
213
+ }
214
+ }
215
+ ) }) });
216
+ }
217
+
218
+ // src/toolbar/components/ZoomLabel.tsx
219
+ function ZoomLabel({ imageSize }) {
220
+ const formattedNumber = (imageSize / 100).toLocaleString(void 0, {
221
+ minimumFractionDigits: 2
222
+ });
223
+ return `${formattedNumber}x`;
224
+ }
225
+
226
+ // src/toolbar/components/ZoomButton.tsx
227
+ import { jsx as jsx6 } from "react/jsx-runtime";
228
+ function ZoomButton({
229
+ value,
230
+ imageSize,
231
+ setImageSize
232
+ }) {
233
+ const isSelected = value === imageSize;
234
+ return /* @__PURE__ */ jsx6(
235
+ "astro-dev-toolbar-button",
236
+ {
237
+ size: "small",
238
+ style: { padding: 0, margin: 0 },
239
+ buttonStyle: isSelected ? "gray" : "ghost",
240
+ className: "zoom-button",
241
+ role: "radio",
242
+ onClick: () => setImageSize(value),
243
+ children: /* @__PURE__ */ jsx6(ZoomLabel, { imageSize: value })
244
+ }
245
+ );
246
+ }
247
+
248
+ // src/toolbar/components/ToolbarSection.tsx
249
+ import { jsx as jsx7, jsxs as jsxs4 } from "react/jsx-runtime";
250
+ function ToolbarSection({
251
+ icon,
252
+ children
253
+ }) {
254
+ return /* @__PURE__ */ jsxs4(
255
+ "div",
256
+ {
257
+ style: {
258
+ fontSize: 12,
259
+ display: "flex",
260
+ alignItems: "center",
261
+ columnGap: 5
262
+ },
263
+ children: [
264
+ /* @__PURE__ */ jsx7(
265
+ "div",
266
+ {
267
+ style: {
268
+ display: "inline-block",
269
+ width: 12,
270
+ height: 12
271
+ },
272
+ children: icon
273
+ }
274
+ ),
275
+ children
276
+ ]
277
+ }
278
+ );
279
+ }
280
+
281
+ // src/toolbar/components/ImageZoomToolbar.tsx
282
+ import { Fragment as Fragment2, jsx as jsx8, jsxs as jsxs5 } from "react/jsx-runtime";
283
+ function ImageZoomToolbar({
284
+ isHovered,
285
+ imageSize,
286
+ setImageSize
287
+ }) {
288
+ return /* @__PURE__ */ jsx8(ToolbarSection, { icon: /* @__PURE__ */ jsx8("astro-dev-toolbar-icon", { icon: "gear" }), children: !isHovered ? /* @__PURE__ */ jsx8(
289
+ ZoomButton,
290
+ {
291
+ value: imageSize,
292
+ imageSize,
293
+ setImageSize
294
+ }
295
+ ) : /* @__PURE__ */ jsxs5(Fragment2, { children: [
296
+ /* @__PURE__ */ jsx8(
297
+ ZoomButton,
298
+ {
299
+ value: 25,
300
+ imageSize,
301
+ setImageSize
302
+ }
303
+ ),
304
+ /* @__PURE__ */ jsx8(
305
+ ZoomButton,
306
+ {
307
+ value: 50,
308
+ imageSize,
309
+ setImageSize
310
+ }
311
+ ),
312
+ /* @__PURE__ */ jsx8(
313
+ ZoomButton,
314
+ {
315
+ value: 75,
316
+ imageSize,
317
+ setImageSize
318
+ }
319
+ ),
320
+ /* @__PURE__ */ jsx8(
321
+ ZoomButton,
322
+ {
323
+ value: 100,
324
+ imageSize,
325
+ setImageSize
326
+ }
327
+ )
328
+ ] }) });
329
+ }
330
+
331
+ // src/toolbar/components/CopyImageUrlButton.tsx
332
+ import React2, {
333
+ useState as useState2,
334
+ useRef as useRef2,
335
+ useEffect as useEffect2
336
+ } from "react";
337
+ import { jsx as jsx9 } from "react/jsx-runtime";
338
+ function CopyImageUrlButton() {
339
+ const imageUrl = useImageURL();
340
+ const [wasRecentlyClicked, setWasRecentlyClicked] = useState2(false);
341
+ const checkTimer = useRef2(null);
342
+ useEffect2(() => {
343
+ if (wasRecentlyClicked && checkTimer.current == null) {
344
+ checkTimer.current = setTimeout(() => {
345
+ setWasRecentlyClicked(false);
346
+ checkTimer.current = null;
347
+ }, 3e3);
348
+ }
349
+ }, [wasRecentlyClicked]);
350
+ const handleClick = React2.useCallback(
351
+ (evt) => {
352
+ void navigator.clipboard.writeText(imageUrl);
353
+ setWasRecentlyClicked(true);
354
+ evt.stopPropagation();
355
+ evt.preventDefault();
356
+ },
357
+ [imageUrl]
358
+ );
359
+ return /* @__PURE__ */ jsx9(ToolbarSection, { icon: /* @__PURE__ */ jsx9("astro-dev-toolbar-icon", { icon: "copy" }), children: /* @__PURE__ */ jsx9("div", { style: { display: "flex", alignItems: "center" }, children: /* @__PURE__ */ jsx9(
360
+ "astro-dev-toolbar-button",
361
+ {
362
+ size: "small",
363
+ buttonStyle: "gray",
364
+ onClick: handleClick,
365
+ children: wasRecentlyClicked ? "Copied to clipboard" : "Copy image URL"
366
+ }
367
+ ) }) });
368
+ }
369
+
370
+ // src/toolbar/components/ImageSection.tsx
371
+ import { useState as useState3 } from "react";
372
+ import { Fragment as Fragment3, jsx as jsx10, jsxs as jsxs6 } from "react/jsx-runtime";
373
+ function ImageSection({
374
+ isHovered
375
+ }) {
376
+ const [imageSize, setImageSize] = useState3(100);
377
+ return /* @__PURE__ */ jsxs6(Fragment3, { children: [
378
+ /* @__PURE__ */ jsx10(Image, { imageSize }),
379
+ /* @__PURE__ */ jsxs6(
380
+ "div",
381
+ {
382
+ "data-testid": "image-section-toolbar",
383
+ style: {
384
+ display: "flex",
385
+ flexDirection: "row",
386
+ alignItems: "center",
387
+ columnGap: 15
388
+ },
389
+ children: [
390
+ /* @__PURE__ */ jsx10(CopyImageUrlButton, {}),
391
+ /* @__PURE__ */ jsx10(
392
+ ImageZoomToolbar,
393
+ {
394
+ isHovered,
395
+ imageSize,
396
+ setImageSize
397
+ }
398
+ )
399
+ ]
400
+ }
401
+ )
402
+ ] });
403
+ }
404
+
405
+ // src/toolbar/components/ToolbarApp.tsx
406
+ import { Fragment as Fragment4, jsx as jsx11, jsxs as jsxs7 } from "react/jsx-runtime";
407
+ function ToolbarApp() {
408
+ const [isImageDetailsHovered, setIsImageDetailsHovered] = useState4(false);
409
+ const imageDetailsRef = useHoverState(
410
+ setIsImageDetailsHovered
411
+ );
412
+ const imageUrl = useImageURLIfAvailable();
413
+ if (imageUrl == null) {
414
+ return /* @__PURE__ */ jsxs7("div", { style: { display: "flex", gap: 10, alignItems: "center" }, children: [
415
+ /* @__PURE__ */ jsx11(
416
+ "astro-dev-toolbar-icon",
417
+ {
418
+ style: { width: 24, height: 24 },
419
+ icon: "warning"
420
+ }
421
+ ),
422
+ " ",
423
+ "No Open Graph image found on this page"
424
+ ] });
425
+ }
426
+ return /* @__PURE__ */ jsxs7(Fragment4, { children: [
427
+ /* @__PURE__ */ jsxs7("details", { open: true, className: "image", ref: imageDetailsRef, children: [
428
+ /* @__PURE__ */ jsx11(Summary, { children: "Image" }),
429
+ /* @__PURE__ */ jsx11(ImageSection, { isHovered: isImageDetailsHovered })
430
+ ] }),
431
+ /* @__PURE__ */ jsxs7("details", { children: [
432
+ /* @__PURE__ */ jsx11(Summary, { children: "Meta Tags" }),
433
+ /* @__PURE__ */ jsx11(MetaTags, {})
434
+ ] })
435
+ ] });
436
+ }
437
+
438
+ // src/toolbar/components/ToolbarAppWindow.tsx
439
+ import { jsx as jsx12, jsxs as jsxs8 } from "react/jsx-runtime";
440
+ function ToolbarAppWindow() {
441
+ return /* @__PURE__ */ jsxs8("astro-dev-toolbar-window", { children: [
442
+ /* @__PURE__ */ jsx12("style", { children: styles }),
443
+ /* @__PURE__ */ jsx12(ToolbarApp, {})
444
+ ] });
445
+ }
446
+
447
+ // src/toolbar/index.tsx
448
+ import { jsx as jsx13 } from "react/jsx-runtime";
449
+ var SESSION_KEY_OPEN = "@altano/astro-opengraph/toolbar:is-open";
450
+ var toolbar_default = defineToolbarApp({
451
+ init(canvas, app) {
452
+ const root = createRoot(canvas);
453
+ app.onToggled((event) => {
454
+ if (event.state) {
455
+ sessionStorage.setItem(SESSION_KEY_OPEN, "true");
456
+ root.render(/* @__PURE__ */ jsx13(ToolbarAppWindow, {}));
457
+ } else {
458
+ sessionStorage.removeItem(SESSION_KEY_OPEN);
459
+ }
460
+ });
461
+ app.toggleNotification({
462
+ state: document.querySelector('meta[property="og:image"]') != null,
463
+ level: "info"
464
+ });
465
+ if (sessionStorage.getItem(SESSION_KEY_OPEN) != null) {
466
+ app.toggleState({ state: true });
467
+ }
468
+ }
469
+ });
470
+ export {
471
+ toolbar_default as default
472
+ };
@@ -0,0 +1,2 @@
1
+ export declare const styles = "\n :host astro-dev-toolbar-window {\n max-height: calc(100vh - 80px);\n overflow-y: auto;\n }\n\n button {\n background: none;\n color: inherit;\n border: none;\n font: inherit;\n cursor: pointer;\n outline: inherit;\n }\n\n details {\n margin-block-end: 10px;\n }\n details:last-of-type {\n margin-block-end: 0;\n }\n\n details[open] summary, .image {\n margin-block-end: 7px;\n }\n\n .image img {\n transition: max-width 0.1s;\n }\n";
2
+ //# sourceMappingURL=styles.d.ts.map
@@ -0,0 +1,67 @@
1
+ import type { SatoriOptions } from "satori";
2
+ export type ImageOptions = SatoriOptions;
3
+ export type FontWithBuffer = ImageOptions["fonts"][0];
4
+ export type FontWithPath = Omit<FontWithBuffer, "data"> & {
5
+ path: string;
6
+ };
7
+ type ImageOptionsBase = {
8
+ width?: number;
9
+ height?: number;
10
+ debug?: boolean;
11
+ };
12
+ type ImageOptionsBaseResolved = {
13
+ width: number;
14
+ height: number;
15
+ debug?: boolean;
16
+ };
17
+ export type ImageOptionsWithFontPaths = ImageOptionsBase & {
18
+ fonts: FontWithPath[];
19
+ };
20
+ export type ImageOptionsWithFontBuffers = ImageOptionsBase & {
21
+ fonts: FontWithBuffer[];
22
+ };
23
+ export type ImageOptionsWithFontBuffersResolved = ImageOptionsBaseResolved & {
24
+ fonts: FontWithBuffer[];
25
+ };
26
+ export type ComponentMetaTagFallbacks = {
27
+ "og:title"?: string;
28
+ "og:description"?: string;
29
+ "og:image"?: string;
30
+ };
31
+ export type MetaTagDefaults = {
32
+ "og:image": string;
33
+ };
34
+ export type OpengraphImageConfig = {
35
+ /**
36
+ * Options to use for the image file that is generated. Most importantly, fonts
37
+ * must be provided.
38
+ */
39
+ getImageOptions(): Promise<ImageOptionsWithFontPaths>;
40
+ /**
41
+ * Defaults to use in the component for tags you don't manually set.
42
+ */
43
+ componentMetaTagFallbacks?: ComponentMetaTagFallbacks;
44
+ };
45
+ type AstroConfigSetupHookOptions = Parameters<Astro.IntegrationHooks["astro:config:setup"]>[0];
46
+ type Command = AstroConfigSetupHookOptions["command"];
47
+ /**
48
+ * This must remain JSON-serializable!
49
+ */
50
+ export type OpengraphImageConfigSerializable = {
51
+ imageOptions: ImageOptionsWithFontPaths;
52
+ componentMetaTagFallbacks?: ComponentMetaTagFallbacks;
53
+ command: Command;
54
+ };
55
+ export type OpengraphImageConfigSerializableMaybeMocked = OpengraphImageConfigSerializable | (() => OpengraphImageConfigSerializable);
56
+ export type OpengraphImageConfigDeserialized = {
57
+ imageOptions: ImageOptionsWithFontBuffers;
58
+ componentMetaTagFallbacks?: ComponentMetaTagFallbacks | undefined;
59
+ command: Command;
60
+ };
61
+ export type OpengraphImageConfigResolved = {
62
+ imageOptions: ImageOptionsWithFontBuffersResolved;
63
+ componentMetaTagFallbacks: ComponentMetaTagFallbacks & MetaTagDefaults;
64
+ command: Command;
65
+ };
66
+ export {};
67
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,8 @@
1
+ import type { ViteUserConfig } from "astro";
2
+ import type { OpengraphImageConfigSerializable } from "./types.js";
3
+ /**
4
+ * Vite plugin that exposes OpengraphImage user config and project context via
5
+ * virtual modules.
6
+ */
7
+ export declare function vitePluginOpengraphImageUserConfig(config: OpengraphImageConfigSerializable): NonNullable<ViteUserConfig["plugins"]>[number];
8
+ //# sourceMappingURL=virtual-user-config.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@altano/astro-opengraph",
3
- "version": "0.0.1-alpha.1",
3
+ "version": "0.1.0-alpha.3",
4
4
  "description": "Open Graph Tools for Astro",
5
5
  "keywords": [
6
6
  "astro",
@@ -29,6 +29,7 @@
29
29
  "./toolbar": "./dist/toolbar/index.js"
30
30
  },
31
31
  "files": [
32
+ "assets",
32
33
  "components",
33
34
  "dist/**/*.js",
34
35
  "dist/**/*.d.ts"
@@ -39,6 +40,7 @@
39
40
  "satori-html": "^0.3.2"
40
41
  },
41
42
  "devDependencies": {
43
+ "@astrojs/check": "^0.9.4",
42
44
  "@inox-tools/astro-tests": "0.5.1",
43
45
  "@playwright/test": "1.47.0",
44
46
  "@types/he": "^1.2.3",
@@ -57,10 +59,12 @@
57
59
  "typescript": "^5.8.2",
58
60
  "vite": "^6.2.5",
59
61
  "vitest": "^3.1.1",
62
+ "zx": "^8.5.2",
63
+ "@altano/assets": "0.0.0",
64
+ "@altano/build-config": "0.0.0",
60
65
  "@altano/testing": "0.0.0",
61
66
  "@altano/tsconfig": "0.0.0",
62
- "@altano/build-config": "0.0.0",
63
- "@altano/assets": "0.0.0",
67
+ "@altano/tiny-async-pool": "3.0.2",
64
68
  "@altano/vitest-plugins": "1.1.0"
65
69
  },
66
70
  "peerDependencies": {
@@ -76,6 +80,7 @@
76
80
  "scripts": {
77
81
  "build": "tsup --config ./tsup.config.ts",
78
82
  "build:types": "tsc --project tsconfig.declarations.json",
83
+ "check:types:astro-fixtures": "node --experimental-strip-types ./scripts/check-fixtures.ts",
79
84
  "check:types:src": "tsc --noEmit",
80
85
  "check:types:test": "tsc --noEmit --project ./tests/tsconfig.json",
81
86
  "check:unused-dependencies": "depcheck",