@studiocms/devapps 0.1.0-beta.18 → 0.1.0-beta.19

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.
@@ -0,0 +1,239 @@
1
+ import fs, { constants } from "node:fs";
2
+ import { access, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { AstroError } from "astro/errors";
5
+ import * as cheerio from "cheerio";
6
+ import sanitizeHtml from "sanitize-html";
7
+ import { Console, Context, Effect, Layer, genLogger } from "studiocms/effect";
8
+ import TurndownService from "turndown";
9
+ import {
10
+ APIEndpointConfig,
11
+ DownloadImageConfig,
12
+ DownloadPostImageConfig,
13
+ StringConfig
14
+ } from "./configs.js";
15
+ const _TurndownService = new TurndownService({
16
+ bulletListMarker: "-",
17
+ codeBlockStyle: "fenced",
18
+ emDelimiter: "*"
19
+ });
20
+ class WordPressAPIUtils extends Effect.Service()("WordPressAPIUtils", {
21
+ effect: genLogger("@studiocms/devapps/effects/WordPressAPI/utils.effect")(function* () {
22
+ const failedDownloads = /* @__PURE__ */ new Set();
23
+ const TDService = Effect.fn(
24
+ (fn) => Effect.try({
25
+ try: () => fn(_TurndownService),
26
+ catch: (cause) => new AstroError(
27
+ "Turndown Error",
28
+ `Failed to convert HTML to Markdown: ${cause.message}`
29
+ )
30
+ })
31
+ );
32
+ const turndown = genLogger("@studiocms/devapps/effects/WordPressAPI/utils.effect.turndown")(
33
+ function* () {
34
+ const { str } = yield* StringConfig;
35
+ return yield* TDService((TD) => TD.turndown(str));
36
+ }
37
+ );
38
+ const stripHtml = genLogger("@studiocms/devapps/effects/WordPressAPI/utils.effect.stripHtml")(
39
+ function* () {
40
+ const { str } = yield* StringConfig;
41
+ return yield* Effect.try(() => sanitizeHtml(str));
42
+ }
43
+ );
44
+ const loadHTML = Effect.fn(
45
+ (fn) => Effect.try({
46
+ try: () => fn(cheerio.load),
47
+ catch: (err) => new AstroError("Error loading content", err instanceof Error ? err.message : `${err}`)
48
+ })
49
+ );
50
+ const cleanUpHtml = genLogger(
51
+ "@studiocms/devapps/effects/WordPressAPI/utils.effect.cleanUpHtml"
52
+ )(function* () {
53
+ const { str } = yield* StringConfig;
54
+ const data = yield* loadHTML((fn) => fn(str));
55
+ const images = data("img");
56
+ for (const image of images) {
57
+ data(image).removeAttr("class").removeAttr("width").removeAttr("height").removeAttr("data-recalc-dims").removeAttr("sizes").removeAttr("srcset");
58
+ }
59
+ data(".wp-polls").html(
60
+ "<em>Polls have been temporarily removed while we migrate to a new platform.</em>"
61
+ );
62
+ data(".wp-polls.loading").remove();
63
+ return data.html();
64
+ });
65
+ const fetchAll = (url, page = 1, results = []) => genLogger("@studiocms/devapps/effects/WordPressAPI/utils.effect.fetchAll")(function* () {
66
+ url.searchParams.set("per_page", "100");
67
+ url.searchParams.set("page", String(page));
68
+ const res = yield* Effect.tryPromise({
69
+ try: () => fetch(url),
70
+ catch: (err) => new AstroError(
71
+ "Unknown Error while querying API",
72
+ err instanceof Error ? err.message : `${err}`
73
+ )
74
+ });
75
+ let data = yield* Effect.tryPromise({
76
+ try: () => res.json(),
77
+ catch: (err) => new AstroError(
78
+ "Unknown Error while reading API data",
79
+ err instanceof Error ? err.message : `${err}`
80
+ )
81
+ });
82
+ if (!Array.isArray(data)) {
83
+ if (typeof data === "object") {
84
+ data = Object.entries(data).map(([id, val]) => {
85
+ if (typeof val === "object") return { id, ...val };
86
+ return { id };
87
+ });
88
+ } else {
89
+ return yield* Effect.fail(
90
+ new AstroError(
91
+ "Expected WordPress API to return an array of items.",
92
+ `Received ${typeof data}:
93
+
94
+ \`\`\`json
95
+ ${JSON.stringify(data, null, 2)}
96
+ \`\`\``
97
+ )
98
+ );
99
+ }
100
+ }
101
+ results.push(...data);
102
+ const totalPages = Number.parseInt(res.headers.get("X-WP-TotalPages") || "1");
103
+ yield* Console.log("Fetched page", page, "of", totalPages);
104
+ if (page < totalPages) {
105
+ yield* Console.log("Fetching next page...");
106
+ return yield* fetchAll(url, page + 1, results);
107
+ }
108
+ return results;
109
+ });
110
+ const apiEndpoint = genLogger(
111
+ "@studiocms/devapps/effects/WordPressAPI/utils.effect.apiEndpoint"
112
+ )(function* () {
113
+ const { endpoint, type, path: path2 } = yield* APIEndpointConfig;
114
+ if (!endpoint) {
115
+ return yield* Effect.fail(
116
+ new AstroError(
117
+ "Missing `endpoint` argument.",
118
+ "Please pass a URL to your WordPress website as the `endpoint` option to the WordPress importer. Most commonly this looks something like `https://example.com/`"
119
+ )
120
+ );
121
+ }
122
+ let newEndpoint = endpoint;
123
+ if (!newEndpoint.endsWith("/")) newEndpoint += "/";
124
+ const apiBase = new URL(newEndpoint);
125
+ if (type === "settings") {
126
+ apiBase.pathname = "wp-json/";
127
+ return apiBase;
128
+ }
129
+ apiBase.pathname = `wp-json/wp/v2/${type}/${path2 ? `${path2}/` : ""}`;
130
+ return apiBase;
131
+ });
132
+ const downloadImage = genLogger(
133
+ "@studiocms/devapps/effects/WordPressAPI/utils.effect.downloadImage"
134
+ )(function* () {
135
+ const { destination, imageUrl } = yield* DownloadImageConfig;
136
+ const fileExists = yield* Effect.tryPromise({
137
+ try: () => access(destination, constants.F_OK).then(() => true),
138
+ catch: () => false
139
+ });
140
+ if (fileExists) {
141
+ yield* Console.error("File already exists:", destination);
142
+ return true;
143
+ }
144
+ const allowedExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"];
145
+ const ext = path.extname(destination.toString()).toLowerCase();
146
+ if (!allowedExtensions.includes(ext)) {
147
+ yield* Console.error("Invalid file extension:", ext);
148
+ return false;
149
+ }
150
+ const response = yield* Effect.tryPromise(() => fetch(imageUrl));
151
+ const contentType = response.headers.get("content-type");
152
+ if (!contentType?.startsWith("image/")) {
153
+ yield* Console.error("Invalid content type:", contentType);
154
+ return false;
155
+ }
156
+ const contentLength = response.headers.get("content-length");
157
+ const maxSize = 100 * 1024 * 1024;
158
+ if (contentLength && Number.parseInt(contentLength) > maxSize) {
159
+ yield* Console.error("File too large:", contentLength);
160
+ return false;
161
+ }
162
+ if (response.ok && response.body) {
163
+ const reader = response.body.getReader();
164
+ const chunks = [];
165
+ let done = false;
166
+ while (!done) {
167
+ const { done: readerDone, value } = yield* Effect.tryPromise(() => reader.read());
168
+ if (value) chunks.push(value);
169
+ done = readerDone;
170
+ }
171
+ const fileBuffer = Buffer.concat(chunks);
172
+ yield* Effect.tryPromise(() => writeFile(destination, fileBuffer, { flag: "wx" }));
173
+ yield* Console.log("Downloaded image:", imageUrl);
174
+ return true;
175
+ }
176
+ yield* Console.error("Failed to download image:", imageUrl);
177
+ return false;
178
+ });
179
+ const downloadPostImage = genLogger(
180
+ "@studiocms/devapps/effects/WordPressAPI/utils.effect.downloadPostImage"
181
+ )(function* () {
182
+ const { str: src, pathToFolder } = yield* DownloadPostImageConfig;
183
+ if (!src || !pathToFolder) return;
184
+ if (!fs.existsSync(pathToFolder)) {
185
+ fs.mkdirSync(pathToFolder, { recursive: true });
186
+ }
187
+ const baseName = path.basename(src);
188
+ const fileName = baseName.split("?")[0];
189
+ if (!fileName) {
190
+ yield* Console.error("Invalid image URL:", src);
191
+ return void 0;
192
+ }
193
+ const destinationFile = path.resolve(pathToFolder, fileName);
194
+ if (fs.existsSync(destinationFile)) {
195
+ yield* Console.log(`Post/Page image "${destinationFile}" already exists, skipping...`);
196
+ return fileName;
197
+ }
198
+ const imageDownloaded = yield* downloadImage.pipe(
199
+ DownloadImageConfig.makeProvide(src, destinationFile)
200
+ );
201
+ if (!imageDownloaded) failedDownloads.add(src);
202
+ return imageDownloaded ? fileName : void 0;
203
+ });
204
+ const downloadAndUpdateImages = genLogger(
205
+ "@studiocms/devapps/effects/WordPressAPI/utils.effect.downloadAndUpdateImages"
206
+ )(function* () {
207
+ const { str: html, pathToFolder } = yield* DownloadPostImageConfig;
208
+ const data = yield* loadHTML((fn) => fn(html));
209
+ const images = data("img");
210
+ for (const image of images) {
211
+ const src = data(image).attr("src");
212
+ if (src) {
213
+ const newSrc = yield* downloadPostImage.pipe(
214
+ DownloadPostImageConfig.makeProvide(src, pathToFolder)
215
+ );
216
+ if (newSrc) {
217
+ data(image).attr("src", newSrc);
218
+ } else {
219
+ data(image).attr("src", src);
220
+ }
221
+ }
222
+ }
223
+ return data.html();
224
+ });
225
+ return {
226
+ turndown,
227
+ stripHtml,
228
+ cleanUpHtml,
229
+ fetchAll,
230
+ apiEndpoint,
231
+ downloadPostImage,
232
+ downloadAndUpdateImages
233
+ };
234
+ })
235
+ }) {
236
+ }
237
+ export {
238
+ WordPressAPIUtils
239
+ };
@@ -0,0 +1,13 @@
1
+ import { Effect } from 'studiocms/effect';
2
+ import { AstroAPIContextProvider } from './WordPressAPI/configs.js';
3
+ import { WordPressAPI } from './WordPressAPI/importers.js';
4
+ declare const WPImporter_base: Effect.Service.Class<WPImporter, "WPImporter", {
5
+ readonly dependencies: readonly [import("effect/Layer").Layer<WordPressAPI, never, never>];
6
+ readonly effect: Effect.Effect<{
7
+ runPostEvent: Effect.Effect<Response, boolean | Error | import("studiocms/sdk/effect/db").LibSQLDatabaseError | import("studiocms/sdk/errors").SDKCoreError | import("effect/ParseResult").ParseError, AstroAPIContextProvider>;
8
+ }, never, WordPressAPI>;
9
+ }>;
10
+ export declare class WPImporter extends WPImporter_base {
11
+ static Provide: <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, WPImporter>>;
12
+ }
13
+ export {};
@@ -0,0 +1,87 @@
1
+ import { Console, Effect, genLogger } from "studiocms/effect";
2
+ import {
3
+ AstroAPIContextProvider,
4
+ ImportEndpointConfig,
5
+ ImportPostsEndpointConfig
6
+ } from "./WordPressAPI/configs.js";
7
+ import { WordPressAPI } from "./WordPressAPI/importers.js";
8
+ const createResponse = (status, statusText) => new Response(null, {
9
+ status,
10
+ statusText,
11
+ headers: {
12
+ "Content-Type": "application/json",
13
+ "Access-Control-Allow-Headers": "*"
14
+ }
15
+ });
16
+ const createErrorResponse = (statusText) => createResponse(400, statusText);
17
+ class WPImporter extends Effect.Service()("WPImporter", {
18
+ dependencies: [WordPressAPI.Default],
19
+ effect: genLogger("@studiocms/devapps/effects/wpImporter.effect")(function* () {
20
+ const WPAPI = yield* WordPressAPI;
21
+ const parseFormData = (formData, name, type, optional = false) => Effect.gen(function* () {
22
+ const data = formData.get(name);
23
+ if (!optional && !data || data === null) {
24
+ throw yield* Effect.fail(new Error(`Missing required form field: ${name}`));
25
+ }
26
+ switch (type) {
27
+ case "string":
28
+ return data.toString();
29
+ case "boolean": {
30
+ const value = data.toString().toLowerCase();
31
+ return value === "true" || value === "1" || value === "yes";
32
+ }
33
+ default:
34
+ throw yield* Effect.fail(
35
+ new Error(`Unsupported type '${type}' for form field: ${name}`)
36
+ );
37
+ }
38
+ });
39
+ const runPostEvent = genLogger("@studiocms/devapps/effects/wpImporter.effect.runPostEvent")(
40
+ function* () {
41
+ const { context } = yield* AstroAPIContextProvider;
42
+ const formData = yield* Effect.tryPromise(() => context.request.formData());
43
+ const url = yield* parseFormData(formData, "url", "string");
44
+ const type = yield* parseFormData(formData, "type", "string");
45
+ const useBlogPlugin = yield* Effect.orElse(
46
+ parseFormData(formData, "useBlogPlugin", "boolean", true),
47
+ () => Effect.succeed(false)
48
+ );
49
+ if (!url || !type) {
50
+ return createErrorResponse("Bad Request");
51
+ }
52
+ yield* Console.log(
53
+ "Starting Import:",
54
+ url,
55
+ "\n Type:",
56
+ type,
57
+ "\n useBlogPlugin:",
58
+ useBlogPlugin
59
+ );
60
+ switch (type) {
61
+ case "pages":
62
+ yield* WPAPI.importPagesFromWPAPI.pipe(ImportEndpointConfig.makeProvide(url));
63
+ break;
64
+ case "posts":
65
+ yield* WPAPI.importPostsFromWPAPI.pipe(
66
+ ImportPostsEndpointConfig.makeProvide(url, useBlogPlugin)
67
+ );
68
+ break;
69
+ case "settings":
70
+ yield* WPAPI.importSettingsFromWPAPI.pipe(ImportEndpointConfig.makeProvide(url));
71
+ break;
72
+ default:
73
+ return createErrorResponse("Bad Request: Invalid import type");
74
+ }
75
+ return createResponse(200, "success");
76
+ }
77
+ );
78
+ return {
79
+ runPostEvent
80
+ };
81
+ })
82
+ }) {
83
+ static Provide = Effect.provide(this.Default);
84
+ }
85
+ export {
86
+ WPImporter
87
+ };
@@ -1,27 +1,2 @@
1
1
  import type { APIRoute } from 'astro';
2
- /**
3
- * Handles the POST request for importing data from a WordPress site.
4
- *
5
- * @param {APIContext} context - The context of the API request.
6
- * @param {Request} context.request - The incoming request object.
7
- *
8
- * @returns {Promise<Response>} The response object indicating the result of the import operation.
9
- *
10
- * The function expects the request to contain form data with the following fields:
11
- * - `url`: The URL of the WordPress site to import data from.
12
- * - `type`: The type of data to import (e.g., 'pages', 'posts', 'settings').
13
- * - `useBlogPlugin` (optional): A boolean value indicating whether to use the blog plugin for importing posts.
14
- *
15
- * The function performs the following steps:
16
- * 1. Extracts the form data from the request.
17
- * 2. Validates the presence and types of the `url` and `type` fields.
18
- * 3. Logs the import operation details.
19
- * 4. Based on the `type` field, calls the appropriate import function:
20
- * - `importPagesFromWPAPI` for importing pages.
21
- * - `importPostsFromWPAPI` for importing posts, optionally using the blog plugin.
22
- * - `importSettingsFromWPAPI` for importing settings.
23
- * 5. Returns a response indicating success or failure.
24
- *
25
- * @throws {Error} If the `type` field contains an invalid value.
26
- */
27
2
  export declare const POST: APIRoute;
@@ -1,57 +1,12 @@
1
- import {
2
- importPagesFromWPAPI,
3
- importPostsFromWPAPI,
4
- importSettingsFromWPAPI
5
- } from "../utils/wp-api/index.js";
6
- const POST = async ({ request }) => {
7
- const data = await request.formData();
8
- const url = data.get("url");
9
- const type = data.get("type");
10
- const useBlogPlugin = data.get("useBlogPlugin");
11
- if (!url || !type) {
12
- return new Response(null, {
13
- status: 400,
14
- statusText: "Bad Request",
15
- headers: {
16
- "Content-Type": "application/json",
17
- "Access-Control-Allow-Headers": "*"
18
- }
19
- });
20
- }
21
- if (typeof url !== "string" || typeof type !== "string") {
22
- return new Response(null, {
23
- status: 400,
24
- statusText: "Bad Request",
25
- headers: {
26
- "Content-Type": "application/json",
27
- "Access-Control-Allow-Headers": "*"
28
- }
29
- });
30
- }
31
- console.log("Starting Import:", url, "\n Type:", type, "\n useBlogPlugin:", useBlogPlugin);
32
- const useBlogPluginValue = useBlogPlugin === "true";
33
- switch (type) {
34
- case "pages":
35
- await importPagesFromWPAPI(url);
36
- break;
37
- case "posts":
38
- await importPostsFromWPAPI(url, useBlogPluginValue);
39
- break;
40
- case "settings":
41
- await importSettingsFromWPAPI(url);
42
- break;
43
- default:
44
- throw new Error("Invalid import type");
45
- }
46
- return new Response(null, {
47
- status: 200,
48
- statusText: "success",
49
- headers: {
50
- "Content-Type": "application/json",
51
- "Access-Control-Allow-Headers": "*"
52
- }
53
- });
54
- };
1
+ import { convertToVanilla, genLogger } from "studiocms/effect";
2
+ import { AstroAPIContextProvider } from "../effects/WordPressAPI/configs.js";
3
+ import { WPImporter } from "../effects/wpImporter.js";
4
+ const POST = async (context) => await convertToVanilla(
5
+ genLogger("@studiocms/devapps/routes/wp-importer.POST")(function* () {
6
+ const WP = yield* WPImporter;
7
+ return yield* WP.runPostEvent.pipe(AstroAPIContextProvider.makeProvide(context));
8
+ }).pipe(WPImporter.Provide)
9
+ );
55
10
  export {
56
11
  POST
57
12
  };
package/dist/virt.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  /// <reference types="@astrojs/db" />
2
+ /// <reference types="studiocms/v/types" />
2
3
 
3
4
  declare module 'virtual:studiocms-devapps/endpoints' {
4
5
  export const wpAPIEndpoint: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studiocms/devapps",
3
- "version": "0.1.0-beta.18",
3
+ "version": "0.1.0-beta.19",
4
4
  "description": "A dedicated CMS for Astro DB. Built from the ground up by the Astro community.",
5
5
  "author": {
6
6
  "name": "Adam Matthiesen | Jacob Jenkins | Paul Valladares",
@@ -57,18 +57,18 @@
57
57
  "astro-integration-kit": "^0.18",
58
58
  "cheerio": "^1.0.0",
59
59
  "turndown": "^7.2.0",
60
- "html-entities": "^2.5.2"
60
+ "sanitize-html": "^2.17.0"
61
61
  },
62
62
  "devDependencies": {
63
- "@types/cheerio": "^0.22.35",
64
63
  "@types/turndown": "^5.0.5",
65
- "typescript": "^5.7"
64
+ "@types/sanitize-html": "^2.16.0",
65
+ "typescript": "^5.8.2"
66
66
  },
67
67
  "peerDependencies": {
68
- "@astrojs/db": "^0.14.10",
69
- "astro": "^5.7.1",
70
- "vite": "^6.2.6",
71
- "studiocms": "0.1.0-beta.18"
68
+ "@astrojs/db": "^0.15",
69
+ "astro": "^5.9",
70
+ "vite": "^6.3.4",
71
+ "studiocms": "0.1.0-beta.19"
72
72
  },
73
73
  "peerDependenciesMeta": {
74
74
  "studiocms": {