astro-buffalo 0.1.2 → 0.3.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/README.md CHANGED
@@ -3,6 +3,8 @@
3
3
  Share newly published Astro articles to Buffer using its experimental `createContentItem` API.
4
4
  One request per article, with a variant for each channel. No SDK, database, or browser JavaScript.
5
5
 
6
+ An independent integration; not affiliated with or endorsed by Buffer.
7
+
6
8
  ## Setup
7
9
 
8
10
  Requires Astro 7.x and Node 24 or newer.
@@ -60,10 +62,12 @@ The endpoint must return a JSON array with these fields on each article:
60
62
  | --- | --- | --- |
61
63
  | `filePath` | Yes | Source file path relative to the Astro project root, using forward slashes; no absolute paths or `.`/`..` segments. Must match the Git path, e.g. `src/content/blog/hello.md`. |
62
64
  | `title` | Yes | Non-empty article title, also used as the default announcement. |
63
- | `url` | Yes | Absolute HTTP(S) canonical production URL, without whitespace, credentials, or a fragment. Must match the deployed URL without a redirect. |
65
+ | `url` | Yes | Absolute HTTP(S) canonical production URL, without whitespace or a fragment. Must match the deployed URL without a redirect. |
64
66
  | `text` | No | Non-empty custom announcement text; the article URL is always appended. |
67
+ | `mode` | No | `shareNow` or `addToQueue`; overrides the integration's default for this article. |
68
+ | `channelIds` | No | Non-empty array of Buffer channel IDs; overrides the integration's selection for this article. |
65
69
 
66
- An empty array is valid; invalid entries stop the build before any Buffer requests.
70
+ An empty array is valid; invalid entries or duplicate URLs stop the build before any Buffer requests.
67
71
  Adapt the collection name and routes to your site. Include only public articles, filtering drafts
68
72
  and future dates if your schema defines them:
69
73
 
@@ -103,6 +107,9 @@ mode: "addToQueue",
103
107
  `mode` supports `shareNow` (default) and `addToQueue`; both use automatic delivery.
104
108
  Disconnected or locked channels are reported and skipped.
105
109
 
110
+ For different choices per article, return `mode` and/or `channelIds` in each manifest entry.
111
+ Articles without these fields use the integration settings.
112
+
106
113
  Announcements use `title + "\n\n" + url`. Add `text: post.data.description` to an article's manifest
107
114
  entry for custom copy; the content item's title stays unchanged. Custom text is trimmed and must
108
115
  be non-empty. The URL is always appended for duplicate detection.
package/dist/git.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export declare function findNewArticles<T extends {
2
+ filePath: string;
3
+ }>(articles: T[], root: URL, contentDirectory: string): T[];
package/dist/git.js ADDED
@@ -0,0 +1,43 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import { parse } from "yaml";
4
+ function isDraft(source) {
5
+ const lines = (source.startsWith("\uFEFF") ? source.slice(1) : source).split("\n");
6
+ if (lines.length < 2 || lines[0].trimEnd() !== "---")
7
+ return false;
8
+ const end = lines.findIndex((line, index) => index > 0 && line.trimEnd() === "---");
9
+ if (end === -1)
10
+ throw new Error("Missing closing YAML frontmatter delimiter.");
11
+ const data = parse(lines.slice(1, end).join("\n"));
12
+ return typeof data === "object" && data !== null && "draft" in data && data.draft === true;
13
+ }
14
+ export function findNewArticles(articles, root, contentDirectory) {
15
+ const base = process.env.BUFFER_BASE_REF;
16
+ if (!base || !/^[a-f0-9]{40}$/i.test(base) || /^0+$/.test(base)) {
17
+ throw new Error("Set BUFFER_BASE_REF to the full commit SHA before the new posts.");
18
+ }
19
+ const git = (...args) => execFileSync("git", args, { cwd: fileURLToPath(root), encoding: "utf8" });
20
+ const changes = git("diff", "--name-status", "--relative", "-z", "--diff-filter=AMR", "--find-renames", base, "HEAD", "--", contentDirectory)
21
+ .split("\0")
22
+ .filter(Boolean);
23
+ const previousPaths = new Map();
24
+ for (let index = 0; index < changes.length;) {
25
+ const status = changes[index++];
26
+ const path = changes[index++];
27
+ if (status.startsWith("R")) {
28
+ previousPaths.set(changes[index++], path);
29
+ }
30
+ else {
31
+ previousPaths.set(path, status === "A" ? undefined : path);
32
+ }
33
+ }
34
+ return articles.filter(({ filePath }) => {
35
+ if (!previousPaths.has(filePath))
36
+ return false;
37
+ const previousPath = previousPaths.get(filePath);
38
+ if (previousPath && !isDraft(git("show", `${base}:./${previousPath}`))) {
39
+ return false;
40
+ }
41
+ return !isDraft(git("show", `HEAD:./${filePath}`));
42
+ });
43
+ }
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
- import { execFileSync } from "node:child_process";
2
1
  import { readFile, unlink } from "node:fs/promises";
