@sarimarcus/content-sites-core 0.20.0 → 0.22.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/CHANGELOG.md +8 -0
- package/README.md +9 -1
- package/dist/affiliate/gyg.d.ts +28 -0
- package/dist/affiliate/gyg.js +60 -0
- package/dist/affiliate/index.d.ts +6 -0
- package/dist/affiliate/index.js +4 -0
- package/dist/affiliate/links.d.ts +17 -0
- package/dist/affiliate/links.js +27 -0
- package/dist/affiliate/travelpayouts.d.ts +38 -0
- package/dist/affiliate/travelpayouts.js +33 -0
- package/dist/config/siteConfig.js +4 -7
- package/dist/images/cli.d.ts +2 -0
- package/dist/images/cli.js +42 -0
- package/dist/images/index.d.ts +24 -0
- package/dist/images/index.js +23 -0
- package/dist/images/service.d.ts +7 -0
- package/dist/images/service.js +85 -0
- package/dist/images/variants.d.ts +45 -0
- package/dist/images/variants.js +145 -0
- package/dist/interludes/index.d.ts +1 -1
- package/dist/url/index.d.ts +1 -0
- package/dist/url/index.js +1 -0
- package/dist/url/pageType.d.ts +8 -0
- package/dist/url/pageType.js +14 -0
- package/package.json +27 -2
- package/src/affiliate/gyg.ts +66 -0
- package/src/affiliate/index.ts +7 -0
- package/src/affiliate/links.ts +35 -0
- package/src/affiliate/travelpayouts.ts +62 -0
- package/src/config/siteConfig.ts +4 -7
- package/src/images/cli.ts +39 -0
- package/src/images/index.ts +41 -0
- package/src/images/service.ts +95 -0
- package/src/images/variants.ts +179 -0
- package/src/url/index.ts +1 -0
- package/src/url/pageType.ts +13 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// The local generator and the gate for committed image variants. sharp is loaded only here, from the
|
|
2
|
+
// site's own install (Astro ships it), so CI never needs it.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { availableParallelism } from 'node:os';
|
|
6
|
+
import { join, relative, sep } from 'node:path';
|
|
7
|
+
import {
|
|
8
|
+
EXCLUDED_DIR, MANIFEST_FILE, VARIANTS_DIR, variantFileName, variantRungs,
|
|
9
|
+
type VariantEntry, type VariantManifest,
|
|
10
|
+
} from './index.ts';
|
|
11
|
+
|
|
12
|
+
const SOURCE_EXT = /\.(webp|jpe?g|png|avif|tiff?)$/i;
|
|
13
|
+
/** Matches the WebP settings the sharp service used, so variants look like what the site served before noop. */
|
|
14
|
+
export const VARIANT_WEBP = { quality: 65, effort: 6 } as const;
|
|
15
|
+
|
|
16
|
+
export interface SourceImage {
|
|
17
|
+
/** Path relative to src/assets, always with forward slashes. */
|
|
18
|
+
rel: string;
|
|
19
|
+
sha: string;
|
|
20
|
+
bytes: Buffer;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Whether a path under src/assets gets variants: a raster image outside any gallery directory. */
|
|
24
|
+
export function isEligibleSource(rel: string): boolean {
|
|
25
|
+
return SOURCE_EXT.test(rel) && !EXCLUDED_DIR.test(rel) && !rel.split('/').some((seg) => seg.startsWith('.'));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function listSources(siteRoot: string): string[] {
|
|
29
|
+
const assets = join(siteRoot, 'src', 'assets');
|
|
30
|
+
if (!existsSync(assets)) throw new Error(`no src/assets directory under ${siteRoot}`);
|
|
31
|
+
const out: string[] = [];
|
|
32
|
+
const walk = (dir: string) => {
|
|
33
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
34
|
+
const abs = join(dir, e.name);
|
|
35
|
+
if (e.isDirectory()) walk(abs);
|
|
36
|
+
else out.push(relative(assets, abs).split(sep).join('/'));
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
walk(assets);
|
|
40
|
+
return out.filter(isEligibleSource).sort();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const sourceSha = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex').slice(0, 16);
|
|
44
|
+
|
|
45
|
+
export function readSource(siteRoot: string, rel: string): SourceImage {
|
|
46
|
+
const bytes = readFileSync(join(siteRoot, 'src', 'assets', rel));
|
|
47
|
+
return { rel, bytes, sha: sourceSha(bytes) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseManifest(text: string | null, where: string): VariantManifest {
|
|
51
|
+
if (text === null) return {};
|
|
52
|
+
const parsed = JSON.parse(text);
|
|
53
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(`${where} is not a variant manifest object`);
|
|
54
|
+
return parsed as VariantManifest;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function readManifest(siteRoot: string): VariantManifest {
|
|
58
|
+
return parseManifest(workingTree(siteRoot).manifestText(), join(siteRoot, VARIANTS_DIR, MANIFEST_FILE));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What the gate reads: the working tree (the CLI) or a pushed commit (the site pre-push hook). */
|
|
62
|
+
export interface VariantTree {
|
|
63
|
+
/** Eligible source paths relative to src/assets. */
|
|
64
|
+
sources(): string[];
|
|
65
|
+
sourceBytes(rel: string): Uint8Array;
|
|
66
|
+
manifestText(): string | null;
|
|
67
|
+
/** File names present in the variants directory. */
|
|
68
|
+
variantFiles(): string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function workingTree(siteRoot: string): VariantTree {
|
|
72
|
+
const dir = join(siteRoot, VARIANTS_DIR);
|
|
73
|
+
return {
|
|
74
|
+
sources: () => listSources(siteRoot),
|
|
75
|
+
sourceBytes: (rel) => readFileSync(join(siteRoot, 'src', 'assets', rel)),
|
|
76
|
+
manifestText: () => (existsSync(join(dir, MANIFEST_FILE)) ? readFileSync(join(dir, MANIFEST_FILE), 'utf-8') : null),
|
|
77
|
+
variantFiles: () => (existsSync(dir) ? readdirSync(dir) : []),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function pool<T>(items: T[], fn: (item: T) => Promise<void>): Promise<void> {
|
|
82
|
+
let next = 0;
|
|
83
|
+
const worker = async () => {
|
|
84
|
+
while (next < items.length) await fn(items[next++]);
|
|
85
|
+
};
|
|
86
|
+
await Promise.all(Array.from({ length: Math.min(items.length, availableParallelism()) }, worker));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface GenerateResult {
|
|
90
|
+
sources: number;
|
|
91
|
+
written: number;
|
|
92
|
+
kept: number;
|
|
93
|
+
pruned: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Write every missing variant, rebuild the manifest, and delete variants no source needs any more. */
|
|
97
|
+
export async function generateVariants(siteRoot: string, log: (line: string) => void = () => {}): Promise<GenerateResult> {
|
|
98
|
+
const { default: sharp } = (await import('sharp')) as { default: any };
|
|
99
|
+
const dir = join(siteRoot, VARIANTS_DIR);
|
|
100
|
+
mkdirSync(dir, { recursive: true });
|
|
101
|
+
const previous = readManifest(siteRoot);
|
|
102
|
+
const manifest: VariantManifest = {};
|
|
103
|
+
const rels = listSources(siteRoot);
|
|
104
|
+
let written = 0;
|
|
105
|
+
let kept = 0;
|
|
106
|
+
await pool(rels, async (rel) => {
|
|
107
|
+
const source = readSource(siteRoot, rel);
|
|
108
|
+
const shared = manifest[source.sha];
|
|
109
|
+
if (shared) {
|
|
110
|
+
shared.src.push(rel);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// Reserve the sha before any await, so a duplicate file in another worker joins this entry.
|
|
114
|
+
const entry: VariantEntry = { w: 0, h: 0, fmt: '', rungs: [], src: [rel] };
|
|
115
|
+
manifest[source.sha] = entry;
|
|
116
|
+
const known = previous[source.sha];
|
|
117
|
+
if (known) {
|
|
118
|
+
Object.assign(entry, { w: known.w, h: known.h, fmt: known.fmt, rungs: variantRungs(known.w, known.fmt) });
|
|
119
|
+
} else {
|
|
120
|
+
const meta = await sharp(source.bytes).metadata();
|
|
121
|
+
Object.assign(entry, { w: meta.width, h: meta.height, fmt: meta.format, rungs: variantRungs(meta.width, meta.format) });
|
|
122
|
+
}
|
|
123
|
+
for (const width of entry.rungs) {
|
|
124
|
+
const file = join(dir, variantFileName(source.sha, width));
|
|
125
|
+
if (existsSync(file)) {
|
|
126
|
+
kept++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
writeFileSync(file, await sharp(source.bytes).resize({ width }).webp(VARIANT_WEBP).toBuffer());
|
|
130
|
+
written++;
|
|
131
|
+
log(`+ ${variantFileName(source.sha, width)} ${rel}`);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
const wanted = new Set(Object.entries(manifest).flatMap(([sha, e]) => e.rungs.map((w) => variantFileName(sha, w))));
|
|
135
|
+
let pruned = 0;
|
|
136
|
+
for (const name of readdirSync(dir)) {
|
|
137
|
+
if (name === MANIFEST_FILE || wanted.has(name)) continue;
|
|
138
|
+
rmSync(join(dir, name));
|
|
139
|
+
pruned++;
|
|
140
|
+
log(`- ${name}`);
|
|
141
|
+
}
|
|
142
|
+
const sorted: VariantManifest = {};
|
|
143
|
+
for (const sha of Object.keys(manifest).sort()) sorted[sha] = { ...manifest[sha], src: manifest[sha].src.sort() };
|
|
144
|
+
writeFileSync(join(dir, MANIFEST_FILE), `${JSON.stringify(sorted, null, 2)}\n`);
|
|
145
|
+
return { sources: rels.length, written, kept, pruned };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface CheckResult {
|
|
149
|
+
sources: number;
|
|
150
|
+
variants: number;
|
|
151
|
+
problems: string[];
|
|
152
|
+
orphans: string[];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The gate: every eligible source is in the manifest and every rung it needs has a committed file. */
|
|
156
|
+
export function checkVariants(tree: VariantTree): CheckResult {
|
|
157
|
+
const manifest = parseManifest(tree.manifestText(), `${VARIANTS_DIR}/${MANIFEST_FILE}`);
|
|
158
|
+
const present = new Set(tree.variantFiles());
|
|
159
|
+
const rels = tree.sources();
|
|
160
|
+
const problems: string[] = [];
|
|
161
|
+
const needed = new Set<string>();
|
|
162
|
+
let variants = 0;
|
|
163
|
+
for (const rel of rels) {
|
|
164
|
+
const sha = sourceSha(tree.sourceBytes(rel));
|
|
165
|
+
const entry = manifest[sha];
|
|
166
|
+
if (!entry) {
|
|
167
|
+
problems.push(`${rel}: not in ${VARIANTS_DIR}/${MANIFEST_FILE} (new or changed image)`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
for (const width of variantRungs(entry.w, entry.fmt)) {
|
|
171
|
+
const name = variantFileName(sha, width);
|
|
172
|
+
needed.add(name);
|
|
173
|
+
if (present.has(name)) variants++;
|
|
174
|
+
else problems.push(`${rel}: missing ${VARIANTS_DIR}/${name}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const orphans = [...present].filter((n) => n !== MANIFEST_FILE && !needed.has(n));
|
|
178
|
+
return { sources: rels.length, variants, problems, orphans };
|
|
179
|
+
}
|
package/src/url/index.ts
CHANGED
|
@@ -5,3 +5,4 @@ export { normalizeHref, extractProseHrefs, dedupRelated } from './relatedDedup.t
|
|
|
5
5
|
export { buildImageMap, keyBySlug } from './imageMap.ts';
|
|
6
6
|
export { articleFeedItem } from './feed.ts';
|
|
7
7
|
export type { FeedArticle, FeedItem } from './feed.ts';
|
|
8
|
+
export { pageTypeOf } from './pageType.ts';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page type: the first path segment, 'home' at the root.
|
|
3
|
+
*
|
|
4
|
+
* The single definition behind both the GA4 `page_type` param (runtime pathname, '/plan/x') and a site's GYG `cmp`
|
|
5
|
+
* segment (BUILD-time `Astro.url.pathname`, '/index.html' under build.format 'file'). The `.html`/`index`
|
|
6
|
+
* normalisation is what makes the two agree.
|
|
7
|
+
*/
|
|
8
|
+
export function pageTypeOf(pathname: string): string {
|
|
9
|
+
const first = pathname.split('/').filter(Boolean)[0];
|
|
10
|
+
if (!first) return 'home';
|
|
11
|
+
const bare = first.replace(/\.html$/, '');
|
|
12
|
+
return bare === 'index' || bare === '' ? 'home' : bare;
|
|
13
|
+
}
|