Compare commits

..
Author SHA1 Message Date
sriram veeraghanta 3e895da0e8 fix: live server setup 2025-04-04 16:42:27 +05:30
Surya Prashanth 483961ea5a chore: refactor redis, hocus-pocus implementations 2025-04-04 14:54:04 +05:30
sriram veeraghanta ecefd47823 fix: startup script fixes 2025-04-04 01:18:22 +05:30
sriram veeraghanta 3359172acf fix: live server setup and start process 2025-04-04 01:10:34 +05:30
sriram veeraghanta 18a3771315 fix: removed unused dependencies 2025-04-03 20:50:29 +05:30
sriram veeraghanta 31c09d5a48 Merge branch 'preview' of github.com:makeplane/plane into fix/live-server-restructuring 2025-04-03 20:25:16 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d62ac6269b chore(deps): bump next in the npm_and_yarn group across 1 directory (#6865)
Bumps the npm_and_yarn group with 1 update in the / directory: [next](https://github.com/vercel/next.js).


Updates `next` from 14.2.25 to 14.2.26
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.25...v14.2.26)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 14.2.26
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-03 20:23:05 +05:30
Palanikannan M 1315acf952 fix: redis extension fixes 2025-04-03 17:56:33 +05:30
Palanikannan M 728f517cb1 fix: logger reference 2025-04-03 17:47:56 +05:30
Palanikannan M cb3b6fbd8d Merge branch 'preview' into fix/live-server-restructuring 2025-04-03 17:37:38 +05:30
Palanikannan M 70dbad648d chore: remove services 2025-04-03 17:37:03 +05:30
83 changed files with 788 additions and 2825 deletions
+1 -1
View File
@@ -29,7 +29,7 @@
"lucide-react": "^0.469.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "^14.2.25",
"next": "^14.2.26",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
-1
View File
@@ -550,7 +550,6 @@ class PagesDescriptionViewSet(BaseViewSet):
new_value=request.data, old_value=existing_instance, page_id=pk
)
# Store the updated binary data
page.name = request.data.get("name", page.name)
page.description_binary = new_binary_data
page.description_html = request.data.get("description_html")
page.description = request.data.get("description")
-23
View File
@@ -1,23 +0,0 @@
{
"presets": [
[
"@babel/preset-env",
{
"modules": false
}
],
"@babel/preset-typescript"
],
"plugins": [
[
"module-resolver",
{
"root": ["./src"],
"alias": {
"@/core": "./src/core",
"@/plane-live": "./src/ce"
}
}
]
]
}
+1 -4
View File
@@ -22,7 +22,6 @@
"@hocuspocus/extension-logger": "^2.15.0",
"@hocuspocus/extension-redis": "^2.15.0",
"@hocuspocus/server": "^2.15.0",
"@hocuspocus/transformer": "^2.15.2",
"@plane/constants": "*",
"@plane/decorators": "*",
"@plane/editor": "*",
@@ -58,9 +57,7 @@
"@types/express": "^4.17.21",
"@types/express-ws": "^3.0.4",
"@types/node": "^20.14.9",
"concurrently": "^9.0.1",
"nodemon": "^3.1.7",
"tsup": "8.3.0",
"tsup": "8.4.0",
"typescript": "5.3.3"
}
}
+1 -1
View File
@@ -1 +1 @@
export * from "./register";
export * from "./register";
@@ -1,107 +0,0 @@
import { PageService } from "@/core/services/page.service";
import { transformHTMLToBinary } from "./transformers";
import { getAllDocumentFormatsFromBinaryData } from "@/core/helpers/page";
import { logger } from "@plane/logger";
import { HocusPocusServerContext } from "@/core/types/common";
const pageService = new PageService();
/**
* Fetches the binary description data for a project page
* Falls back to HTML transformation if binary is not available
*/
export const fetchPageDescriptionBinary = async ({
pageId,
context,
}: {
pageId: string;
context: HocusPocusServerContext;
}) => {
const { workspaceSlug, projectId, cookie } = context;
if (!workspaceSlug || !projectId || !cookie) return null;
const response = await pageService.fetchDescriptionBinary(workspaceSlug, projectId, pageId, cookie);
const binaryData = new Uint8Array(response);
if (binaryData.byteLength === 0) {
const binary = await transformHTMLToBinary(workspaceSlug, projectId, pageId, cookie);
if (binary) {
return binary;
}
}
return binaryData;
};
/**
* Updates the description of a project page
*/
export const updatePageDescription = async ({
context,
pageId,
state: updatedDescription,
title,
}: {
context: HocusPocusServerContext;
pageId: string;
state: Uint8Array;
title: string;
}) => {
if (!(updatedDescription instanceof Uint8Array)) {
throw new Error("Invalid updatedDescription: must be an instance of Uint8Array");
}
const { workspaceSlug, projectId, cookie } = context;
if (!workspaceSlug || !projectId || !cookie) return;
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromBinaryData(updatedDescription);
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
name: title,
};
await pageService.updateDescription(workspaceSlug, projectId, pageId, payload, cookie);
};
export const fetchProjectPageTitle = async ({
context,
pageId,
}: {
context: HocusPocusServerContext;
pageId: string;
}) => {
const { workspaceSlug, projectId, cookie } = context;
if (!workspaceSlug || !projectId || !cookie) return;
try {
const pageDetails = await pageService.fetchDetails(workspaceSlug, projectId, pageId, cookie);
return pageDetails.name;
} catch (error) {
logger.error("Error while transforming from HTML to Uint8Array", error);
throw error;
}
};
export const updateProjectPageTitle = async ({
context,
pageId,
title,
abortSignal,
}: {
context: HocusPocusServerContext;
pageId: string;
title: string;
abortSignal?: AbortSignal;
}) => {
const { workspaceSlug, projectId, cookie } = context;
if (!workspaceSlug || !projectId || !cookie) return;
const payload = {
name: title,
};
await pageService.updateTitle(workspaceSlug, projectId, pageId, payload, cookie, abortSignal);
};
@@ -1,3 +1,2 @@
export * from "./handlers"
export * from "./transformers";
export * from "./project-page-handler";
@@ -5,12 +5,11 @@ import {
HandlerDefinition,
} from "@/core/types/document-handler";
import { handlerFactory } from "@/core/handlers/document-handlers/handler-factory";
import {
fetchPageDescriptionBinary,
updatePageDescription,
fetchProjectPageTitle,
updateProjectPageTitle,
} from "./handlers";
import { PageService } from "@/services/page.service";
import { transformHTMLToBinary } from "./transformers";
import { getAllDocumentFormatsFromBinaryData } from "@/core/helpers/page";
const pageService = new PageService();
/**
* Handler for "project_page" document type
@@ -19,19 +18,47 @@ export const projectPageHandler: DocumentHandler = {
/**
* Fetch project page description
*/
fetch: fetchPageDescriptionBinary,
fetch: async ({ pageId, params, context }: DocumentFetchParams) => {
const { cookie } = context;
const workspaceSlug = params.get("workspaceSlug")?.toString();
const projectId = params.get("projectId")?.toString();
if (!workspaceSlug || !projectId || !cookie) return null;
const response = await pageService.fetchDescriptionBinary(workspaceSlug, projectId, pageId, cookie);
const binaryData = new Uint8Array(response);
if (binaryData.byteLength === 0) {
const binary = await transformHTMLToBinary(workspaceSlug, projectId, pageId, cookie);
if (binary) {
return binary;
}
}
return binaryData;
},
/**
* Store project page description
*/
store: updatePageDescription,
/**
* Fetch project page title
*/
fetchTitle: fetchProjectPageTitle,
/**
* Store project page title
*/
updateTitle: updateProjectPageTitle,
store: async ({ pageId, state, params, context }: DocumentStoreParams) => {
const { cookie } = context;
if (!(state instanceof Uint8Array)) {
throw new Error("Invalid state: must be an instance of Uint8Array");
}
const workspaceSlug = params?.get("workspaceSlug")?.toString();
const projectId = params?.get("projectId")?.toString();
if (!workspaceSlug || !projectId || !cookie) return;
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromBinaryData(state);
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
await pageService.updateDescription(workspaceSlug, projectId, pageId, payload, cookie);
},
};
// Define the project page handler definition
@@ -1,6 +1,6 @@
import { PageService } from "@/core/services/page.service";
import { PageService } from "@/services/page.service";
import { getBinaryDataFromHTMLString } from "@/core/helpers/page";
import logger from "@plane/logger";
import { logger } from "@plane/logger";
const pageService = new PageService();
@@ -23,4 +23,4 @@ export const transformHTMLToBinary = async (
logger.error("Error while transforming from HTML to Uint8Array", error);
throw error;
}
};
};
+1 -1
View File
@@ -1,4 +1,4 @@
import { registerProjectPageHandler } from "./project-page";
import { registerProjectPageHandler } from "./project-page/project-page-handler";
export function initializeDocumentHandlers() {
registerProjectPageHandler();
-25
View File
@@ -1,25 +0,0 @@
// types
import { AppError, catchAsync } from "@/core/helpers/error-handling/error-handler";
import { TDocumentTypes } from "@/core/types/common";
interface TArgs {
cookie: string;
documentType: TDocumentTypes;
pageId: string;
params: URLSearchParams;
}
export const fetchDocument = async (args: TArgs): Promise<Uint8Array | null> => {
const { cookie, documentType, pageId, params } = args;
if (!documentType) {
throw new AppError("Document type is required");
}
if (!pageId) {
throw new AppError("Page ID is required");
}
return null
};
-15
View File
@@ -1,15 +0,0 @@
// types
import { TDocumentTypes } from "@/core/types/common";
type TArgs = {
cookie: string | undefined;
documentType: TDocumentTypes | undefined;
pageId: string;
params: URLSearchParams;
updatedDescription: Uint8Array;
}
export const updateDocument = async (args: TArgs): Promise<void> => {
const { documentType } = args;
throw Error(`Update failed: Invalid document type ${documentType} provided.`);
}
-66
View File
@@ -1,66 +0,0 @@
import { env } from "@/env";
import compression from "compression";
import helmet from "helmet";
import cors from "cors";
import cookieParser from "cookie-parser";
import express from "express";
import { logger } from "@plane/logger";
import { logger as loggerMiddleware } from "@/core/helpers/logger";
/**
* Configure server middleware
* @param app Express application
*/
export function configureServerMiddleware(app: express.Application): void {
// Security middleware
app.use(helmet());
// CORS configuration
configureCors(app);
// Compression middleware
app.use(
compression({
level: env.COMPRESSION_LEVEL,
threshold: env.COMPRESSION_THRESHOLD,
}) as unknown as express.RequestHandler
);
// Cookie parsing
app.use(cookieParser());
// Logging middleware
app.use(loggerMiddleware);
// Body parsing middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
}
/**
* Configure CORS
* @param app Express application
*/
function configureCors(app: express.Application): void {
const origins = env.CORS_ALLOWED_ORIGINS?.split(",").map((origin) => origin.trim()) || [];
for (const origin of origins) {
logger.info(`Adding CORS allowed origin: ${origin}`);
app.use(
cors({
origin,
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "x-api-key"],
})
);
}
}
/**
* Server configuration
*/
export const serverConfig = {
port: env.PORT,
basePath: env.LIVE_BASE_PATH,
terminationTimeout: env.SHUTDOWN_TIMEOUT,
};
@@ -1,9 +1,9 @@
import type { Request } from "express";
import type { WebSocket as WS } from "ws";
import type { Hocuspocus } from "@hocuspocus/server";
import { ErrorCategory } from "@/core/helpers/error-handling/error-handler";
import { ErrorCategory } from "@/lib/error-handling/error-handler";
import { logger } from "@plane/logger";
import Errors from "@/core/helpers/error-handling/error-factory";
import Errors from "@/lib/error-handling/error-factory";
import { Controller, WebSocket } from "@plane/decorators";
@Controller("/collaboration")
@@ -5,12 +5,12 @@ import { convertHTMLDocumentToAllFormats } from "@/core/helpers/convert-document
// types
import { TConvertDocumentRequestBody } from "@/core/types/common";
// decorators
import { CatchErrors } from "@/lib/decorators";
import { CatchErrors } from "@/lib/error";
// logger
import { logger } from "@plane/logger";
import { Controller, Post } from "@plane/decorators";
import { AppError } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { AppError } from "@/lib/error-handling/error-handler";
import { handleError } from "@/lib/error-handling/error-factory";
// Define the schema with more robust validation
const convertDocumentSchema = z.object({
@@ -1,4 +1,4 @@
import { CatchErrors } from "@/lib/decorators";
import { CatchErrors } from "@/lib/error";
import { Controller, Get } from "@plane/decorators";
import type { Request, Response } from "express";
+7
View File
@@ -0,0 +1,7 @@
import { HealthController } from "./health.controller";
import { DocumentController } from "./document.controller";
import { CollaborationController } from "./collaboration.controller";
export const REST_CONTROLLERS = [HealthController, DocumentController];
export const WEBSOCKET_CONTROLLERS = [CollaborationController];
-21
View File
@@ -1,21 +0,0 @@
import { HealthController } from "@/core/controllers/health.controller";
import { DocumentController } from "@/core/controllers/document.controller";
import { CollaborationController } from "@/core/controllers/collaboration.controller";
/**
* Controller registry exports
* Simple grouped arrays of controller classes for better organization
*/
export const CONTROLLERS = {
// Core system controllers (health checks, status endpoints)
CORE: [HealthController],
// Document management controllers
DOCUMENT: [DocumentController],
// WebSocket controllers for real-time functionality
WEBSOCKET: [CollaborationController],
};
// Helper to get all REST controllers
export const getAllControllers = () => [...CONTROLLERS.CORE, ...CONTROLLERS.DOCUMENT, ...CONTROLLERS.WEBSOCKET];
-4
View File
@@ -1,4 +0,0 @@
// Export all controllers from this barrel file
export { HealthController } from "./health.controller";
export { CollaborationController } from "./collaboration.controller";
export { DocumentController } from "./document.controller";
+15 -13
View File
@@ -1,10 +1,8 @@
import { Database } from "@hocuspocus/extension-database";
import { catchAsync } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { catchAsync } from "@/lib/error-handling/error-handler";
import { handleError } from "@/lib/error-handling/error-factory";
import { getDocumentHandler } from "../handlers/document-handlers";
import { type HocusPocusServerContext, type TDocumentTypes } from "@/core/types/common";
import { storePayload } from "@hocuspocus/server";
import { extractTextFromHTML } from "./title-update/title-utils";
export const createDatabaseExtension = () => {
return new Database({
@@ -16,11 +14,15 @@ export const createDatabaseExtension = () => {
const handleFetch = async ({
context,
documentName: pageId,
requestParameters,
}: {
context: HocusPocusServerContext;
documentName: TDocumentTypes;
requestParameters: URLSearchParams;
}) => {
const { documentType } = context;
const params = requestParameters;
let fetchedData = null;
fetchedData = await catchAsync(
async () => {
@@ -35,10 +37,11 @@ const handleFetch = async ({
});
}
const documentHandler = getDocumentHandler(context);
const documentHandler = getDocumentHandler(documentType, context);
fetchedData = await documentHandler.fetch({
context: context as HocusPocusServerContext,
pageId,
params,
});
if (!fetchedData) {
@@ -65,10 +68,12 @@ const handleStore = async ({
context,
state,
documentName: pageId,
document,
}: Partial<storePayload> & {
requestParameters,
}: {
context: HocusPocusServerContext;
state: Buffer;
documentName: TDocumentTypes;
requestParameters: URLSearchParams;
}) => {
catchAsync(
async () => {
@@ -82,11 +87,8 @@ const handleStore = async ({
throw: true,
});
}
let title = "";
if (document) {
title = extractTextFromHTML(document?.getXmlFragment("title")?.toJSON());
}
const { documentType } = context as HocusPocusServerContext;
const params = requestParameters;
if (!documentType) {
handleError(null, {
errorType: "bad-request",
@@ -98,12 +100,12 @@ const handleStore = async ({
});
}
const documentHandler = getDocumentHandler(context);
const documentHandler = getDocumentHandler(documentType, context);
await documentHandler.store({
context: context as HocusPocusServerContext,
pageId,
state,
title,
params,
});
},
{
+5 -6
View File
@@ -4,7 +4,6 @@ import { Logger } from "@hocuspocus/extension-logger";
import { setupRedisExtension } from "@/core/extensions/redis";
import { createDatabaseExtension } from "@/core/extensions/database";
import { logger } from "@plane/logger";
import { TitleSyncExtension } from "./title-sync";
export const getExtensions = async (): Promise<Extension[]> => {
const extensions: Extension[] = [
@@ -17,12 +16,12 @@ export const getExtensions = async (): Promise<Extension[]> => {
createDatabaseExtension(),
];
const titleSyncExtension = new TitleSyncExtension();
extensions.push(titleSyncExtension);
// Add Redis extensions if Redis is available
const redisExtensions = await setupRedisExtension();
extensions.push(...redisExtensions);
const redisExtension = await setupRedisExtension();
if (redisExtension) {
logger.info("HocusPocus Redis extension configured ✅");
extensions.push(redisExtension);
}
return extensions;
};
+5 -30
View File
@@ -1,41 +1,16 @@
import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis";
import { Extension } from "@hocuspocus/server";
// core helpers and utilities
import { logger } from "@plane/logger";
import { RedisManager } from "@/core/lib/redis-manager";
import { getRedisClient } from "@/core/lib/redis-manager";
/**
* Sets up the Redis extension for HocusPocus using the RedisManager singleton
* @returns Promise that resolves to a Redis extension array
*/
export const setupRedisExtension = async (): Promise<Extension[]> => {
const extensions: Extension[] = [];
const redisManager = RedisManager.getInstance();
export const setupRedisExtension = () => {
// Wait for Redis connection
const redisClient = await redisManager.connect();
if (redisClient) {
extensions.push(
new HocusPocusRedis({
redis: redisClient,
})
);
logger.info("HocusPocus Redis extension configured ✅");
} else {
logger.warn(
"Redis connection failed, continuing without Redis extension (you won't be able to sync data between multiple plane live servers)"
);
}
return extensions;
return new HocusPocusRedis({
redis: getRedisClient(),
});
};
/**
* Helper to get the current Redis status
* Useful for health checks
*/
export const getRedisStatus = (): "connected" | "connecting" | "disconnected" | "not-configured" => {
const redisManager = RedisManager.getInstance();
return redisManager.getStatus();
};
-124
View File
@@ -1,124 +0,0 @@
// hocuspocus
import { Extension, Hocuspocus, Document } from "@hocuspocus/server";
import { TiptapTransformer } from "@hocuspocus/transformer";
import * as Y from "yjs";
// types
import { HocusPocusServerContext } from "@/core/types/common";
// editor extensions
import { TITLE_EDITOR_EXTENSIONS } from "@plane/editor";
// handlers
import { getDocumentHandler } from "@/core/handlers/document-handlers";
// helpers
import { generateTitleProsemirrorJson } from "@/core/helpers/generate-title-prosemirror-json";
import { extractTextFromHTML } from "./title-update/title-utils";
import { TitleUpdateManager } from "./title-update/title-update-manager";
/**
* Hocuspocus extension for synchronizing document titles
*/
export class TitleSyncExtension implements Extension {
instance!: Hocuspocus;
// Maps document names to their observers and update managers
private titleObservers: Map<string, (events: Y.YEvent<any>[]) => void> = new Map();
private titleUpdateManagers: Map<string, TitleUpdateManager> = new Map();
async onLoadDocument({ context, document }: { context: HocusPocusServerContext; document: Document }) {
try {
// initially for on demand migration of old titles to a new title field
// in the yjs binary
if (document.isEmpty("title")) {
const { workspaceSlug, projectId } = context;
const documentHandler = getDocumentHandler(context);
if (!workspaceSlug || !projectId) return;
const title = await documentHandler.fetchTitle({
context,
pageId: document.name,
});
if (title == null) return;
const titleField = TiptapTransformer.toYdoc(
generateTitleProsemirrorJson(title),
"title",
TITLE_EDITOR_EXTENSIONS
);
document.merge(titleField);
}
} catch (error) {
console.error("Error in onLoadDocument: ", error);
}
}
/**
* Set up title synchronization for a document after it's loaded
*/
async afterLoadDocument({
document,
documentName,
context,
}: {
document: Document;
documentName: string;
context: HocusPocusServerContext;
}) {
const { workspaceSlug, projectId } = context;
// Exit if we don't have the required information
if (!workspaceSlug || !projectId) return;
const documentHandler = getDocumentHandler(context);
// Create a title update manager for this document
const updateManager = new TitleUpdateManager(documentName, documentHandler, context);
// Store the manager
this.titleUpdateManagers.set(documentName, updateManager);
// Set up observer for title field
const titleObserver = (events: Y.YEvent<any>[]) => {
let title = "";
events.forEach((event) => {
title = extractTextFromHTML(event.currentTarget.toJSON());
});
// Schedule an update with the manager
const manager = this.titleUpdateManagers.get(documentName);
if (manager) {
manager.scheduleUpdate(title);
}
};
// Observe the title field
document.getXmlFragment("title").observeDeep(titleObserver);
this.titleObservers.set(documentName, titleObserver);
}
/**
* Force save title before unloading the document
*/
async beforeUnloadDocument({ documentName }: { documentName: string }) {
const updateManager = this.titleUpdateManagers.get(documentName);
if (updateManager) {
// Force immediate save and wait for it to complete
await updateManager.forceSave();
// Clean up the manager
this.titleUpdateManagers.delete(documentName);
}
}
/**
* Remove observers after document unload
*/
async afterUnloadDocument({ documentName }: { documentName: string }) {
// Clean up observer when document is unloaded
const observer = this.titleObservers.get(documentName);
if (observer) {
this.titleObservers.delete(documentName);
}
// Ensure manager is cleaned up if beforeUnloadDocument somehow didn't run
if (this.titleUpdateManagers.has(documentName)) {
const manager = this.titleUpdateManagers.get(documentName)!;
manager.cancel();
this.titleUpdateManagers.delete(documentName);
}
}
}
@@ -1,342 +0,0 @@
/**
* DebounceState - Tracks the state of a debounced function
*/
export interface DebounceState {
lastArgs: any[] | null;
timerId: NodeJS.Timeout | null;
lastCallTime: number | undefined;
lastExecutionTime: number;
inProgress: boolean;
abortController: AbortController | null;
}
/**
* Creates a new DebounceState object
*/
export const createDebounceState = (): DebounceState => ({
lastArgs: null,
timerId: null,
lastCallTime: undefined,
lastExecutionTime: 0,
inProgress: false,
abortController: null,
});
/**
* DebounceOptions - Configuration options for debounce
*/
export interface DebounceOptions {
/** The wait time in milliseconds */
wait: number;
/** Optional logging prefix for debug messages */
logPrefix?: string;
}
/**
* Enhanced debounce manager with abort support
* Manages the state and timing of debounced function calls
*/
export class DebounceManager {
private state: DebounceState;
private wait: number;
private logPrefix: string;
/**
* Creates a new DebounceManager
* @param options Debounce configuration options
*/
constructor(options: DebounceOptions) {
this.state = createDebounceState();
this.wait = options.wait;
this.logPrefix = options.logPrefix || "";
}
/**
* Schedule a debounced function call
* @param func The function to call
* @param args The arguments to pass to the function
*/
schedule(func: (...args: any[]) => Promise<void>, ...args: any[]): void {
// Always update the last arguments
this.state.lastArgs = args;
const time = Date.now();
this.state.lastCallTime = time;
// If an operation is in progress, just store the new args and start the timer
if (this.state.inProgress) {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Operation in progress, storing new args and starting new timer`);
}
// Always restart the timer for the new call, even if an operation is in progress
if (this.state.timerId) {
clearTimeout(this.state.timerId);
}
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
return;
}
// If already scheduled, update the args and restart the timer
if (this.state.timerId) {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Already scheduled, updating args and restarting timer`);
}
clearTimeout(this.state.timerId);
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
return;
}
// Start the timer for the trailing edge execution
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
if (this.logPrefix) {
console.log(`${this.logPrefix}: Scheduled execution with wait time ${this.wait}ms`);
}
}
/**
* Called when the timer expires
*/
private timerExpired(func: (...args: any[]) => Promise<void>): void {
const time = Date.now();
// Check if this timer expiration represents the end of the debounce period
if (this.shouldInvoke(time)) {
// If an operation is already in progress, abort it if the debounce period has completed
if (this.state.inProgress) {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Timer expired while operation in progress - will abort current operation`);
}
}
// Execute the function
this.executeFunction(func, time);
return;
}
// Otherwise restart the timer
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.remainingWait(time));
}
/**
* Execute the debounced function
*/
private executeFunction(func: (...args: any[]) => Promise<void>, time: number): void {
this.state.timerId = null;
this.state.lastExecutionTime = time;
// Execute the function asynchronously
this.performFunction(func).catch((error) => {
console.error(`${this.logPrefix}: Error in execution:`, error);
});
}
/**
* Perform the actual function call, handling any in-progress operations
*/
private async performFunction(func: (...args: any[]) => Promise<void>): Promise<void> {
const args = this.state.lastArgs;
if (!args) return;
// Store the args we're about to use
const currentArgs = [...args];
// If another operation is in progress, abort it
await this.abortOngoingOperation();
// Mark that we're starting a new operation
this.state.inProgress = true;
this.state.abortController = new AbortController();
try {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Starting operation`);
}
// Add the abort signal to the arguments if the function can use it
const execArgs = [...currentArgs];
execArgs.push(this.state.abortController.signal);
await func(...execArgs);
if (this.logPrefix) {
console.log(`${this.logPrefix}: Completed operation`);
}
// Only clear lastArgs if they haven't been changed during this operation
if (this.state.lastArgs && this.arraysEqual(this.state.lastArgs, currentArgs)) {
this.state.lastArgs = null;
if (this.logPrefix) {
console.log(`${this.logPrefix}: Args have not changed during operation, clearing lastArgs`);
}
// Clear any timer as we've successfully processed the latest args
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
} else if (this.state.lastArgs) {
// If lastArgs have changed during this operation, the timer should already be running
// but let's make sure it is
if (!this.state.timerId) {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Args changed during operation, ensuring timer is running`);
}
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
}
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Operation was aborted, another operation should be starting`);
}
// Nothing to do here, the new operation will be triggered by the timer expiration
} else {
console.error(`${this.logPrefix}: Error during operation:`, error);
// On error (not abort), make sure we have a timer running to retry
if (!this.state.timerId && this.state.lastArgs) {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Rescheduling failed operation`);
}
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
}
}
} finally {
this.state.inProgress = false;
this.state.abortController = null;
}
}
/**
* Abort any ongoing operation
*/
private async abortOngoingOperation(): Promise<void> {
if (this.state.inProgress && this.state.abortController) {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Aborting in-progress operation`);
}
this.state.abortController.abort();
// Small delay to ensure the abort has had time to propagate
await new Promise((resolve) => setTimeout(resolve, 20));
// Double-check that state has been reset, force it if not
if (this.state.inProgress || this.state.abortController) {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Force resetting in-progress state after abort`);
}
this.state.inProgress = false;
this.state.abortController = null;
}
}
}
/**
* Determine if we should invoke the function now
*/
private shouldInvoke(time: number): boolean {
// Either this is the first call, or we've waited long enough since the last call
return this.state.lastCallTime === undefined || time - this.state.lastCallTime >= this.wait;
}
/**
* Calculate how much longer we should wait
*/
private remainingWait(time: number): number {
const timeSinceLastCall = time - (this.state.lastCallTime || 0);
return Math.max(0, this.wait - timeSinceLastCall);
}
/**
* Force immediate execution
*/
async flush(func: (...args: any[]) => Promise<void>): Promise<void> {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Force immediate execution`);
}
// Clear any pending timeout
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
// Reset timing state
this.state.lastCallTime = undefined;
// Perform the function immediately
if (this.state.lastArgs) {
await this.performFunction(func);
} else if (this.state.inProgress) {
if (this.logPrefix) {
console.log(`${this.logPrefix}: No new args to process, letting current operation complete`);
}
} else {
if (this.logPrefix) {
console.log(`${this.logPrefix}: No args to process`);
}
}
}
/**
* Cancel any pending operations without executing
*/
cancel(): void {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Cancelling pending operations`);
}
// Clear any pending timeout
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
// Reset timing state
this.state.lastCallTime = undefined;
// Abort any in-progress operation
if (this.state.inProgress && this.state.abortController) {
this.state.abortController.abort();
this.state.inProgress = false;
this.state.abortController = null;
}
// Clear args
this.state.lastArgs = null;
}
/**
* Compare two arrays for equality
*/
private arraysEqual(a: any[], b: any[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
}
@@ -1,96 +0,0 @@
import { DocumentHandler } from "@/core/types/document-handler";
import { DebounceManager } from "./debounce";
import { HocusPocusServerContext } from "@/core/types/common";
import { env } from "@/env";
/**
* Manages title update operations for a single document
* Handles debouncing, aborting, and force saving title updates
*/
export class TitleUpdateManager {
private documentName: string;
private documentHandler: DocumentHandler;
private debounceManager: DebounceManager;
private lastTitle: string | null = null;
private context: HocusPocusServerContext;
/**
* Create a new TitleUpdateManager instance
*/
constructor(
documentName: string,
documentHandler: DocumentHandler,
context: HocusPocusServerContext,
wait: number = 3000
) {
this.context = context;
this.documentName = documentName;
this.documentHandler = documentHandler;
// Set up debounce manager with logging
this.debounceManager = new DebounceManager({
wait,
logPrefix: env.NODE_ENV === "development" ? `TitleManager[${documentName.substring(0, 8)}]` : "",
});
}
/**
* Schedule a debounced title update
*/
scheduleUpdate(title: string): void {
// Store the latest title
this.lastTitle = title;
// Schedule the update with the debounce manager
this.debounceManager.schedule(this.updateTitle.bind(this), title);
}
/**
* Update the title - will be called by the debounce manager
*/
private async updateTitle(title: string, signal?: AbortSignal): Promise<void> {
if (!this.documentHandler.updateTitle) {
console.log(`No updateTitle method found for document ${this.documentName}`);
return;
}
try {
await this.documentHandler.updateTitle({
context: this.context,
pageId: this.documentName,
title,
abortSignal: signal,
});
// Clear last title only if it matches what we just updated
if (this.lastTitle === title) {
this.lastTitle = null;
}
} catch (error) {
if (error instanceof Error && !(error.name === "AbortError")) {
console.error(`Error updating title for ${this.documentName}:`, error);
}
}
}
/**
* Force save the current title immediately
*/
async forceSave(): Promise<void> {
// Ensure we have the current title
if (!this.lastTitle) {
return;
}
// Use the debounce manager to flush the operation
await this.debounceManager.flush(this.updateTitle.bind(this));
}
/**
* Cancel any pending updates
*/
cancel(): void {
this.debounceManager.cancel();
this.lastTitle = null;
}
}
@@ -1,8 +0,0 @@
/**
* Utility function to extract text from HTML content
*/
export const extractTextFromHTML = (html: string): string => {
// Use a regex to extract text between tags
const textMatch = html.replace(/<[^>]*>/g, "");
return textMatch || "";
};
@@ -1,10 +1,10 @@
import { DocumentHandler } from "@/core/types/document-handler";
import { DocumentHandler, HandlerContext } from "@/core/types/document-handler";
import { handlerFactory } from "@/core/handlers/document-handlers/handler-factory";
import { HocusPocusServerContext } from "@/core/types/common";
import { initializeDocumentHandlers } from "@/plane-live/document-types";
// initialize all document handlers
// Initialize all CE document handlers
initializeDocumentHandlers();
/**
@@ -13,7 +13,17 @@ initializeDocumentHandlers();
* @param additionalContext Optional additional context criteria
* @returns The appropriate document handler
*/
export function getDocumentHandler(context: HocusPocusServerContext): DocumentHandler {
export function getDocumentHandler(
documentType: string,
additionalContext: Omit<HocusPocusServerContext, "documentType">
): DocumentHandler {
// Create a context object with all criteria
const context: HandlerContext = {
documentType: documentType as any,
...additionalContext,
};
// Use the factory to get the appropriate handler
return handlerFactory.getHandler(context);
}
@@ -1,158 +0,0 @@
import { handleError } from "./error-factory";
/**
* A simple validation utility that integrates with our error system.
*
* This provides a fluent interface for validating data and throwing
* appropriate errors if validation fails.
*/
export class Validator<T> {
constructor(
private readonly data: T,
private readonly name: string = "data"
) {}
/**
* Ensures a value is defined (not undefined or null)
*/
required(message?: string): Validator<T> {
if (this.data === undefined || this.data === null) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} is required`,
component: 'validator',
operation: 'required',
throw: true
});
}
return this;
}
/**
* Ensures a value is a string
*/
string(message?: string): Validator<T> {
if (typeof this.data !== "string") {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be a string`,
component: 'validator',
operation: 'string',
throw: true
});
}
return this;
}
/**
* Ensures a string is not empty
*/
notEmpty(message?: string): Validator<T> {
if (typeof this.data === "string" && this.data.trim() === "") {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} cannot be empty`,
component: 'validator',
operation: 'notEmpty',
throw: true
});
}
return this;
}
/**
* Ensures a value is a number
*/
number(message?: string): Validator<T> {
if (typeof this.data !== "number" || isNaN(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be a valid number`,
component: 'validator',
operation: 'number',
throw: true
});
}
return this;
}
/**
* Ensures an array is not empty
*/
nonEmptyArray(message?: string): Validator<T> {
if (!Array.isArray(this.data) || this.data.length === 0) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be a non-empty array`,
component: 'validator',
operation: 'nonEmptyArray',
throw: true
});
}
return this;
}
/**
* Ensures a value matches a regular expression
*/
match(regex: RegExp, message?: string): Validator<T> {
if (typeof this.data !== "string" || !regex.test(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} has an invalid format`,
component: 'validator',
operation: 'match',
throw: true
});
}
return this;
}
/**
* Ensures a value is one of the allowed values
*/
oneOf(allowedValues: any[], message?: string): Validator<T> {
if (!allowedValues.includes(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be one of: ${allowedValues.join(", ")}`,
component: 'validator',
operation: 'oneOf',
throw: true
});
}
return this;
}
/**
* Custom validation function
*/
custom(validationFn: (value: T) => boolean, message?: string): Validator<T> {
if (!validationFn(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} is invalid`,
component: 'validator',
operation: 'custom',
throw: true
});
}
return this;
}
/**
* Get the validated data
*/
get(): T {
return this.data;
}
}
/**
* Create a new validator for a value
*/
export const validate = <T>(data: T, name?: string): Validator<T> => {
return new Validator(data, name);
};
export default validate;
@@ -1,17 +0,0 @@
export const generateTitleProsemirrorJson = (text: string) => {
return {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 1 },
content: [
{
type: "text",
text,
},
],
},
],
};
};
+77 -71
View File
@@ -1,4 +1,4 @@
import { handleError } from "./error-handling/error-factory";
import { handleError } from "../../lib/error-handling/error-factory";
/**
* A simple validation utility that integrates with our error system.
@@ -18,11 +18,11 @@ export class Validator<T> {
required(message?: string): Validator<T> {
if (this.data === undefined || this.data === null) {
throw handleError(new ValidationError(this.name, message || `${this.name} is required`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateRequired',
errorType: "bad-request",
component: "validation",
operation: "validateRequired",
extraContext: { field: this.name },
throw: true
throw: true,
});
}
return this;
@@ -34,11 +34,11 @@ export class Validator<T> {
string(message?: string): Validator<T> {
if (typeof this.data !== "string") {
throw handleError(new ValidationError(this.name, message || `${this.name} must be a string`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateString',
errorType: "bad-request",
component: "validation",
operation: "validateString",
extraContext: { field: this.name },
throw: true
throw: true,
});
}
return this;
@@ -50,11 +50,11 @@ export class Validator<T> {
notEmpty(message?: string): Validator<T> {
if (typeof this.data === "string" && this.data.trim() === "") {
throw handleError(new ValidationError(this.name, message || `${this.name} cannot be empty`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateNonEmptyString',
errorType: "bad-request",
component: "validation",
operation: "validateNonEmptyString",
extraContext: { field: this.name },
throw: true
throw: true,
});
}
return this;
@@ -66,11 +66,11 @@ export class Validator<T> {
number(message?: string): Validator<T> {
if (typeof this.data !== "number" || isNaN(this.data)) {
throw handleError(new ValidationError(this.name, message || `${this.name} must be a valid number`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateNumber',
errorType: "bad-request",
component: "validation",
operation: "validateNumber",
extraContext: { field: this.name },
throw: true
throw: true,
});
}
return this;
@@ -82,11 +82,11 @@ export class Validator<T> {
nonEmptyArray(message?: string): Validator<T> {
if (!Array.isArray(this.data) || this.data.length === 0) {
throw handleError(new ValidationError(this.name, message || `${this.name} must be a non-empty array`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateArray',
errorType: "bad-request",
component: "validation",
operation: "validateArray",
extraContext: { field: this.name },
throw: true
throw: true,
});
}
return this;
@@ -98,11 +98,11 @@ export class Validator<T> {
match(regex: RegExp, message?: string): Validator<T> {
if (typeof this.data !== "string" || !regex.test(this.data)) {
throw handleError(new ValidationError(this.name, message || `${this.name} has an invalid format`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateFormat',
errorType: "bad-request",
component: "validation",
operation: "validateFormat",
extraContext: { field: this.name, format: regex.toString() },
throw: true
throw: true,
});
}
return this;
@@ -113,13 +113,16 @@ export class Validator<T> {
*/
oneOf(allowedValues: any[], message?: string): Validator<T> {
if (!allowedValues.includes(this.data)) {
throw handleError(new ValidationError(this.name, message || `${this.name} must be one of: ${allowedValues.join(", ")}`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateEnum',
extraContext: { field: this.name, allowedValues },
throw: true
});
throw handleError(
new ValidationError(this.name, message || `${this.name} must be one of: ${allowedValues.join(", ")}`),
{
errorType: "bad-request",
component: "validation",
operation: "validateEnum",
extraContext: { field: this.name, allowedValues },
throw: true,
}
);
}
return this;
}
@@ -130,11 +133,11 @@ export class Validator<T> {
custom(validationFn: (value: T) => boolean, message?: string): Validator<T> {
if (!validationFn(this.data)) {
throw handleError(new ValidationError(this.name, message || `${this.name} is invalid`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateCustom',
errorType: "bad-request",
component: "validation",
operation: "validateCustom",
extraContext: { field: this.name },
throw: true
throw: true,
});
}
return this;
@@ -158,7 +161,10 @@ export const validate = <T>(data: T, name?: string): Validator<T> => {
export default validate;
export class ValidationError extends Error {
constructor(public name: string, message: string) {
constructor(
public name: string,
message: string
) {
super(message);
this.name = name;
}
@@ -167,23 +173,23 @@ export class ValidationError extends Error {
export const validateRequired = (value: any, name: string, message?: string) => {
if (value === undefined || value === null) {
throw handleError(new ValidationError(name, message || `${name} is required`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateRequired',
errorType: "bad-request",
component: "validation",
operation: "validateRequired",
extraContext: { field: name },
throw: true
throw: true,
});
}
};
export const validateString = (value: any, name: string, message?: string) => {
if (typeof value !== 'string') {
if (typeof value !== "string") {
throw handleError(new ValidationError(name, message || `${name} must be a string`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateString',
errorType: "bad-request",
component: "validation",
operation: "validateString",
extraContext: { field: name },
throw: true
throw: true,
});
}
};
@@ -191,23 +197,23 @@ export const validateString = (value: any, name: string, message?: string) => {
export const validateNonEmptyString = (value: string, name: string, message?: string) => {
if (!value.trim()) {
throw handleError(new ValidationError(name, message || `${name} cannot be empty`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateNonEmptyString',
errorType: "bad-request",
component: "validation",
operation: "validateNonEmptyString",
extraContext: { field: name },
throw: true
throw: true,
});
}
};
export const validateNumber = (value: any, name: string, message?: string) => {
if (typeof value !== 'number' || isNaN(value)) {
if (typeof value !== "number" || isNaN(value)) {
throw handleError(new ValidationError(name, message || `${name} must be a valid number`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateNumber',
errorType: "bad-request",
component: "validation",
operation: "validateNumber",
extraContext: { field: name },
throw: true
throw: true,
});
}
};
@@ -215,11 +221,11 @@ export const validateNumber = (value: any, name: string, message?: string) => {
export const validateArray = (value: any, name: string, message?: string) => {
if (!Array.isArray(value) || value.length === 0) {
throw handleError(new ValidationError(name, message || `${name} must be a non-empty array`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateArray',
errorType: "bad-request",
component: "validation",
operation: "validateArray",
extraContext: { field: name },
throw: true
throw: true,
});
}
};
@@ -227,11 +233,11 @@ export const validateArray = (value: any, name: string, message?: string) => {
export const validateFormat = (value: string, name: string, format: RegExp, message?: string) => {
if (!format.test(value)) {
throw handleError(new ValidationError(name, message || `${name} has an invalid format`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateFormat',
errorType: "bad-request",
component: "validation",
operation: "validateFormat",
extraContext: { field: name, format: format.toString() },
throw: true
throw: true,
});
}
};
@@ -239,11 +245,11 @@ export const validateFormat = (value: string, name: string, format: RegExp, mess
export const validateEnum = (value: any, name: string, allowedValues: any[], message?: string) => {
if (!allowedValues.includes(value)) {
throw handleError(new ValidationError(name, message || `${name} must be one of: ${allowedValues.join(", ")}`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateEnum',
errorType: "bad-request",
component: "validation",
operation: "validateEnum",
extraContext: { field: name, allowedValues },
throw: true
throw: true,
});
}
};
@@ -251,11 +257,11 @@ export const validateEnum = (value: any, name: string, allowedValues: any[], mes
export const validateCustom = (value: any, name: string, validator: (value: any) => boolean, message?: string) => {
if (!validator(value)) {
throw handleError(new ValidationError(name, message || `${name} is invalid`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateCustom',
errorType: "bad-request",
component: "validation",
operation: "validateCustom",
extraContext: { field: name },
throw: true
throw: true,
});
}
};
-101
View File
@@ -1,101 +0,0 @@
import { Server } from "@hocuspocus/server";
import { v4 as uuidv4 } from "uuid";
import { IncomingHttpHeaders } from "http";
// lib
import { handleAuthentication } from "@/core/lib/authentication";
// extensions
import { getExtensions } from "@/core/extensions/index";
import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib";
// editor types
import { TUserDetails } from "@plane/editor";
// types
import { TDocumentTypes, type HocusPocusServerContext } from "@/core/types/common";
// error handling
import { catchAsync } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
export const getHocusPocusServer = async () => {
const extensions = await getExtensions();
const serverName = process.env.HOSTNAME || uuidv4();
return Server.configure({
name: serverName,
onAuthenticate: async ({
requestHeaders,
context,
requestParameters,
// user id used as token for authentication
token,
}: {
requestHeaders: IncomingHttpHeaders;
context: HocusPocusServerContext; // Better than 'any', still allows property assignment
requestParameters: URLSearchParams;
token: string;
}) => {
// need to rethrow all errors since hocuspocus needs to know to stop
// further propagation of events to other document lifecycle
return catchAsync(
async () => {
let cookie: string | undefined = undefined;
let userId: string | undefined = undefined;
// Extract cookie (fallback to request headers) and userId from token (for scenarios where
// the cookies are not passed in the request headers)
try {
const parsedToken = JSON.parse(token) as TUserDetails;
userId = parsedToken.id;
cookie = parsedToken.cookie;
} catch (error) {
// If token parsing fails, fallback to request headers
console.error("Token parsing failed, using request headers:", error);
} finally {
// If cookie is still not found, fallback to request headers
if (!cookie) {
cookie = requestHeaders.cookie?.toString();
}
}
if (!cookie || !userId) {
handleError(null, {
errorType: "unauthorized",
message: "Credentials not provided",
component: "hocuspocus",
operation: "authenticate",
extraContext: { tokenProvided: !!token },
throw: true,
});
}
context.documentType = requestParameters.get("documentType")?.toString() as TDocumentTypes;
context.cookie = cookie ?? requestParameters.get("cookie");
context.userId = userId;
context.workspaceSlug = requestParameters.get("workspaceSlug")?.toString() as string;
context.projectId = requestParameters.get("projectId")?.toString() as string;
return await handleAuthentication({
cookie: context.cookie,
userId: context.userId,
workspaceSlug: context.workspaceSlug,
});
},
{ extra: { operation: "authenticate" } },
{
rethrow: true,
}
)();
},
onStateless: async ({ payload, document }) => {
return catchAsync(
async () => {
// broadcast the client event (derived from the server event) to all the clients so that they can update their state
const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
if (response) {
document.broadcastStateless(response);
}
},
{ extra: { operation: "stateless", payload } }
);
},
extensions,
debounce: 1000,
});
};
+2 -2
View File
@@ -1,6 +1,6 @@
// services
import { UserService } from "@/core/services/user.service";
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { UserService } from "@/services/user.service";
import { handleError } from "@/lib/error-handling/error-factory";
const userService = new UserService();
+5 -54
View File
@@ -1,23 +1,21 @@
// helpers
import { getAllDocumentFormatsFromBinaryData, getBinaryDataFromHTMLString } from "@/core/helpers/page";
// services
import { PageService } from "@/core/services/page.service";
import logger from "@plane/logger";
import { PageService } from "@/services/page.service";
const pageService = new PageService();
export const updatePageDescription = async (
params: URLSearchParams | undefined,
params: URLSearchParams,
pageId: string,
updatedDescription: Uint8Array,
cookie: string | undefined,
title: string
cookie: string | undefined
) => {
if (!(updatedDescription instanceof Uint8Array)) {
throw new Error("Invalid updatedDescription: must be an instance of Uint8Array");
}
const workspaceSlug = params?.get("workspaceSlug")?.toString();
const projectId = params?.get("projectId")?.toString();
const workspaceSlug = params.get("workspaceSlug")?.toString();
const projectId = params.get("projectId")?.toString();
if (!workspaceSlug || !projectId || !cookie) return;
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromBinaryData(updatedDescription);
@@ -25,7 +23,6 @@ export const updatePageDescription = async (
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
name: title,
};
await pageService.updateDescription(workspaceSlug, projectId, pageId, payload, cookie);
@@ -44,52 +41,6 @@ const fetchDescriptionHTMLAndTransform = async (
return contentBinary;
};
export const fetchProjectPageTitle = async ({
workspaceSlug,
projectId,
pageId,
cookie,
}: {
workspaceSlug: string;
projectId: string;
pageId: string;
cookie: string | undefined;
}) => {
if (!workspaceSlug || !cookie) return;
try {
const pageDetails = await pageService.fetchDetails(workspaceSlug, projectId, pageId, cookie);
return pageDetails.name;
} catch (error) {
logger.error("Error while transforming from HTML to Uint8Array", error);
throw error;
}
};
export const updateProjectPageTitle = async ({
workspaceSlug,
projectId,
pageId,
title,
cookie,
abortSignal,
}: {
workspaceSlug: string;
projectId: string;
pageId: string;
title: string;
cookie: string | undefined;
abortSignal?: AbortSignal;
}) => {
if (!workspaceSlug || !projectId || !cookie) return;
const payload = {
name: title,
};
await pageService.updateTitle(workspaceSlug, projectId, pageId, payload, cookie, abortSignal);
};
export const fetchPageDescriptionBinary = async (
params: URLSearchParams,
pageId: string,
+46 -111
View File
@@ -1,127 +1,62 @@
import { Redis } from "ioredis";
import { logger } from "@plane/logger";
import { getRedisUrl } from "@/core/lib/utils/redis-url";
import { ShutdownManager } from "@/core/shutdown-manager";
// Define Redis error interface to handle specific error properties
interface RedisError extends Error {
code?: string;
}
let redisClient: Redis | null = null;
export class RedisManager {
private static instance: RedisManager;
private client: Redis | null = null;
private hasEverConnected = false;
private readonly maxReconnectAttempts = 3;
export async function initializeRedis(): Promise<Redis> {
const redisUrl = getRedisUrl();
// Private constructor to enforce singleton pattern
private constructor() {}
public static getInstance(): RedisManager {
if (!RedisManager.instance) {
RedisManager.instance = new RedisManager();
}
return RedisManager.instance;
if (!redisUrl) {
logger.error("Redis URL is not configured. Please set REDIS_URL environment variable.");
process.exit(1);
}
public getClient(): Redis | null {
return this.client;
}
public async connect(): Promise<Redis | null> {
const redisUrl = getRedisUrl();
if (!redisUrl) {
logger.warn(
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)"
);
return null;
}
this.client = new Redis(redisUrl, {
retryStrategy: (times: number): number | null => {
if (!this.hasEverConnected) {
// If we've never connected successfully, don't retry
logger.warn(
"Initial Redis connection attempt failed. Continuing without Redis (you won't be able to sync data between multiple plane live servers)"
);
return null;
} else {
// Once connected at least once, try a few times before giving up
if (times > this.maxReconnectAttempts) {
logger.error(`Exceeded ${this.maxReconnectAttempts} Redis reconnect attempts. Shutting down the server.`);
// Use ShutdownManager to gracefully terminate the server
const shutdownManager = ShutdownManager.getInstance();
shutdownManager.shutdown("Redis connection lost and could not be recovered", 1);
return null; // This will never be reached due to shutdown, but needed for type safety
}
logger.warn(`Redis connection lost. Attempting to reconnect (#${times}) in 1000 ms...`);
return 1000; // wait 1 second between attempts
}
},
try {
redisClient = new Redis(redisUrl);
redisClient.on("error", (error) => {
logger.error("Redis connection error:", error);
process.exit(1);
});
// Set up event handlers
this.client.on("connect", () => {
logger.info("Redis: connecting...");
});
this.client.on("ready", () => {
if (!this.hasEverConnected) {
logger.info("Redis: initial connection established and ready ✅");
} else {
logger.info("Redis: reconnected and ready ✅");
}
this.hasEverConnected = true;
});
this.client.on("error", (error: RedisError) => {
if (
error?.code === "ENOTFOUND" ||
error.message.includes("WRONGPASS") ||
error.message.includes("NOAUTH") ||
error.message.includes("ECONNREFUSED")
) {
if (this.client) this.client.disconnect();
}
logger.warn("Redis error:", error);
});
this.client.on("close", () => {
logger.warn("Redis connection closed.");
});
this.client.on("reconnecting", (delay: number) => {
logger.info(`Redis: reconnecting in ${delay} ms...`);
});
// Wait for connection to be ready or fail
return new Promise<Redis | null>((resolve) => {
if (!this.client) {
resolve(null);
return;
}
this.client.once("ready", () => {
resolve(this.client);
// Wait for the connection to be ready
await new Promise<void>((resolve, reject) => {
redisClient!.on("ready", () => {
logger.info("Redis connection established successfully");
resolve();
});
this.client.once("error", () => {
// The retryStrategy will handle this, we just need to resolve with null
// if initial connection fails
if (!this.hasEverConnected) {
resolve(null);
}
redisClient!.on("error", (error) => {
reject(error);
});
});
}
public getStatus(): "connected" | "connecting" | "disconnected" | "not-configured" {
if (!this.client) return "not-configured";
const status = this.client.status;
if (status === "ready") return "connected";
if (status === "connect" || status === "reconnecting") return "connecting";
return "disconnected";
return redisClient;
} catch (error) {
logger.error("Failed to initialize Redis:", error);
process.exit(1);
}
}
export function getRedisClient(): Redis {
if (!redisClient) {
throw new Error("Redis client not initialized. Call initializeRedis() first.");
}
return redisClient;
}
function getRedisUrl() {
const redisUrl = process.env.REDIS_URL?.trim();
const redisHost = process.env.REDIS_HOST?.trim();
const redisPort = process.env.REDIS_PORT?.trim();
if (redisUrl) {
return redisUrl;
}
if (redisHost && redisPort && !Number.isNaN(Number(redisPort))) {
return `redis://${redisHost}:${redisPort}`;
}
return "";
}
-15
View File
@@ -1,15 +0,0 @@
export function getRedisUrl() {
const redisUrl = process.env.REDIS_URL?.trim();
const redisHost = process.env.REDIS_HOST?.trim();
const redisPort = process.env.REDIS_PORT?.trim();
if (redisUrl) {
return redisUrl;
}
if (redisHost && redisPort && !Number.isNaN(Number(redisPort))) {
return `redis://${redisHost}:${redisPort}`;
}
return "";
}
-84
View File
@@ -1,84 +0,0 @@
// server
import { Server } from "http";
// hocuspocus server
import type { Hocuspocus } from "@hocuspocus/server";
// logger
import { logger } from "@plane/logger";
// config
// error handling
import { handleError } from "@/core/helpers/error-handling/error-factory";
// shutdown manager
import { ShutdownManager } from "@/core/shutdown-manager";
/**
* ProcessManager handles graceful process termination and resource cleanup
*/
export class ProcessManager {
private readonly hocusPocusServer: Hocuspocus;
private readonly httpServer: Server;
private shutdownManager: ShutdownManager;
/**
* Initialize the process manager
* @param hocusPocusServer Hocuspocus server instance
* @param httpServer HTTP server instance
*/
constructor(hocusPocusServer: Hocuspocus, httpServer: Server) {
this.hocusPocusServer = hocusPocusServer;
this.httpServer = httpServer;
this.shutdownManager = ShutdownManager.getInstance();
}
/**
* Register process termination signal handlers
*/
registerTerminationHandlers(): void {
const gracefulTermination = this.getGracefulTerminationHandler();
// Handle process signals
process.on("SIGTERM", gracefulTermination);
process.on("SIGINT", gracefulTermination);
// Handle uncaught exceptions - create AppError but DON'T terminate
process.on("uncaughtException", (error) => {
logger.error("Uncaught exception:", error);
// Create AppError to track the issue but don't terminate
handleError(error, {
errorType: "internal",
component: "process",
operation: "uncaughtException",
extraContext: { source: "uncaughtException" },
});
});
// Handle unhandled promise rejections - create AppError but DON'T terminate
process.on("unhandledRejection", (reason) => {
logger.error("Unhandled rejection:", reason);
// Create AppError to track the issue but don't terminate
handleError(reason, {
errorType: "internal",
component: "process",
operation: "unhandledRejection",
extraContext: { source: "unhandledRejection" },
});
});
}
/**
* Get the graceful termination handler
* @returns Termination function
*/
private getGracefulTerminationHandler(): () => Promise<void> {
return async () => {
// Check if ShutdownManager is already handling a shutdown
if (this.shutdownManager.isShutdownInProgress()) {
logger.info("Shutdown already in progress via ShutdownManager, deferring to its process");
return;
}
logger.info("Signal received, delegating to ShutdownManager for graceful termination");
await this.shutdownManager.shutdown("Process termination signal received", 1);
};
}
}
-112
View File
@@ -1,112 +0,0 @@
// types
import { TPage } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/core/services/api.service";
export class PageService extends APIService {
constructor() {
super(API_BASE_URL);
}
async fetchDetails(workspaceSlug: string, projectId: string, pageId: string, cookie: string): Promise<TPage> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`, {
headers: {
Cookie: cookie,
},
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async fetchDescriptionBinary(workspaceSlug: string, projectId: string, pageId: string, cookie: string): Promise<any> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, {
headers: {
"Content-Type": "application/octet-stream",
Cookie: cookie,
},
responseType: "arraybuffer",
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async updateTitle(
workspaceSlug: string,
projectId: string,
pageId: string,
data: {
name: string;
},
cookie: string,
abortSignal?: AbortSignal
): Promise<any> {
// Early abort check
if (abortSignal?.aborted) {
throw new DOMException("Aborted", "AbortError");
}
// Create an abort listener that will reject the pending promise
let abortListener: (() => void) | undefined;
const abortPromise = new Promise((_, reject) => {
if (abortSignal) {
abortListener = () => {
reject(new DOMException("Aborted", "AbortError"));
};
abortSignal.addEventListener("abort", abortListener);
}
});
try {
// The actual API call that can be aborted
return await Promise.race([
this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`, data, {
headers: {
Cookie: cookie,
},
signal: abortSignal,
})
.then((response) => response?.data)
.catch((error) => {
// Special handling for aborted fetch requests
if (error.name === "AbortError") {
throw new DOMException("Aborted", "AbortError");
}
throw error;
}),
abortPromise,
]);
} finally {
// Clean up abort listener
if (abortSignal && abortListener) {
abortSignal.removeEventListener("abort", abortListener);
}
}
}
async updateDescription(
workspaceSlug: string,
projectId: string,
pageId: string,
data: {
description_binary: string;
description_html: string;
description: object;
name: string;
},
cookie: string
): Promise<any> {
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, data, {
headers: {
Cookie: cookie,
},
})
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
}
-167
View File
@@ -1,167 +0,0 @@
import { Server as HttpServer } from "http";
import { Hocuspocus } from "@hocuspocus/server";
import { logger } from "@plane/logger";
import { RedisManager } from "@/core/lib/redis-manager";
// config
import { serverConfig } from "@/config/server-config";
import { Server } from "http";
// Global flag to prevent duplicate shutdown sequences
let isGlobalShutdownInProgress = false;
/**
* ShutdownManager - Handles graceful shutdown of all server components
*
* Implements the singleton pattern to ensure only one shutdown sequence
* can be initiated throughout the application.
*/
export class ShutdownManager {
private static instance: ShutdownManager;
private httpServer: HttpServer | null = null;
private hocuspocusServer: Hocuspocus | null = null;
private isShuttingDown = false;
private exitCode = 0;
private forceExitTimeout: NodeJS.Timeout | null = null;
// Private constructor to enforce singleton pattern
private constructor() {}
/**
* Get the singleton instance
*/
public static getInstance(): ShutdownManager {
if (!ShutdownManager.instance) {
ShutdownManager.instance = new ShutdownManager();
}
return ShutdownManager.instance;
}
/**
* Register server instances that need to be gracefully closed during shutdown
*/
public register(httpServer: Server, hocuspocusServer: Hocuspocus): void {
this.httpServer = httpServer;
this.hocuspocusServer = hocuspocusServer;
logger.info("ShutdownManager registered with server instances");
}
/**
* Check if a shutdown is in progress
*/
public isShutdownInProgress(): boolean {
return this.isShuttingDown || isGlobalShutdownInProgress;
}
/**
* Initiate graceful shutdown sequence
* @param reason Reason for shutdown
* @param exitCode Process exit code (default: 0)
*/
public async shutdown(reason: string, exitCode = 0): Promise<void> {
// Prevent multiple shutdown attempts
if (this.isShuttingDown || isGlobalShutdownInProgress) {
logger.warn("Shutdown already in progress, ignoring additional shutdown request");
return;
}
this.isShuttingDown = true;
isGlobalShutdownInProgress = true;
this.exitCode = exitCode;
logger.info(`Initiating graceful shutdown: ${reason}`);
// Create a timeout to force exit if shutdown takes too long
this.forceExitTimeout = setTimeout(() => {
logger.error("Forcing termination after timeout - some connections may not have closed gracefully.");
process.exit(1);
}, serverConfig.terminationTimeout || 10000); // Default to 10 seconds if not configured
try {
// Close components in order: Redis, HocusPocus, HTTP server
await this.closeRedisConnections();
await this.closeHocusPocusServer();
await this.closeHttpServer();
// Wait a bit to allow handles to close
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log("All components shut down successfully");
} catch (error) {
console.error("Error during graceful shutdown:", error);
} finally {
// Clear timeout if we've made it this far
if (this.forceExitTimeout) {
clearTimeout(this.forceExitTimeout);
}
// Give a small delay before exiting to ensure all handles are closed
setTimeout(() => {
console.info(`Exiting process with code ${this.exitCode}`);
process.exit(this.exitCode);
}, 100);
}
}
/**
* Close Redis connections
*/
private async closeRedisConnections(): Promise<void> {
console.info("Closing Redis connections...");
try {
const redisManager = RedisManager.getInstance();
const redisClient = redisManager.getClient();
if (redisClient) {
await redisClient.quit();
console.info("Redis connections closed successfully");
} else {
console.info("No Redis connections to close");
}
} catch (error) {
console.error("Error closing Redis connections:", error);
}
}
/**
* Close HocusPocus server
*/
private async closeHocusPocusServer(): Promise<void> {
console.info("Shutting down HocusPocus server...");
try {
if (this.hocuspocusServer) {
await this.hocuspocusServer.destroy();
console.info("HocusPocus server shut down successfully");
} else {
console.info("No HocusPocus server to shut down");
}
} catch (error) {
console.error("Error shutting down HocusPocus server:", error);
}
}
/**
* Close HTTP server
*/
private async closeHttpServer(): Promise<void> {
logger.info("Closing HTTP server...");
return new Promise<void>((resolve) => {
if (!this.httpServer) {
console.info("No HTTP server to close");
resolve();
return;
}
// Close all connections
this.httpServer.closeAllConnections?.();
this.httpServer.close((error) => {
if (error) {
console.error("Error closing HTTP server:", error);
} else {
console.info("HTTP server closed successfully");
}
resolve();
});
});
}
}
-1
View File
@@ -10,7 +10,6 @@ export type HocusPocusServerContext = {
documentType: TDocumentTypes;
userId: string;
agentId: string;
parentId: string;
};
export type TConvertDocumentRequestBody = {
+14 -20
View File
@@ -1,4 +1,4 @@
import { HocusPocusServerContext } from "@/core/types/common";
import { HocusPocusServerContext, TDocumentTypes } from "@/core/types/common";
/**
* Parameters for document fetch operations
@@ -6,6 +6,7 @@ import { HocusPocusServerContext } from "@/core/types/common";
export interface DocumentFetchParams {
context: HocusPocusServerContext;
pageId: string;
params: URLSearchParams;
}
/**
@@ -14,8 +15,8 @@ export interface DocumentFetchParams {
export interface DocumentStoreParams {
context: HocusPocusServerContext;
pageId: string;
state: Uint8Array;
title: string;
state: any;
params: URLSearchParams;
}
/**
@@ -26,32 +27,25 @@ export interface DocumentHandler {
* Fetch a document
*/
fetch: (params: DocumentFetchParams) => Promise<any>;
/**
* Store a document
*/
store: (params: DocumentStoreParams) => Promise<void>;
}
/**
* Fetch title
*/
fetchTitle: (params: { pageId: string; context: HocusPocusServerContext }) => Promise<string | undefined>;
/**
* Update title
*/
updateTitle?: (params: {
context: HocusPocusServerContext;
pageId: string;
title: string;
abortSignal?: AbortSignal;
}) => Promise<void>;
/**
* Handler context interface - extend this to add new criteria for handler selection
*/
export interface HandlerContext {
documentType?: TDocumentTypes;
agentId?: string;
}
/**
* Handler selector function type - determines if a handler should be used based on context
*/
export type HandlerSelector = (context: HocusPocusServerContext) => boolean;
export type HandlerSelector = (context: HandlerContext) => boolean;
/**
* Handler definition combining a selector and implementation
@@ -65,4 +59,4 @@ export interface HandlerDefinition {
/**
* Type for a handler registration function
*/
export type RegisterHandler = (definition: HandlerDefinition) => void;
export type RegisterHandler = (definition: HandlerDefinition) => void;
+145
View File
@@ -0,0 +1,145 @@
import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib";
import { Logger } from "@hocuspocus/extension-logger";
import { Database } from "@hocuspocus/extension-database";
import { Redis } from "@hocuspocus/extension-redis";
import { Hocuspocus } from "@hocuspocus/server";
import { v4 as uuidv4 } from "uuid";
import { getRedisClient } from "./redis";
import { UserService } from "./services/user.service";
// import { handleError } from "@/core/helpers/error-handling/error-factory";
// import { TDocumentTypes } from "@/core/types/common";
// import { handleAuthentication } from "@/core/lib/authentication";
// import { IncomingHttpHeaders } from "http";
export const createHocusPocus = () => {
const serverName = process.env.HOSTNAME || uuidv4();
return new Hocuspocus({
name: serverName,
onAuthenticate: onAuthenticate(),
onStateless: onStateless(),
extensions: [
new Logger(),
new Database({
fetch: handleDataFetch,
store: handleDataStore,
}),
new Redis({ redis: getRedisClient() }),
],
debounce: 1000,
});
};
const validateToken = async (token: string | undefined) => {
try {
if (!token) {
throw new Error("Token not provided");
}
const userService = new UserService();
const response = await userService.currentUser(token);
return response;
} catch (error) {
return null;
}
};
const onAuthenticate = () => {
return async ({ token }: { token: string | undefined }) => {
const user = await validateToken(token);
if (!user) {
throw new Error("Invalid token");
}
return user;
};
};
const onStateless = () => {
return async ({ payload, document }: any) => {
// broadcast the client event (derived from the server event) to all the clients so that they can update their state
const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
if (response) {
document.broadcastStateless(response);
}
};
};
const handleDataFetch = async (data: any) => {
try {
const { context, documentName, requestParameters } = data;
console.log("handleDataFetch", context);
console.log("handleDataFetch", documentName);
console.log("handleDataFetch", requestParameters);
return documentName; // TODO: remove this once the API integration is done
// fetch the data using page service
// const pageService = new PageService();
// const page = await pageService.getPage(documentName);
// return page;
} catch (error) {
console.error("handleDataFetch", error);
throw error;
}
};
const handleDataStore = async (data: any) => {
try {
const { context, documentName, requestParameters } = data;
console.log("handleDataStore", context);
console.log("handleDataStore", documentName);
console.log("handleDataStore", requestParameters);
return documentName; // TODO: remove this once the API integration is done
// store the data using page service
// const pageService = new PageService();
// const page = await pageService.updatePage(documentName, requestParameters);
// return page;
} catch (error) {
console.error("handleDataStore", error);
throw error;
}
};
// async ({
// token,
// requestParameters,
// requestHeaders,
// }: {
// token: string;
// requestParameters: URLSearchParams;
// requestHeaders: IncomingHttpHeaders;
// }) => {
// let cookie: string | undefined = undefined;
// let userId: string | undefined = undefined;
// try {
// const parsedToken = JSON.parse(token) as { id: string; cookie: string };
// userId = parsedToken.id;
// cookie = parsedToken.cookie;
// } catch (error) {
// console.error("Token parsing failed, using request headers:", error);
// } finally {
// if (!cookie) {
// cookie = requestHeaders.cookie?.toString();
// }
// }
// if (!cookie || !userId) {
// handleError(null, {
// errorType: "unauthorized",
// message: "Credentials not provided",
// component: "hocuspocus",
// operation: "authenticate",
// extraContext: { tokenProvided: !!token },
// throw: true,
// });
// }
// const documentType = requestParameters.get("documentType")?.toString() as TDocumentTypes;
// const workspaceSlug = requestParameters.get("workspaceSlug")?.toString() as string;
// return await handleAuthentication({
// cookie,
// userId,
// workspaceSlug,
// });
// }
-32
View File
@@ -1,32 +0,0 @@
import { Router } from "express";
import { registerControllers as registerRestControllers, registerWebSocketControllers } from "@plane/decorators";
import "reflect-metadata";
/**
* Register all controllers from the controllers array
* @param router Express router to register routes on
* @param controllers Array of controller classes to register
* @param dependencies Array of dependencies to pass to controllers
*/
export function registerControllers(router: Router, controllers: any[], dependencies: any[] = []): void {
controllers.forEach((Controller) => {
// Create the controller instance with dependencies
const instance = new Controller(...dependencies);
// Determine if it's a WebSocket controller or REST controller by checking
// if it has any methods with the "ws" method metadata
const isWebsocket = Object.getOwnPropertyNames(Controller.prototype).some((methodName) => {
if (methodName === "constructor") return false;
return Reflect.getMetadata("method", instance, methodName) === "ws";
});
if (isWebsocket) {
// Register as WebSocket controller
// Pass the existing instance with dependencies to avoid creating a new instance without them
registerWebSocketControllers(router, Controller, instance);
} else {
// Register as REST controller - doesn't accept an instance parameter
registerRestControllers(router, Controller);
}
});
}
-21
View File
@@ -1,21 +0,0 @@
import "reflect-metadata";
import { asyncHandler } from "@/core/helpers/error-handling/error-handler";
/**
* Decorator to wrap controller methods with error handling
* This automatically catches and processes all errors using our error handling system
*/
export const CatchErrors = (): MethodDecorator => {
return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
// Only apply to methods that are not WebSocket handlers
const isWebSocketHandler = Reflect.getMetadata("method", target, propertyKey) === "ws";
if (typeof originalMethod === "function" && !isWebSocketHandler) {
descriptor.value = asyncHandler(originalMethod);
}
return descriptor;
};
};
@@ -152,11 +152,13 @@ export function handleError(error: unknown, options: ThrowingOptions): never;
* });
* return { error: appError.output() };
*/
// eslint-disable-next-line no-redeclare
export function handleError(error: unknown, options: NonThrowingOptions): AppError;
/**
* Implementation of handleError that handles both throwing and non-throwing cases
*/
// eslint-disable-next-line no-redeclare
export function handleError(error: unknown, options: ThrowingOptions | NonThrowingOptions): AppError | never {
// Only throw if throw is explicitly true
const shouldThrow = (options as ThrowingOptions).throw === true;
@@ -4,7 +4,7 @@ import { env } from "@/env";
import { logger } from "@plane/logger";
import { handleError } from "./error-factory";
import { ErrorContext, reportError } from "./error-reporting";
import { manualLogger } from "../logger";
import { manualLogger } from "../../core/helpers/logger";
/**
* HTTP Status Codes
@@ -1,4 +1,4 @@
import { AppError } from "./error-handler";
import { AppError, ErrorCategory } from "./error-handler";
import { logger } from "@plane/logger";
import { handleError } from "./error-factory";
+62
View File
@@ -0,0 +1,62 @@
import { Redis } from "ioredis";
import { logger } from "@plane/logger";
let redisClient: Redis | null = null;
export async function initializeRedis(): Promise<Redis> {
const redisUrl = getRedisUrl();
if (!redisUrl) {
logger.error("Redis URL is not configured. Please set REDIS_URL environment variable.");
process.exit(1);
}
try {
redisClient = new Redis(redisUrl);
redisClient.on("error", (error) => {
logger.error("Redis connection error:", error);
process.exit(1);
});
// Wait for the connection to be ready
await new Promise<void>((resolve, reject) => {
redisClient!.on("ready", () => {
logger.info("Redis connection established successfully");
resolve();
});
redisClient!.on("error", (error) => {
reject(error);
});
});
return redisClient;
} catch (error) {
logger.error("Failed to initialize Redis:", error);
process.exit(1);
}
}
export function getRedisClient(): Redis {
if (!redisClient) {
throw new Error("Redis client not initialized. Call initializeRedis() first.");
}
return redisClient;
}
function getRedisUrl() {
const redisUrl = process.env.REDIS_URL?.trim();
const redisHost = process.env.REDIS_HOST?.trim();
const redisPort = process.env.REDIS_PORT?.trim();
if (redisUrl) {
return redisUrl;
}
if (redisHost && redisPort && !Number.isNaN(Number(redisPort))) {
return `redis://${redisHost}:${redisPort}`;
}
return "";
}
+97 -139
View File
@@ -1,161 +1,119 @@
import express from "express";
import type { Application, Request, Router } from "express";
import cookieParser from "cookie-parser";
import cors from "cors";
import express, { Application } from "express";
import expressWs from "express-ws";
import type * as ws from "ws";
import type { Hocuspocus } from "@hocuspocus/server";
import helmet from "helmet";
import path from "path";
import { Hocuspocus } from "@hocuspocus/server";
// controllers
// Environment and configuration
import { serverConfig, configureServerMiddleware } from "./config/server-config";
// Core functionality
import { getHocusPocusServer } from "@/core/hocuspocus-server";
import { ProcessManager } from "@/core/process-manager";
import { ShutdownManager } from "@/core/shutdown-manager";
import { registerControllers } from "./lib/controller.utils";
// Redis manager
import { RedisManager } from "@/core/lib/redis-manager";
// Logging
import { initializeRedis } from "./core/lib/redis-manager";
import { logger } from "@plane/logger";
import { createHocusPocus } from "./hocuspocus";
// Error handling
import { configureErrorHandlers } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { getAllControllers } from "./core/controller-registry";
import { registerWebSocketController, registerController } from "@plane/decorators";
import { REST_CONTROLLERS, WEBSOCKET_CONTROLLERS } from "./controllers";
// WebSocket router type definition
interface WebSocketRouter extends Router {
ws: (_path: string, _handler: (ws: ws.WebSocket, req: Request) => void) => void;
}
export default class Server {
app: Application;
PORT: number;
BASE_PATH: string;
CORS_ALLOWED_ORIGINS: string;
hocuspocusServer: Hocuspocus | null = null;
private httpServer: any;
/**
* Main server class for the application
*/
export class Server {
private readonly app: Application;
private readonly port: number;
private hocusPocusServer!: Hocuspocus;
private redisManager: RedisManager;
/**
* Creates an instance of the server class.
* @param port Optional port number, defaults to environment configuration
*/
constructor(port?: number) {
constructor() {
this.PORT = parseInt(process.env.PORT || "3000");
this.BASE_PATH = process.env.LIVE_BASE_PATH || "/";
this.CORS_ALLOWED_ORIGINS = process.env.CORS_ALLOWED_ORIGINS || "*";
// Initialize express app
this.app = express();
this.port = port || serverConfig.port;
this.redisManager = RedisManager.getInstance();
// Initialize express-ws after Express setup
expressWs(this.app as any);
configureServerMiddleware(this.app);
// Security middleware
this.app.use(helmet());
// cors
this.setupCors();
// Cookie parsing
this.app.use(cookieParser());
// Body parsing middleware
this.app.use(express.json());
this.app.use(express.urlencoded({ extended: true }));
// static files
this.app.use(express.static(path.join(__dirname, "public")));
// setup redis
initializeRedis();
// setup hocuspocus server
this.hocuspocusServer = createHocusPocus();
// setup controllers
this.setupControllers();
}
/**
* Get the Express application instance
* Useful for testing
*/
getApp(): Application {
return this.app;
private setupCors() {
const origins = this.CORS_ALLOWED_ORIGINS.split(",").map((origin) => origin.trim());
this.app.use(
cors({
origin: origins,
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "x-api-key"],
})
);
}
/**
* Initialize the server with all required components
* @returns The server instance for chaining
*/
async initialize() {
try {
// Initialize core services
await this.initializeServices();
private setupControllers() {
const router = express.Router();
// Set up routes
await this.setupRoutes();
REST_CONTROLLERS.forEach((controller: any) => {
registerController(router, controller, [this.hocuspocusServer]);
});
// Set up error handlers
logger.info("Setting up error handlers");
configureErrorHandlers(this.app);
WEBSOCKET_CONTROLLERS.forEach((controller: any) => {
registerWebSocketController(router, controller, [this.hocuspocusServer]);
});
return this;
} catch (error) {
logger.error("Failed to initialize server:", error);
this.app.use(this.BASE_PATH, router);
}
// This will always throw (never returns) - TypeScript correctly infers this
handleError(error, {
errorType: "internal",
component: "server",
operation: "initialize",
throw: true,
});
start() {
this.httpServer = this.app.listen(this.PORT, () => {
console.log(`Plane Live server has started at port ${this.PORT}`);
});
// Setup graceful shutdown
process.on("SIGTERM", () => this.shutdown("Received SIGTERM"));
process.on("SIGINT", () => this.shutdown("Received SIGINT"));
process.on("uncaughtException", (error) => {
logger.error("Uncaught exception:", error);
});
// Handle unhandled promise rejections - create AppError but DON'T terminate
process.on("unhandledRejection", (error) => {
logger.error("Unhandled rejection:", error);
});
}
private async shutdown(error: string): Promise<void> {
logger.info(`Initiating graceful shutdown: ${error}`);
if (!this.httpServer) {
logger.info("No HTTP server to close");
return;
}
}
/**
* Initialize core services
*/
private async initializeServices() {
logger.info("Initializing Redis connection...");
await this.redisManager.connect();
// Close all existing connections
this.httpServer.closeAllConnections?.();
// Initialize the Hocuspocus server
this.hocusPocusServer = await getHocusPocusServer();
}
/**
* Set up API routes and WebSocket endpoints
*/
private async setupRoutes() {
try {
const router = express.Router() as WebSocketRouter;
// Get all controller classes
const controllers = getAllControllers();
// Register controllers with our simplified approach
// Pass the hocuspocus server as a dependency to the controllers that need it
registerControllers(router, controllers, [this.hocusPocusServer]);
// Mount the router on the base path
this.app.use(serverConfig.basePath, router);
} catch (error) {
handleError(error, {
errorType: "internal",
component: "server",
operation: "setupRoutes",
throw: true,
// Close the server
return new Promise<void>((resolve) => {
this.httpServer.close((error: Error | undefined) => {
if (error) {
logger.error("Error closing HTTP server:", error);
} else {
logger.info("HTTP server closed successfully");
}
resolve();
});
}
}
/**
* Start the server
* @returns HTTP Server instance
*/
async start() {
try {
const server = this.app.listen(this.port, () => {
logger.info(`Plane Live server has started at port ${this.port}`);
});
// Register servers with ShutdownManager
const shutdownManager = ShutdownManager.getInstance();
shutdownManager.register(server, this.hocusPocusServer);
// Setup graceful termination via ProcessManager (for signal handling)
const processManager = new ProcessManager(this.hocusPocusServer, server);
processManager.registerTerminationHandlers();
return server;
} catch (error) {
handleError(error, {
errorType: "service-unavailable",
component: "server",
operation: "start",
extraContext: { port: this.port },
throw: true,
});
}
});
}
}
+58
View File
@@ -0,0 +1,58 @@
// types
import { TPage } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/services/api.service";
export class PageService extends APIService {
constructor() {
super(API_BASE_URL);
}
async fetchDetails(workspaceSlug: string, projectId: string, pageId: string, cookie: string): Promise<TPage> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`, {
headers: {
Cookie: cookie,
},
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async fetchDescriptionBinary(workspaceSlug: string, projectId: string, pageId: string, cookie: string): Promise<any> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, {
headers: {
"Content-Type": "application/octet-stream",
Cookie: cookie,
},
responseType: "arraybuffer",
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async updateDescription(
workspaceSlug: string,
projectId: string,
pageId: string,
data: {
description_binary: string;
description_html: string;
description: object;
},
cookie: string
): Promise<any> {
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, data, {
headers: {
Cookie: cookie,
},
})
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
}
@@ -1,7 +1,7 @@
// types
import type { IUser } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/core/services/api.service";
import { API_BASE_URL, APIService } from "@/services/api.service";
export class UserService extends APIService {
constructor() {
+6 -33
View File
@@ -1,37 +1,10 @@
import { Server } from "./server";
import Server from "./server";
import { env } from "./env";
import { logger } from "@plane/logger";
import { handleError } from "@/core/helpers/error-handling/error-factory";
/**
* The main entry point for the application
* Starts the server and handles any startup errors
*/
const startServer = async () => {
try {
// Log server startup details
logger.info(`Starting Plane Live server in ${env.NODE_ENV} environment`);
// Log server startup details
logger.info(`Starting Plane Live server in ${env.NODE_ENV} environment`);
// Initialize and start the server
const server = await new Server().initialize();
await server.start();
logger.info(`Server running at base path: ${env.LIVE_BASE_PATH}`);
} catch (error) {
logger.error("Failed to start server:", error);
// Create an AppError but DON'T exit
handleError(error, {
errorType: "internal",
component: "startup",
operation: "startServer",
extraContext: { environment: env.NODE_ENV }
});
// Continue running even if startup had issues
logger.warn("Server encountered errors during startup but will continue running");
}
};
// Start the server
startServer();
// Initialize and start the server
const server = new Server();
server.start();
+5 -18
View File
@@ -3,19 +3,13 @@
"compilerOptions": {
"module": "ES2015",
"moduleResolution": "Bundler",
"lib": [
"ES2015"
],
"lib": ["ES2015"],
"outDir": "./dist",
"rootDir": ".",
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
],
"@/plane-live/*": [
"./src/ce/*"
]
"@/*": ["./src/*"],
"@/plane-live/*": ["./src/ce/*"]
},
"removeComments": true,
"esModuleInterop": true,
@@ -26,13 +20,6 @@
"emitDecoratorMetadata": true,
"sourceRoot": "/"
},
"include": [
"src/**/*.ts",
"tsup.config.ts"
],
"exclude": [
"./dist",
"./build",
"./node_modules"
]
"include": ["src/**/*.ts", "tsup.config.ts"],
"exclude": ["./dist", "./build", "./node_modules"]
}
+5 -4
View File
@@ -20,11 +20,12 @@ interface ControllerConstructor {
prototype: ControllerInstance;
}
export function registerControllers(
export function registerController(
router: Router,
Controller: ControllerConstructor,
dependencies: any[] = []
): void {
const instance = new Controller();
const instance = new Controller(...dependencies);
const baseRoute = Reflect.getMetadata("baseRoute", Controller) as string;
Object.getOwnPropertyNames(Controller.prototype).forEach((methodName) => {
@@ -33,14 +34,14 @@ export function registerControllers(
const method = Reflect.getMetadata(
"method",
instance,
methodName,
methodName
) as HttpMethod;
const route = Reflect.getMetadata("route", instance, methodName) as string;
const middlewares =
(Reflect.getMetadata(
"middlewares",
instance,
methodName,
methodName
) as RequestHandler[]) || [];
if (method && route) {
+2 -2
View File
@@ -2,8 +2,8 @@
export { Controller, Middleware } from "./rest";
export { Get, Post, Put, Patch, Delete } from "./rest";
export { WebSocket } from "./websocket";
export { registerControllers } from "./controller";
export { registerWebSocketControllers } from "./websocket-controller";
export { registerController } from "./controller";
export { registerWebSocketController } from "./websocket-controller";
// Also provide namespaced exports for better organization
import * as RestDecorators from "./rest";
+21 -56
View File
@@ -1,5 +1,3 @@
import { Router, Request } from "express";
import type { WebSocket } from "ws";
import "reflect-metadata";
interface ControllerInstance {
@@ -11,23 +9,19 @@ interface ControllerConstructor {
prototype: ControllerInstance;
}
export function registerWebSocketControllers(
router: Router,
export function registerWebSocketController(
router: any,
Controller: ControllerConstructor,
existingInstance?: ControllerInstance,
dependencies: any[] = []
): void {
const instance = existingInstance || new Controller();
const baseRoute = Reflect.getMetadata("baseRoute", Controller) as string;
const instance = new Controller(...dependencies);
const baseRoute = Reflect.getMetadata("baseRoute", Controller);
Object.getOwnPropertyNames(Controller.prototype).forEach((methodName) => {
if (methodName === "constructor") return; // Skip the constructor
const method = Reflect.getMetadata(
"method",
instance,
methodName,
) as string;
const route = Reflect.getMetadata("route", instance, methodName) as string;
const method = Reflect.getMetadata("method", instance, methodName);
const route = Reflect.getMetadata("route", instance, methodName);
if (method === "ws" && route) {
const handler = instance[methodName] as unknown;
@@ -36,50 +30,21 @@ export function registerWebSocketControllers(
typeof handler === "function" &&
typeof (router as any).ws === "function"
) {
(router as any).ws(
`${baseRoute}${route}`,
(ws: WebSocket, req: Request) => {
try {
handler.call(instance, ws, req);
} catch (error) {
console.error(
`WebSocket error in ${Controller.name}.${methodName}`,
error,
);
ws.close(
1011,
error instanceof Error
? error.message
: "Internal server error",
);
}
},
);
router.ws(`${baseRoute}${route}`, (ws: any, req: any) => {
try {
handler.call(instance, ws, req);
} catch (error) {
console.error(
`WebSocket error in ${Controller.name}.${methodName}`,
error
);
ws.close(
1011,
error instanceof Error ? error.message : "Internal server error"
);
}
});
}
}
});
}
/**
* Base controller class for WebSocket endpoints
*/
export abstract class BaseWebSocketController {
protected router: Router;
constructor() {
this.router = Router();
}
/**
* Get the base route for this controller
*/
protected getBaseRoute(): string {
return Reflect.getMetadata("baseRoute", this.constructor) || "";
}
/**
* Abstract method to handle WebSocket connections
* Implement this in your derived class
*/
abstract handleConnection(ws: WebSocket, req: Request): void;
}
+1 -1
View File
@@ -9,7 +9,7 @@ export function WebSocket(route: string): MethodDecorator {
return function (
target: object,
propertyKey: string | symbol,
descriptor: PropertyDescriptor,
descriptor: PropertyDescriptor
) {
Reflect.defineMetadata("method", "ws", target, propertyKey);
Reflect.defineMetadata("route", route, target, propertyKey);
-3
View File
@@ -43,9 +43,6 @@
"@tiptap/extension-blockquote": "2.10.4",
"@tiptap/extension-character-count": "2.11.0",
"@tiptap/extension-collaboration": "2.11.0",
"@tiptap/extension-text": "2.11.0",
"@tiptap/extension-document": "2.11.0",
"@tiptap/extension-heading": "2.11.0",
"@tiptap/extension-image": "2.11.0",
"@tiptap/extension-list-item": "2.11.0",
"@tiptap/extension-mention": "2.11.0",
@@ -36,7 +36,6 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
serverHandler,
tabIndex,
user,
updatePageProperties,
} = props;
const extensions: Extensions = [];
@@ -49,7 +48,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
}
// use document editor
const { editor, hasServerConnectionFailed, hasServerSynced, titleEditor } = useCollaborativeEditor({
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
disabledExtensions,
editable,
editorClassName,
@@ -66,7 +65,6 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
serverHandler,
tabIndex,
user,
updatePageProperties,
});
const editorContainerClassNames = getEditorClassNames({
@@ -75,7 +73,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
containerClassName,
});
if (!editor || !titleEditor) return null;
if (!editor) return null;
const blockWidthClassName = cn("w-full max-w-[720px] mx-auto transition-all duration-200 ease-in-out", {
"max-w-[1152px]": displayConfig.wideLayout,
@@ -89,8 +87,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
bubbleMenuEnabled={bubbleMenuEnabled}
displayConfig={displayConfig}
editor={editor}
titleEditor={titleEditor}
editorContainerClassName={editorContainerClassNames}
editorContainerClassName={cn(editorContainerClassNames, "document-editor")}
id={id}
tabIndex={tabIndex}
/>
@@ -10,35 +10,16 @@ type IPageRenderer = {
bubbleMenuEnabled: boolean;
displayConfig: TDisplayConfig;
editor: Editor;
titleEditor?: Editor;
editorContainerClassName: string;
id: string;
tabIndex?: number;
};
export const PageRenderer = (props: IPageRenderer) => {
const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, tabIndex, titleEditor } =
props;
const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
return (
<div className={"frame-renderer flex-grow w-full space-y-4 document-editor-container"}>
{titleEditor && (
<div className="relative w-full py-3">
<EditorContainer
editor={titleEditor}
id={id + "-title"}
editorContainerClassName={"page-title-editor bg-transparent p-0 border-none"}
displayConfig={displayConfig}
>
<EditorContentWrapper
focus={false}
editor={titleEditor}
id={id + "-title"}
className="no-scrollbar placeholder-custom-text-400 border-[0.5px] border-custom-border-200 bg-transparent tracking-[-2%] font-bold text-[2rem] leading-[2.375rem] w-full outline-none p-0 border-none resize-none rounded-none"
/>
</EditorContainer>
</div>
)}
<div className="frame-renderer flex-grow w-full">
<EditorContainer
displayConfig={displayConfig}
editor={editor}
@@ -1,5 +1,7 @@
import { Extensions } from "@tiptap/core";
import { forwardRef, MutableRefObject } from "react";
// plane imports
import { cn } from "@plane/utils";
// components
import { PageRenderer } from "@/components/editors";
// constants
@@ -79,7 +81,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
bubbleMenuEnabled={false}
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
editorContainerClassName={cn(editorContainerClassName, "document-editor")}
id={id}
/>
);
@@ -6,22 +6,13 @@ interface EditorContentProps {
editor: Editor | null;
id: string;
tabIndex?: number;
focus?: boolean;
className?: string;
}
export const EditorContentWrapper: FC<EditorContentProps> = (props) => {
const { editor, children, tabIndex, className, focus } = props;
const { editor, children, id, tabIndex } = props;
return (
<div
tabIndex={tabIndex}
onFocus={() => {
if (!focus) return;
editor?.chain().focus(undefined, { scrollIntoView: false }).run();
}}
className={className}
>
<div tabIndex={tabIndex} onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}>
<EditorContent editor={editor} />
{children}
</div>
@@ -46,7 +46,7 @@ export const LinkViewContainer: FC<LinkViewContainerProps> = ({ editor, containe
const handleLinkHover = useCallback(
(event: MouseEvent) => {
if (!editor || editorState?.linkExtensionStorage?.isBubbleMenuOpen) return;
if (!editor || editorState.linkExtensionStorage.isBubbleMenuOpen) return;
// Find the closest anchor tag from the event target
const target = (event.target as HTMLElement)?.closest("a");
@@ -109,7 +109,7 @@ export const LinkViewContainer: FC<LinkViewContainerProps> = ({ editor, containe
// Close link view when bubble menu opens
useEffect(() => {
if (editorState?.linkExtensionStorage?.isBubbleMenuOpen && isOpen) {
if (editorState.linkExtensionStorage.isBubbleMenuOpen && isOpen) {
setIsOpen(false);
}
}, [editorState.linkExtensionStorage, isOpen]);
@@ -1,14 +0,0 @@
import { AnyExtension, Extensions } from "@tiptap/core";
import Document from "@tiptap/extension-document";
import Heading from "@tiptap/extension-heading";
import Text from "@tiptap/extension-text";
export const TitleExtensions: Extensions = [
Document.extend({
content: "heading",
}),
Heading.configure({
levels: [1],
}) as AnyExtension,
Text,
];
@@ -13,31 +13,37 @@ export const setText = (editor: Editor, range?: Range) => {
export const toggleHeadingOne = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
// @ts-expect-error tiptap types are incorrect
else editor.chain().focus().toggleHeading({ level: 1 }).run();
};
export const toggleHeadingTwo = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
// @ts-expect-error tiptap types are incorrect
else editor.chain().focus().toggleHeading({ level: 2 }).run();
};
export const toggleHeadingThree = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
// @ts-expect-error tiptap types are incorrect
else editor.chain().focus().toggleHeading({ level: 3 }).run();
};
export const toggleHeadingFour = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 4 }).run();
// @ts-expect-error tiptap types are incorrect
else editor.chain().focus().toggleHeading({ level: 4 }).run();
};
export const toggleHeadingFive = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 5 }).run();
// @ts-expect-error tiptap types are incorrect
else editor.chain().focus().toggleHeading({ level: 5 }).run();
};
export const toggleHeadingSix = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 6 }).run();
// @ts-expect-error tiptap types are incorrect
else editor.chain().focus().toggleHeading({ level: 6 }).run();
};
@@ -7,12 +7,10 @@ import {
CoreEditorExtensionsWithoutProps,
DocumentEditorExtensionsWithoutProps,
} from "@/extensions/core-without-props";
import { TitleExtensions } from "@/extensions/title-extension";
// editor extension configs
const RICH_TEXT_EDITOR_EXTENSIONS = CoreEditorExtensionsWithoutProps;
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
export const TITLE_EDITOR_EXTENSIONS = TitleExtensions;
// editor schemas
// @ts-expect-error tiptap types are incorrect
const richTextEditorSchema = getSchema(RICH_TEXT_EDITOR_EXTENSIONS);
-7
View File
@@ -1,7 +0,0 @@
export * from "./use-collaborative-editor";
export * from "./use-editor";
export * from "./use-editor-markings";
export * from "./use-editor-navigation";
export * from "./use-file-upload";
export * from "./use-read-only-editor";
export * from "./use-title-editor";
@@ -1,25 +1,16 @@
// core
import { useEffect, useMemo, useState } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import Collaboration from "@tiptap/extension-collaboration";
// react
import { useEffect, useMemo, useState } from "react";
// indexeddb
import { IndexeddbPersistence } from "y-indexeddb";
// extensions
import { HeadingListExtension, SideMenuExtension } from "@/extensions";
// hooks
import { useEditor } from "@/hooks/use-editor";
import { useEditorNavigation } from "./use-editor-navigation";
import { useTitleEditor } from "./use-title-editor";
// plane editor extensions
import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import { TCollaborativeEditorProps } from "@/types";
/**
* Hook that creates a collaborative editor with title and main editor components
* Handles real-time collaboration, local persistence, and keyboard navigation between editors
*/
export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
const {
onTransaction,
@@ -39,23 +30,18 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
serverHandler,
tabIndex,
user,
updatePageProperties,
} = props;
// Server connection states
// states
const [hasServerConnectionFailed, setHasServerConnectionFailed] = useState(false);
const [hasServerSynced, setHasServerSynced] = useState(false);
// Create keyboard navigation extensions between editors
const { setTitleEditor, setMainEditor, titleNavigationExtension, mainNavigationExtension } = useEditorNavigation();
// Initialize Hocuspocus provider for real-time collaboration
// initialize Hocuspocus provider
const provider = useMemo(
() =>
new HocuspocusProvider({
name: id,
parameters: realtimeConfig.queryParams,
token: JSON.stringify(user), // Using user id as token for server auth
// using user id as a token to verify the user on the server
token: JSON.stringify(user),
url: realtimeConfig.url,
onAuthenticationFailed: () => {
serverHandler?.onServerError?.();
@@ -73,13 +59,12 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
[id, realtimeConfig, serverHandler, user]
);
// Initialize local persistence using IndexedDB
const localProvider = useMemo(
() => (id ? new IndexeddbPersistence(id, provider.document) : undefined),
[id, provider]
);
// Clean up providers on unmount
// destroy and disconnect all providers connection on unmount
useEffect(
() => () => {
provider?.destroy();
@@ -88,7 +73,6 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
[provider, localProvider]
);
// Initialize main document editor
const editor = useEditor({
disabledExtensions,
id,
@@ -97,31 +81,21 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
editorClassName,
enableHistory: false,
extensions: [
// Core extensions
SideMenuExtension({
aiEnabled: !disabledExtensions?.includes("ai"),
dragDropEnabled: true,
}),
HeadingListExtension,
// Collaboration extension
Collaboration.configure({
document: provider.document,
}),
// User-provided extensions
...(extensions ?? []),
// Additional document editor extensions
...DocumentEditorAdditionalExtensions({
disabledExtensions,
issueEmbedConfig: embedHandler?.issue,
provider,
userDetails: user,
}),
// Navigation extension for keyboard shortcuts
mainNavigationExtension,
],
fileHandler,
forwardedRef,
@@ -133,35 +107,8 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
tabIndex,
});
// Initialize title editor
const titleEditor = useTitleEditor({
editable: editable,
provider,
forwardedRef,
updatePageProperties,
extensions: [
// Collaboration extension for title field
Collaboration.configure({
document: provider.document,
field: "title",
}),
// Navigation extension for keyboard shortcuts
titleNavigationExtension,
],
});
// Connect editors for navigation once they're initialized
useEffect(() => {
if (editor && titleEditor) {
setMainEditor(editor);
setTitleEditor(titleEditor);
}
}, [editor, titleEditor, setMainEditor, setTitleEditor]);
return {
editor,
titleEditor,
hasServerConnectionFailed,
hasServerSynced,
};
@@ -1,160 +0,0 @@
import { Editor, Extension } from "@tiptap/core";
import { useCallback, useRef } from "react";
/**
* Creates a title editor extension that enables keyboard navigation to the main editor
*
* @param getMainEditor Function to get the main editor instance
* @returns A Tiptap extension with keyboard shortcuts
*/
export const createTitleNavigationExtension = (getMainEditor: () => Editor | null) => {
return Extension.create({
name: "titleEditorNavigation",
addKeyboardShortcuts() {
return {
// Arrow down at end of title - Move to main editor
ArrowDown: ({ editor }) => {
const mainEditor = getMainEditor();
if (!mainEditor) return false;
const { from, to } = editor.state.selection;
const documentLength = editor.state.doc.content.size;
// If cursor is at the end of the title
if (from === to && to === documentLength - 1) {
mainEditor.commands.focus("start");
return true;
}
return false;
},
// Tab - Move to main editor
Tab: () => {
const mainEditor = getMainEditor();
if (!mainEditor) return false;
mainEditor.commands.focus("start");
return true;
},
// Enter - Move to main editor
Enter: () => {
const mainEditor = getMainEditor();
if (!mainEditor) return false;
mainEditor.commands.focus("start");
return true;
},
};
},
});
};
/**
* Creates a main editor extension that enables keyboard navigation to the title editor
*
* @param getTitleEditor Function to get the title editor instance
* @returns A Tiptap extension with keyboard shortcuts
*/
export const createMainNavigationExtension = (getTitleEditor: () => Editor | null) => {
return Extension.create({
name: "mainEditorNavigation",
addKeyboardShortcuts() {
return {
// Arrow up at start of main editor - Move to title editor
ArrowUp: ({ editor }) => {
const titleEditor = getTitleEditor();
if (!titleEditor) return false;
const { from, to } = editor.state.selection;
// If cursor is at the start of the main editor
if (from === 1 && to === 1) {
titleEditor.commands.focus("end");
return true;
}
return false;
},
// Shift+Tab - Move to title editor
"Shift-Tab": () => {
const titleEditor = getTitleEditor();
if (!titleEditor) return false;
titleEditor.commands.focus("end");
return true;
},
// Backspace - Special handling for first paragraph
Backspace: ({ editor }) => {
const titleEditor = getTitleEditor();
if (!titleEditor) return false;
const { from, to, empty } = editor.state.selection;
// Only handle when cursor is at position 1 with empty selection
if (from === 1 && to === 1 && empty) {
const firstNode = editor.state.doc.firstChild;
// If first node is a paragraph
if (firstNode && firstNode.type.name === "paragraph") {
// If paragraph is already empty, delete it and focus title editor
if (firstNode.content.size === 0) {
editor.commands.deleteNode("paragraph");
// Use setTimeout to ensure the node is deleted before changing focus
setTimeout(() => titleEditor.commands.focus("end"), 0);
return true;
}
// If paragraph is not empty, just move focus to title editor
else {
titleEditor.commands.focus("end");
return true;
}
}
}
return false;
},
};
},
});
};
/**
* Hook to manage navigation between title and main editors
*
* Creates extension factories for keyboard navigation between editors
* and maintains references to both editors
*
* @returns Object with editor setters and extensions
*/
export const useEditorNavigation = () => {
// Create refs to store editor instances
const titleEditorRef = useRef<Editor | null>(null);
const mainEditorRef = useRef<Editor | null>(null);
// Create stable getter functions
const getTitleEditor = useCallback(() => titleEditorRef.current, []);
const getMainEditor = useCallback(() => mainEditorRef.current, []);
// Create stable setter functions
const setTitleEditor = useCallback((editor: Editor | null) => {
titleEditorRef.current = editor;
}, []);
const setMainEditor = useCallback((editor: Editor | null) => {
mainEditorRef.current = editor;
}, []);
// Create extension factories that access editor refs
const titleNavigationExtension = createTitleNavigationExtension(getMainEditor);
const mainNavigationExtension = createMainNavigationExtension(getTitleEditor);
return {
setTitleEditor,
setMainEditor,
titleNavigationExtension,
mainNavigationExtension,
};
};
+1 -1
View File
@@ -25,7 +25,7 @@ import type {
} from "@/types";
export interface CustomEditorProps {
editable?: boolean;
editable: boolean;
editorClassName: string;
editorProps?: EditorProps;
enableHistory: boolean;
@@ -1,51 +0,0 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { Extensions } from "@tiptap/core";
import Placeholder from "@tiptap/extension-placeholder";
import { useEditor } from "@tiptap/react";
import { MutableRefObject } from "react";
import { TitleExtensions } from "@/extensions/title-extension";
import { EditorTitleRefApi } from "@/types/editor";
export interface TitleEditorProps {
editable?: boolean;
provider: HocuspocusProvider;
forwardedRef?: MutableRefObject<EditorTitleRefApi | null>;
extensions?: Extensions;
initialValue?: string;
field?: string;
placeholder?: string;
updatePageProperties?: (data: { name?: string }) => void;
}
/**
* A hook that creates a title editor with collaboration features
* Uses the same Y.Doc as the main editor but a different field
*/
export const useTitleEditor = (props: TitleEditorProps) => {
const { editable = true, initialValue = "", extensions, updatePageProperties } = props;
const editor = useEditor(
{
onUpdate: () => {
if (updatePageProperties) {
updatePageProperties({ name: editor?.getText() });
}
},
editable,
extensions: [
...TitleExtensions,
...(extensions ?? []),
Placeholder.configure({
placeholder: () => "Untitled",
includeChildren: true,
showOnlyWhenEditable: false,
}),
],
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<h1></h1>",
},
[editable, initialValue]
);
return editor;
};
@@ -41,7 +41,6 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
mentionHandler: TMentionHandler;
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
updatePageProperties?: (data: { name?: string }) => void;
};
export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & {
-6
View File
@@ -93,11 +93,6 @@ export type EditorReadOnlyRefApi = {
};
};
// title ref api
export interface EditorTitleRefApi extends EditorReadOnlyRefApi {
setEditorValue: (content: string) => void;
}
export interface EditorRefApi extends EditorReadOnlyRefApi {
blur: () => void;
scrollToNodeViaDOMCoordinates: (behavior?: ScrollBehavior, position?: number) => void;
@@ -157,7 +152,6 @@ export interface ICollaborativeDocumentEditor
realtimeConfig: TRealtimeConfig;
serverHandler?: TServerHandler;
user: TUserDetails;
updatePageProperties?: (data: { name?: string }) => void;
}
// read only editor props
-1
View File
@@ -5,7 +5,6 @@ import "./styles/editor.css";
import "./styles/table.css";
import "./styles/github-dark.css";
import "./styles/drag-drop.css";
import "./styles/title-editor.css";
// editors
export {
@@ -1,49 +0,0 @@
/* Title editor styles */
.page-title-editor {
width: 100%;
outline: none;
resize: none;
border-radius: 0;
}
.page-title-editor .ProseMirror {
background-color: transparent;
font-weight: bold;
letter-spacing: -2%;
padding: 0;
margin-bottom: 0;
}
/* Handle font sizes */
.page-title-editor.small-font .ProseMirror h1 {
font-size: 1.6rem;
line-height: 1.9rem;
}
.page-title-editor.large-font .ProseMirror h1 {
font-size: 2rem;
line-height: 2.375rem;
}
/* Focus state */
.page-title-editor.active-editor .ProseMirror {
box-shadow: none;
outline: none;
}
/* Placeholder */
.page-title-editor .ProseMirror h1.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--color-placeholder);
pointer-events: none;
height: 0;
}
.page-title-editor .ProseMirror h1.is-empty::before {
content: attr(data-placeholder);
float: left;
color: var(--color-placeholder);
pointer-events: none;
height: 0;
}
+7 -4
View File
@@ -171,7 +171,7 @@
container-type: inline-size;
}
.document-editor-container {
.editor-container.document-editor {
--editor-content-width: var(--normal-content-width);
&.wide-layout {
@@ -210,7 +210,8 @@
/* keep a static padding of 96px for wide layouts for container width >912px and <1344px */
@container page-content-container (min-width: 912px) and (max-width: 1344px) {
.document-editor-container.wide-layout {
.editor-container.wide-layout,
.page-title-container {
padding-left: var(--wide-content-margin);
padding-right: var(--wide-content-margin);
}
@@ -218,7 +219,8 @@
/* keep a static padding of 20px for wide layouts for container width <912px */
@container page-content-container (max-width: 912px) {
.document-editor-container.wide-layout {
.editor-container.wide-layout,
.page-title-container {
padding-left: var(--normal-content-margin);
padding-right: var(--normal-content-margin);
}
@@ -226,7 +228,8 @@
/* keep a static padding of 20px for normal layouts for container width <760px */
@container page-content-container (max-width: 760px) {
.document-editor-container:not(.wide-layout) {
.editor-container:not(.wide-layout),
.page-title-container {
padding-left: var(--normal-content-margin);
padding-right: var(--normal-content-margin);
}
+1 -1
View File
@@ -36,7 +36,7 @@
"mobx": "^6.10.0",
"mobx-react": "^9.1.1",
"mobx-utils": "^6.0.8",
"next": "^14.2.25",
"next": "^14.2.26",
"next-themes": "^0.2.1",
"nprogress": "^0.2.0",
"react": "^18.3.1",
@@ -15,7 +15,7 @@ import { ERowVariant, Row } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { EditorMentionsRoot } from "@/components/editor";
import { PageContentBrowser, PageContentLoader } from "@/components/pages";
import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/components/pages";
// helpers
import { LIVE_BASE_PATH, LIVE_BASE_URL } from "@/helpers/common.helper";
import { generateRandomColor } from "@/helpers/string.helper";
@@ -68,7 +68,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
const { getUserDetails } = useMember();
// derived values
const { id: pageId, isContentEditable } = page;
const { id: pageId, name: pageTitle, isContentEditable, updateTitle } = page;
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? "";
// issue-embed
const { issueEmbedProps } = useIssueEmbed({
@@ -93,16 +93,6 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
[fontSize, fontStyle, isFullWidth]
);
const updatePageProperties = useCallback(
(data: { name?: string }) => {
if (data.name != null) {
console.log("data", data.name);
page.mutateProperties({ name: data.name });
}
},
[page]
);
const getAIMenu = useCallback(
({ isOpen, onClose }: TAIMenuProps) => (
<EditorAIMenu
@@ -174,7 +164,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
className="relative size-full flex flex-col pt-[64px] overflow-y-auto overflow-x-hidden vertical-scrollbar scrollbar-md duration-200"
variant={ERowVariant.HUGGING}
>
<div id="page-content-container" className="relative w-full flex-shrink-0">
<div id="page-content-container" className="relative w-full flex-shrink-0 space-y-4">
{/* table of content */}
<div className="page-summary-container absolute h-full right-0 top-[64px] z-[5]">
<div className="sticky top-[72px]">
@@ -188,6 +178,13 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
</div>
</div>
</div>
<PageEditorTitle
editorRef={editorRef}
readOnly={!isContentEditable}
title={pageTitle}
updateTitle={updateTitle}
widthClassName={blockWidthClassName}
/>
<CollaborativeDocumentEditorWithRef
editable={isContentEditable}
id={pageId}
@@ -215,7 +212,6 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
aiHandler={{
menu: getAIMenu,
}}
updatePageProperties={updatePageProperties}
/>
</div>
</Row>
@@ -1,4 +1,5 @@
export * from "./header";
export * from "./summary";
export * from "./editor-body";
export * from "./title";
export * from "./page-root";
-14
View File
@@ -31,7 +31,6 @@ export type TBasePage = TPage & {
addToFavorites: () => Promise<void>;
removePageFromFavorites: () => Promise<void>;
duplicate: () => Promise<TPage | undefined>;
mutateProperties: (data: Partial<TPage>, shouldUpdateName?: boolean) => void;
};
export type TBasePagePermissions = {
@@ -165,7 +164,6 @@ export class BasePage implements TBasePage {
addToFavorites: action,
removePageFromFavorites: action,
duplicate: action,
mutateProperties: action,
});
this.rootStore = store;
@@ -486,16 +484,4 @@ export class BasePage implements TBasePage {
* @description duplicate the page
*/
duplicate = async () => await this.services.duplicate();
/**
* @description mutate multiple properties at once
* @param data Partial<TPage>
*/
mutateProperties = (data: Partial<TPage>, shouldUpdateName: boolean = true) => {
Object.keys(data).forEach((key) => {
const value = data[key as keyof TPage];
if (key === "name" && !shouldUpdateName) return;
set(this, key, value);
});
};
}
+2 -24
View File
@@ -195,19 +195,7 @@ export class ProjectPageStore implements IProjectPageStore {
const pages = await this.service.fetchAll(workspaceSlug, projectId);
runInAction(() => {
for (const page of pages) {
if (page?.id) {
const existingPage = this.getPageById(page.id);
if (existingPage) {
// If page already exists, update all fields except name
const { name, ...otherFields } = page;
existingPage.mutateProperties(otherFields, false);
} else {
// If new page, create a new instance with all data
set(this.data, [page.id], new ProjectPage(this.store, page));
}
}
}
for (const page of pages) if (page?.id) set(this.data, [page.id], new ProjectPage(this.store, page));
this.loader = undefined;
});
@@ -240,17 +228,7 @@ export class ProjectPageStore implements IProjectPageStore {
const page = await this.service.fetchById(workspaceSlug, projectId, pageId);
runInAction(() => {
if (page?.id) {
const existingPage = this.getPageById(page.id);
if (existingPage) {
// If page already exists, update all fields except name
const { name, ...otherFields } = page;
existingPage.mutateProperties(otherFields, false);
} else {
// If new page, create a new instance with all data
set(this.data, [page.id], new ProjectPage(this.store, page));
}
}
if (page?.id) set(this.data, [page.id], new ProjectPage(this.store, page));
this.loader = undefined;
});
+1 -1
View File
@@ -53,7 +53,7 @@
"mobx": "^6.10.0",
"mobx-react": "^9.1.1",
"mobx-utils": "^6.0.8",
"next": "^14.2.25",
"next": "^14.2.26",
"next-themes": "^0.2.1",
"nprogress": "^0.2.0",
"posthog-js": "^1.131.3",
+89 -233
View File
@@ -104,11 +104,11 @@
jsesc "^3.0.2"
"@babel/helper-compilation-targets@^7.26.5":
version "7.26.5"
resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz#75d92bb8d8d51301c0d49e52a65c9a7fe94514d8"
integrity sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==
version "7.27.0"
resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.0.tgz#de0c753b1cd1d9ab55d473c5a5cf7170f0a81880"
integrity sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==
dependencies:
"@babel/compat-data" "^7.26.5"
"@babel/compat-data" "^7.26.8"
"@babel/helper-validator-option" "^7.25.9"
browserslist "^4.24.0"
lru-cache "^5.1.1"
@@ -154,14 +154,7 @@
"@babel/template" "^7.26.9"
"@babel/types" "^7.26.10"
"@babel/parser@^7.1.0", "@babel/parser@^7.20.7":
version "7.26.8"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.8.tgz#deca2b4d99e5e1b1553843b99823f118da6107c2"
integrity sha512-TZIQ25pkSoaKEYYaHbbxkfL36GNsQ6iFiBbeuzAkLnXayKR1yP1zFe+NxuZWWsUyvt8icPU9CCq0sgWGXR1GEw==
dependencies:
"@babel/types" "^7.26.8"
"@babel/parser@^7.25.9", "@babel/parser@^7.26.3":
"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.25.9", "@babel/parser@^7.26.3":
version "7.26.3"
resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz"
integrity sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==
@@ -226,10 +219,10 @@
debug "^4.3.1"
globals "^11.1.0"
"@babel/types@^7.0.0", "@babel/types@^7.18.9", "@babel/types@^7.20.7", "@babel/types@^7.25.9":
version "7.26.8"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.8.tgz#97dcdc190fab45be7f3dc073e3c11160d677c127"
integrity sha512-eUuWapzEGWFEpHFxgEaBG8e3n6S8L3MSu0oda755rOfabWPnh0Our1AozNFVUxGFIhbKgd1ksprsoDGMinTOTA==
"@babel/types@^7.0.0", "@babel/types@^7.18.9", "@babel/types@^7.20.7", "@babel/types@^7.25.9", "@babel/types@^7.26.3":
version "7.26.3"
resolved "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz"
integrity sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==
dependencies:
"@babel/helper-string-parser" "^7.25.9"
"@babel/helper-validator-identifier" "^7.25.9"
@@ -242,14 +235,6 @@
"@babel/helper-string-parser" "^7.25.9"
"@babel/helper-validator-identifier" "^7.25.9"
"@babel/types@^7.26.3":
version "7.26.3"
resolved "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz"
integrity sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==
dependencies:
"@babel/helper-string-parser" "^7.25.9"
"@babel/helper-validator-identifier" "^7.25.9"
"@blueprintjs/colors@^4.2.1":
version "4.2.1"
resolved "https://registry.npmjs.org/@blueprintjs/colors/-/colors-4.2.1.tgz"
@@ -730,13 +715,6 @@
uuid "^11.0.3"
ws "^8.5.0"
"@hocuspocus/transformer@^2.15.2":
version "2.15.2"
resolved "https://registry.yarnpkg.com/@hocuspocus/transformer/-/transformer-2.15.2.tgz#8e8975b4f1ac18e8c613519b8280f9596321ca60"
integrity sha512-FAo/rt0kch+YeBBp/iit74q+4RV6YRjdZXmvE0/Aw+n/nSW1W0ZNGXnhncfm8qP0CuXnss/Aw8dpfEPZKUWDJw==
dependencies:
"@tiptap/starter-kit" "^2.6.4"
"@humanwhocodes/config-array@^0.13.0":
version "0.13.0"
resolved "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz"
@@ -921,10 +899,10 @@
prop-types "^15.8.1"
react-is "^18.3.1"
"@next/env@14.2.25":
version "14.2.25"
resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.25.tgz#936d10b967e103e49a4bcea1e97292d5605278dd"
integrity sha512-JnzQ2cExDeG7FxJwqAksZ3aqVJrHjFwZQAEJ9gQZSoEhIow7SNoKZzju/AwQ+PLIR4NY8V0rhcVozx/2izDO0w==
"@next/env@14.2.26":
version "14.2.26"
resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.26.tgz#5d55f72d2edb7246607c78f61e7d3ff21516bc2e"
integrity sha512-vO//GJ/YBco+H7xdQhzJxF7ub3SUwft76jwaeOyVVQFHCi5DCnkP16WHB+JBylo4vOKPoZBlR94Z8xBxNBdNJA==
"@next/eslint-plugin-next@14.2.20":
version "14.2.20"
@@ -933,50 +911,50 @@
dependencies:
glob "10.3.10"
"@next/swc-darwin-arm64@14.2.25":
version "14.2.25"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.25.tgz#7bcccfda0c0ff045c45fbe34c491b7368e373e3d"
integrity sha512-09clWInF1YRd6le00vt750s3m7SEYNehz9C4PUcSu3bAdCTpjIV4aTYQZ25Ehrr83VR1rZeqtKUPWSI7GfuKZQ==
"@next/swc-darwin-arm64@14.2.26":
version "14.2.26"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.26.tgz#84b31a22149b2c49f5c5b29cddd7acb3a84d7e1c"
integrity sha512-zDJY8gsKEseGAxG+C2hTMT0w9Nk9N1Sk1qV7vXYz9MEiyRoF5ogQX2+vplyUMIfygnjn9/A04I6yrUTRTuRiyQ==
"@next/swc-darwin-x64@14.2.25":
version "14.2.25"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.25.tgz#b489e209d7b405260b73f69a38186ed150fb7a08"
integrity sha512-V+iYM/QR+aYeJl3/FWWU/7Ix4b07ovsQ5IbkwgUK29pTHmq+5UxeDr7/dphvtXEq5pLB/PucfcBNh9KZ8vWbug==
"@next/swc-darwin-x64@14.2.26":
version "14.2.26"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.26.tgz#50a5eb37972d313951f76f36f1f0b7100d063ebd"
integrity sha512-U0adH5ryLfmTDkahLwG9sUQG2L0a9rYux8crQeC92rPhi3jGQEY47nByQHrVrt3prZigadwj/2HZ1LUUimuSbg==
"@next/swc-linux-arm64-gnu@14.2.25":
version "14.2.25"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.25.tgz#ba064fabfdce0190d9859493d8232fffa84ef2e2"
integrity sha512-LFnV2899PJZAIEHQ4IMmZIgL0FBieh5keMnriMY1cK7ompR+JUd24xeTtKkcaw8QmxmEdhoE5Mu9dPSuDBgtTg==
"@next/swc-linux-arm64-gnu@14.2.26":
version "14.2.26"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.26.tgz#c4278c157623b05886e37ff17194811aca1c2d00"
integrity sha512-SINMl1I7UhfHGM7SoRiw0AbwnLEMUnJ/3XXVmhyptzriHbWvPPbbm0OEVG24uUKhuS1t0nvN/DBvm5kz6ZIqpg==
"@next/swc-linux-arm64-musl@14.2.25":
version "14.2.25"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.25.tgz#bf0018267e4e0fbfa1524750321f8cae855144a3"
integrity sha512-QC5y5PPTmtqFExcKWKYgUNkHeHE/z3lUsu83di488nyP0ZzQ3Yse2G6TCxz6nNsQwgAx1BehAJTZez+UQxzLfw==
"@next/swc-linux-arm64-musl@14.2.26":
version "14.2.26"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.26.tgz#5751132764b7a1f13a5a3fe447b03d564eb29705"
integrity sha512-s6JaezoyJK2DxrwHWxLWtJKlqKqTdi/zaYigDXUJ/gmx/72CrzdVZfMvUc6VqnZ7YEvRijvYo+0o4Z9DencduA==
"@next/swc-linux-x64-gnu@14.2.25":
version "14.2.25"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.25.tgz#64f5a6016a7148297ee80542e0fd788418a32472"
integrity sha512-y6/ML4b9eQ2D/56wqatTJN5/JR8/xdObU2Fb1RBidnrr450HLCKr6IJZbPqbv7NXmje61UyxjF5kvSajvjye5w==
"@next/swc-linux-x64-gnu@14.2.26":
version "14.2.26"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.26.tgz#74312cac45704762faa73e0880be6549027303af"
integrity sha512-FEXeUQi8/pLr/XI0hKbe0tgbLmHFRhgXOUiPScz2hk0hSmbGiU8aUqVslj/6C6KA38RzXnWoJXo4FMo6aBxjzg==
"@next/swc-linux-x64-musl@14.2.25":
version "14.2.25"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.25.tgz#58dc636d7c55828478159546f7b95ab1e902301c"
integrity sha512-sPX0TSXHGUOZFvv96GoBXpB3w4emMqKeMgemrSxI7A6l55VBJp/RKYLwZIB9JxSqYPApqiREaIIap+wWq0RU8w==
"@next/swc-linux-x64-musl@14.2.26":
version "14.2.26"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.26.tgz#5d96464d71d2000ec704e650a1a86bb9d73f760d"
integrity sha512-BUsomaO4d2DuXhXhgQCVt2jjX4B4/Thts8nDoIruEJkhE5ifeQFtvW5c9JkdOtYvE5p2G0hcwQ0UbRaQmQwaVg==
"@next/swc-win32-arm64-msvc@14.2.25":
version "14.2.25"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.25.tgz#93562d447c799bded1e89c1a62d5195a2a8c6c0d"
integrity sha512-ReO9S5hkA1DU2cFCsGoOEp7WJkhFzNbU/3VUF6XxNGUCQChyug6hZdYL/istQgfT/GWE6PNIg9cm784OI4ddxQ==
"@next/swc-win32-arm64-msvc@14.2.26":
version "14.2.26"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.26.tgz#859472b532b11499b8f5c2237f54401456286913"
integrity sha512-5auwsMVzT7wbB2CZXQxDctpWbdEnEW/e66DyXO1DcgHxIyhP06awu+rHKshZE+lPLIGiwtjo7bsyeuubewwxMw==
"@next/swc-win32-ia32-msvc@14.2.25":
version "14.2.25"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.25.tgz#ad85a33466be1f41d083211ea21adc0d2c6e6554"
integrity sha512-DZ/gc0o9neuCDyD5IumyTGHVun2dCox5TfPQI/BJTYwpSNYM3CZDI4i6TOdjeq1JMo+Ug4kPSMuZdwsycwFbAw==
"@next/swc-win32-ia32-msvc@14.2.26":
version "14.2.26"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.26.tgz#e52e9bd0c43b7a469b03eda6d7a07c3d0c28f549"
integrity sha512-GQWg/Vbz9zUGi9X80lOeGsz1rMH/MtFO/XqigDznhhhTfDlDoynCM6982mPCbSlxJ/aveZcKtTlwfAjwhyxDpg==
"@next/swc-win32-x64-msvc@14.2.25":
version "14.2.25"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.25.tgz#3969c66609e683ec63a6a9f320a855f7be686a08"
integrity sha512-KSznmS6eFjQ9RJ1nEc66kJvtGIL1iZMYmGEXsZPh2YtnLtqrgdVvKXJY2ScjjoFnG6nGLyPFR0UiEvDwVah4Tw==
"@next/swc-win32-x64-msvc@14.2.26":
version "14.2.26"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.26.tgz#6f42a3ae16ae15c5c5e36efa9b7e291c86ab1275"
integrity sha512-2rdB3T1/Gp7bv1eQTTm9d1Y1sv9UuJ2LAwOE0Pe2prHKe32UNscj7YS13fRB37d0GAiGNR+Y7ZcW8YjDI8Ns0w==
"@nivo/annotations@0.88.0":
version "0.88.0"
@@ -2265,7 +2243,7 @@
resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.10.4.tgz#0b3ad822d71f5834b281590809f430e6fd41523c"
integrity sha512-fExFRTRgb6MSpg2VvR5qO2dPTQAZWuUoU4UsBCurIVcPWcyVv4FG1YzgMyoLDKy44rebFtwUGJbfU9NzX7Q/bA==
"@tiptap/core@^2.11.0", "@tiptap/core@^2.11.5":
"@tiptap/core@^2.11.0":
version "2.11.5"
resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.11.5.tgz#2bf1b08c4ca2467778d0a109634c45ab475522f4"
integrity sha512-jb0KTdUJaJY53JaN7ooY3XAxHQNoMYti/H6ANo707PsLXVeEqJ9o8+eBup1JU5CuwzrgnDc2dECt2WIGX9f8Jw==
@@ -2275,12 +2253,12 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-blockquote/-/extension-blockquote-2.10.4.tgz#0f9a26740009fc7430fabc423077c369feaebb42"
integrity sha512-4JSwAM3B92YWvGzu/Vd5rovPrCGwLSaSLD5rxcLyfxLSrTDQd3n7lp78pzVgGhunVECzaGF5A0ByWWpEyS0a3w==
"@tiptap/extension-blockquote@^2.11.0", "@tiptap/extension-blockquote@^2.11.5":
"@tiptap/extension-blockquote@^2.11.0":
version "2.11.5"
resolved "https://registry.yarnpkg.com/@tiptap/extension-blockquote/-/extension-blockquote-2.11.5.tgz#d43ae78f5eba7de1b9138820502e950bae83c31c"
integrity sha512-MZfcRIzKRD8/J1hkt/eYv49060GTL6qGR3NY/oTDuw2wYzbQXXLEbjk8hxAtjwNn7G+pWQv3L+PKFzZDxibLuA==
"@tiptap/extension-bold@^2.11.0", "@tiptap/extension-bold@^2.11.5":
"@tiptap/extension-bold@^2.11.0":
version "2.11.5"
resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-2.11.5.tgz#7fc13d835067fbee4ff2be83a694f5200ba50e41"
integrity sha512-OAq03MHEbl7MtYCUzGuwb0VpOPnM0k5ekMbEaRILFU5ZC7cEAQ36XmPIw1dQayrcuE8GZL35BKub2qtRxyC9iA==
@@ -2292,7 +2270,7 @@
dependencies:
tippy.js "^6.3.7"
"@tiptap/extension-bullet-list@^2.11.0", "@tiptap/extension-bullet-list@^2.11.5":
"@tiptap/extension-bullet-list@^2.11.0":
version "2.11.5"
resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-2.11.5.tgz#84c6bf623c5dffcd73dd24d012c9636191031d43"
integrity sha512-VXwHlX6A/T6FAspnyjbKDO0TQ+oetXuat6RY1/JxbXphH42nLuBaGWJ6pgy6xMl6XY8/9oPkTNrfJw/8/eeRwA==
@@ -2302,12 +2280,12 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-character-count/-/extension-character-count-2.11.0.tgz#7d3992ceba72f1eeb2db3a1fc997c50dddd7cf8c"
integrity sha512-WbqVr1QY62vxpmDJP5k3bwyzoHha1sZTs0xj3L+4s1j/SB2A7tAlFdcNPPwfbPOINHQgomSAyClfTyd4Gor7HA==
"@tiptap/extension-code-block@^2.11.0", "@tiptap/extension-code-block@^2.11.5":
"@tiptap/extension-code-block@^2.11.0":
version "2.11.5"
resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-2.11.5.tgz#b90cea403884630f3f86c7629815250e8a266802"
integrity sha512-ksxMMvqLDlC+ftcQLynqZMdlJT1iHYZorXsXw/n+wuRd7YElkRkd6YWUX/Pq/njFY6lDjKiqFLEXBJB8nrzzBA==
"@tiptap/extension-code@^2.11.0", "@tiptap/extension-code@^2.11.5":
"@tiptap/extension-code@^2.11.0":
version "2.11.5"
resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-2.11.5.tgz#a550c544804e65507ab66dc8ab89a1e2f7d9228d"
integrity sha512-xOvHevNIQIcCCVn9tpvXa1wBp0wHN/2umbAZGTVzS+AQtM7BTo0tz8IyzwxkcZJaImONcUVYLOLzt2AgW1LltA==
@@ -2317,17 +2295,12 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-collaboration/-/extension-collaboration-2.11.0.tgz#21ac1c8bc2ca6d8ba2a838e63457528627294888"
integrity sha512-pS3E//ODD80PwVXp7zOqek0q9z5AtZ6sMSK5nPneNioe7dSvCeQzToOD9V6EevK4KChUIN9wEryK8mkQs57ioA==
"@tiptap/extension-document@2.11.0":
version "2.11.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.11.0.tgz#dcae7bc3a29b279ddbf88fc2b248817705ccb8a6"
integrity sha512-9YI0AT3mxyUZD7NHECHyV1uAjQ8KwxOS5ACwvrK1MU8TqY084LmodYNTXPKwpqbr51yvt3qZq1R7UIVu4/22Cg==
"@tiptap/extension-document@^2.11.0", "@tiptap/extension-document@^2.11.5":
"@tiptap/extension-document@^2.11.0":
version "2.11.5"
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.11.5.tgz#1d650d232df46cf07b83e0a5cc64db1c70057f37"
integrity sha512-7I4BRTpIux2a0O2qS3BDmyZ5LGp3pszKbix32CmeVh7lN9dV7W5reDqtJJ9FCZEEF+pZ6e1/DQA362dflwZw2g==
"@tiptap/extension-dropcursor@^2.11.0", "@tiptap/extension-dropcursor@^2.11.5":
"@tiptap/extension-dropcursor@^2.11.0":
version "2.11.5"
resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-2.11.5.tgz#a1d6fad3379551449534bdb8135da2577a8ec8fb"
integrity sha512-uIN7L3FU0904ec7FFFbndO7RQE/yiON4VzAMhNn587LFMyWO8US139HXIL4O8dpZeYwYL3d1FnDTflZl6CwLlg==
@@ -2339,32 +2312,27 @@
dependencies:
tippy.js "^6.3.7"
"@tiptap/extension-gapcursor@^2.11.0", "@tiptap/extension-gapcursor@^2.11.5":
"@tiptap/extension-gapcursor@^2.11.0":
version "2.11.5"
resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-2.11.5.tgz#6771e387d90ef85ee834f4572627d76e303e1297"
integrity sha512-kcWa+Xq9cb6lBdiICvLReuDtz/rLjFKHWpW3jTTF3FiP3wx4H8Rs6bzVtty7uOVTfwupxZRiKICAMEU6iT0xrQ==
"@tiptap/extension-hard-break@^2.11.0", "@tiptap/extension-hard-break@^2.11.5":
"@tiptap/extension-hard-break@^2.11.0":
version "2.11.5"
resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.11.5.tgz#cf9610846cb7ab0f3a8d8dc37fd1fcee6a39d72f"
integrity sha512-q9doeN+Yg9F5QNTG8pZGYfNye3tmntOwch683v0CCVCI4ldKaLZ0jG3NbBTq+mosHYdgOH2rNbIORlRRsQ+iYQ==
"@tiptap/extension-heading@2.11.0":
version "2.11.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-2.11.0.tgz#8b7530a325faffdf325d03d82a2d6947a440d662"
integrity sha512-vrYvxibsY7/Sd2wYQDZ8AfIORfFi/UHZAWI7JmaMtDkILuMLYQ+jXb7p4K2FFW/1nN7C8QqgLLFI5AfjZUusgw==
"@tiptap/extension-heading@^2.11.0", "@tiptap/extension-heading@^2.11.5":
"@tiptap/extension-heading@^2.11.0":
version "2.11.5"
resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-2.11.5.tgz#e9a54e4cbb5c9c7fc95a24cc894a16751ecd185f"
integrity sha512-x/MV53psJ9baRcZ4k4WjnCUBMt8zCX7mPlKVT+9C/o+DEs/j/qxPLs95nHeQv70chZpSwCQCt93xMmuF0kPoAg==
"@tiptap/extension-history@^2.11.0", "@tiptap/extension-history@^2.11.5":
"@tiptap/extension-history@^2.11.0":
version "2.11.5"
resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-2.11.5.tgz#c636c8da784ad25886eb617cff6b4752ac9586d1"
integrity sha512-b+wOS33Dz1azw6F1i9LFTEIJ/gUui0Jwz5ZvmVDpL2ZHBhq1Ui0/spTT+tuZOXq7Y/uCbKL8Liu4WoedIvhboQ==
"@tiptap/extension-horizontal-rule@^2.11.0", "@tiptap/extension-horizontal-rule@^2.11.5":
"@tiptap/extension-horizontal-rule@^2.11.0":
version "2.11.5"
resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.11.5.tgz#b876f606386c51bc2ff45d4bd26267f5b104a850"
integrity sha512-3up2r1Du8/5/4ZYzTC0DjTwhgPI3dn8jhOCLu73m5F3OGvK/9whcXoeWoX103hYMnGDxBlfOje71yQuN35FL4A==
@@ -2374,7 +2342,7 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-image/-/extension-image-2.11.0.tgz#e9611a6a032804279f9b12a483a7afe696358a3f"
integrity sha512-R+JkK5ocX35ag1c42aAw6rcb9QlLUBB0ju8A7b+8qZXN5yWKE0yO/oixYFmnZN7WSnBYtzuCVDX8cvRG+BPbgA==
"@tiptap/extension-italic@^2.11.0", "@tiptap/extension-italic@^2.11.5":
"@tiptap/extension-italic@^2.11.0":
version "2.11.5"
resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-2.11.5.tgz#63b09c7fb41ab64681983df7be8cf6bc330c0ede"
integrity sha512-9VGfb2/LfPhQ6TjzDwuYLRvw0A6VGbaIp3F+5Mql8XVdTBHb2+rhELbyhNGiGVR78CaB/EiKb6dO9xu/tBWSYA==
@@ -2384,7 +2352,7 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.11.0.tgz#15889fd8217b998bfef78cd9079c8405b5a9abcd"
integrity sha512-Jikcg0fccpM13a3hAFLtguMcpVg4eMWI8NnC0aUULD9rFhvWZQYQYQuoK3fO6vQrAQpNhsV4oa0dfSq1btu9kg==
"@tiptap/extension-list-item@^2.11.0", "@tiptap/extension-list-item@^2.11.5":
"@tiptap/extension-list-item@^2.11.0":
version "2.11.5"
resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.11.5.tgz#6ada38dd4e6db889288242542bc0490b0908d190"
integrity sha512-Mp5RD/pbkfW1vdc6xMVxXYcta73FOwLmblQlFNn/l/E5/X1DUSA4iGhgDDH4EWO3swbs03x2f7Zka/Xoj3+WLg==
@@ -2394,12 +2362,12 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-mention/-/extension-mention-2.11.0.tgz#515f831975188ce33e3c0951f94a34396a3f35cd"
integrity sha512-5/Yk2rTpsoIZaNyo4f+CgsCCkQkSiNAp24HOvvCm9Dp9w1gIFm6y6dSj5RYqzEucGjOkoaBbfMcm1QxKWIj6/A==
"@tiptap/extension-ordered-list@^2.11.0", "@tiptap/extension-ordered-list@^2.11.5":
"@tiptap/extension-ordered-list@^2.11.0":
version "2.11.5"
resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-2.11.5.tgz#c81e33b5bc885450d412e9ea644cc666407e0c13"
integrity sha512-Cu8KwruBNWAaEfshRQR0yOSaUKAeEwxW7UgbvF9cN/zZuKgK5uZosPCPTehIFCcRe+TBpRtZQh+06f/gNYpYYg==
"@tiptap/extension-paragraph@^2.11.0", "@tiptap/extension-paragraph@^2.11.5":
"@tiptap/extension-paragraph@^2.11.0":
version "2.11.5"
resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.11.5.tgz#05575f0264a435837483831eebffc5e3af279cb1"
integrity sha512-YFBWeg7xu/sBnsDIF/+nh9Arf7R0h07VZMd0id5Ydd2Qe3c1uIZwXxeINVtH0SZozuPIQFAT8ICe9M0RxmE+TA==
@@ -2409,7 +2377,7 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-placeholder/-/extension-placeholder-2.11.0.tgz#a330df0bc791df82b3a84490031a8db9a68f4061"
integrity sha512-ee8vz51pW6H+1rEDMFg2FnBs2Tj5rUHlJ1JgD7Dcp3+89SVHGB3UILGfbNpAnHZvhmsTY3NcfPAcZZ80QfQFMQ==
"@tiptap/extension-strike@^2.11.0", "@tiptap/extension-strike@^2.11.5":
"@tiptap/extension-strike@^2.11.0":
version "2.11.5"
resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-2.11.5.tgz#94e214dcede09f6c5f99d0c58290a1d3f5db61eb"
integrity sha512-PVfUiCqrjvsLpbIoVlegSY8RlkR64F1Rr2RYmiybQfGbg+AkSZXDeO0eIrc03//4gua7D9DfIozHmAKv1KN3ow==
@@ -2434,17 +2402,12 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-text-style/-/extension-text-style-2.11.0.tgz#2acb6207fe6acdb5479384682846980be15caa80"
integrity sha512-vuA16wMZ6J3fboL7FObwV2f5uN9Vg0WYmqU7971vxzJyaRj9VE1eeH8Kh5fq4RgwDzc13MZGvZZV4HcE1R8o8A==
"@tiptap/extension-text-style@^2.11.0", "@tiptap/extension-text-style@^2.11.5":
"@tiptap/extension-text-style@^2.11.0":
version "2.11.5"
resolved "https://registry.yarnpkg.com/@tiptap/extension-text-style/-/extension-text-style-2.11.5.tgz#f1b3882de489328203187e6256e6ee130477cfad"
integrity sha512-YUmYl0gILSd/u/ZkOmNxjNXVw+mu8fpC2f8G4I4tLODm0zCx09j9DDEJXSrM5XX72nxJQqtSQsCpNKnL0hfeEQ==
"@tiptap/extension-text@2.11.0":
version "2.11.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-2.11.0.tgz#56ad3d9b14a34f67f7f8b82a499ef7a9a10ef0a6"
integrity sha512-LcyrP+7ZEVx3YaKzjMAeujq+4xRt4mZ3ITGph2CQ4vOKFaMI8bzSR909q18t7Qyyvek0a9VydEU1NHSaq4G5jw==
"@tiptap/extension-text@^2.11.0", "@tiptap/extension-text@^2.11.5":
"@tiptap/extension-text@^2.11.0":
version "2.11.5"
resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-2.11.5.tgz#10cc6ec519aac71a6841ec9bd914ded747f6ec3f"
integrity sha512-Gq1WwyhFpCbEDrLPIHt5A8aLSlf8bfz4jm417c8F/JyU0J5dtYdmx0RAxjnLw1i7ZHE7LRyqqAoS0sl7JHDNSQ==
@@ -2485,7 +2448,7 @@
prosemirror-transform "^1.10.2"
prosemirror-view "^1.37.0"
"@tiptap/pm@^2.11.0", "@tiptap/pm@^2.11.5":
"@tiptap/pm@^2.11.0":
version "2.11.5"
resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-2.11.5.tgz#6577e277e5a991c605a3dfcebde7c0b794d8def4"
integrity sha512-z9JFtqc5ZOsdQLd9vRnXfTCQ8v5ADAfRt9Nm7SqP6FUHII8E1hs38ACzf5xursmth/VonJYb5+73Pqxk1hGIPw==
@@ -2547,33 +2510,6 @@
"@tiptap/extension-text-style" "^2.11.0"
"@tiptap/pm" "^2.11.0"
"@tiptap/starter-kit@^2.6.4":
version "2.11.5"
resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-2.11.5.tgz#7d1b0b866b10c0f9c98214588639cda204c4f3b4"
integrity sha512-SLI7Aj2ruU1t//6Mk8f+fqW+18uTqpdfLUJYgwu0CkqBckrkRZYZh6GVLk/02k3H2ki7QkFxiFbZrdbZdng0JA==
dependencies:
"@tiptap/core" "^2.11.5"
"@tiptap/extension-blockquote" "^2.11.5"
"@tiptap/extension-bold" "^2.11.5"
"@tiptap/extension-bullet-list" "^2.11.5"
"@tiptap/extension-code" "^2.11.5"
"@tiptap/extension-code-block" "^2.11.5"
"@tiptap/extension-document" "^2.11.5"
"@tiptap/extension-dropcursor" "^2.11.5"
"@tiptap/extension-gapcursor" "^2.11.5"
"@tiptap/extension-hard-break" "^2.11.5"
"@tiptap/extension-heading" "^2.11.5"
"@tiptap/extension-history" "^2.11.5"
"@tiptap/extension-horizontal-rule" "^2.11.5"
"@tiptap/extension-italic" "^2.11.5"
"@tiptap/extension-list-item" "^2.11.5"
"@tiptap/extension-ordered-list" "^2.11.5"
"@tiptap/extension-paragraph" "^2.11.5"
"@tiptap/extension-strike" "^2.11.5"
"@tiptap/extension-text" "^2.11.5"
"@tiptap/extension-text-style" "^2.11.5"
"@tiptap/pm" "^2.11.5"
"@tiptap/suggestion@2.11.0":
version "2.11.0"
resolved "https://registry.yarnpkg.com/@tiptap/suggestion/-/suggestion-2.11.0.tgz#826140c6f1a76af2d91713dafb4b142c803eabef"
@@ -4204,7 +4140,7 @@ check-error@^2.1.1:
resolved "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz"
integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==
chokidar@^3.3.0, chokidar@^3.5.2, chokidar@^3.5.3, chokidar@^3.6.0:
chokidar@^3.3.0, chokidar@^3.5.3, chokidar@^3.6.0:
version "3.6.0"
resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz"
integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==
@@ -4437,19 +4373,6 @@ concat-map@0.0.1:
resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
concurrently@^9.0.1:
version "9.1.0"
resolved "https://registry.npmjs.org/concurrently/-/concurrently-9.1.0.tgz"
integrity sha512-VxkzwMAn4LP7WyMnJNbHN5mKV9L2IbyDjpzemKr99sXNR3GqRNMMHdm7prV1ws9wg7ETj6WUkNOigZVsptwbgg==
dependencies:
chalk "^4.1.2"
lodash "^4.17.21"
rxjs "^7.8.1"
shell-quote "^1.8.1"
supports-color "^8.1.1"
tree-kill "^1.2.2"
yargs "^17.7.2"
consola@^3.2.3, consola@^3.4.0:
version "3.4.2"
resolved "https://registry.yarnpkg.com/consola/-/consola-3.4.2.tgz#5af110145397bb67afdab77013fdc34cae590ea7"
@@ -4849,7 +4772,7 @@ debug@2.6.9:
dependencies:
ms "2.0.0"
debug@4, debug@^4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.3.7, debug@^4.4.0:
debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.3.7, debug@^4.4.0:
version "4.4.0"
resolved "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz"
integrity sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==
@@ -6473,11 +6396,6 @@ has-bigints@^1.0.2:
resolved "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz"
integrity sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==
has-flag@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz"
integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==
has-flag@^4.0.0:
version "4.0.0"
resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz"
@@ -6680,11 +6598,6 @@ ieee754@^1.1.13, ieee754@^1.2.1:
resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz"
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
ignore-by-default@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz"
integrity sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==
ignore@^5.2.0, ignore@^5.2.4, ignore@^5.3.1:
version "5.3.2"
resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz"
@@ -8086,12 +7999,12 @@ next-themes@^0.2.1:
resolved "https://registry.npmjs.org/next-themes/-/next-themes-0.2.1.tgz"
integrity sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A==
next@^14.2.25:
version "14.2.25"
resolved "https://registry.yarnpkg.com/next/-/next-14.2.25.tgz#0657551fde6a97f697cf9870e9ccbdaa465c6008"
integrity sha512-N5M7xMc4wSb4IkPvEV5X2BRRXUmhVHNyaXwEM86+voXthSZz8ZiRyQW4p9mwAoAPIm6OzuVZtn7idgEJeAJN3Q==
next@^14.2.26:
version "14.2.26"
resolved "https://registry.yarnpkg.com/next/-/next-14.2.26.tgz#b918b3fc5c55e1a67aada1347907675713687721"
integrity sha512-b81XSLihMwCfwiUVRRja3LphLo4uBBMZEzBBWMaISbKTwOmq3wPknIETy/8000tr7Gq4WmbuFYPS7jOYIf+ZJw==
dependencies:
"@next/env" "14.2.25"
"@next/env" "14.2.26"
"@swc/helpers" "0.5.5"
busboy "1.6.0"
caniuse-lite "^1.0.30001579"
@@ -8099,15 +8012,15 @@ next@^14.2.25:
postcss "8.4.31"
styled-jsx "5.1.1"
optionalDependencies:
"@next/swc-darwin-arm64" "14.2.25"
"@next/swc-darwin-x64" "14.2.25"
"@next/swc-linux-arm64-gnu" "14.2.25"
"@next/swc-linux-arm64-musl" "14.2.25"
"@next/swc-linux-x64-gnu" "14.2.25"
"@next/swc-linux-x64-musl" "14.2.25"
"@next/swc-win32-arm64-msvc" "14.2.25"
"@next/swc-win32-ia32-msvc" "14.2.25"
"@next/swc-win32-x64-msvc" "14.2.25"
"@next/swc-darwin-arm64" "14.2.26"
"@next/swc-darwin-x64" "14.2.26"
"@next/swc-linux-arm64-gnu" "14.2.26"
"@next/swc-linux-arm64-musl" "14.2.26"
"@next/swc-linux-x64-gnu" "14.2.26"
"@next/swc-linux-x64-musl" "14.2.26"
"@next/swc-win32-arm64-msvc" "14.2.26"
"@next/swc-win32-ia32-msvc" "14.2.26"
"@next/swc-win32-x64-msvc" "14.2.26"
no-case@^3.0.4:
version "3.0.4"
@@ -8154,22 +8067,6 @@ node-releases@^2.0.18:
resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz"
integrity sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==
nodemon@^3.1.7:
version "3.1.7"
resolved "https://registry.npmjs.org/nodemon/-/nodemon-3.1.7.tgz"
integrity sha512-hLj7fuMow6f0lbB0cD14Lz2xNjwsyruH251Pk4t/yIitCFJbmY1myuLlHm/q06aST4jg6EgAh74PIBBrRqpVAQ==
dependencies:
chokidar "^3.5.2"
debug "^4"
ignore-by-default "^1.0.1"
minimatch "^3.1.2"
pstree.remy "^1.1.8"
semver "^7.5.3"
simple-update-notifier "^2.0.0"
supports-color "^5.5.0"
touch "^3.1.0"
undefsafe "^2.0.5"
normalize-path@^3.0.0, normalize-path@~3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz"
@@ -9124,11 +9021,6 @@ proxy-from-env@^1.1.0:
resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
pstree.remy@^1.1.8:
version "1.1.8"
resolved "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz"
integrity sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==
pump@^3.0.0:
version "3.0.2"
resolved "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz"
@@ -9793,13 +9685,6 @@ run-parallel@^1.1.9:
dependencies:
queue-microtask "^1.2.2"
rxjs@^7.8.1:
version "7.8.1"
resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz"
integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==
dependencies:
tslib "^2.1.0"
sade@^1.7.3:
version "1.8.1"
resolved "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz"
@@ -9932,7 +9817,7 @@ semver@^6.0.0, semver@^6.3.1:
resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
semver@^7.3.5, semver@^7.3.7, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.2, semver@^7.6.3:
semver@^7.3.5, semver@^7.3.7, semver@^7.5.4, semver@^7.6.0, semver@^7.6.2, semver@^7.6.3:
version "7.7.1"
resolved "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz#abd5098d82b18c6c81f6074ff2647fd3e7220c9f"
integrity sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==
@@ -10044,11 +9929,6 @@ shebang-regex@^3.0.0:
resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz"
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
shell-quote@^1.8.1:
version "1.8.2"
resolved "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz"
integrity sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==
side-channel-list@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad"
@@ -10130,13 +10010,6 @@ simple-swizzle@^0.2.2:
dependencies:
is-arrayish "^0.3.1"
simple-update-notifier@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz"
integrity sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==
dependencies:
semver "^7.5.3"
slash@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz"
@@ -10469,13 +10342,6 @@ sucrase@^3.35.0:
pirates "^4.0.1"
ts-interface-checker "^0.1.9"
supports-color@^5.5.0:
version "5.5.0"
resolved "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz"
integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==
dependencies:
has-flag "^3.0.0"
supports-color@^7.1.0:
version "7.2.0"
resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz"
@@ -10483,7 +10349,7 @@ supports-color@^7.1.0:
dependencies:
has-flag "^4.0.0"
supports-color@^8.0.0, supports-color@^8.1.1:
supports-color@^8.0.0:
version "8.1.1"
resolved "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz"
integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==
@@ -10752,11 +10618,6 @@ toidentifier@1.0.1:
resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz"
integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
touch@^3.1.0:
version "3.1.1"
resolved "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz"
integrity sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==
tough-cookie@^5.0.0:
version "5.0.0"
resolved "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.0.0.tgz"
@@ -11100,11 +10961,6 @@ unbox-primitive@^1.1.0:
has-symbols "^1.1.0"
which-boxed-primitive "^1.1.1"
undefsafe@^2.0.5:
version "2.0.5"
resolved "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz"
integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==
undici-types@~6.19.2:
version "6.19.8"
resolved "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz"
@@ -11765,7 +11621,7 @@ yargs-parser@^21.1.1:
resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz"
integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==
yargs@^17.0.0, yargs@^17.7.2:
yargs@^17.0.0:
version "17.7.2"
resolved "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz"
integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==