3
- import timers from "node:timers/promises";
4
2
  import { fileURLToPath } from "node:url";
5
3
  import { z } from "astro/zod";
6
- import { parse } from "yaml";
7
- const articlesSchema = z.array(z.object({
4
+ import { findNewArticles } from "./git.js";
5
+ import { publishPosts } from "./publish.js";
6
+ const articlesSchema = z
7
+ .array(z.object({
8
8
  filePath: z
9
9
  .string()
10
10
  .refine((path) => path.trim() &&
@@ -17,75 +17,31 @@ const articlesSchema = z.array(z.object({
17
17
  if (/\s/.test(value) || !URL.canParse(value))
18
18
  return false;
19
19
  const url = new URL(value);
20
- return (["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.hash);
21
- }, "url must be an absolute HTTP(S) URL without whitespace, credentials, or a fragment."),
20
+ return ["http:", "https:"].includes(url.protocol) && !url.hash;
21
+ }, "url must be an absolute HTTP(S) URL without whitespace or a fragment."),
22
22
  text: z
23
23
  .string({ message: "announcement text must be a non-empty string." })
24
24
  .refine((text) => text.trim(), "announcement text must be a non-empty string.")
25
25
  .optional(),
26
- }, { message: "must be an object." }), { message: "Buffer manifest must be an array of articles." });
27
- function isDraft(source) {
28
- const lines = (source.startsWith("\uFEFF") ? source.slice(1) : source).split("\n");
29
- if (lines.length < 2 || lines[0].trimEnd() !== "---")
30
- return false;
31
- const end = lines.findIndex((line, index) => index > 0 && line.trimEnd() === "---");
32
- if (end === -1)
33
- throw new Error("Missing closing YAML frontmatter delimiter.");
34
- const data = parse(lines.slice(1, end).join("\n"));
35
- return typeof data === "object" && data !== null && "draft" in data && data.draft === true;
36
- }
37
- async function waitForDeployment(url, logger) {
38
- const deadline = Date.now() + 300_000;
39
- while (Date.now() < deadline) {
40
- let response;
41
- try {
42
- response = await fetch(url, {
43
- method: "HEAD",
44
- signal: AbortSignal.timeout(10_000),
26
+ mode: z.enum(["shareNow", "addToQueue"]).optional(),
27
+ channelIds: z
28
+ .array(z.string().refine((id) => id.trim(), "channel ID must be non-empty."))
29
+ .min(1)
30
+ .optional(),
31
+ }, { message: "must be an object." }), { message: "Buffer manifest must be an array of articles." })
32
+ .superRefine((articles, context) => {
33
+ const urls = new Set();
34
+ for (const [index, { url }] of articles.entries()) {
35
+ if (urls.has(url)) {
36
+ context.addIssue({
37
+ code: "custom",
38
+ path: [index, "url"],
39
+ message: `Duplicate article URL: ${url}.`,
45
40
  });
46
41
  }
47
- catch (error) {
48
- if (!(error instanceof TypeError) &&
49
- !(error instanceof Error && error.name === "TimeoutError")) {
50
- throw error;
51
- }
52
- logger.warn(`Cannot reach ${url}: ${error.message}. Retrying.`);
53
- }
54
- if (response) {
55
- if (response.ok && response.url === url)
56
- return;
57
- if (![404, 502, 503, 504].includes(response.status)) {
58
- throw new Error(`Cannot verify ${url}: HTTP ${response.status}.`);
59
- }
60
- }
61
- logger.info(`Waiting for ${url} to be deployed...`);
62
- await timers.setTimeout(10_000);
63
- }
64
- throw new Error(`${url} is not live yet. Rerun after deployment succeeds.`);
65
- }
66
- async function request(token, query, variables) {
67
- const response = await fetch("https://api.buffer.com", {
68
- method: "POST",
69
- headers: {
70
- Authorization: `Bearer ${token}`,
71
- "Content-Type": "application/json",
72
- },
73
- body: JSON.stringify({ query, variables }),
74
- signal: AbortSignal.timeout(30_000),
75
- });
76
- if (!response.ok) {
77
- const retryAfter = response.headers.get("Retry-After");
78
- throw new Error(`Buffer request failed: HTTP ${response.status}.${retryAfter ? ` Retry after ${retryAfter} seconds.` : ""}`);
79
- }
80
- const result = await response.json();
81
- if (result.errors?.length) {
82
- throw new Error(`Buffer: ${result.errors.map((error) => error.message).join("; ")}`);
42
+ urls.add(url);
83
43
  }
84
- if (!result.data) {
85
- throw new Error("Buffer returned no data.");
86
- }
87
- return result.data;
88
- }
44
+ });
89
45
  export default function buffer({ posts: entrypoint, contentDirectory, channelIds, mode = "shareNow", }) {
90
46
  if (mode !== "shareNow" && mode !== "addToQueue") {
91
47
  throw new Error('mode must be "shareNow" or "addToQueue".');
@@ -131,152 +87,16 @@ export default function buffer({ posts: entrypoint, contentDirectory, channelIds
131
87
  .join("\n"));
132
88
  }
133
89
  const articles = result.data;
134
- const base = process.env.BUFFER_BASE_REF;
135
- if (!base || !/^[a-f0-9]{40}$/i.test(base) || /^0+$/.test(base)) {
136
- throw new Error("Set BUFFER_BASE_REF to the full commit SHA before the new posts.");
137
- }
138
- const git = (...args) => execFileSync("git", args, { cwd: fileURLToPath(root), encoding: "utf8" });
139
- const changes = git("diff", "--name-status", "--relative", "-z", "--diff-filter=AMR", "--find-renames", base, "HEAD", "--", contentDirectory)
140
- .split("\0")
141
- .filter(Boolean);
142
- const previousPaths = new Map();
143
- for (let index = 0; index < changes.length;) {
144
- const status = changes[index++];
145
- const path = changes[index++];
146
- if (status.startsWith("R")) {
147
- previousPaths.set(changes[index++], path);
148
- }
149
- else {
150
- previousPaths.set(path, status === "A" ? undefined : path);
151
- }
152
- }
153
- const posts = articles
154
- .filter(({ filePath }) => {
155
- if (!previousPaths.has(filePath))
156
- return false;
157
- const previousPath = previousPaths.get(filePath);
158
- if (previousPath && !isDraft(git("show", `${base}:./${previousPath}`))) {
159
- return false;
160
- }
161
- return !isDraft(git("show", `HEAD:./${filePath}`));
162
- })
163
- .map((article) => ({
90
+ const posts = findNewArticles(articles, root, contentDirectory).map((article) => ({
164
91
  ...article,
165
92
  text: `${article.text?.trim() ?? article.title}\n\n${article.url}`,
93
+ mode: article.mode ?? mode,
166
94
  }));
167
95
  if (!posts.length) {
168
96
  logger.info("No new blog posts to share.");
169
97
  return;
170
98
  }
171
- const token = process.env.BUFFER_API_KEY;
172
- const organizationId = process.env.BUFFER_ORGANIZATION_ID;
173
- if (!token || !organizationId) {
174
- throw new Error("Set BUFFER_API_KEY and BUFFER_ORGANIZATION_ID before publishing.");
175
- }
176
- const { channels } = await request(token, `query Channels($input: ChannelsInput!) {
177
- channels(input: $input) { id name isDisconnected isLocked }
178
- }`, { input: { organizationId } });
179
- if (channelIds) {
180
- const unknown = channelIds.filter((id) => !channels.some((channel) => channel.id === id));
181
- if (unknown.length) {
182
- throw new Error(`Unknown Buffer channel IDs: ${unknown.join(", ")}.`);
183
- }
184
- }
185
- const connected = channels.filter((channel) => {
186
- if (channelIds && !channelIds.includes(channel.id))
187
- return false;
188
- if (channel.isDisconnected || channel.isLocked) {
189
- logger.warn(`Cannot publish to "${channel.name}": disconnected or locked in Buffer.`);
190
- return false;
191
- }
192
- return true;
193
- });
194
- if (!connected.length) {
195
- throw new Error("No connected, unlocked Buffer channels found.");
196
- }
197
- const existing = [];
198
- let after = null;
199
- do {
200
- const { posts: page } = await request(token, `query Posts($input: PostsInput!, $after: String) {
201
- posts(input: $input, first: 100, after: $after) {
202
- edges { node { id text channelId status } }
203
- pageInfo { hasNextPage endCursor }
204
- }
205
- }`, {
206
- input: { organizationId, filter: { channelIds: connected.map(({ id }) => id) } },
207
- after,
208
- });
209
- if (!page.edges) {
210
- throw new Error("Buffer returned no post history.");
211
- }
212
- existing.push(...page.edges.map(({ node }) => node));
213
- if (page.pageInfo.hasNextPage &&
214
- (!page.pageInfo.endCursor || page.pageInfo.endCursor === after)) {
215
- throw new Error("Buffer returned an invalid pagination cursor.");
216
- }
217
- after = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null;
218
- } while (after);
219
- const failures = [];
220
- for (const article of posts) {
221
- const pending = [];
222
- for (const channel of connected) {
223
- const shared = existing.find((post) => post.channelId === channel.id && post.text.split(/\s+/).includes(article.url));
224
- if (shared) {
225
- if (!["sent", "scheduled", "sending"].includes(shared.status)) {
226
- failures.push(new Error(`Review Buffer post ${shared.id} for "${channel.name}": ${shared.status}.`));
227
- }
228
- logger.info(`Already in Buffer: "${article.title}" -> "${channel.name}".`);
229
- continue;
230
- }
231
- pending.push(channel);
232
- }
233
- if (!pending.length)
234
- continue;
235
- if (dryRun) {
236
- logger.info(`Dry run: ${JSON.stringify({
237
- title: article.title,
238
- text: article.text,
239
- channels: pending.map(({ id, name }) => ({ id, name })),
240
- mode,
241
- })}`);
242
- continue;
243
- }
244
- await waitForDeployment(article.url, logger);
245
- const { createContentItem } = await request(token, `mutation Publish($input: CreateContentItemInput!) {
246
- createContentItem(input: $input) {
247
- ... on CreateContentItemSuccess { content { id } }
248
- ... on CreateContentItemFailure {
249
- message
250
- errors { ... on MutationError { message } }
251
- }
252
- }
253
- }`, {
254
- input: {
255
- organizationId,
256
- title: article.title,
257
- posts: pending.map(({ id }) => ({
258
- channelId: id,
259
- text: article.text,
260
- schedulingType: "automatic",
261
- mode,
262
- })),
263
- },
264
- });
265
- if (!createContentItem.content?.id) {
266
- failures.push(new Error(`"${article.title}": ${[
267
- createContentItem.message ?? "No content item ID returned.",
268
- ...(createContentItem.errors ?? []).map(({ message }) => message),
269
- ].join("; ")}`));
270
- continue;
271
- }
272
- logger.info(`Submitted "${article.title}" to ${pending.length} channels (${createContentItem.content.id}).`);
273
- }
274
- if (failures.length) {
275
- throw new AggregateError(failures, [
276
- "Some Buffer posts need attention; reruns skip existing posts.",
277
- ...failures.map((error) => error.message),
278
- ].join("\n"));
279
- }
99
+ await publishPosts(posts, { channelIds, dryRun, logger });
280
100
  },
281
101
  },
282
102
  };
@@ -0,0 +1,14 @@
1
+ import type { AstroIntegrationLogger } from "astro";
2
+ interface Article {
3
+ title: string;
4
+ url: string;
5
+ text: string;
6
+ mode: "shareNow" | "addToQueue";
7
+ channelIds?: string[];
8
+ }
9
+ export declare function publishPosts(posts: Article[], { channelIds, dryRun, logger, }: {
10
+ channelIds?: string[];
11
+ dryRun: boolean;
12
+ logger: AstroIntegrationLogger;
13
+ }): Promise<void>;
14
+ export {};
@@ -0,0 +1,175 @@
1
+ import timers from "node:timers/promises";
2
+ async function waitForDeployment(url, logger) {
3
+ const deadline = Date.now() + 300_000;
4
+ while (Date.now() < deadline) {
5
+ let response;
6
+ try {
7
+ response = await fetch(url, {
8
+ method: "HEAD",
9
+ signal: AbortSignal.timeout(10_000),
10
+ });
11
+ }
12
+ catch (error) {
13
+ if (!(error instanceof TypeError) &&
14
+ !(error instanceof Error && error.name === "TimeoutError")) {
15
+ throw error;
16
+ }
17
+ logger.warn(`Cannot reach ${url}: ${error.message}. Retrying.`);
18
+ }
19
+ if (response) {
20
+ if (response.ok && response.url === url)
21
+ return;
22
+ if (![404, 502, 503, 504].includes(response.status)) {
23
+ throw new Error(`Cannot verify ${url}: HTTP ${response.status}.`);
24
+ }
25
+ }
26
+ logger.info(`Waiting for ${url} to be deployed...`);
27
+ await timers.setTimeout(10_000);
28
+ }
29
+ throw new Error(`${url} is not live yet. Rerun after deployment succeeds.`);
30
+ }
31
+ async function request(token, query, variables) {
32
+ const response = await fetch("https://api.buffer.com", {
33
+ method: "POST",
34
+ headers: {
35
+ Authorization: `Bearer ${token}`,
36
+ "Content-Type": "application/json",
37
+ },
38
+ body: JSON.stringify({ query, variables }),
39
+ signal: AbortSignal.timeout(30_000),
40
+ });
41
+ if (!response.ok) {
42
+ const retryAfter = response.headers.get("Retry-After");
43
+ throw new Error(`Buffer request failed: HTTP ${response.status}.${retryAfter ? ` Retry after ${retryAfter} seconds.` : ""}`);
44
+ }
45
+ const result = await response.json();
46
+ if (result.errors?.length) {
47
+ throw new Error(`Buffer: ${result.errors.map((error) => error.message).join("; ")}`);
48
+ }
49
+ if (!result.data) {
50
+ throw new Error("Buffer returned no data.");
51
+ }
52
+ return result.data;
53
+ }
54
+ export async function publishPosts(posts, { channelIds, dryRun, logger, }) {
55
+ const token = process.env.BUFFER_API_KEY;
56
+ const organizationId = process.env.BUFFER_ORGANIZATION_ID;
57
+ if (!token || !organizationId) {
58
+ throw new Error("Set BUFFER_API_KEY and BUFFER_ORGANIZATION_ID before publishing.");
59
+ }
60
+ const { channels } = await request(token, `query Channels($input: ChannelsInput!) {
61
+ channels(input: $input) { id name isDisconnected isLocked }
62
+ }`, { input: { organizationId } });
63
+ const selections = posts.map((article) => ({
64
+ article,
65
+ ids: article.channelIds ?? channelIds,
66
+ }));
67
+ const unknown = [
68
+ ...new Set(selections.flatMap(({ ids }) => ids?.filter((id) => !channels.some((channel) => channel.id === id)) ?? [])),
69
+ ];
70
+ if (unknown.length) {
71
+ throw new Error(`Unknown Buffer channel IDs: ${unknown.join(", ")}.`);
72
+ }
73
+ const connected = channels.filter((channel) => {
74
+ if (!selections.some(({ ids }) => !ids || ids.includes(channel.id)))
75
+ return false;
76
+ if (channel.isDisconnected || channel.isLocked) {
77
+ logger.warn(`Cannot publish to "${channel.name}": disconnected or locked in Buffer.`);
78
+ return false;
79
+ }
80
+ return true;
81
+ });
82
+ if (!connected.length) {
83
+ throw new Error("No connected, unlocked Buffer channels found.");
84
+ }
85
+ const postChannels = selections.map(({ article, ids }) => {
86
+ const selected = connected.filter((channel) => !ids || ids.includes(channel.id));
87
+ if (!selected.length) {
88
+ throw new Error(`No connected, unlocked Buffer channels found for "${article.title}".`);
89
+ }
90
+ return { article, channels: selected };
91
+ });
92
+ const existing = [];
93
+ let after = null;
94
+ do {
95
+ const { posts: page } = await request(token, `query Posts($input: PostsInput!, $after: String) {
96
+ posts(input: $input, first: 100, after: $after) {
97
+ edges { node { id text channelId status } }
98
+ pageInfo { hasNextPage endCursor }
99
+ }
100
+ }`, {
101
+ input: { organizationId, filter: { channelIds: connected.map(({ id }) => id) } },
102
+ after,
103
+ });
104
+ if (!page.edges) {
105
+ throw new Error("Buffer returned no post history.");
106
+ }
107
+ existing.push(...page.edges.map(({ node }) => node));
108
+ if (page.pageInfo.hasNextPage &&
109
+ (!page.pageInfo.endCursor || page.pageInfo.endCursor === after)) {
110
+ throw new Error("Buffer returned an invalid pagination cursor.");
111
+ }
112
+ after = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null;
113
+ } while (after);
114
+ const failures = [];
115
+ for (const { article, channels } of postChannels) {
116
+ const pending = [];
117
+ for (const channel of channels) {
118
+ const shared = existing.find((post) => post.channelId === channel.id && post.text.split(/\s+/).includes(article.url));
119
+ if (shared) {
120
+ if (!["sent", "scheduled", "sending"].includes(shared.status)) {
121
+ failures.push(new Error(`Review Buffer post ${shared.id} for "${channel.name}": ${shared.status}.`));
122
+ }
123
+ logger.info(`Already in Buffer: "${article.title}" -> "${channel.name}".`);
124
+ continue;
125
+ }
126
+ pending.push(channel);
127
+ }
128
+ if (!pending.length)
129
+ continue;
130
+ if (dryRun) {
131
+ logger.info(`Dry run: ${JSON.stringify({
132
+ title: article.title,
133
+ text: article.text,
134
+ channels: pending.map(({ id, name }) => ({ id, name })),
135
+ mode: article.mode,
136
+ })}`);
137
+ continue;
138
+ }
139
+ await waitForDeployment(article.url, logger);
140
+ const { createContentItem } = await request(token, `mutation Publish($input: CreateContentItemInput!) {
141
+ createContentItem(input: $input) {
142
+ ... on CreateContentItemSuccess { content { id } }
143
+ ... on CreateContentItemFailure {
144
+ message
145
+ errors { ... on MutationError { message } }
146
+ }
147
+ }
148
+ }`, {
149
+ input: {
150
+ organizationId,
151
+ title: article.title,
152
+ posts: pending.map(({ id }) => ({
153
+ channelId: id,
154
+ text: article.text,
155
+ schedulingType: "automatic",
156
+ mode: article.mode,
157
+ })),
158
+ },
159
+ });
160
+ if (!createContentItem.content?.id) {
161
+ failures.push(new Error(`"${article.title}": ${[
162
+ createContentItem.message ?? "No content item ID returned.",
163
+ ...(createContentItem.errors ?? []).map(({ message }) => message),
164
+ ].join("; ")}`));
165
+ continue;
166
+ }
167
+ logger.info(`Submitted "${article.title}" to ${pending.length} channels (${createContentItem.content.id}).`);
168
+ }
169
+ if (failures.length) {
170
+ throw new AggregateError(failures, [
171
+ "Some Buffer posts need attention; reruns skip existing posts.",
172
+ ...failures.map((error) => error.message),
173
+ ].join("\n"));
174
+ }
175
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-buffalo",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Share newly published Astro articles through Buffer.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/HiroAgustin/astro-buffalo#readme",