@happyvertical/smrt-content 0.43.8 → 0.43.10
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/agents/content-list.md +69 -0
- package/dist/chunks/content-corrections-BxfA3tW1.js +107 -0
- package/dist/chunks/content-corrections-BxfA3tW1.js.map +1 -0
- package/dist/chunks/content-query-4w62u5_0.js +4092 -0
- package/dist/chunks/content-query-4w62u5_0.js.map +1 -0
- package/dist/chunks/content-reviews-BWrYEQc6.js +124 -0
- package/dist/chunks/content-reviews-BWrYEQc6.js.map +1 -0
- package/dist/chunks/content-versions-Di3vcTgb.js +284 -0
- package/dist/chunks/content-versions-Di3vcTgb.js.map +1 -0
- package/dist/chunks/contents-BV8RoP_R.js +612 -0
- package/dist/chunks/contents-BV8RoP_R.js.map +1 -0
- package/dist/chunks/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/content-chat-handlers.d.ts +1 -1
- package/dist/content-chat-handlers.d.ts.map +1 -1
- package/dist/content-governance.d.ts +2 -0
- package/dist/content-governance.d.ts.map +1 -1
- package/dist/content.d.ts +2 -2
- package/dist/content.d.ts.map +1 -1
- package/dist/index.js +142 -4717
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +142 -5
- package/dist/server/content-list-actions.d.ts +123 -0
- package/dist/server/content-list-actions.d.ts.map +1 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +874 -0
- package/dist/server.js.map +1 -0
- package/dist/smrt-knowledge.json +96 -9
- package/dist/svelte/components/ContentList.svelte +1028 -24
- package/dist/svelte/components/ContentList.svelte.d.ts +3 -0
- package/dist/svelte/components/ContentList.svelte.d.ts.map +1 -1
- package/dist/svelte/content-list-controller.d.ts +3 -1
- package/dist/svelte/content-list-controller.d.ts.map +1 -1
- package/dist/svelte/content-list-controller.js +15 -1
- package/dist/svelte/content-list-workflows.d.ts +87 -0
- package/dist/svelte/content-list-workflows.d.ts.map +1 -0
- package/dist/svelte/content-list-workflows.js +199 -0
- package/dist/svelte/i18n.contribution.d.ts +109 -0
- package/dist/svelte/i18n.contribution.d.ts.map +1 -1
- package/dist/svelte/i18n.contribution.js +109 -0
- package/dist/svelte/index.d.ts +1 -0
- package/dist/svelte/index.d.ts.map +1 -1
- package/dist/svelte/index.js +2 -0
- package/package.json +27 -22
- package/dist/chunks/content-transparency-Bh6axaZj.js +0 -606
- package/dist/chunks/content-transparency-Bh6axaZj.js.map +0 -1
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
|
|
2
|
+
import { D as serializeFact, E as serializeContent, J as getEffectiveContentGovernanceConfig, Q as loadPersistedContentGovernanceDefinitions, nt as resolveEffectiveContentGovernance, w as Content, x as executeContentQuery } from "./content-query-4w62u5_0.js";
|
|
3
|
+
import { htmlToMarkdown, resolveBodyFormat } from "../body-format.js";
|
|
4
|
+
import { SmrtCollection, smrt } from "@happyvertical/smrt-core";
|
|
5
|
+
import { queryGlobal, queryWithGlobals } from "@happyvertical/smrt-tenancy";
|
|
6
|
+
import { writeFile } from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fetchDocument } from "@happyvertical/documents";
|
|
9
|
+
import { ensureDirectoryExists } from "@happyvertical/files";
|
|
10
|
+
import { createLogger } from "@happyvertical/logger";
|
|
11
|
+
import { makeSlug } from "@happyvertical/utils";
|
|
12
|
+
import YAML from "yaml";
|
|
13
|
+
import { lookup } from "node:dns/promises";
|
|
14
|
+
import { isIP } from "node:net";
|
|
15
|
+
//#region src/safe-remote-url.ts
|
|
16
|
+
async function defaultResolveHostname(hostname) {
|
|
17
|
+
return lookup(hostname, {
|
|
18
|
+
all: true,
|
|
19
|
+
verbatim: false
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function isBlockedIPv4(address) {
|
|
23
|
+
const parts = address.split(".");
|
|
24
|
+
if (parts.length !== 4) return true;
|
|
25
|
+
const octets = parts.map((part) => /^\d{1,3}$/.test(part) ? Number(part) : NaN);
|
|
26
|
+
if (octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true;
|
|
27
|
+
const [first, second] = octets;
|
|
28
|
+
return first === 0 || first === 10 || first === 127 || first >= 224 || first === 100 && second >= 64 && second <= 127 || first === 169 && second === 254 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 198 && (second === 18 || second === 19);
|
|
29
|
+
}
|
|
30
|
+
function expandIPv6(address) {
|
|
31
|
+
let work = address.toLowerCase().replace(/^\[|\]$/g, "");
|
|
32
|
+
if (work.includes(".")) {
|
|
33
|
+
const m = work.match(/(\d{1,3}(?:\.\d{1,3}){3})$/);
|
|
34
|
+
if (!m) return null;
|
|
35
|
+
const o = m[1].split(".").map(Number);
|
|
36
|
+
if (o.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;
|
|
37
|
+
const hi = (o[0] << 8 | o[1]).toString(16);
|
|
38
|
+
const lo = (o[2] << 8 | o[3]).toString(16);
|
|
39
|
+
work = `${work.slice(0, work.length - m[1].length)}${hi}:${lo}`;
|
|
40
|
+
}
|
|
41
|
+
const halves = work.split("::");
|
|
42
|
+
if (halves.length > 2) return null;
|
|
43
|
+
const head = halves[0] ? halves[0].split(":") : [];
|
|
44
|
+
const tail = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
|
|
45
|
+
const groups = halves.length === 2 ? [
|
|
46
|
+
...head,
|
|
47
|
+
...Array(Math.max(0, 8 - head.length - tail.length)).fill("0"),
|
|
48
|
+
...tail
|
|
49
|
+
] : head;
|
|
50
|
+
if (groups.length !== 8) return null;
|
|
51
|
+
const hextets = groups.map((g) => /^[0-9a-f]{1,4}$/.test(g) ? Number.parseInt(g, 16) : NaN);
|
|
52
|
+
return hextets.some((n) => Number.isNaN(n)) ? null : hextets;
|
|
53
|
+
}
|
|
54
|
+
function isBlockedIPv6(address) {
|
|
55
|
+
const hextets = expandIPv6(address);
|
|
56
|
+
if (hextets) {
|
|
57
|
+
if (hextets.slice(0, 5).every((h) => h === 0) && (hextets[5] === 65535 || hextets[5] === 0)) {
|
|
58
|
+
const [, , , , , , g6, g7] = hextets;
|
|
59
|
+
return isBlockedIPv4(`${g6 >> 8}.${g6 & 255}.${g7 >> 8}.${g7 & 255}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const normalized = address.toLowerCase().replace(/^\[|\]$/g, "");
|
|
63
|
+
return normalized === "::" || normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || /^fe[89ab]/.test(normalized) || normalized.startsWith("ff");
|
|
64
|
+
}
|
|
65
|
+
function isBlockedAddress(address) {
|
|
66
|
+
const family = isIP(address);
|
|
67
|
+
if (family === 4) return isBlockedIPv4(address);
|
|
68
|
+
if (family === 6) return isBlockedIPv6(address);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
async function assertSafeRemoteUrl(rawUrl, options = {}) {
|
|
72
|
+
let url;
|
|
73
|
+
try {
|
|
74
|
+
url = new URL(rawUrl);
|
|
75
|
+
} catch {
|
|
76
|
+
throw new Error("Remote URL must be an absolute URL");
|
|
77
|
+
}
|
|
78
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("Remote URL must use http or https");
|
|
79
|
+
if (url.username || url.password) throw new Error("Remote URL must not include credentials");
|
|
80
|
+
if (!url.hostname) throw new Error("Remote URL must include a hostname");
|
|
81
|
+
if (options.allowPrivateNetworkHosts) return url;
|
|
82
|
+
const resolver = options.resolveHostname ?? defaultResolveHostname;
|
|
83
|
+
const addresses = isIP(url.hostname) === 0 ? await resolver(url.hostname) : [{ address: url.hostname }];
|
|
84
|
+
if (!addresses.length || addresses.some(({ address }) => isBlockedAddress(address))) throw new Error("Remote URL must resolve to a public network address");
|
|
85
|
+
return url;
|
|
86
|
+
}
|
|
87
|
+
var REDIRECT_STATUSES = /* @__PURE__ */ new Set([
|
|
88
|
+
301,
|
|
89
|
+
302,
|
|
90
|
+
303,
|
|
91
|
+
307,
|
|
92
|
+
308
|
|
93
|
+
]);
|
|
94
|
+
var DEFAULT_MAX_REDIRECTS = 5;
|
|
95
|
+
var DEFAULT_RESOLVE_TIMEOUT_MS = 1e4;
|
|
96
|
+
async function resolveSafeFinalUrl(rawUrl, options = {}) {
|
|
97
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
98
|
+
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
99
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_RESOLVE_TIMEOUT_MS;
|
|
100
|
+
let current = await assertSafeRemoteUrl(rawUrl, options);
|
|
101
|
+
for (let hop = 0; hop <= maxRedirects; hop += 1) {
|
|
102
|
+
const response = await fetchImpl(current, {
|
|
103
|
+
method: "GET",
|
|
104
|
+
redirect: "manual",
|
|
105
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
106
|
+
});
|
|
107
|
+
try {
|
|
108
|
+
await response.body?.cancel();
|
|
109
|
+
} catch {}
|
|
110
|
+
if (!REDIRECT_STATUSES.has(response.status)) return current;
|
|
111
|
+
const location = response.headers.get("location");
|
|
112
|
+
if (!location) return current;
|
|
113
|
+
current = await assertSafeRemoteUrl(new URL(location, current).toString(), options);
|
|
114
|
+
}
|
|
115
|
+
throw new Error("Remote URL exceeded the maximum number of redirects");
|
|
116
|
+
}
|
|
117
|
+
function redactUrlCredentials(raw) {
|
|
118
|
+
try {
|
|
119
|
+
const url = new URL(raw);
|
|
120
|
+
if (url.username || url.password) {
|
|
121
|
+
url.username = "";
|
|
122
|
+
url.password = "";
|
|
123
|
+
return url.toString();
|
|
124
|
+
}
|
|
125
|
+
return raw;
|
|
126
|
+
} catch {
|
|
127
|
+
return "[unparseable url]";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
//#endregion
|
|
131
|
+
//#region src/contents.ts
|
|
132
|
+
var contents_exports = /* @__PURE__ */ __exportAll({ Contents: () => Contents });
|
|
133
|
+
var __defProp = Object.defineProperty;
|
|
134
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
135
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, {
|
|
136
|
+
enumerable: true,
|
|
137
|
+
configurable: true,
|
|
138
|
+
writable: true,
|
|
139
|
+
value
|
|
140
|
+
}) : obj[key] = value;
|
|
141
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
142
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
143
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
144
|
+
if (kind && result) __defProp(target, key, result);
|
|
145
|
+
return result;
|
|
146
|
+
};
|
|
147
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
148
|
+
var logger = createLogger({ level: "info" });
|
|
149
|
+
function isAIClientOptions(ai) {
|
|
150
|
+
return !!ai && typeof ai === "object" && !("embed" in ai) && !("generateImage" in ai);
|
|
151
|
+
}
|
|
152
|
+
var Contents = class extends SmrtCollection {
|
|
153
|
+
/**
|
|
154
|
+
* Configuration options
|
|
155
|
+
*/
|
|
156
|
+
options = {};
|
|
157
|
+
/**
|
|
158
|
+
* Directory to store content files
|
|
159
|
+
*/
|
|
160
|
+
contentDir;
|
|
161
|
+
/**
|
|
162
|
+
* Cache for loaded content
|
|
163
|
+
*/
|
|
164
|
+
loaded;
|
|
165
|
+
/**
|
|
166
|
+
* Creates a new Contents collection
|
|
167
|
+
*
|
|
168
|
+
* Use the static `create()` method inherited from SmrtCollection for proper initialization.
|
|
169
|
+
*
|
|
170
|
+
* @param options - Configuration options
|
|
171
|
+
*/
|
|
172
|
+
constructor(options = {}) {
|
|
173
|
+
super(options);
|
|
174
|
+
this.options = options;
|
|
175
|
+
this.loaded = /* @__PURE__ */ new Map();
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Gets the database interface
|
|
179
|
+
*
|
|
180
|
+
* @returns Database interface
|
|
181
|
+
*/
|
|
182
|
+
getDb() {
|
|
183
|
+
return this._db;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Initializes the collection
|
|
187
|
+
*
|
|
188
|
+
* @returns Promise that resolves to this instance
|
|
189
|
+
*/
|
|
190
|
+
async initialize() {
|
|
191
|
+
await super.initialize();
|
|
192
|
+
return this;
|
|
193
|
+
}
|
|
194
|
+
async getFactCollection() {
|
|
195
|
+
const { FactCollection } = await import("@happyvertical/smrt-facts");
|
|
196
|
+
return FactCollection.create(this.options);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Bounded, tenant-safe content query (`POST /api/v1/contents/query`).
|
|
200
|
+
*
|
|
201
|
+
* Accepts the canonical `DataQueryRequest` envelope (#2444) and returns a
|
|
202
|
+
* normalized `DataQueryResult`, so a list surface can filter, sort, page,
|
|
203
|
+
* count, and facet server-side instead of hydrating the whole collection.
|
|
204
|
+
*
|
|
205
|
+
* The request body carries no authority: only schema-declared field ids are
|
|
206
|
+
* accepted, and tenant scoping is applied by `executeContentQuery` itself
|
|
207
|
+
* (fail-closed to global rows when tenancy is enabled with no active tenant
|
|
208
|
+
* context). Applications that need additional site/organization scoping call
|
|
209
|
+
* `executeContentQuery` directly with a trusted `scope`.
|
|
210
|
+
*
|
|
211
|
+
* The parameter is named `options` deliberately: the route generator passes
|
|
212
|
+
* the raw request body straight through as a single `options` argument, so
|
|
213
|
+
* the wire body IS the `DataQueryRequest` rather than a wrapper object.
|
|
214
|
+
*
|
|
215
|
+
* @param options Untrusted `DataQueryRequest` from the caller.
|
|
216
|
+
* @returns A validated, bounded `DataQueryResult`.
|
|
217
|
+
*/
|
|
218
|
+
async queryAction(options) {
|
|
219
|
+
return executeContentQuery(this, options);
|
|
220
|
+
}
|
|
221
|
+
async browseFacts(options = {}) {
|
|
222
|
+
try {
|
|
223
|
+
const facts = await this.getFactCollection();
|
|
224
|
+
const query = options.query || options.q || "";
|
|
225
|
+
const limit = options.limit !== void 0 ? Number(options.limit) : void 0;
|
|
226
|
+
const offset = options.offset !== void 0 ? Number(options.offset) : void 0;
|
|
227
|
+
const minSimilarity = options.minSimilarity !== void 0 ? Number(options.minSimilarity) : void 0;
|
|
228
|
+
const includeSuperseded = options.includeSuperseded === true || options.includeSuperseded === "true";
|
|
229
|
+
const latestOnly = options.latestOnly === void 0 ? true : options.latestOnly === true || options.latestOnly === "true";
|
|
230
|
+
return (await facts.browseCatalog(query, {
|
|
231
|
+
limit: Number.isFinite(limit) ? limit : void 0,
|
|
232
|
+
offset: Number.isFinite(offset) ? offset : void 0,
|
|
233
|
+
minSimilarity: Number.isFinite(minSimilarity) ? minSimilarity : void 0,
|
|
234
|
+
includeSuperseded,
|
|
235
|
+
latestOnly,
|
|
236
|
+
tenantId: options.tenantId ?? null
|
|
237
|
+
})).map(serializeFact);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (typeof error === "object" && error !== null && error.code === "DB_SCHEMA_MISSING") return [];
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async getBySlug(options = {}) {
|
|
244
|
+
if (!options.slug) throw new Error("slug is required");
|
|
245
|
+
const where = {
|
|
246
|
+
slug: options.slug,
|
|
247
|
+
context: options.context || ""
|
|
248
|
+
};
|
|
249
|
+
if (options.tenantId !== void 0) where.tenantId = options.tenantId;
|
|
250
|
+
const content = await this.get(where);
|
|
251
|
+
if (!content) return null;
|
|
252
|
+
if (options.status && content.status !== options.status) return null;
|
|
253
|
+
return serializeContent(content);
|
|
254
|
+
}
|
|
255
|
+
async getGovernanceDefinitionsAction(options = {}) {
|
|
256
|
+
const [effective, persisted] = await Promise.all([getEffectiveContentGovernanceConfig({
|
|
257
|
+
db: this.db,
|
|
258
|
+
tenantId: options.tenantId
|
|
259
|
+
}), loadPersistedContentGovernanceDefinitions({
|
|
260
|
+
db: this.db,
|
|
261
|
+
tenantId: options.tenantId
|
|
262
|
+
})]);
|
|
263
|
+
return {
|
|
264
|
+
effective: {
|
|
265
|
+
policies: effective.policies.map((policy) => ({
|
|
266
|
+
...policy,
|
|
267
|
+
...persisted.policies.find((item) => item.key === policy.key) || {}
|
|
268
|
+
})),
|
|
269
|
+
profiles: effective.profiles.map((profile) => ({
|
|
270
|
+
...profile,
|
|
271
|
+
...persisted.profiles.find((item) => item.key === profile.key) || {}
|
|
272
|
+
})),
|
|
273
|
+
assignments: effective.assignments.map((assignment) => ({
|
|
274
|
+
...assignment,
|
|
275
|
+
...persisted.assignments.find((item) => item.key === assignment.key) || {}
|
|
276
|
+
}))
|
|
277
|
+
},
|
|
278
|
+
persisted: {
|
|
279
|
+
policies: persisted.policies,
|
|
280
|
+
profiles: persisted.profiles,
|
|
281
|
+
assignments: persisted.assignments
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
async resolveGovernanceAction(options = {}) {
|
|
286
|
+
return resolveEffectiveContentGovernance({
|
|
287
|
+
contentType: options.type || null,
|
|
288
|
+
contentVariant: options.variant || null,
|
|
289
|
+
db: this.db,
|
|
290
|
+
tenantId: options.tenantId
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Mirrors content from a remote URL
|
|
295
|
+
*
|
|
296
|
+
* Downloads and stores content from a remote URL, extracting text
|
|
297
|
+
* and saving it as a Content object.
|
|
298
|
+
*
|
|
299
|
+
* @param options - Mirror options
|
|
300
|
+
* @param options.url - URL to mirror
|
|
301
|
+
* @param options.mirrorDir - Directory for caching mirrored files
|
|
302
|
+
* @param options.context - Context for the mirrored content
|
|
303
|
+
* @returns Promise resolving to the mirrored Content object
|
|
304
|
+
* @throws Error if URL is invalid or missing
|
|
305
|
+
*/
|
|
306
|
+
async mirror(options) {
|
|
307
|
+
if (!options.url) throw new Error("No URL provided");
|
|
308
|
+
let url;
|
|
309
|
+
try {
|
|
310
|
+
url = await resolveSafeFinalUrl(options.url, {
|
|
311
|
+
allowPrivateNetworkHosts: options.allowPrivateNetworkHosts,
|
|
312
|
+
resolveHostname: options.resolveHostname,
|
|
313
|
+
fetchImpl: options.fetchImpl
|
|
314
|
+
});
|
|
315
|
+
} catch (error) {
|
|
316
|
+
const safeUrl = redactUrlCredentials(options.url);
|
|
317
|
+
logger.error("Refusing to mirror unsafe URL", {
|
|
318
|
+
error,
|
|
319
|
+
url: safeUrl
|
|
320
|
+
});
|
|
321
|
+
throw new Error(`Invalid URL provided: ${safeUrl}`);
|
|
322
|
+
}
|
|
323
|
+
const existing = await this.get({ url: options.url });
|
|
324
|
+
if (existing) return existing;
|
|
325
|
+
const doc = await fetchDocument(url.toString(), { cacheDir: options?.mirrorDir });
|
|
326
|
+
const title = (url.pathname.split("/").pop()?.replace(/\.[^/.]+$/, ""))?.replace(/[-_]/g, " ");
|
|
327
|
+
const slug = makeSlug(title);
|
|
328
|
+
const body = doc.parts.map((part) => part.content).join("\n\n");
|
|
329
|
+
if (body) {
|
|
330
|
+
const content = new Content({
|
|
331
|
+
url: options.url,
|
|
332
|
+
type: "mirror",
|
|
333
|
+
title,
|
|
334
|
+
slug,
|
|
335
|
+
context: options.context || "",
|
|
336
|
+
body
|
|
337
|
+
});
|
|
338
|
+
await content.initialize();
|
|
339
|
+
await content.save();
|
|
340
|
+
return content;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Writes a Content object to the filesystem as a markdown file
|
|
345
|
+
*
|
|
346
|
+
* @param options - Options for writing the content file
|
|
347
|
+
* @param options.content - Content object to write
|
|
348
|
+
* @param options.contentDir - Directory to write the file to
|
|
349
|
+
* @returns Promise that resolves when the file is written
|
|
350
|
+
* @throws Error if contentDir is not provided
|
|
351
|
+
*/
|
|
352
|
+
async writeContentFile(options) {
|
|
353
|
+
const { content, contentDir } = options;
|
|
354
|
+
if (!contentDir) throw new Error("No content dir provided");
|
|
355
|
+
const { body } = content;
|
|
356
|
+
const frontMatter = {
|
|
357
|
+
title: content.title,
|
|
358
|
+
slug: content.slug,
|
|
359
|
+
context: content.context,
|
|
360
|
+
author: content.author,
|
|
361
|
+
publish_date: content.publish_date
|
|
362
|
+
};
|
|
363
|
+
let output = "";
|
|
364
|
+
if (frontMatter && Object.keys(frontMatter).length > 0) {
|
|
365
|
+
output += "---\n";
|
|
366
|
+
output += YAML.stringify(frontMatter);
|
|
367
|
+
output += "---\n";
|
|
368
|
+
}
|
|
369
|
+
let formattedBody = body || "";
|
|
370
|
+
if (resolveBodyFormat(content.bodyFormat, body) === "html") formattedBody = htmlToMarkdown(body || "");
|
|
371
|
+
else if (body && !this.isMarkdown(body)) formattedBody = this.formatAsMarkdown(body);
|
|
372
|
+
output += formattedBody;
|
|
373
|
+
const pathParts = [
|
|
374
|
+
contentDir,
|
|
375
|
+
content.context || "",
|
|
376
|
+
content.slug,
|
|
377
|
+
"index.md"
|
|
378
|
+
].filter(Boolean);
|
|
379
|
+
const outputFile = path.join(...pathParts);
|
|
380
|
+
const resolvedDir = path.resolve(contentDir);
|
|
381
|
+
const resolvedFile = path.resolve(outputFile);
|
|
382
|
+
if (resolvedFile !== resolvedDir && !resolvedFile.startsWith(resolvedDir + path.sep)) throw new Error("Refusing to write content file outside of the content directory");
|
|
383
|
+
await ensureDirectoryExists(path.dirname(outputFile));
|
|
384
|
+
await writeFile(outputFile, output);
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Checks if text appears to be in markdown format
|
|
388
|
+
*
|
|
389
|
+
* @param text - Text to check
|
|
390
|
+
* @returns Boolean indicating if the text contains markdown syntax
|
|
391
|
+
*/
|
|
392
|
+
isMarkdown(text) {
|
|
393
|
+
return [
|
|
394
|
+
/^#\s/m,
|
|
395
|
+
/\*\*.+\*\*/,
|
|
396
|
+
/\*.+\*/,
|
|
397
|
+
/\[.+\]\(.+\)/,
|
|
398
|
+
/^\s*[-*+]\s/m,
|
|
399
|
+
/^\s*\d+\.\s/m,
|
|
400
|
+
/```[\s\S]*```/,
|
|
401
|
+
/^\s*>/m
|
|
402
|
+
].some((indicator) => indicator.test(text));
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Formats plain text as simple markdown
|
|
406
|
+
*
|
|
407
|
+
* @param text - Plain text to format
|
|
408
|
+
* @returns Text formatted as basic markdown
|
|
409
|
+
*/
|
|
410
|
+
formatAsMarkdown(text) {
|
|
411
|
+
return text.split(/\n\n+/).map((paragraph) => paragraph.trim()).filter(Boolean).join("\n\n");
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Synchronizes content to the filesystem
|
|
415
|
+
*
|
|
416
|
+
* Writes all article-type Content objects to the filesystem
|
|
417
|
+
* as markdown files.
|
|
418
|
+
*
|
|
419
|
+
* @param options - Sync options
|
|
420
|
+
* @param options.contentDir - Directory to write content files to
|
|
421
|
+
* @returns Promise that resolves when synchronization is complete
|
|
422
|
+
*/
|
|
423
|
+
async syncContentDir(options) {
|
|
424
|
+
const contents = await this.list({ where: { type: "article" } });
|
|
425
|
+
for (const content of contents) await this.writeContentFile({
|
|
426
|
+
content,
|
|
427
|
+
contentDir: options.contentDir || this.options.contentDir || ""
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Generate thumbnails for content that doesn't have one
|
|
432
|
+
*
|
|
433
|
+
* @param options - Options for bulk thumbnail generation
|
|
434
|
+
* @returns Promise resolving to result object with generated images and failed content IDs
|
|
435
|
+
*
|
|
436
|
+
* @example Generate headline cards for all published articles
|
|
437
|
+
* ```typescript
|
|
438
|
+
* const result = await contents.generateMissingThumbnails({
|
|
439
|
+
* strategy: 'headline-card',
|
|
440
|
+
* where: { type: 'article', status: 'published' },
|
|
441
|
+
* brandColor: '#1a56db'
|
|
442
|
+
* });
|
|
443
|
+
* console.log(`Generated ${result.images.length} thumbnails`);
|
|
444
|
+
* if (result.failed.length > 0) {
|
|
445
|
+
* console.warn(`Failed to generate ${result.failed.length} thumbnails`);
|
|
446
|
+
* }
|
|
447
|
+
* ```
|
|
448
|
+
*/
|
|
449
|
+
async generateMissingThumbnails(options) {
|
|
450
|
+
const mergedOptions = {
|
|
451
|
+
...this.options?.thumbnail || {},
|
|
452
|
+
...options
|
|
453
|
+
};
|
|
454
|
+
const whereClause = {
|
|
455
|
+
...mergedOptions.where,
|
|
456
|
+
thumbnailAssetId: null
|
|
457
|
+
};
|
|
458
|
+
const contents = await this.list({
|
|
459
|
+
where: whereClause,
|
|
460
|
+
limit: mergedOptions.limit
|
|
461
|
+
});
|
|
462
|
+
const generatedImages = [];
|
|
463
|
+
const failed = [];
|
|
464
|
+
for (const content of contents) try {
|
|
465
|
+
let thumbnailOptions;
|
|
466
|
+
switch (mergedOptions.strategy) {
|
|
467
|
+
case "headline-card":
|
|
468
|
+
thumbnailOptions = {
|
|
469
|
+
strategy: "headline-card",
|
|
470
|
+
brandColor: mergedOptions.brandColor,
|
|
471
|
+
backgroundColor: mergedOptions.backgroundColor,
|
|
472
|
+
logoUrl: mergedOptions.logoUrl,
|
|
473
|
+
template: mergedOptions.template,
|
|
474
|
+
width: mergedOptions.width,
|
|
475
|
+
height: mergedOptions.height
|
|
476
|
+
};
|
|
477
|
+
break;
|
|
478
|
+
case "static-map":
|
|
479
|
+
thumbnailOptions = {
|
|
480
|
+
strategy: "static-map",
|
|
481
|
+
mapProvider: mergedOptions.mapProvider,
|
|
482
|
+
zoom: mergedOptions.zoom,
|
|
483
|
+
width: mergedOptions.width,
|
|
484
|
+
height: mergedOptions.height
|
|
485
|
+
};
|
|
486
|
+
break;
|
|
487
|
+
case "ai-generate":
|
|
488
|
+
thumbnailOptions = {
|
|
489
|
+
strategy: "ai-generate",
|
|
490
|
+
style: mergedOptions.style,
|
|
491
|
+
width: mergedOptions.width,
|
|
492
|
+
height: mergedOptions.height,
|
|
493
|
+
ai: isAIClientOptions(this.options.ai) ? this.options.ai : void 0
|
|
494
|
+
};
|
|
495
|
+
break;
|
|
496
|
+
default: throw new Error(`Unknown strategy: ${mergedOptions.strategy}`);
|
|
497
|
+
}
|
|
498
|
+
const image = await content.generateThumbnail(thumbnailOptions);
|
|
499
|
+
generatedImages.push(image);
|
|
500
|
+
} catch (error) {
|
|
501
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
502
|
+
logger.error(`Failed to generate thumbnail for content ${content.id}: ${errorMessage}`);
|
|
503
|
+
failed.push({
|
|
504
|
+
contentId: content.id ?? "unknown",
|
|
505
|
+
error: errorMessage
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
return {
|
|
509
|
+
images: generatedImages,
|
|
510
|
+
failed
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Find all content belonging to a specific tenant
|
|
515
|
+
*
|
|
516
|
+
* @param tenantId - The tenant ID to filter by
|
|
517
|
+
* @returns Promise resolving to array of Content objects for the tenant
|
|
518
|
+
*
|
|
519
|
+
* @example
|
|
520
|
+
* ```typescript
|
|
521
|
+
* const tenantContent = await contents.findByTenant('tenant-123');
|
|
522
|
+
* ```
|
|
523
|
+
*/
|
|
524
|
+
async findByTenant(tenantId) {
|
|
525
|
+
return this.list({ where: { tenantId } });
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Find all global content (not associated with any tenant).
|
|
529
|
+
*
|
|
530
|
+
* Routes through the shared tenant-global helper so it does not throw under
|
|
531
|
+
* an active tenant context (an explicit `tenant_id IS NULL` filter would be
|
|
532
|
+
* flagged as an isolation violation). (#1600)
|
|
533
|
+
*
|
|
534
|
+
* @returns Promise resolving to array of global Content objects
|
|
535
|
+
*
|
|
536
|
+
* @example
|
|
537
|
+
* ```typescript
|
|
538
|
+
* const globalContent = await contents.findGlobal();
|
|
539
|
+
* ```
|
|
540
|
+
*/
|
|
541
|
+
async findGlobal() {
|
|
542
|
+
return queryGlobal(this);
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Find content for a tenant including global content.
|
|
546
|
+
*
|
|
547
|
+
* This returns both tenant-specific content and global content (tenantId is null),
|
|
548
|
+
* useful for showing a tenant their content plus any shared/global resources.
|
|
549
|
+
*
|
|
550
|
+
* Fails closed if an active tenant context requests a different tenant's
|
|
551
|
+
* rows; the admin/system path keeps the cross-tenant capability. (#1600)
|
|
552
|
+
*
|
|
553
|
+
* @param tenantId - The tenant ID to include
|
|
554
|
+
* @returns Promise resolving to array of Content objects (tenant + global)
|
|
555
|
+
*
|
|
556
|
+
* @example
|
|
557
|
+
* ```typescript
|
|
558
|
+
* const allAccessibleContent = await contents.findWithGlobals('tenant-123');
|
|
559
|
+
* ```
|
|
560
|
+
*/
|
|
561
|
+
async findWithGlobals(tenantId) {
|
|
562
|
+
return queryWithGlobals(this, tenantId, "Content.findWithGlobals");
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
/**
|
|
566
|
+
* Class constructor for collection items
|
|
567
|
+
*/
|
|
568
|
+
__publicField(Contents, "_itemClass", Content);
|
|
569
|
+
Contents = __decorateClass([smrt({
|
|
570
|
+
api: {
|
|
571
|
+
include: [
|
|
572
|
+
"browseFacts",
|
|
573
|
+
"getBySlug",
|
|
574
|
+
"getGovernanceDefinitionsAction",
|
|
575
|
+
"resolveGovernanceAction",
|
|
576
|
+
"queryAction"
|
|
577
|
+
],
|
|
578
|
+
routes: {
|
|
579
|
+
queryAction: {
|
|
580
|
+
scope: "collection",
|
|
581
|
+
method: "POST",
|
|
582
|
+
path: "query"
|
|
583
|
+
},
|
|
584
|
+
browseFacts: {
|
|
585
|
+
scope: "collection",
|
|
586
|
+
method: "GET",
|
|
587
|
+
path: "facts"
|
|
588
|
+
},
|
|
589
|
+
getBySlug: {
|
|
590
|
+
scope: "collection",
|
|
591
|
+
method: "GET",
|
|
592
|
+
path: "by-slug"
|
|
593
|
+
},
|
|
594
|
+
getGovernanceDefinitionsAction: {
|
|
595
|
+
scope: "collection",
|
|
596
|
+
method: "GET",
|
|
597
|
+
path: "governance"
|
|
598
|
+
},
|
|
599
|
+
resolveGovernanceAction: {
|
|
600
|
+
scope: "collection",
|
|
601
|
+
method: "GET",
|
|
602
|
+
path: "governance/resolve"
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
},
|
|
606
|
+
mcp: false,
|
|
607
|
+
cli: false
|
|
608
|
+
})], Contents);
|
|
609
|
+
//#endregion
|
|
610
|
+
export { contents_exports as n, assertSafeRemoteUrl as r, Contents as t };
|
|
611
|
+
|
|
612
|
+
//# sourceMappingURL=contents-BV8RoP_R.js.map
|