@cogenta/cli 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin-assets/assets/index-Buwdj1V2.js +71 -0
- package/dist/admin-assets/assets/index-Csef0SiA.css +1 -0
- package/dist/admin-assets/index.html +2 -2
- package/dist/commands/assistant.d.ts +65 -0
- package/dist/commands/assistant.d.ts.map +1 -0
- package/dist/commands/assistant.js +207 -0
- package/dist/commands/assistant.js.map +1 -0
- package/dist/commands/content-webhooks.d.ts +41 -0
- package/dist/commands/content-webhooks.d.ts.map +1 -0
- package/dist/commands/content-webhooks.js +61 -0
- package/dist/commands/content-webhooks.js.map +1 -0
- package/dist/commands/http-security.d.ts +39 -0
- package/dist/commands/http-security.d.ts.map +1 -0
- package/dist/commands/http-security.js +153 -0
- package/dist/commands/http-security.js.map +1 -0
- package/dist/commands/links.d.ts +24 -0
- package/dist/commands/links.d.ts.map +1 -0
- package/dist/commands/links.js +109 -0
- package/dist/commands/links.js.map +1 -0
- package/dist/commands/media-images.d.ts +67 -0
- package/dist/commands/media-images.d.ts.map +1 -0
- package/dist/commands/media-images.js +107 -0
- package/dist/commands/media-images.js.map +1 -0
- package/dist/commands/search-page.d.ts +40 -0
- package/dist/commands/search-page.d.ts.map +1 -0
- package/dist/commands/search-page.js +104 -0
- package/dist/commands/search-page.js.map +1 -0
- package/dist/commands/security-alerts.d.ts +24 -0
- package/dist/commands/security-alerts.d.ts.map +1 -0
- package/dist/commands/security-alerts.js +82 -0
- package/dist/commands/security-alerts.js.map +1 -0
- package/dist/commands/seo.d.ts +88 -0
- package/dist/commands/seo.d.ts.map +1 -0
- package/dist/commands/seo.js +155 -0
- package/dist/commands/seo.js.map +1 -0
- package/dist/commands/serve.d.ts +87 -4
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +740 -40
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/site-plan.d.ts +86 -0
- package/dist/commands/site-plan.d.ts.map +1 -0
- package/dist/commands/site-plan.js +235 -0
- package/dist/commands/site-plan.js.map +1 -0
- package/dist/commands/theme-css.d.ts +54 -0
- package/dist/commands/theme-css.d.ts.map +1 -0
- package/dist/commands/theme-css.js +121 -0
- package/dist/commands/theme-css.js.map +1 -0
- package/dist/commands/theme-render.d.ts +78 -4
- package/dist/commands/theme-render.d.ts.map +1 -1
- package/dist/commands/theme-render.js +170 -16
- package/dist/commands/theme-render.js.map +1 -1
- package/dist/commands/users.d.ts +7 -1
- package/dist/commands/users.d.ts.map +1 -1
- package/dist/commands/users.js +159 -15
- package/dist/commands/users.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -1
- package/package.json +12 -10
- package/dist/admin-assets/assets/index-21ZcDkDC.css +0 -1
- package/dist/admin-assets/assets/index-CKLmd_Fi.js +0 -23
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headers every response carries, whatever it is.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately short. `X-Content-Type-Options` stops a browser guessing that
|
|
5
|
+
* an uploaded file is a script; `X-Frame-Options` and `Referrer-Policy` are
|
|
6
|
+
* the two others with no plausible downside and a real failure mode without
|
|
7
|
+
* them. Anything beyond that (`Permissions-Policy`, COEP/COOP) breaks real
|
|
8
|
+
* pages depending on what a site embeds, so it belongs in the site's own
|
|
9
|
+
* `csp` string rather than in a default nobody chose.
|
|
10
|
+
*/
|
|
11
|
+
const ALWAYS = Object.freeze({
|
|
12
|
+
'x-content-type-options': 'nosniff',
|
|
13
|
+
'x-frame-options': 'SAMEORIGIN',
|
|
14
|
+
'referrer-policy': 'strict-origin-when-cross-origin',
|
|
15
|
+
});
|
|
16
|
+
/** Which cache-control a path class gets. */
|
|
17
|
+
export function cacheControlFor(pathname, security) {
|
|
18
|
+
// Never store an API response: they are per-actor by construction — the same
|
|
19
|
+
// URL answers differently for an editor and for a stranger — and a shared
|
|
20
|
+
// cache that keeps one is a permission leak, not a performance win.
|
|
21
|
+
if (pathname.startsWith('/api/'))
|
|
22
|
+
return 'no-store';
|
|
23
|
+
// The admin shell is a signed-in application. Its hashed assets could be
|
|
24
|
+
// cached forever, but `index.html` must not be, and telling them apart here
|
|
25
|
+
// would duplicate Vite's naming convention in a second place.
|
|
26
|
+
if (pathname === '/admin' || pathname.startsWith('/admin/'))
|
|
27
|
+
return 'no-store';
|
|
28
|
+
// Images and sitemaps set their own, longer, values at their route.
|
|
29
|
+
if (pathname === '/_image' || pathname === '/robots.txt')
|
|
30
|
+
return null;
|
|
31
|
+
if (pathname === '/search')
|
|
32
|
+
return null;
|
|
33
|
+
// A public page: cacheable, briefly. `must-revalidate` rather than a long
|
|
34
|
+
// max-age because publishing is supposed to be visible immediately, and a
|
|
35
|
+
// CMS whose edits appear ten minutes later is a CMS people stop trusting.
|
|
36
|
+
return security.pageMaxAge === 0
|
|
37
|
+
? 'no-store'
|
|
38
|
+
: `public, max-age=0, s-maxage=${security.pageMaxAge}, must-revalidate`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The CORS headers this request earns, or an empty object.
|
|
42
|
+
*
|
|
43
|
+
* The origin is echoed rather than reflected blindly: only an exact match in
|
|
44
|
+
* the configured list, or `*` when the list holds it. `Vary: Origin` goes out
|
|
45
|
+
* with any match, because a shared cache that stores one origin's response
|
|
46
|
+
* and serves it to another is the whole class of CORS cache-poisoning bugs.
|
|
47
|
+
*/
|
|
48
|
+
export function corsHeadersFor(origin, security) {
|
|
49
|
+
const { cors } = security;
|
|
50
|
+
if (cors.origins.length === 0 || origin === undefined)
|
|
51
|
+
return {};
|
|
52
|
+
const wildcard = cors.origins.includes('*');
|
|
53
|
+
if (!wildcard && !cors.origins.includes(origin))
|
|
54
|
+
return {};
|
|
55
|
+
return {
|
|
56
|
+
'access-control-allow-origin': wildcard && !cors.credentials ? '*' : origin,
|
|
57
|
+
vary: 'Origin',
|
|
58
|
+
...(cors.credentials ? { 'access-control-allow-credentials': 'true' } : {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** The extra headers a preflight needs on top of the ones above. */
|
|
62
|
+
export function preflightHeadersFor(security) {
|
|
63
|
+
return {
|
|
64
|
+
'access-control-allow-methods': security.cors.methods.join(', '),
|
|
65
|
+
'access-control-allow-headers': security.cors.headers.join(', '),
|
|
66
|
+
'access-control-max-age': String(security.cors.maxAge),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function baseHeadersFor(req, pathname, security) {
|
|
70
|
+
const headers = { ...ALWAYS };
|
|
71
|
+
if (security.csp !== undefined && security.csp !== false) {
|
|
72
|
+
headers['content-security-policy'] = security.csp;
|
|
73
|
+
}
|
|
74
|
+
// Only over TLS. Sent on a plain HTTP response the header is ignored by
|
|
75
|
+
// browsers anyway, and sending it locally is how somebody pins
|
|
76
|
+
// `localhost` to HTTPS and cannot develop for the next year.
|
|
77
|
+
if (security.hstsMaxAge > 0 && isSecure(req)) {
|
|
78
|
+
headers['strict-transport-security'] = `max-age=${security.hstsMaxAge}${security.hstsIncludeSubDomains ? '; includeSubDomains' : ''}`;
|
|
79
|
+
}
|
|
80
|
+
const origin = headerOf(req, 'origin');
|
|
81
|
+
Object.assign(headers, corsHeadersFor(origin, security));
|
|
82
|
+
// A page render is per-actor: `renderRequestedPage` passes the requesting
|
|
83
|
+
// actor's context down, so an editor's `GET /blog/embargoed` returns the
|
|
84
|
+
// draft. `public, s-maxage=…` is precisely the pair RFC 9111 §3.5 says
|
|
85
|
+
// re-authorises a *shared* cache to store a response to a request carrying
|
|
86
|
+
// `Authorization` — a CDN in front of the site would then serve that draft
|
|
87
|
+
// to everyone for a minute. Anything sent with credentials is private and
|
|
88
|
+
// unstorable, whatever class its path belongs to. Found by the security
|
|
89
|
+
// review of L10 task 6.
|
|
90
|
+
const cacheControl = hasCredentials(req)
|
|
91
|
+
? 'private, no-store'
|
|
92
|
+
: cacheControlFor(pathname, security);
|
|
93
|
+
if (cacheControl !== null)
|
|
94
|
+
headers['cache-control'] = cacheControl;
|
|
95
|
+
return headers;
|
|
96
|
+
}
|
|
97
|
+
/** True when the request carries something that makes its answer actor-specific. */
|
|
98
|
+
function hasCredentials(req) {
|
|
99
|
+
return headerOf(req, 'authorization') !== undefined || headerOf(req, 'cookie') !== undefined;
|
|
100
|
+
}
|
|
101
|
+
function headerOf(req, name) {
|
|
102
|
+
const value = req.headers[name];
|
|
103
|
+
return Array.isArray(value) ? value[0] : value;
|
|
104
|
+
}
|
|
105
|
+
/** True behind a TLS socket, or behind a proxy that says it terminated one. */
|
|
106
|
+
function isSecure(req) {
|
|
107
|
+
if ('encrypted' in req.socket && req.socket.encrypted === true)
|
|
108
|
+
return true;
|
|
109
|
+
return headerOf(req, 'x-forwarded-proto')?.split(',')[0]?.trim() === 'https';
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Applies the headers to every response of this request, and answers a CORS
|
|
113
|
+
* preflight outright.
|
|
114
|
+
*
|
|
115
|
+
* Returns `true` when the request is finished — the caller must then do
|
|
116
|
+
* nothing else. Patching `writeHead` rather than calling `setHeader` up front
|
|
117
|
+
* is what makes this survive the routes that pass a header object of their
|
|
118
|
+
* own: `res.writeHead(200, { 'content-type': … })` replaces every header
|
|
119
|
+
* previously set, so setting them early would silently lose them on most of
|
|
120
|
+
* the routes in this file.
|
|
121
|
+
*/
|
|
122
|
+
export function applySecurity(req, res, pathname, security) {
|
|
123
|
+
const base = baseHeadersFor(req, pathname, security);
|
|
124
|
+
const original = res.writeHead.bind(res);
|
|
125
|
+
// Both of Node's overloads are accepted, so no existing call site changes.
|
|
126
|
+
// An array-of-raw-headers form exists too and is not used anywhere here; it
|
|
127
|
+
// is passed through untouched rather than half-merged.
|
|
128
|
+
res.writeHead = ((status, reasonOrHeaders, maybeHeaders) => {
|
|
129
|
+
const supplied = typeof reasonOrHeaders === 'string' ? maybeHeaders : (reasonOrHeaders ?? maybeHeaders);
|
|
130
|
+
if (Array.isArray(supplied)) {
|
|
131
|
+
return typeof reasonOrHeaders === 'string'
|
|
132
|
+
? original(status, reasonOrHeaders, supplied)
|
|
133
|
+
: original(status, supplied);
|
|
134
|
+
}
|
|
135
|
+
const merged = { ...base, ...(supplied ?? {}) };
|
|
136
|
+
return typeof reasonOrHeaders === 'string'
|
|
137
|
+
? original(status, reasonOrHeaders, merged)
|
|
138
|
+
: original(status, merged);
|
|
139
|
+
});
|
|
140
|
+
if (req.method === 'OPTIONS') {
|
|
141
|
+
const origin = headerOf(req, 'origin');
|
|
142
|
+
const allowed = corsHeadersFor(origin, security);
|
|
143
|
+
// A preflight for an origin that is not allowed gets a plain 204 with no
|
|
144
|
+
// CORS headers: the browser then refuses the real request on its own. A
|
|
145
|
+
// 403 would be no more secure and would look like a server fault in the
|
|
146
|
+
// console instead of the policy decision it is.
|
|
147
|
+
res.writeHead(204, Object.keys(allowed).length === 0 ? {} : preflightHeadersFor(security));
|
|
148
|
+
res.end();
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
//# sourceMappingURL=http-security.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http-security.js","sourceRoot":"","sources":["../../src/commands/http-security.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Logger } from '@cogenta/core';
|
|
2
|
+
import type { Output, Writer } from '../output.js';
|
|
3
|
+
export type LinksSubcommand = 'check';
|
|
4
|
+
export interface LinksOptions {
|
|
5
|
+
readonly subcommand: string | undefined;
|
|
6
|
+
readonly cwd?: string;
|
|
7
|
+
readonly env?: Record<string, string | undefined>;
|
|
8
|
+
readonly logger?: Logger;
|
|
9
|
+
readonly out: Output;
|
|
10
|
+
readonly stderr: Writer;
|
|
11
|
+
/** Also follow `http(s)` links that leave the site. Off unless asked. */
|
|
12
|
+
readonly external?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* `cogenta links check` (L14 task 3).
|
|
16
|
+
*
|
|
17
|
+
* Exit codes are the ones a cron entry can act on: 0 nothing broken, 1 broken
|
|
18
|
+
* links were found or the site could not be read, 2 the command line was
|
|
19
|
+
* wrong. A non-zero exit for "found broken links" is deliberate — that is what
|
|
20
|
+
* makes this usable as a CI or scheduled check rather than something whose
|
|
21
|
+
* output somebody has to read.
|
|
22
|
+
*/
|
|
23
|
+
export declare function runLinks(options: LinksOptions): Promise<number>;
|
|
24
|
+
//# sourceMappingURL=links.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"links.d.ts","sourceRoot":"","sources":["../../src/commands/links.ts"],"names":[],"mappings":"AAEA,OAAO,EAIL,KAAK,MAAM,EAEZ,MAAM,eAAe,CAAA;AAStB,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,cAAc,CAAA;AAGlD,MAAM,MAAM,eAAe,GAAG,OAAO,CAAA;AAErC,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,CAAA;IACvC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;IACjD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,yEAAyE;IACzE,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAC5B;AA+BD;;;;;;;;GAQG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,CA+ErE"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { dirname, resolve as resolvePath } from 'node:path';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { createDatabaseRegistry, createLogger, isCogentaError, loadConfig, } from '@cogenta/core';
|
|
4
|
+
import { checkLinks, createContentStore, createSchemaTables, } from '@cogenta/schema';
|
|
5
|
+
import { loadCollections } from './serve.js';
|
|
6
|
+
const USAGE = `Usage
|
|
7
|
+
cogenta links check [--external]
|
|
8
|
+
|
|
9
|
+
Walks every published entry, follows every link it holds, and reports the ones
|
|
10
|
+
that lead nowhere: a reference to a deleted or still-unpublished entry, a
|
|
11
|
+
site-relative path that matches no route, and — with --external — an outside
|
|
12
|
+
URL that answers with an error or cannot be reached at all.
|
|
13
|
+
|
|
14
|
+
Nothing about this runs on its own. This project guarantees no durable
|
|
15
|
+
background worker (rule R1), so "periodically" means a cron entry calling this
|
|
16
|
+
command, not a scheduler pretending to exist inside the site.
|
|
17
|
+
`;
|
|
18
|
+
const REASON_TEXT = {
|
|
19
|
+
unknown_collection: 'points at a collection this site does not have',
|
|
20
|
+
target_missing: 'points at content that does not exist',
|
|
21
|
+
target_unpublished: 'points at content that is not published',
|
|
22
|
+
unroutable_path: 'is a path no route can serve',
|
|
23
|
+
http_error: 'answered with an error status',
|
|
24
|
+
unreachable: 'could not be reached',
|
|
25
|
+
};
|
|
26
|
+
function describe(broken) {
|
|
27
|
+
const target = broken.link.kind === 'url' ? broken.link.href : `${broken.link.collection}/${broken.link.id}`;
|
|
28
|
+
const status = broken.status === undefined ? '' : ` (${broken.status})`;
|
|
29
|
+
return `${broken.collection}/${broken.entryId} [${broken.locale}] ${broken.at}\n → ${target}${status} — ${REASON_TEXT[broken.reason]}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* `cogenta links check` (L14 task 3).
|
|
33
|
+
*
|
|
34
|
+
* Exit codes are the ones a cron entry can act on: 0 nothing broken, 1 broken
|
|
35
|
+
* links were found or the site could not be read, 2 the command line was
|
|
36
|
+
* wrong. A non-zero exit for "found broken links" is deliberate — that is what
|
|
37
|
+
* makes this usable as a CI or scheduled check rather than something whose
|
|
38
|
+
* output somebody has to read.
|
|
39
|
+
*/
|
|
40
|
+
export async function runLinks(options) {
|
|
41
|
+
const { out, stderr } = options;
|
|
42
|
+
const env = options.env ?? process.env;
|
|
43
|
+
const logger = options.logger ?? createLogger({ level: 'silent' });
|
|
44
|
+
if (options.subcommand === undefined) {
|
|
45
|
+
stderr(`cogenta links needs a subcommand.\n\n${USAGE}`);
|
|
46
|
+
return 2;
|
|
47
|
+
}
|
|
48
|
+
if (options.subcommand !== 'check') {
|
|
49
|
+
stderr(`Unknown subcommand "${options.subcommand}". Only "check" exists today.\n\n${USAGE}`);
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
const loaded = await loadConfig({
|
|
53
|
+
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
54
|
+
env,
|
|
55
|
+
});
|
|
56
|
+
const projectRoot = loaded.path === null ? resolvePath(options.cwd ?? process.cwd()) : dirname(loaded.path);
|
|
57
|
+
let selection = null;
|
|
58
|
+
try {
|
|
59
|
+
const collections = await loadCollections(projectRoot);
|
|
60
|
+
selection = await createDatabaseRegistry({ logger }).select(loaded.config.database);
|
|
61
|
+
const db = selection.instance;
|
|
62
|
+
await createSchemaTables(db, collections);
|
|
63
|
+
const stores = new Map();
|
|
64
|
+
const storeFor = (collection) => {
|
|
65
|
+
const existing = stores.get(collection.name);
|
|
66
|
+
if (existing !== undefined)
|
|
67
|
+
return existing;
|
|
68
|
+
const created = createContentStore({ db, collection });
|
|
69
|
+
stores.set(collection.name, created);
|
|
70
|
+
return created;
|
|
71
|
+
};
|
|
72
|
+
const report = await checkLinks({
|
|
73
|
+
collections,
|
|
74
|
+
storeFor,
|
|
75
|
+
locales: loaded.config.site.locales,
|
|
76
|
+
defaultLocale: loaded.config.site.defaultLocale,
|
|
77
|
+
...(options.external === true ? { checkExternal: true } : {}),
|
|
78
|
+
});
|
|
79
|
+
if (report.broken.length === 0) {
|
|
80
|
+
out.ok(`Checked ${report.checkedLinks} link(s) across ${report.checkedEntries} published entries. Nothing broken.`);
|
|
81
|
+
if (report.skippedExternal > 0) {
|
|
82
|
+
out.detail(`${report.skippedExternal} external link(s) were not followed. Pass --external to check them.`);
|
|
83
|
+
}
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
out.warn(`${report.broken.length} broken link(s) in ${report.checkedEntries} published entries:`);
|
|
87
|
+
for (const broken of report.broken)
|
|
88
|
+
out.detail(describe(broken));
|
|
89
|
+
if (report.skippedExternal > 0) {
|
|
90
|
+
out.detail(`${report.skippedExternal} external link(s) were not followed. Pass --external to check them.`);
|
|
91
|
+
}
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (isCogentaError(error)) {
|
|
96
|
+
stderr(`${error.code}: ${error.message}\n`);
|
|
97
|
+
if (error.hint !== undefined)
|
|
98
|
+
stderr(`${error.hint}\n`);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
stderr(`${error instanceof Error ? error.stack : String(error)}\n`);
|
|
102
|
+
}
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
await selection?.instance.close();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=links.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"links.js","sourceRoot":"","sources":["../../src/commands/links.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ImageSize, MediaImageProcessor } from '@cogenta/api';
|
|
2
|
+
import type { Logger } from '@cogenta/core';
|
|
3
|
+
import { contentTypeOf, type ImageFormat, type ImageTransformer } from '@cogenta/render';
|
|
4
|
+
/**
|
|
5
|
+
* `@cogenta/render`'s image pipeline, wired to the media upload (L10 task 5).
|
|
6
|
+
*
|
|
7
|
+
* The pipeline, `srcset.ts` and the two driver tiers (sharp, WASM libvips)
|
|
8
|
+
* have existed since L3 and were called by nothing. This module is where they
|
|
9
|
+
* meet a real upload.
|
|
10
|
+
*
|
|
11
|
+
* ## Why variants are produced at upload rather than on demand
|
|
12
|
+
*
|
|
13
|
+
* `createImagePipeline` is deliberately lazy — it renders a variant the first
|
|
14
|
+
* time a browser asks for one — and that is the right design for a build step
|
|
15
|
+
* that would otherwise materialise N formats × M widths × K images. It is the
|
|
16
|
+
* wrong design here, and the lot says so explicitly ("au moment de l'upload,
|
|
17
|
+
* pas à la volée"), for a reason worth writing down: `cogenta serve` has no
|
|
18
|
+
* durable variant cache. `createMemoryVariantStore` dies with the process, so
|
|
19
|
+
* a lazy pipeline behind it re-decodes every image after every restart, and
|
|
20
|
+
* on the shared hosting Cogenta targets (R10's own list) that is the slowest
|
|
21
|
+
* possible answer on the coldest possible machine.
|
|
22
|
+
*
|
|
23
|
+
* Producing a bounded, deterministic set once — the `SRCSET_WIDTHS` ladder,
|
|
24
|
+
* capped at the intrinsic width, in WebP — makes the set nameable, which is
|
|
25
|
+
* what lets `variantNames` clean them up on delete without a `list` call the
|
|
26
|
+
* `StorageDriver` interface does not have.
|
|
27
|
+
*
|
|
28
|
+
* ## Why WebP and not AVIF
|
|
29
|
+
*
|
|
30
|
+
* WebP is universally supported in 2026 and encodes in a fraction of AVIF's
|
|
31
|
+
* time. AVIF's extra compression is real, but paying it per upload on a
|
|
32
|
+
* WASM tier — the tier that always exists (R10) — would make an upload of a
|
|
33
|
+
* handful of images take minutes. A second format can be added the day the
|
|
34
|
+
* pipeline is asked for one, and the ladder is the only thing that has to
|
|
35
|
+
* change.
|
|
36
|
+
*/
|
|
37
|
+
declare const VARIANT_FORMAT: ImageFormat;
|
|
38
|
+
/** The name of one rendition under an asset's variant prefix. */
|
|
39
|
+
export declare function variantName(width: number, format?: ImageFormat): string;
|
|
40
|
+
/**
|
|
41
|
+
* The widths actually produced for an image.
|
|
42
|
+
*
|
|
43
|
+
* `candidateWidths` caps the ladder at the intrinsic width, so a 500px logo
|
|
44
|
+
* yields 320 and 500 rather than five upscaled copies of itself — upscaling
|
|
45
|
+
* costs bytes and gains nothing.
|
|
46
|
+
*/
|
|
47
|
+
export declare function variantWidthsFor(intrinsic: ImageSize): readonly number[];
|
|
48
|
+
export interface MediaImageProcessorOptions {
|
|
49
|
+
readonly transformer: ImageTransformer;
|
|
50
|
+
readonly quality?: number;
|
|
51
|
+
}
|
|
52
|
+
export declare function createMediaImageProcessor(options: MediaImageProcessorOptions): MediaImageProcessor;
|
|
53
|
+
/**
|
|
54
|
+
* The processor for the driver this host can actually run, or `null`.
|
|
55
|
+
*
|
|
56
|
+
* `null` rather than a throw: an install where neither tier loads must still
|
|
57
|
+
* upload and serve images at their original size. That is the same "absent,
|
|
58
|
+
* not broken" degradation R2 applies to the LLM and R1 to every other driver
|
|
59
|
+
* — a CMS that refuses an upload because a WebAssembly module would not boot
|
|
60
|
+
* is worse than one that serves a full-size JPEG.
|
|
61
|
+
*/
|
|
62
|
+
export declare function selectMediaImageProcessor(logger: Logger): Promise<{
|
|
63
|
+
processor: MediaImageProcessor;
|
|
64
|
+
driver: string;
|
|
65
|
+
} | null>;
|
|
66
|
+
export { contentTypeOf, VARIANT_FORMAT };
|
|
67
|
+
//# sourceMappingURL=media-images.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"media-images.d.ts","sourceRoot":"","sources":["../../src/commands/media-images.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,mBAAmB,EAAwB,MAAM,cAAc,CAAA;AACxF,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAA;AAC3C,OAAO,EAEL,aAAa,EAGb,KAAK,WAAW,EAChB,KAAK,gBAAgB,EAItB,MAAM,iBAAiB,CAAA;AAExB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,QAAA,MAAM,cAAc,EAAE,WAAoB,CAAA;AAE1C,iEAAiE;AACjE,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,GAAE,WAA4B,GAAG,MAAM,CAEvF;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,SAAS,GAAG,SAAS,MAAM,EAAE,CAExE;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,WAAW,EAAE,gBAAgB,CAAA;IACtC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAC1B;AAED,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,0BAA0B,GAClC,mBAAmB,CAsCrB;AAED;;;;;;;;GAQG;AACH,wBAAsB,yBAAyB,CAC7C,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,SAAS,EAAE,mBAAmB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAapE;AAED,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,CAAA"}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { candidateWidths, contentTypeOf, createImageRegistry, DEFAULT_QUALITY, planTransform, SRCSET_WIDTHS, suffixOf, } from '@cogenta/render';
|
|
2
|
+
/**
|
|
3
|
+
* `@cogenta/render`'s image pipeline, wired to the media upload (L10 task 5).
|
|
4
|
+
*
|
|
5
|
+
* The pipeline, `srcset.ts` and the two driver tiers (sharp, WASM libvips)
|
|
6
|
+
* have existed since L3 and were called by nothing. This module is where they
|
|
7
|
+
* meet a real upload.
|
|
8
|
+
*
|
|
9
|
+
* ## Why variants are produced at upload rather than on demand
|
|
10
|
+
*
|
|
11
|
+
* `createImagePipeline` is deliberately lazy — it renders a variant the first
|
|
12
|
+
* time a browser asks for one — and that is the right design for a build step
|
|
13
|
+
* that would otherwise materialise N formats × M widths × K images. It is the
|
|
14
|
+
* wrong design here, and the lot says so explicitly ("au moment de l'upload,
|
|
15
|
+
* pas à la volée"), for a reason worth writing down: `cogenta serve` has no
|
|
16
|
+
* durable variant cache. `createMemoryVariantStore` dies with the process, so
|
|
17
|
+
* a lazy pipeline behind it re-decodes every image after every restart, and
|
|
18
|
+
* on the shared hosting Cogenta targets (R10's own list) that is the slowest
|
|
19
|
+
* possible answer on the coldest possible machine.
|
|
20
|
+
*
|
|
21
|
+
* Producing a bounded, deterministic set once — the `SRCSET_WIDTHS` ladder,
|
|
22
|
+
* capped at the intrinsic width, in WebP — makes the set nameable, which is
|
|
23
|
+
* what lets `variantNames` clean them up on delete without a `list` call the
|
|
24
|
+
* `StorageDriver` interface does not have.
|
|
25
|
+
*
|
|
26
|
+
* ## Why WebP and not AVIF
|
|
27
|
+
*
|
|
28
|
+
* WebP is universally supported in 2026 and encodes in a fraction of AVIF's
|
|
29
|
+
* time. AVIF's extra compression is real, but paying it per upload on a
|
|
30
|
+
* WASM tier — the tier that always exists (R10) — would make an upload of a
|
|
31
|
+
* handful of images take minutes. A second format can be added the day the
|
|
32
|
+
* pipeline is asked for one, and the ladder is the only thing that has to
|
|
33
|
+
* change.
|
|
34
|
+
*/
|
|
35
|
+
const VARIANT_FORMAT = 'webp';
|
|
36
|
+
/** The name of one rendition under an asset's variant prefix. */
|
|
37
|
+
export function variantName(width, format = VARIANT_FORMAT) {
|
|
38
|
+
return `${width}.${suffixOf(format)}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The widths actually produced for an image.
|
|
42
|
+
*
|
|
43
|
+
* `candidateWidths` caps the ladder at the intrinsic width, so a 500px logo
|
|
44
|
+
* yields 320 and 500 rather than five upscaled copies of itself — upscaling
|
|
45
|
+
* costs bytes and gains nothing.
|
|
46
|
+
*/
|
|
47
|
+
export function variantWidthsFor(intrinsic) {
|
|
48
|
+
return candidateWidths(intrinsic.width, intrinsic.width, SRCSET_WIDTHS);
|
|
49
|
+
}
|
|
50
|
+
export function createMediaImageProcessor(options) {
|
|
51
|
+
const { transformer } = options;
|
|
52
|
+
const quality = options.quality ?? DEFAULT_QUALITY;
|
|
53
|
+
return {
|
|
54
|
+
probe: async (bytes) => {
|
|
55
|
+
try {
|
|
56
|
+
const metadata = await transformer.metadata(bytes);
|
|
57
|
+
return { width: metadata.width, height: metadata.height };
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Not an image this tier can decode. The upload is still legitimate —
|
|
61
|
+
// `verifyRealType` already sniffed the container — so the honest
|
|
62
|
+
// answer is "no dimensions", not a refusal.
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
variants: async (bytes, intrinsic) => {
|
|
67
|
+
const metadata = await transformer.metadata(bytes);
|
|
68
|
+
const produced = [];
|
|
69
|
+
for (const width of variantWidthsFor(intrinsic)) {
|
|
70
|
+
const rendered = await transformer.transform(bytes, planTransform(metadata, { width, format: VARIANT_FORMAT, quality }));
|
|
71
|
+
produced.push({
|
|
72
|
+
name: variantName(width, VARIANT_FORMAT),
|
|
73
|
+
bytes: rendered.bytes,
|
|
74
|
+
contentType: rendered.contentType,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return produced;
|
|
78
|
+
},
|
|
79
|
+
variantNames: (intrinsic) => variantWidthsFor(intrinsic).map((width) => variantName(width)),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The processor for the driver this host can actually run, or `null`.
|
|
84
|
+
*
|
|
85
|
+
* `null` rather than a throw: an install where neither tier loads must still
|
|
86
|
+
* upload and serve images at their original size. That is the same "absent,
|
|
87
|
+
* not broken" degradation R2 applies to the LLM and R1 to every other driver
|
|
88
|
+
* — a CMS that refuses an upload because a WebAssembly module would not boot
|
|
89
|
+
* is worse than one that serves a full-size JPEG.
|
|
90
|
+
*/
|
|
91
|
+
export async function selectMediaImageProcessor(logger) {
|
|
92
|
+
try {
|
|
93
|
+
const selection = await createImageRegistry({ logger }).select({});
|
|
94
|
+
return {
|
|
95
|
+
processor: createMediaImageProcessor({ transformer: selection.instance }),
|
|
96
|
+
driver: selection.driver,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
logger.warn('no image driver available; uploads will carry no variants', {
|
|
101
|
+
error: String(error),
|
|
102
|
+
});
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export { contentTypeOf, VARIANT_FORMAT };
|
|
107
|
+
//# sourceMappingURL=media-images.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"media-images.js","sourceRoot":"","sources":["../../src/commands/media-images.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AccessContext, ContentGateway, SearchRouter } from '@cogenta/api';
|
|
2
|
+
import { type CollectionDefinition } from '@cogenta/schema';
|
|
3
|
+
/**
|
|
4
|
+
* `GET /search?q=…` — the public half of L10 task 3.
|
|
5
|
+
*
|
|
6
|
+
* The lot asks for "un bloc `search` optionnel côté thème public (formulaire
|
|
7
|
+
* + page de résultats)". The form and the results page are here; the **block**
|
|
8
|
+
* deliberately is not.
|
|
9
|
+
*
|
|
10
|
+
* Contract B is frozen (2026-08-13) and AGENTS.md forbids adding a block to
|
|
11
|
+
* the vocabulary without an RFC. A `search` block would be a contract B
|
|
12
|
+
* addition, so it would need a major version bump and a migration note — a
|
|
13
|
+
* decision that does not belong inside a lot whose whole premise is "no new
|
|
14
|
+
* capability, only wiring". A real route serving a real results page gives a
|
|
15
|
+
* visitor the same thing without touching the contract at all; when an RFC
|
|
16
|
+
* for a `search` block does happen, it can render through this same query.
|
|
17
|
+
*
|
|
18
|
+
* The page is `noindex`: a search results page is exactly the kind of thin,
|
|
19
|
+
* infinitely-many-URLs page a crawler must not index, and `buildMetaTags`
|
|
20
|
+
* takes `noindex` as an option precisely for it.
|
|
21
|
+
*/
|
|
22
|
+
export interface SearchPageOptions {
|
|
23
|
+
readonly router: SearchRouter;
|
|
24
|
+
readonly gateway: ContentGateway;
|
|
25
|
+
readonly collections: readonly CollectionDefinition[];
|
|
26
|
+
readonly site: {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly url: string;
|
|
29
|
+
readonly locales: readonly string[];
|
|
30
|
+
readonly defaultLocale: string;
|
|
31
|
+
};
|
|
32
|
+
/** The joined skin+theme stylesheet `cogenta serve` serves at `STYLESHEET_PATH` (`theme-render.ts`'s `joinStyles`). `null` when neither could be loaded — served unstyled rather than refused. */
|
|
33
|
+
readonly styles: string | null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The whole page. Returns HTML; the caller decides the status code (always
|
|
37
|
+
* 200 — an empty result set is an answer, not a failure).
|
|
38
|
+
*/
|
|
39
|
+
export declare function renderSearchPage(query: string, options: SearchPageOptions, context: AccessContext): Promise<string>;
|
|
40
|
+
//# sourceMappingURL=search-page.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search-page.d.ts","sourceRoot":"","sources":["../../src/commands/search-page.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAC/E,OAAO,EAAa,KAAK,oBAAoB,EAAkB,MAAM,iBAAiB,CAAA;AAItF;;;;;;;;;;;;;;;;;;GAkBG;AAEH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAA;IAC7B,QAAQ,CAAC,OAAO,EAAE,cAAc,CAAA;IAChC,QAAQ,CAAC,WAAW,EAAE,SAAS,oBAAoB,EAAE,CAAA;IACrD,QAAQ,CAAC,IAAI,EAAE;QACb,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;QACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;QACpB,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;QACnC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;KAC/B,CAAA;IACD,kMAAkM;IAClM,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CAC/B;AA+ED;;;GAGG;AACH,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,iBAAiB,EAC1B,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC,MAAM,CAAC,CAmDjB"}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { buildPath } from '@cogenta/schema';
|
|
2
|
+
import { escapeHtmlAttribute, escapeHtmlText } from '@cogenta/seo';
|
|
3
|
+
import { STYLESHEET_PATH } from './theme-render.js';
|
|
4
|
+
/**
|
|
5
|
+
* Turns hits into links.
|
|
6
|
+
*
|
|
7
|
+
* The hit itself carries no URL — the index stores text, not routes — so each
|
|
8
|
+
* one is read back through the same permission-checked gateway that answered
|
|
9
|
+
* the search. An entry in a collection with no `routing`, or whose route
|
|
10
|
+
* parameters are incomplete, is listed without a link rather than with a URL
|
|
11
|
+
* that 404s.
|
|
12
|
+
*/
|
|
13
|
+
async function resolveHits(hits, options, context) {
|
|
14
|
+
const byName = new Map(options.collections.map((collection) => [collection.name, collection]));
|
|
15
|
+
const resolved = [];
|
|
16
|
+
for (const hit of hits) {
|
|
17
|
+
const collection = byName.get(hit.collection);
|
|
18
|
+
if (collection === undefined)
|
|
19
|
+
continue;
|
|
20
|
+
let href = null;
|
|
21
|
+
if (collection.routing !== undefined) {
|
|
22
|
+
const entry = await options.gateway.read(hit.collection, hit.id, context);
|
|
23
|
+
if (entry !== null) {
|
|
24
|
+
const params = Object.fromEntries(Object.entries(entry.values).filter((pair) => typeof pair[1] === 'string'));
|
|
25
|
+
const complete = collection.routing.pattern
|
|
26
|
+
.split('/')
|
|
27
|
+
.filter((segment) => segment.startsWith(':'))
|
|
28
|
+
.every((segment) => params[segment.slice(1)] !== undefined || segment === ':id');
|
|
29
|
+
if (complete) {
|
|
30
|
+
href = buildPath(collection, { ...params, id: entry.id }, collection.routing.locale === true ? entry.locale : undefined);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
resolved.push({ title: hit.title.length > 0 ? hit.title : hit.id, href });
|
|
35
|
+
}
|
|
36
|
+
return resolved;
|
|
37
|
+
}
|
|
38
|
+
/** The form on its own, so an empty query still gets a usable page. */
|
|
39
|
+
function searchForm(query) {
|
|
40
|
+
return `<form class="cg-search__form" action="/search" method="get" role="search">
|
|
41
|
+
<label for="cg-search-q">Search</label>
|
|
42
|
+
<input id="cg-search-q" type="search" name="q" value="${escapeHtmlAttribute(query)}" required>
|
|
43
|
+
<button type="submit">Search</button>
|
|
44
|
+
</form>`;
|
|
45
|
+
}
|
|
46
|
+
function resultList(results) {
|
|
47
|
+
if (results.length === 0)
|
|
48
|
+
return `<p class="cg-search__empty">Nothing matched that search.</p>`;
|
|
49
|
+
return `<ol class="cg-search__results">
|
|
50
|
+
${results
|
|
51
|
+
.map((result) => result.href === null
|
|
52
|
+
? `<li>${escapeHtmlText(result.title)}</li>`
|
|
53
|
+
: `<li><a href="${escapeHtmlAttribute(result.href)}">${escapeHtmlText(result.title)}</a></li>`)
|
|
54
|
+
.join('\n')}
|
|
55
|
+
</ol>`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The whole page. Returns HTML; the caller decides the status code (always
|
|
59
|
+
* 200 — an empty result set is an answer, not a failure).
|
|
60
|
+
*/
|
|
61
|
+
export async function renderSearchPage(query, options, context) {
|
|
62
|
+
const trimmed = query.trim();
|
|
63
|
+
let results = [];
|
|
64
|
+
let failure = null;
|
|
65
|
+
if (trimmed.length > 0) {
|
|
66
|
+
const response = await options.router.handle({ method: 'GET', path: '/api/search', query: { q: trimmed } }, context);
|
|
67
|
+
if (response.status === 200) {
|
|
68
|
+
const body = response.body;
|
|
69
|
+
results = await resolveHits(body.data, options, context);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
// The router's own message, never a rewritten one: it already says what
|
|
73
|
+
// failed and what to do about it, and inventing a second wording here
|
|
74
|
+
// is how the two drift apart.
|
|
75
|
+
const body = response.body;
|
|
76
|
+
failure = body.error?.message ?? 'The search could not be completed.';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const heading = trimmed.length === 0 ? 'Search' : `Search results for “${escapeHtmlText(trimmed)}”`;
|
|
80
|
+
const main = failure !== null
|
|
81
|
+
? `<p class="cg-search__error" role="alert">${escapeHtmlText(failure)}</p>`
|
|
82
|
+
: trimmed.length === 0
|
|
83
|
+
? ''
|
|
84
|
+
: resultList(results);
|
|
85
|
+
return `<!doctype html>
|
|
86
|
+
<html lang="${escapeHtmlAttribute(options.site.defaultLocale)}">
|
|
87
|
+
<head>
|
|
88
|
+
<meta charset="utf-8">
|
|
89
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
90
|
+
<title>${heading} — ${escapeHtmlText(options.site.name)}</title>
|
|
91
|
+
<meta name="robots" content="noindex, follow" />
|
|
92
|
+
${options.styles === null ? '' : `<link rel="stylesheet" href="${STYLESHEET_PATH}">`}
|
|
93
|
+
</head>
|
|
94
|
+
<body>
|
|
95
|
+
<main class="cg-main" id="cg-main">
|
|
96
|
+
<h1 class="cg-page__title">${heading}</h1>
|
|
97
|
+
${searchForm(trimmed)}
|
|
98
|
+
${main}
|
|
99
|
+
</main>
|
|
100
|
+
</body>
|
|
101
|
+
</html>
|
|
102
|
+
`;
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=search-page.js.map
|