@checkstack/backend-api 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +228 -0
- package/package.json +33 -0
- package/src/assertions.test.ts +345 -0
- package/src/assertions.ts +371 -0
- package/src/auth-strategy.ts +58 -0
- package/src/chart-metadata.ts +77 -0
- package/src/config-service.ts +71 -0
- package/src/config-versioning.ts +310 -0
- package/src/contract.ts +8 -0
- package/src/core-services.ts +45 -0
- package/src/email-layout.ts +246 -0
- package/src/encryption.ts +95 -0
- package/src/event-bus-types.ts +28 -0
- package/src/extension-point.ts +11 -0
- package/src/health-check.ts +68 -0
- package/src/hooks.ts +182 -0
- package/src/index.ts +23 -0
- package/src/markdown.test.ts +106 -0
- package/src/markdown.ts +104 -0
- package/src/notification-strategy.ts +436 -0
- package/src/oauth-handler.ts +442 -0
- package/src/plugin-admin-contract.ts +64 -0
- package/src/plugin-system.ts +103 -0
- package/src/rpc.ts +284 -0
- package/src/schema-utils.ts +79 -0
- package/src/service-ref.ts +15 -0
- package/src/test-utils.ts +65 -0
- package/src/types.ts +111 -0
- package/src/zod-config.ts +149 -0
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Master encryption key from environment variable.
|
|
5
|
+
* Should be 32 bytes (64 hex characters) for AES-256.
|
|
6
|
+
*/
|
|
7
|
+
const getMasterKey = (): Buffer => {
|
|
8
|
+
const key = process.env.ENCRYPTION_MASTER_KEY;
|
|
9
|
+
if (!key) {
|
|
10
|
+
throw new Error(
|
|
11
|
+
"ENCRYPTION_MASTER_KEY environment variable is required for secret encryption"
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Convert hex string to buffer
|
|
16
|
+
const keyBuffer = Buffer.from(key, "hex");
|
|
17
|
+
if (keyBuffer.length !== 32) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
"ENCRYPTION_MASTER_KEY must be 32 bytes (64 hex characters)"
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return keyBuffer;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Encrypts a plaintext string using AES-256-GCM.
|
|
28
|
+
* Returns base64-encoded string in format: iv:authTag:ciphertext
|
|
29
|
+
*/
|
|
30
|
+
export const encrypt = (plaintext: string): string => {
|
|
31
|
+
const key = getMasterKey();
|
|
32
|
+
|
|
33
|
+
// Generate random IV (12 bytes for GCM)
|
|
34
|
+
const iv = crypto.randomBytes(12);
|
|
35
|
+
|
|
36
|
+
// Create cipher
|
|
37
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
38
|
+
|
|
39
|
+
// Encrypt
|
|
40
|
+
const encrypted = Buffer.concat([
|
|
41
|
+
cipher.update(plaintext, "utf8"),
|
|
42
|
+
cipher.final(),
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
// Get auth tag
|
|
46
|
+
const authTag = cipher.getAuthTag();
|
|
47
|
+
|
|
48
|
+
// Return base64-encoded iv:authTag:ciphertext
|
|
49
|
+
return `${iv.toString("base64")}:${authTag.toString(
|
|
50
|
+
"base64"
|
|
51
|
+
)}:${encrypted.toString("base64")}`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Decrypts an encrypted string.
|
|
56
|
+
* Expects base64-encoded format: iv:authTag:ciphertext
|
|
57
|
+
*/
|
|
58
|
+
export const decrypt = (encrypted: string): string => {
|
|
59
|
+
const key = getMasterKey();
|
|
60
|
+
|
|
61
|
+
// Parse the encrypted string
|
|
62
|
+
const parts = encrypted.split(":");
|
|
63
|
+
if (parts.length !== 3) {
|
|
64
|
+
throw new Error("Invalid encrypted format");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const iv = Buffer.from(parts[0], "base64");
|
|
68
|
+
const authTag = Buffer.from(parts[1], "base64");
|
|
69
|
+
const ciphertext = Buffer.from(parts[2], "base64");
|
|
70
|
+
|
|
71
|
+
// Create decipher
|
|
72
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
|
73
|
+
decipher.setAuthTag(authTag);
|
|
74
|
+
|
|
75
|
+
// Decrypt
|
|
76
|
+
const decrypted = Buffer.concat([
|
|
77
|
+
decipher.update(ciphertext),
|
|
78
|
+
decipher.final(),
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
return decrypted.toString("utf8");
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Checks if a value appears to be encrypted.
|
|
86
|
+
* Encrypted values follow the format: base64:base64:base64
|
|
87
|
+
*/
|
|
88
|
+
export const isEncrypted = (value: string): boolean => {
|
|
89
|
+
const parts = value.split(":");
|
|
90
|
+
if (parts.length !== 3) return false;
|
|
91
|
+
|
|
92
|
+
// Check if all parts are valid base64
|
|
93
|
+
const base64Regex = /^[A-Za-z0-9+/]+=*$/;
|
|
94
|
+
return parts.every((part) => base64Regex.test(part));
|
|
95
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Hook, HookSubscribeOptions, HookUnsubscribe } from "./hooks";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* EventBus interface for dependency injection
|
|
5
|
+
*/
|
|
6
|
+
export interface EventBus {
|
|
7
|
+
subscribe<T>(
|
|
8
|
+
pluginId: string,
|
|
9
|
+
hook: Hook<T>,
|
|
10
|
+
listener: (payload: T) => Promise<void>,
|
|
11
|
+
options?: HookSubscribeOptions
|
|
12
|
+
): Promise<HookUnsubscribe>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Emit a hook through the distributed queue system.
|
|
16
|
+
* All instances receive broadcast hooks; one instance handles work-queue hooks.
|
|
17
|
+
*/
|
|
18
|
+
emit<T>(hook: Hook<T>, payload: T): Promise<void>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Emit a hook locally only (not distributed).
|
|
22
|
+
* Use for instance-local hooks that should only run on THIS instance.
|
|
23
|
+
* Uses Promise.allSettled to ensure one listener error doesn't block others.
|
|
24
|
+
*/
|
|
25
|
+
emitLocal<T>(hook: Hook<T>, payload: T): Promise<void>;
|
|
26
|
+
|
|
27
|
+
shutdown(): Promise<void>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ExtensionPoint<T> {
|
|
2
|
+
id: string;
|
|
3
|
+
T: T; // phantom type for type safety
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Creates a reference to an extension point.
|
|
8
|
+
*/
|
|
9
|
+
export function createExtensionPoint<T>(id: string): ExtensionPoint<T> {
|
|
10
|
+
return { id } as ExtensionPoint<T>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Versioned } from "./config-versioning";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Health check result with typed metadata.
|
|
5
|
+
* TMetadata is defined by each strategy's resultMetadata schema.
|
|
6
|
+
*/
|
|
7
|
+
export interface HealthCheckResult<TMetadata = Record<string, unknown>> {
|
|
8
|
+
status: "healthy" | "unhealthy" | "degraded";
|
|
9
|
+
latencyMs?: number;
|
|
10
|
+
message?: string;
|
|
11
|
+
metadata?: TMetadata;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Raw run data for aggregation (passed to aggregateMetadata function).
|
|
16
|
+
*/
|
|
17
|
+
export interface HealthCheckRunForAggregation<
|
|
18
|
+
TResultMetadata = Record<string, unknown>
|
|
19
|
+
> {
|
|
20
|
+
status: "healthy" | "unhealthy" | "degraded";
|
|
21
|
+
latencyMs?: number;
|
|
22
|
+
metadata?: TResultMetadata;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Health check strategy definition with typed config and result.
|
|
27
|
+
* @template TConfig - Configuration type for this strategy
|
|
28
|
+
* @template TResult - Per-run result type
|
|
29
|
+
* @template TAggregatedResult - Aggregated result type for buckets
|
|
30
|
+
*/
|
|
31
|
+
export interface HealthCheckStrategy<
|
|
32
|
+
TConfig = unknown,
|
|
33
|
+
TResult = Record<string, unknown>,
|
|
34
|
+
TAggregatedResult = Record<string, unknown>
|
|
35
|
+
> {
|
|
36
|
+
id: string;
|
|
37
|
+
displayName: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
|
|
40
|
+
/** Configuration schema with versioning and migrations */
|
|
41
|
+
config: Versioned<TConfig>;
|
|
42
|
+
|
|
43
|
+
/** Optional result schema with versioning and migrations */
|
|
44
|
+
result?: Versioned<TResult>;
|
|
45
|
+
|
|
46
|
+
/** Aggregated result schema for long-term bucket storage */
|
|
47
|
+
aggregatedResult: Versioned<TAggregatedResult>;
|
|
48
|
+
|
|
49
|
+
execute(config: TConfig): Promise<HealthCheckResult<TResult>>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Aggregate results from multiple runs into a summary for bucket storage.
|
|
53
|
+
* Called during retention processing when raw data is aggregated.
|
|
54
|
+
* Core metrics (counts, latency) are auto-calculated by platform.
|
|
55
|
+
* This function only handles strategy-specific result aggregation.
|
|
56
|
+
*/
|
|
57
|
+
aggregateResult(
|
|
58
|
+
runs: HealthCheckRunForAggregation<TResult>[]
|
|
59
|
+
): TAggregatedResult;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface HealthCheckRegistry {
|
|
63
|
+
register(strategy: HealthCheckStrategy<unknown, unknown, unknown>): void;
|
|
64
|
+
getStrategy(
|
|
65
|
+
id: string
|
|
66
|
+
): HealthCheckStrategy<unknown, unknown, unknown> | undefined;
|
|
67
|
+
getStrategies(): HealthCheckStrategy<unknown, unknown, unknown>[];
|
|
68
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { Permission } from "@checkstack/common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hook definition for type-safe event emission and subscription
|
|
5
|
+
*/
|
|
6
|
+
export interface Hook<T = unknown> {
|
|
7
|
+
id: string;
|
|
8
|
+
_type?: T; // Phantom type for TypeScript inference
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create a typed hook
|
|
13
|
+
*/
|
|
14
|
+
export function createHook<T>(id: string): Hook<T> {
|
|
15
|
+
return { id } as Hook<T>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Core platform hooks
|
|
20
|
+
*/
|
|
21
|
+
export const coreHooks = {
|
|
22
|
+
/**
|
|
23
|
+
* Emitted when a plugin registers permissions
|
|
24
|
+
*/
|
|
25
|
+
permissionsRegistered: createHook<{
|
|
26
|
+
pluginId: string;
|
|
27
|
+
permissions: Permission[];
|
|
28
|
+
}>("core.permissions.registered"),
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Emitted when plugin configuration is updated
|
|
32
|
+
*/
|
|
33
|
+
configUpdated: createHook<{
|
|
34
|
+
pluginId: string;
|
|
35
|
+
key: string;
|
|
36
|
+
value: unknown;
|
|
37
|
+
}>("core.config.updated"),
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Emitted when a plugin completes initialization (Phase 2)
|
|
41
|
+
*/
|
|
42
|
+
pluginInitialized: createHook<{
|
|
43
|
+
pluginId: string;
|
|
44
|
+
}>("core.plugin.initialized"),
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Distributed request to deregister a plugin.
|
|
48
|
+
* All instances receive this and should perform local cleanup.
|
|
49
|
+
* Emitted via broadcast mode.
|
|
50
|
+
*/
|
|
51
|
+
pluginDeregistrationRequested: createHook<{
|
|
52
|
+
pluginId: string;
|
|
53
|
+
deleteSchema: boolean;
|
|
54
|
+
}>("core.plugin.deregistration-requested"),
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* INSTANCE-LOCAL: Emitted BEFORE a plugin begins shutdown on THIS instance.
|
|
58
|
+
* Use emitLocal() to emit this hook - it does NOT go through the Queue.
|
|
59
|
+
* Listeners should perform cleanup that depends on cross-plugin services.
|
|
60
|
+
*/
|
|
61
|
+
pluginDeregistering: createHook<{
|
|
62
|
+
pluginId: string;
|
|
63
|
+
reason: "uninstall" | "disable" | "shutdown";
|
|
64
|
+
}>("core.plugin.deregistering"),
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Emitted AFTER a plugin has been fully removed.
|
|
68
|
+
* Use this for orphan cleanup (e.g., removing permissions from DB).
|
|
69
|
+
* Should be emitted with work-queue mode for DB operations.
|
|
70
|
+
*/
|
|
71
|
+
pluginDeregistered: createHook<{
|
|
72
|
+
pluginId: string;
|
|
73
|
+
}>("core.plugin.deregistered"),
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Emitted when the platform is shutting down.
|
|
77
|
+
* Gives plugins time to gracefully cleanup resources.
|
|
78
|
+
*/
|
|
79
|
+
platformShutdown: createHook<{
|
|
80
|
+
reason: "signal" | "error" | "manual";
|
|
81
|
+
}>("core.platform.shutdown"),
|
|
82
|
+
|
|
83
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
84
|
+
// Plugin Installation (Multi-Instance Coordination)
|
|
85
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Distributed request to install/enable a plugin.
|
|
89
|
+
* All instances receive this and should load the plugin into memory.
|
|
90
|
+
* Emitted via broadcast mode.
|
|
91
|
+
*/
|
|
92
|
+
pluginInstallationRequested: createHook<{
|
|
93
|
+
pluginId: string;
|
|
94
|
+
pluginPath: string;
|
|
95
|
+
}>("core.plugin.installation-requested"),
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* INSTANCE-LOCAL: Emitted when a plugin is being loaded on THIS instance.
|
|
99
|
+
* Use emitLocal() to emit this hook - it does NOT go through the Queue.
|
|
100
|
+
*/
|
|
101
|
+
pluginInstalling: createHook<{
|
|
102
|
+
pluginId: string;
|
|
103
|
+
}>("core.plugin.installing"),
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Emitted AFTER a plugin has been fully installed and loaded.
|
|
107
|
+
* Use this for post-installation tasks (e.g., syncing to DB).
|
|
108
|
+
* Should be emitted with work-queue mode for DB operations.
|
|
109
|
+
*/
|
|
110
|
+
pluginInstalled: createHook<{
|
|
111
|
+
pluginId: string;
|
|
112
|
+
}>("core.plugin.installed"),
|
|
113
|
+
} as const;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Hook subscription options using discriminated union for type safety.
|
|
117
|
+
*
|
|
118
|
+
* When mode is 'work-queue', workerGroup is REQUIRED at compile-time.
|
|
119
|
+
* When mode is 'broadcast', workerGroup is not allowed.
|
|
120
|
+
*/
|
|
121
|
+
export type HookSubscribeOptions =
|
|
122
|
+
| {
|
|
123
|
+
/**
|
|
124
|
+
* Broadcast mode: All instances receive and process the hook
|
|
125
|
+
*/
|
|
126
|
+
mode?: "broadcast";
|
|
127
|
+
/**
|
|
128
|
+
* Not allowed in broadcast mode
|
|
129
|
+
*/
|
|
130
|
+
workerGroup?: never;
|
|
131
|
+
/**
|
|
132
|
+
* Maximum retry attempts (not used in broadcast mode)
|
|
133
|
+
*/
|
|
134
|
+
maxRetries?: number;
|
|
135
|
+
}
|
|
136
|
+
| {
|
|
137
|
+
/**
|
|
138
|
+
* Work-queue mode: Only one instance processes (load-balanced with retry)
|
|
139
|
+
*/
|
|
140
|
+
mode: "work-queue";
|
|
141
|
+
/**
|
|
142
|
+
* Worker group identifier. REQUIRED for work-queue mode.
|
|
143
|
+
*
|
|
144
|
+
* Workers with the same group name compete (only one processes the message).
|
|
145
|
+
* Workers with different group names both process the message.
|
|
146
|
+
*
|
|
147
|
+
* Automatically namespaced by plugin ID to prevent conflicts.
|
|
148
|
+
*
|
|
149
|
+
* Examples:
|
|
150
|
+
* - 'db-sync' → becomes 'my-plugin.db-sync'
|
|
151
|
+
* - 'email-sender' → becomes 'my-plugin.email-sender'
|
|
152
|
+
*/
|
|
153
|
+
workerGroup: string;
|
|
154
|
+
/**
|
|
155
|
+
* Maximum retry attempts for work-queue mode
|
|
156
|
+
* @default 3
|
|
157
|
+
*/
|
|
158
|
+
maxRetries?: number;
|
|
159
|
+
}
|
|
160
|
+
| {
|
|
161
|
+
/**
|
|
162
|
+
* Instance-local mode: Events bypass the Queue and run in-memory only.
|
|
163
|
+
* Use for cleanup hooks that should NOT be distributed across instances.
|
|
164
|
+
* Emitted via emitLocal() instead of emit().
|
|
165
|
+
*/
|
|
166
|
+
mode: "instance-local";
|
|
167
|
+
/**
|
|
168
|
+
* Not allowed in instance-local mode
|
|
169
|
+
*/
|
|
170
|
+
workerGroup?: never;
|
|
171
|
+
/**
|
|
172
|
+
* Not applicable in instance-local mode
|
|
173
|
+
*/
|
|
174
|
+
maxRetries?: never;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Handle for unsubscribing from a hook
|
|
179
|
+
*/
|
|
180
|
+
export interface HookUnsubscribe {
|
|
181
|
+
(): Promise<void>;
|
|
182
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export * from "./service-ref";
|
|
2
|
+
export * from "./extension-point";
|
|
3
|
+
export * from "./core-services";
|
|
4
|
+
export * from "./plugin-system";
|
|
5
|
+
export * from "./health-check";
|
|
6
|
+
export * from "./auth-strategy";
|
|
7
|
+
export * from "./zod-config";
|
|
8
|
+
export * from "./encryption";
|
|
9
|
+
export * from "./schema-utils";
|
|
10
|
+
export * from "./config-service";
|
|
11
|
+
export * from "zod";
|
|
12
|
+
export * from "./config-versioning";
|
|
13
|
+
export * from "./rpc";
|
|
14
|
+
export * from "./test-utils";
|
|
15
|
+
export * from "./hooks";
|
|
16
|
+
export * from "./event-bus-types";
|
|
17
|
+
export * from "./plugin-admin-contract";
|
|
18
|
+
export * from "./notification-strategy";
|
|
19
|
+
export * from "./oauth-handler";
|
|
20
|
+
export * from "./markdown";
|
|
21
|
+
export * from "./email-layout";
|
|
22
|
+
export * from "./assertions";
|
|
23
|
+
export * from "./chart-metadata";
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
markdownToHtml,
|
|
4
|
+
markdownToPlainText,
|
|
5
|
+
markdownToSlackMrkdwn,
|
|
6
|
+
} from "./markdown";
|
|
7
|
+
|
|
8
|
+
describe("markdownToHtml", () => {
|
|
9
|
+
it("converts bold text", () => {
|
|
10
|
+
const result = markdownToHtml("**bold**");
|
|
11
|
+
expect(result).toContain("<strong>bold</strong>");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("converts italic text", () => {
|
|
15
|
+
const result = markdownToHtml("*italic*");
|
|
16
|
+
expect(result).toContain("<em>italic</em>");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("converts links", () => {
|
|
20
|
+
const result = markdownToHtml("[link](https://example.com)");
|
|
21
|
+
expect(result).toContain('<a href="https://example.com">link</a>');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("converts headers", () => {
|
|
25
|
+
const result = markdownToHtml("# Header");
|
|
26
|
+
expect(result).toContain("<h1");
|
|
27
|
+
expect(result).toContain("Header");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("converts unordered lists", () => {
|
|
31
|
+
const result = markdownToHtml("- item 1\n- item 2");
|
|
32
|
+
expect(result).toContain("<ul>");
|
|
33
|
+
expect(result).toContain("<li>item 1</li>");
|
|
34
|
+
expect(result).toContain("<li>item 2</li>");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("converts code blocks", () => {
|
|
38
|
+
const result = markdownToHtml("```\ncode\n```");
|
|
39
|
+
expect(result).toContain("<code>");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("converts inline code", () => {
|
|
43
|
+
const result = markdownToHtml("use `code` here");
|
|
44
|
+
expect(result).toContain("<code>code</code>");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("markdownToPlainText", () => {
|
|
49
|
+
it("strips bold formatting", () => {
|
|
50
|
+
const result = markdownToPlainText("**bold** text");
|
|
51
|
+
expect(result).toBe("bold text");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("strips italic formatting", () => {
|
|
55
|
+
const result = markdownToPlainText("*italic* text");
|
|
56
|
+
expect(result).toBe("italic text");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("strips links but keeps text", () => {
|
|
60
|
+
const result = markdownToPlainText("[link](https://example.com)");
|
|
61
|
+
expect(result).toBe("link");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("strips headers", () => {
|
|
65
|
+
const result = markdownToPlainText("# Header");
|
|
66
|
+
expect(result).toBe("Header");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("preserves line breaks between paragraphs", () => {
|
|
70
|
+
const result = markdownToPlainText("First paragraph.\n\nSecond paragraph.");
|
|
71
|
+
expect(result).toContain("First paragraph.");
|
|
72
|
+
expect(result).toContain("Second paragraph.");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("decodes HTML entities", () => {
|
|
76
|
+
const result = markdownToPlainText("A & B");
|
|
77
|
+
expect(result).toBe("A & B");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("markdownToSlackMrkdwn", () => {
|
|
82
|
+
it("converts bold to Slack format", () => {
|
|
83
|
+
const result = markdownToSlackMrkdwn("**bold**");
|
|
84
|
+
expect(result).toBe("*bold*");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("converts double underscore to Slack bold", () => {
|
|
88
|
+
const result = markdownToSlackMrkdwn("__bold__");
|
|
89
|
+
expect(result).toBe("*bold*");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("converts links to Slack format", () => {
|
|
93
|
+
const result = markdownToSlackMrkdwn("[link](https://example.com)");
|
|
94
|
+
expect(result).toBe("<https://example.com|link>");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("converts strikethrough", () => {
|
|
98
|
+
const result = markdownToSlackMrkdwn("~~strikethrough~~");
|
|
99
|
+
expect(result).toBe("~strikethrough~");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("preserves inline code", () => {
|
|
103
|
+
const result = markdownToSlackMrkdwn("`code`");
|
|
104
|
+
expect(result).toBe("`code`");
|
|
105
|
+
});
|
|
106
|
+
});
|
package/src/markdown.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown conversion utilities for notification strategies.
|
|
3
|
+
*
|
|
4
|
+
* These utilities allow strategies to convert markdown content to their
|
|
5
|
+
* native format (HTML for email, plain text for SMS, etc.).
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { marked } from "marked";
|
|
11
|
+
|
|
12
|
+
// Configure marked for email-safe HTML
|
|
13
|
+
marked.setOptions({
|
|
14
|
+
gfm: true, // GitHub Flavored Markdown
|
|
15
|
+
breaks: true, // Convert \n to <br>
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Convert markdown to HTML (safe for email rendering).
|
|
20
|
+
*
|
|
21
|
+
* Uses GitHub Flavored Markdown with line break support.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```typescript
|
|
25
|
+
* const html = markdownToHtml("**Bold** and *italic*");
|
|
26
|
+
* // Returns: "<p><strong>Bold</strong> and <em>italic</em></p>"
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export function markdownToHtml(markdown: string): string {
|
|
30
|
+
// marked.parse can return string | Promise<string>, but with sync config it's always string
|
|
31
|
+
return marked.parse(markdown) as string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Strip markdown to plain text.
|
|
36
|
+
*
|
|
37
|
+
* Removes all formatting while preserving the content.
|
|
38
|
+
* Useful for strategies that don't support rich formatting (SMS, push).
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```typescript
|
|
42
|
+
* const text = markdownToPlainText("**Bold** and [link](https://example.com)");
|
|
43
|
+
* // Returns: "Bold and link"
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function markdownToPlainText(markdown: string): string {
|
|
47
|
+
// Convert to HTML first, then strip tags
|
|
48
|
+
const html = markdownToHtml(markdown);
|
|
49
|
+
|
|
50
|
+
// Strip HTML tags
|
|
51
|
+
let text = html.replaceAll(/<[^>]*>/g, "");
|
|
52
|
+
|
|
53
|
+
// Decode common HTML entities
|
|
54
|
+
text = text
|
|
55
|
+
.replaceAll("&", "&")
|
|
56
|
+
.replaceAll("<", "<")
|
|
57
|
+
.replaceAll(">", ">")
|
|
58
|
+
.replaceAll(""", '"')
|
|
59
|
+
.replaceAll("'", "'")
|
|
60
|
+
.replaceAll(" ", " ");
|
|
61
|
+
|
|
62
|
+
// Collapse multiple whitespace/newlines
|
|
63
|
+
text = text.replaceAll(/\n\s*\n/g, "\n").trim();
|
|
64
|
+
|
|
65
|
+
return text;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Convert markdown to Slack mrkdwn format.
|
|
70
|
+
*
|
|
71
|
+
* Slack uses a different markdown flavor with ~strikethrough~ syntax
|
|
72
|
+
* and different link formatting.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```typescript
|
|
76
|
+
* const mrkdwn = markdownToSlackMrkdwn("**Bold** and [link](https://example.com)");
|
|
77
|
+
* // Returns: "*Bold* and <https://example.com|link>"
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export function markdownToSlackMrkdwn(markdown: string): string {
|
|
81
|
+
let result = markdown;
|
|
82
|
+
|
|
83
|
+
// Convert strikethrough first: ~~text~~ -> ~text~
|
|
84
|
+
result = result.replaceAll(/~~(.+?)~~/g, "~$1~");
|
|
85
|
+
|
|
86
|
+
// Convert links: [text](url) -> <url|text>
|
|
87
|
+
result = result.replaceAll(/\[([^\]]+)\]\(([^)]+)\)/g, "<$2|$1>");
|
|
88
|
+
|
|
89
|
+
// Convert italic (single underscore only): _text_ -> _text_
|
|
90
|
+
// Note: We skip *italic* conversion because Slack uses * for bold
|
|
91
|
+
// and it would conflict with the **bold** -> *bold* conversion
|
|
92
|
+
|
|
93
|
+
// Convert bold: **text** or __text__ -> *text*
|
|
94
|
+
result = result.replaceAll(/\*\*(.+?)\*\*/g, "*$1*");
|
|
95
|
+
result = result.replaceAll(/__(.+?)__/g, "*$1*");
|
|
96
|
+
|
|
97
|
+
// Convert inline code: `code` -> `code` (same in Slack)
|
|
98
|
+
// No change needed
|
|
99
|
+
|
|
100
|
+
// Convert code blocks: ```code``` -> ```code``` (same in Slack)
|
|
101
|
+
// No change needed
|
|
102
|
+
|
|
103
|
+
return result;
|
|
104
|
+
}
|