@sarimarcus/content-sites-core 0.8.0 → 0.9.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 +4 -0
- package/dist/config/guideDates.d.ts +11 -0
- package/dist/config/guideDates.js +65 -0
- package/dist/config/index.d.ts +5 -1
- package/dist/config/index.js +4 -2
- package/dist/config/outboundLinkRel.d.ts +11 -0
- package/dist/config/outboundLinkRel.js +82 -0
- package/dist/config/siteConfig.d.ts +55 -0
- package/dist/config/siteConfig.js +155 -0
- package/dist/dates/lastmod.d.ts +1 -0
- package/dist/dates/lastmod.js +1 -1
- package/package.json +4 -2
- package/src/config/guideDates.ts +62 -0
- package/src/config/index.ts +6 -2
- package/src/config/outboundLinkRel.ts +83 -0
- package/src/config/siteConfig.ts +185 -0
- package/src/dates/lastmod.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
All packages in the platform release in lockstep; entries are per release version.
|
|
4
4
|
|
|
5
|
+
## 0.9.0 (2026-09-30)
|
|
6
|
+
|
|
7
|
+
- `./config`: `defineSiteConfig` (the shared Astro config: static skeleton, image service, Vite settings, outbound-rel → sitemap → Pagefind; lastmod map from content, guide dirs, `itemKeys`, `extraKeys`; sitemap `exclude` and `rules`; optional `redirects` and `ssrNoExternal`; `pagefind` injected), `buildLastmodMap`, `sitemapSerialize`, `outboundLinkRel({ root })`, and the guide scanners `latestGuideDate`, `guideDateMap`, `guidePublishDates` with one `dateModified` regex. Peer-depends on `@astrojs/sitemap` and `@tailwindcss/vite` (SLA-2747).
|
|
8
|
+
|
|
5
9
|
## 0.8.0 (2026-09-30)
|
|
6
10
|
|
|
7
11
|
- Stable release of 0.8.0-rc.0, unchanged: adopted on all three sites with every page identical (Córdoba 631, Granada 589, Dark Skies 654, including Córdoba's .ics feeds); astro check 0 errors (SLA-2747).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Latest `dateModified` across the `.astro` pages of one directory; 1970-01-01 when it is missing or has none. */
|
|
2
|
+
export declare function latestGuideDate(dirPath: string): string;
|
|
3
|
+
/** `<dir>/<slug>` → latest `dateModified`, for each `.astro` page of `pagesDir/<dir>` that has one. */
|
|
4
|
+
export declare function guideDateMap(pagesDir: string, dir: string): Map<string, string>;
|
|
5
|
+
/**
|
|
6
|
+
* Route path → earliest `datePublished` for the guide pages in the given `pagesDir` subdirectories, e.g.
|
|
7
|
+
* '/plan/getting-there' → '2026-06-12'. Keyed by full route so a /plan/<slug> never lends its date to a
|
|
8
|
+
* same-named /discover/<slug>. Pages without a `datePublished` are absent. A missing directory throws: an
|
|
9
|
+
* empty map would silently drop every badge.
|
|
10
|
+
*/
|
|
11
|
+
export declare function guidePublishDates(pagesDir: string, ...dirs: string[]): Record<string, string>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { EPOCH } from "../dates/lastmod.js";
|
|
4
|
+
const DATE_MODIFIED = /dateModified[:\s=]*["'](\d{4}-\d{2}-\d{2})/g;
|
|
5
|
+
const DATE_PUBLISHED = /datePublished\s*[=:]\s*["'](\d{4}-\d{2}-\d{2})/g;
|
|
6
|
+
function astroFiles(dir) {
|
|
7
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith('.astro'));
|
|
8
|
+
}
|
|
9
|
+
function latestIn(content) {
|
|
10
|
+
let latest = EPOCH;
|
|
11
|
+
for (const m of content.matchAll(DATE_MODIFIED))
|
|
12
|
+
if (m[1] > latest)
|
|
13
|
+
latest = m[1];
|
|
14
|
+
return latest;
|
|
15
|
+
}
|
|
16
|
+
/** Latest `dateModified` across the `.astro` pages of one directory; 1970-01-01 when it is missing or has none. */
|
|
17
|
+
export function latestGuideDate(dirPath) {
|
|
18
|
+
let latest = EPOCH;
|
|
19
|
+
if (!fs.existsSync(dirPath))
|
|
20
|
+
return latest;
|
|
21
|
+
for (const file of astroFiles(dirPath)) {
|
|
22
|
+
const d = latestIn(fs.readFileSync(path.join(dirPath, file), 'utf-8'));
|
|
23
|
+
if (d > latest)
|
|
24
|
+
latest = d;
|
|
25
|
+
}
|
|
26
|
+
return latest;
|
|
27
|
+
}
|
|
28
|
+
/** `<dir>/<slug>` → latest `dateModified`, for each `.astro` page of `pagesDir/<dir>` that has one. */
|
|
29
|
+
export function guideDateMap(pagesDir, dir) {
|
|
30
|
+
const map = new Map();
|
|
31
|
+
const dirPath = path.join(pagesDir, dir);
|
|
32
|
+
if (!fs.existsSync(dirPath))
|
|
33
|
+
return map;
|
|
34
|
+
for (const file of astroFiles(dirPath)) {
|
|
35
|
+
const d = latestIn(fs.readFileSync(path.join(dirPath, file), 'utf-8'));
|
|
36
|
+
if (d > EPOCH)
|
|
37
|
+
map.set(`${dir}/${file.replace('.astro', '')}`, d);
|
|
38
|
+
}
|
|
39
|
+
return map;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Route path → earliest `datePublished` for the guide pages in the given `pagesDir` subdirectories, e.g.
|
|
43
|
+
* '/plan/getting-there' → '2026-06-12'. Keyed by full route so a /plan/<slug> never lends its date to a
|
|
44
|
+
* same-named /discover/<slug>. Pages without a `datePublished` are absent. A missing directory throws: an
|
|
45
|
+
* empty map would silently drop every badge.
|
|
46
|
+
*/
|
|
47
|
+
export function guidePublishDates(pagesDir, ...dirs) {
|
|
48
|
+
const dates = {};
|
|
49
|
+
for (const rawDir of dirs) {
|
|
50
|
+
const dir = rawDir.replace(/^\/+|\/+$/g, '');
|
|
51
|
+
const dirPath = path.join(pagesDir, dir);
|
|
52
|
+
if (!fs.existsSync(dirPath))
|
|
53
|
+
throw new Error(`guidePublishDates: ${path.join(path.basename(pagesDir), dir)} does not exist`);
|
|
54
|
+
for (const file of astroFiles(dirPath)) {
|
|
55
|
+
const content = fs.readFileSync(path.join(dirPath, file), 'utf-8');
|
|
56
|
+
let published = null;
|
|
57
|
+
for (const m of content.matchAll(DATE_PUBLISHED))
|
|
58
|
+
if (published === null || m[1] < published)
|
|
59
|
+
published = m[1];
|
|
60
|
+
if (published)
|
|
61
|
+
dates[`/${dir}/${file.replace(/\.astro$/, '')}`] = published;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return dates;
|
|
65
|
+
}
|
package/dist/config/index.d.ts
CHANGED
|
@@ -1 +1,5 @@
|
|
|
1
|
-
export {};
|
|
1
|
+
export { defineSiteConfig, buildLastmodMap, sitemapSerialize } from './siteConfig.ts';
|
|
2
|
+
export type { SiteConfigOptions, SitemapRule, PagefindIntegration } from './siteConfig.ts';
|
|
3
|
+
export { outboundLinkRel } from './outboundLinkRel.ts';
|
|
4
|
+
export type { OutboundLinkRelOptions } from './outboundLinkRel.ts';
|
|
5
|
+
export { latestGuideDate, guideDateMap, guidePublishDates } from './guideDates.ts';
|
package/dist/config/index.js
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
|
-
// Public surface of @sarimarcus/content-sites-core/config.
|
|
2
|
-
export {};
|
|
1
|
+
// Public surface of @sarimarcus/content-sites-core/config. Node-only: astro.config.mjs imports it.
|
|
2
|
+
export { defineSiteConfig, buildLastmodMap, sitemapSerialize } from "./siteConfig.js";
|
|
3
|
+
export { outboundLinkRel } from "./outboundLinkRel.js";
|
|
4
|
+
export { latestGuideDate, guideDateMap, guidePublishDates } from "./guideDates.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AstroIntegration } from 'astro';
|
|
2
|
+
export interface OutboundLinkRelOptions {
|
|
3
|
+
/** Site root; the classification store is read from `src/data/outbound-domains.json` under it. */
|
|
4
|
+
root: string | URL;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Astro integration enforcing the outbound rel policy on every anchor of the built HTML, whichever component
|
|
8
|
+
* rendered it: competitor-classified hosts gain `nofollow`, affiliate-classified hosts gain `sponsored`, and
|
|
9
|
+
* everything else is untouched.
|
|
10
|
+
*/
|
|
11
|
+
export declare function outboundLinkRel({ root }: OutboundLinkRelOptions): AstroIntegration;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
function walk(dir, out = []) {
|
|
5
|
+
let entries;
|
|
6
|
+
try {
|
|
7
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
12
|
+
for (const e of entries) {
|
|
13
|
+
const p = path.join(dir, e.name);
|
|
14
|
+
if (e.isDirectory())
|
|
15
|
+
walk(p, out);
|
|
16
|
+
else if (/\.html$/.test(e.name))
|
|
17
|
+
out.push(p);
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
function mergeRel(attrs, tokens) {
|
|
22
|
+
if (/\brel\s*=\s*["'][^"']*["']/i.test(attrs)) {
|
|
23
|
+
return attrs.replace(/\brel\s*=\s*["']([^"']*)["']/i, (_m, existing) => {
|
|
24
|
+
const set = new Set([...existing.split(/\s+/).filter(Boolean), ...tokens]);
|
|
25
|
+
return `rel="${[...set].join(' ')}"`;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return `${attrs} rel="${tokens.join(' ')}"`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Astro integration enforcing the outbound rel policy on every anchor of the built HTML, whichever component
|
|
32
|
+
* rendered it: competitor-classified hosts gain `nofollow`, affiliate-classified hosts gain `sponsored`, and
|
|
33
|
+
* everything else is untouched.
|
|
34
|
+
*/
|
|
35
|
+
export function outboundLinkRel({ root }) {
|
|
36
|
+
const siteRoot = root instanceof URL ? fileURLToPath(root) : root;
|
|
37
|
+
return {
|
|
38
|
+
name: 'outbound-link-rel',
|
|
39
|
+
hooks: {
|
|
40
|
+
'astro:build:done': async ({ dir, logger }) => {
|
|
41
|
+
const store = JSON.parse(fs.readFileSync(path.join(siteRoot, 'src/data/outbound-domains.json'), 'utf-8'));
|
|
42
|
+
const classes = new Map(Object.entries(store.domains).map(([d, e]) => [d.replace(/^www\./, '').toLowerCase(), e.class]));
|
|
43
|
+
const outDir = fileURLToPath(dir);
|
|
44
|
+
let files = 0;
|
|
45
|
+
let anchors = 0;
|
|
46
|
+
for (const file of walk(outDir)) {
|
|
47
|
+
const original = fs.readFileSync(file, 'utf-8');
|
|
48
|
+
let changed = false;
|
|
49
|
+
const html = original.replace(/<a\b([^>]*)>/gi, (tag, attrs) => {
|
|
50
|
+
const href = attrs.match(/href\s*=\s*"([^"]*)"/i)?.[1] ?? attrs.match(/href\s*=\s*'([^']*)'/i)?.[1];
|
|
51
|
+
if (!href || !/^https?:\/\//i.test(href))
|
|
52
|
+
return tag;
|
|
53
|
+
let host;
|
|
54
|
+
try {
|
|
55
|
+
host = new URL(href).hostname.toLowerCase().replace(/^www\./, '');
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return tag;
|
|
59
|
+
}
|
|
60
|
+
const cls = classes.get(host) ?? 'other';
|
|
61
|
+
if (cls !== 'competitor' && cls !== 'affiliate')
|
|
62
|
+
return tag;
|
|
63
|
+
anchors++;
|
|
64
|
+
const tokens = [];
|
|
65
|
+
if (cls === 'competitor' && !/\bnofollow\b/i.test(attrs))
|
|
66
|
+
tokens.push('nofollow');
|
|
67
|
+
if (cls === 'affiliate' && !/\bsponsored\b/i.test(attrs))
|
|
68
|
+
tokens.push('sponsored');
|
|
69
|
+
if (!tokens.length)
|
|
70
|
+
return tag;
|
|
71
|
+
changed = true;
|
|
72
|
+
return `<a${mergeRel(attrs, ['noopener', ...tokens])}>`;
|
|
73
|
+
});
|
|
74
|
+
if (changed)
|
|
75
|
+
fs.writeFileSync(file, html);
|
|
76
|
+
files++;
|
|
77
|
+
}
|
|
78
|
+
logger.info(`outbound-link-rel: scanned ${files} html file(s), enforced policy on ${anchors} affiliate/competitor anchor(s)`);
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { AstroIntegration, AstroUserConfig } from 'astro';
|
|
2
|
+
import { type SitemapItem } from '@astrojs/sitemap';
|
|
3
|
+
export interface SitemapRule {
|
|
4
|
+
match: (parts: string[]) => boolean;
|
|
5
|
+
priority?: number;
|
|
6
|
+
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
|
7
|
+
}
|
|
8
|
+
/** astro-pagefind's default export. Passed in: it ships TypeScript source, which plain Node refuses under node_modules. */
|
|
9
|
+
export type PagefindIntegration = (options: {
|
|
10
|
+
indexConfig: {
|
|
11
|
+
excludeSelectors: string[];
|
|
12
|
+
forceLanguage: string;
|
|
13
|
+
};
|
|
14
|
+
}) => AstroIntegration;
|
|
15
|
+
export interface SiteConfigOptions {
|
|
16
|
+
/** Absolute origin, e.g. `https://www.example.com`. */
|
|
17
|
+
site: string;
|
|
18
|
+
/** Site root (pass `new URL('.', import.meta.url)`): holds src/content, src/pages and src/data/outbound-domains.json. */
|
|
19
|
+
root: string | URL;
|
|
20
|
+
redirects?: Record<string, string>;
|
|
21
|
+
lastmod: {
|
|
22
|
+
/** URL segment → content directory, e.g. `{ monument: 'monuments' }`. */
|
|
23
|
+
contentDirMap: Record<string, string>;
|
|
24
|
+
/** src/pages subdirectories whose `.astro` guides carry a `dateModified`. */
|
|
25
|
+
guideDirs: string[];
|
|
26
|
+
/** Extra lookup keys an item's date also feeds; each keeps the latest date it sees. */
|
|
27
|
+
itemKeys?: (urlType: string, item: Record<string, unknown>) => string[];
|
|
28
|
+
/** Extra lookup keys computed from each content directory's latest date. */
|
|
29
|
+
extraKeys?: (collectionMax: Record<string, string>) => Record<string, string | undefined>;
|
|
30
|
+
};
|
|
31
|
+
sitemap?: {
|
|
32
|
+
/** A pathname any predicate accepts is left out; `/search` always is. */
|
|
33
|
+
exclude?: Array<(pathname: string) => boolean>;
|
|
34
|
+
/** Every matching rule sets its fields on the item. */
|
|
35
|
+
rules?: SitemapRule[];
|
|
36
|
+
};
|
|
37
|
+
/** Packages Vite must bundle during SSR. */
|
|
38
|
+
ssrNoExternal?: string[];
|
|
39
|
+
pagefind: PagefindIntegration;
|
|
40
|
+
/** Pagefind index language. */
|
|
41
|
+
language?: string;
|
|
42
|
+
}
|
|
43
|
+
/** Lookup key → date for the sitemap, plus the latest date overall (the fallback). */
|
|
44
|
+
export declare function buildLastmodMap(rootDir: string, opts: SiteConfigOptions['lastmod']): {
|
|
45
|
+
lastmodMap: Map<string, string>;
|
|
46
|
+
mostRecent: string;
|
|
47
|
+
};
|
|
48
|
+
/** The sitemap `serialize` hook: trailing slash stripped, exclusions dropped, lastmod looked up, rules applied. */
|
|
49
|
+
export declare function sitemapSerialize(lastmodMap: Map<string, string>, mostRecent: string, { exclude, rules }?: NonNullable<SiteConfigOptions['sitemap']>): (item: SitemapItem) => SitemapItem | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* The Astro config every content site shares: static output, the image service, build and Vite settings, and the
|
|
52
|
+
* outbound-rel → sitemap → Pagefind integrations. The sitemap `lastmod` comes from content `dateModified`, guide
|
|
53
|
+
* pages and the site's extra keys, looked up by the first 3, 2, then 1 path segments.
|
|
54
|
+
*/
|
|
55
|
+
export declare function defineSiteConfig(opts: SiteConfigOptions): AstroUserConfig;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
5
|
+
import sitemap, {} from '@astrojs/sitemap';
|
|
6
|
+
import { outboundLinkRel } from "./outboundLinkRel.js";
|
|
7
|
+
import { guideDateMap } from "./guideDates.js";
|
|
8
|
+
import { EPOCH } from "../dates/lastmod.js";
|
|
9
|
+
// A missing date, or a date-only value equal to today, is stamped with the build instant.
|
|
10
|
+
function toDateWithTime(dateStr) {
|
|
11
|
+
if (!dateStr)
|
|
12
|
+
return new Date().toISOString();
|
|
13
|
+
if (dateStr.includes('T'))
|
|
14
|
+
return new Date(dateStr).toISOString();
|
|
15
|
+
const today = new Date().toISOString().split('T')[0];
|
|
16
|
+
if (dateStr === today)
|
|
17
|
+
return new Date().toISOString();
|
|
18
|
+
return new Date(`${dateStr}T12:00:00Z`).toISOString();
|
|
19
|
+
}
|
|
20
|
+
/** Lookup key → date for the sitemap, plus the latest date overall (the fallback). */
|
|
21
|
+
export function buildLastmodMap(rootDir, opts) {
|
|
22
|
+
const lastmodMap = new Map();
|
|
23
|
+
const contentBase = path.join(rootDir, 'src/content');
|
|
24
|
+
let mostRecent = EPOCH;
|
|
25
|
+
const collectionMax = {};
|
|
26
|
+
for (const [urlType, dir] of Object.entries(opts.contentDirMap)) {
|
|
27
|
+
const dirPath = path.join(contentBase, dir);
|
|
28
|
+
if (!fs.existsSync(dirPath))
|
|
29
|
+
continue;
|
|
30
|
+
let maxForCollection = EPOCH;
|
|
31
|
+
for (const file of fs.readdirSync(dirPath)) {
|
|
32
|
+
if (!file.endsWith('.json'))
|
|
33
|
+
continue;
|
|
34
|
+
try {
|
|
35
|
+
const content = JSON.parse(fs.readFileSync(path.join(dirPath, file), 'utf-8'));
|
|
36
|
+
if (content.slug && content.dateModified) {
|
|
37
|
+
lastmodMap.set(`${urlType}/${content.slug}`, content.dateModified);
|
|
38
|
+
for (const key of opts.itemKeys?.(urlType, content) ?? []) {
|
|
39
|
+
if (content.dateModified > (lastmodMap.get(key) || ''))
|
|
40
|
+
lastmodMap.set(key, content.dateModified);
|
|
41
|
+
}
|
|
42
|
+
if (content.dateModified > maxForCollection)
|
|
43
|
+
maxForCollection = content.dateModified;
|
|
44
|
+
if (content.dateModified > mostRecent)
|
|
45
|
+
mostRecent = content.dateModified;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// an unparseable file contributes nothing
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
collectionMax[dir] = maxForCollection;
|
|
53
|
+
}
|
|
54
|
+
for (const dir of Object.values(opts.contentDirMap)) {
|
|
55
|
+
if (collectionMax[dir] && collectionMax[dir] > EPOCH)
|
|
56
|
+
lastmodMap.set(dir, collectionMax[dir]);
|
|
57
|
+
}
|
|
58
|
+
const pagesDir = path.join(rootDir, 'src/pages');
|
|
59
|
+
for (const dir of opts.guideDirs) {
|
|
60
|
+
let maxForHub = EPOCH;
|
|
61
|
+
for (const [key, date] of guideDateMap(pagesDir, dir)) {
|
|
62
|
+
lastmodMap.set(key, date);
|
|
63
|
+
if (date > maxForHub)
|
|
64
|
+
maxForHub = date;
|
|
65
|
+
}
|
|
66
|
+
if (maxForHub > EPOCH)
|
|
67
|
+
lastmodMap.set(dir, maxForHub);
|
|
68
|
+
}
|
|
69
|
+
for (const [key, date] of Object.entries(opts.extraKeys?.(collectionMax) ?? {})) {
|
|
70
|
+
if (date && date > EPOCH)
|
|
71
|
+
lastmodMap.set(key, date);
|
|
72
|
+
}
|
|
73
|
+
return { lastmodMap, mostRecent };
|
|
74
|
+
}
|
|
75
|
+
/** The sitemap `serialize` hook: trailing slash stripped, exclusions dropped, lastmod looked up, rules applied. */
|
|
76
|
+
export function sitemapSerialize(lastmodMap, mostRecent, { exclude = [], rules = [] } = {}) {
|
|
77
|
+
return (item) => {
|
|
78
|
+
if (item.url.endsWith('/'))
|
|
79
|
+
item.url = item.url.slice(0, -1);
|
|
80
|
+
const url = new URL(item.url);
|
|
81
|
+
if (url.pathname === '/search')
|
|
82
|
+
return undefined;
|
|
83
|
+
if (exclude.some((ex) => ex(url.pathname)))
|
|
84
|
+
return undefined;
|
|
85
|
+
const pathParts = url.pathname.split('/').filter(Boolean);
|
|
86
|
+
const dateModified = lastmodMap.get(pathParts.slice(0, 3).join('/')) ||
|
|
87
|
+
lastmodMap.get(pathParts.slice(0, 2).join('/')) ||
|
|
88
|
+
lastmodMap.get(pathParts[0]) ||
|
|
89
|
+
mostRecent;
|
|
90
|
+
item.lastmod = toDateWithTime(dateModified);
|
|
91
|
+
for (const rule of rules) {
|
|
92
|
+
if (!rule.match(pathParts))
|
|
93
|
+
continue;
|
|
94
|
+
if (rule.priority !== undefined)
|
|
95
|
+
item.priority = rule.priority;
|
|
96
|
+
if (rule.changefreq !== undefined)
|
|
97
|
+
item.changefreq = rule.changefreq;
|
|
98
|
+
}
|
|
99
|
+
return item;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The Astro config every content site shares: static output, the image service, build and Vite settings, and the
|
|
104
|
+
* outbound-rel → sitemap → Pagefind integrations. The sitemap `lastmod` comes from content `dateModified`, guide
|
|
105
|
+
* pages and the site's extra keys, looked up by the first 3, 2, then 1 path segments.
|
|
106
|
+
*/
|
|
107
|
+
export function defineSiteConfig(opts) {
|
|
108
|
+
const rootDir = opts.root instanceof URL ? fileURLToPath(opts.root) : opts.root;
|
|
109
|
+
const { lastmodMap, mostRecent } = buildLastmodMap(rootDir, opts.lastmod);
|
|
110
|
+
return {
|
|
111
|
+
...(opts.redirects ? { redirects: opts.redirects } : {}),
|
|
112
|
+
site: opts.site,
|
|
113
|
+
base: '/',
|
|
114
|
+
output: 'static',
|
|
115
|
+
trailingSlash: 'never',
|
|
116
|
+
compressHTML: true,
|
|
117
|
+
image: {
|
|
118
|
+
service: {
|
|
119
|
+
entrypoint: process.env.CI ? 'astro/assets/services/noop' : 'astro/assets/services/sharp',
|
|
120
|
+
config: {
|
|
121
|
+
limitInputPixels: false,
|
|
122
|
+
avif: { quality: 65, effort: 6, chromaSubsampling: '4:2:0' },
|
|
123
|
+
// Astro re-encodes from decoded pixels, so the delivered quality is set here, not by the source file.
|
|
124
|
+
webp: { quality: 65, effort: 6 },
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
build: {
|
|
129
|
+
format: 'file',
|
|
130
|
+
inlineStylesheets: 'auto',
|
|
131
|
+
},
|
|
132
|
+
vite: {
|
|
133
|
+
plugins: [tailwindcss()],
|
|
134
|
+
worker: { format: 'es' },
|
|
135
|
+
...(opts.ssrNoExternal?.length ? { ssr: { noExternal: opts.ssrNoExternal } } : {}),
|
|
136
|
+
build: {
|
|
137
|
+
cssMinify: true,
|
|
138
|
+
minify: 'esbuild',
|
|
139
|
+
rollupOptions: {
|
|
140
|
+
external: ['/pagefind/pagefind-ui.js', '/pagefind/pagefind.js'],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
integrations: [
|
|
145
|
+
outboundLinkRel({ root: rootDir }),
|
|
146
|
+
sitemap({ serialize: sitemapSerialize(lastmodMap, mostRecent, opts.sitemap) }),
|
|
147
|
+
opts.pagefind({
|
|
148
|
+
indexConfig: {
|
|
149
|
+
excludeSelectors: ['nav', 'footer', '.breadcrumb', '[data-pagefind-ignore]'],
|
|
150
|
+
forceLanguage: opts.language ?? 'en',
|
|
151
|
+
},
|
|
152
|
+
}),
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
}
|
package/dist/dates/lastmod.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export declare const EPOCH = "1970-01-01";
|
|
1
2
|
/** The latest of several `YYYY-MM-DD` strings; empty values are skipped, and none gives 1970-01-01. */
|
|
2
3
|
export declare function maxDate(...dates: string[]): string;
|
|
3
4
|
/** The latest `data.dateModified` across collection entries; entries without one are skipped. */
|
package/dist/dates/lastmod.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sarimarcus/content-sites-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Non-visual utilities, Astro config builders and SEO schema assembly shared by the content sites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -62,6 +62,8 @@
|
|
|
62
62
|
"marked": "^18.0.14"
|
|
63
63
|
},
|
|
64
64
|
"peerDependencies": {
|
|
65
|
-
"astro": "^7.0.6"
|
|
65
|
+
"astro": "^7.0.6",
|
|
66
|
+
"@astrojs/sitemap": "^3.7.4",
|
|
67
|
+
"@tailwindcss/vite": "~4.3.3"
|
|
66
68
|
}
|
|
67
69
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { EPOCH } from '../dates/lastmod.ts';
|
|
5
|
+
|
|
6
|
+
const DATE_MODIFIED = /dateModified[:\s=]*["'](\d{4}-\d{2}-\d{2})/g;
|
|
7
|
+
const DATE_PUBLISHED = /datePublished\s*[=:]\s*["'](\d{4}-\d{2}-\d{2})/g;
|
|
8
|
+
|
|
9
|
+
function astroFiles(dir: string): string[] {
|
|
10
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith('.astro'));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function latestIn(content: string): string {
|
|
14
|
+
let latest = EPOCH;
|
|
15
|
+
for (const m of content.matchAll(DATE_MODIFIED)) if (m[1] > latest) latest = m[1];
|
|
16
|
+
return latest;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Latest `dateModified` across the `.astro` pages of one directory; 1970-01-01 when it is missing or has none. */
|
|
20
|
+
export function latestGuideDate(dirPath: string): string {
|
|
21
|
+
let latest = EPOCH;
|
|
22
|
+
if (!fs.existsSync(dirPath)) return latest;
|
|
23
|
+
for (const file of astroFiles(dirPath)) {
|
|
24
|
+
const d = latestIn(fs.readFileSync(path.join(dirPath, file), 'utf-8'));
|
|
25
|
+
if (d > latest) latest = d;
|
|
26
|
+
}
|
|
27
|
+
return latest;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `<dir>/<slug>` → latest `dateModified`, for each `.astro` page of `pagesDir/<dir>` that has one. */
|
|
31
|
+
export function guideDateMap(pagesDir: string, dir: string): Map<string, string> {
|
|
32
|
+
const map = new Map<string, string>();
|
|
33
|
+
const dirPath = path.join(pagesDir, dir);
|
|
34
|
+
if (!fs.existsSync(dirPath)) return map;
|
|
35
|
+
for (const file of astroFiles(dirPath)) {
|
|
36
|
+
const d = latestIn(fs.readFileSync(path.join(dirPath, file), 'utf-8'));
|
|
37
|
+
if (d > EPOCH) map.set(`${dir}/${file.replace('.astro', '')}`, d);
|
|
38
|
+
}
|
|
39
|
+
return map;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Route path → earliest `datePublished` for the guide pages in the given `pagesDir` subdirectories, e.g.
|
|
44
|
+
* '/plan/getting-there' → '2026-06-12'. Keyed by full route so a /plan/<slug> never lends its date to a
|
|
45
|
+
* same-named /discover/<slug>. Pages without a `datePublished` are absent. A missing directory throws: an
|
|
46
|
+
* empty map would silently drop every badge.
|
|
47
|
+
*/
|
|
48
|
+
export function guidePublishDates(pagesDir: string, ...dirs: string[]): Record<string, string> {
|
|
49
|
+
const dates: Record<string, string> = {};
|
|
50
|
+
for (const rawDir of dirs) {
|
|
51
|
+
const dir = rawDir.replace(/^\/+|\/+$/g, '');
|
|
52
|
+
const dirPath = path.join(pagesDir, dir);
|
|
53
|
+
if (!fs.existsSync(dirPath)) throw new Error(`guidePublishDates: ${path.join(path.basename(pagesDir), dir)} does not exist`);
|
|
54
|
+
for (const file of astroFiles(dirPath)) {
|
|
55
|
+
const content = fs.readFileSync(path.join(dirPath, file), 'utf-8');
|
|
56
|
+
let published: string | null = null;
|
|
57
|
+
for (const m of content.matchAll(DATE_PUBLISHED)) if (published === null || m[1] < published) published = m[1];
|
|
58
|
+
if (published) dates[`/${dir}/${file.replace(/\.astro$/, '')}`] = published;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return dates;
|
|
62
|
+
}
|
package/src/config/index.ts
CHANGED
|
@@ -1,2 +1,6 @@
|
|
|
1
|
-
// Public surface of @sarimarcus/content-sites-core/config.
|
|
2
|
-
export {};
|
|
1
|
+
// Public surface of @sarimarcus/content-sites-core/config. Node-only: astro.config.mjs imports it.
|
|
2
|
+
export { defineSiteConfig, buildLastmodMap, sitemapSerialize } from './siteConfig.ts';
|
|
3
|
+
export type { SiteConfigOptions, SitemapRule, PagefindIntegration } from './siteConfig.ts';
|
|
4
|
+
export { outboundLinkRel } from './outboundLinkRel.ts';
|
|
5
|
+
export type { OutboundLinkRelOptions } from './outboundLinkRel.ts';
|
|
6
|
+
export { latestGuideDate, guideDateMap, guidePublishDates } from './guideDates.ts';
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import type { AstroIntegration } from 'astro';
|
|
5
|
+
|
|
6
|
+
export interface OutboundLinkRelOptions {
|
|
7
|
+
/** Site root; the classification store is read from `src/data/outbound-domains.json` under it. */
|
|
8
|
+
root: string | URL;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function walk(dir: string, out: string[] = []): string[] {
|
|
12
|
+
let entries: fs.Dirent[];
|
|
13
|
+
try {
|
|
14
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
15
|
+
} catch {
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
for (const e of entries) {
|
|
19
|
+
const p = path.join(dir, e.name);
|
|
20
|
+
if (e.isDirectory()) walk(p, out);
|
|
21
|
+
else if (/\.html$/.test(e.name)) out.push(p);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function mergeRel(attrs: string, tokens: string[]): string {
|
|
27
|
+
if (/\brel\s*=\s*["'][^"']*["']/i.test(attrs)) {
|
|
28
|
+
return attrs.replace(/\brel\s*=\s*["']([^"']*)["']/i, (_m, existing: string) => {
|
|
29
|
+
const set = new Set([...existing.split(/\s+/).filter(Boolean), ...tokens]);
|
|
30
|
+
return `rel="${[...set].join(' ')}"`;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return `${attrs} rel="${tokens.join(' ')}"`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Astro integration enforcing the outbound rel policy on every anchor of the built HTML, whichever component
|
|
38
|
+
* rendered it: competitor-classified hosts gain `nofollow`, affiliate-classified hosts gain `sponsored`, and
|
|
39
|
+
* everything else is untouched.
|
|
40
|
+
*/
|
|
41
|
+
export function outboundLinkRel({ root }: OutboundLinkRelOptions): AstroIntegration {
|
|
42
|
+
const siteRoot = root instanceof URL ? fileURLToPath(root) : root;
|
|
43
|
+
return {
|
|
44
|
+
name: 'outbound-link-rel',
|
|
45
|
+
hooks: {
|
|
46
|
+
'astro:build:done': async ({ dir, logger }) => {
|
|
47
|
+
const store = JSON.parse(fs.readFileSync(path.join(siteRoot, 'src/data/outbound-domains.json'), 'utf-8')) as {
|
|
48
|
+
domains: Record<string, { class: string }>;
|
|
49
|
+
};
|
|
50
|
+
const classes = new Map(Object.entries(store.domains).map(([d, e]) => [d.replace(/^www\./, '').toLowerCase(), e.class]));
|
|
51
|
+
const outDir = fileURLToPath(dir);
|
|
52
|
+
let files = 0;
|
|
53
|
+
let anchors = 0;
|
|
54
|
+
for (const file of walk(outDir)) {
|
|
55
|
+
const original = fs.readFileSync(file, 'utf-8');
|
|
56
|
+
let changed = false;
|
|
57
|
+
const html = original.replace(/<a\b([^>]*)>/gi, (tag, attrs: string) => {
|
|
58
|
+
const href = attrs.match(/href\s*=\s*"([^"]*)"/i)?.[1] ?? attrs.match(/href\s*=\s*'([^']*)'/i)?.[1];
|
|
59
|
+
if (!href || !/^https?:\/\//i.test(href)) return tag;
|
|
60
|
+
let host: string;
|
|
61
|
+
try {
|
|
62
|
+
host = new URL(href).hostname.toLowerCase().replace(/^www\./, '');
|
|
63
|
+
} catch {
|
|
64
|
+
return tag;
|
|
65
|
+
}
|
|
66
|
+
const cls = classes.get(host) ?? 'other';
|
|
67
|
+
if (cls !== 'competitor' && cls !== 'affiliate') return tag;
|
|
68
|
+
anchors++;
|
|
69
|
+
const tokens: string[] = [];
|
|
70
|
+
if (cls === 'competitor' && !/\bnofollow\b/i.test(attrs)) tokens.push('nofollow');
|
|
71
|
+
if (cls === 'affiliate' && !/\bsponsored\b/i.test(attrs)) tokens.push('sponsored');
|
|
72
|
+
if (!tokens.length) return tag;
|
|
73
|
+
changed = true;
|
|
74
|
+
return `<a${mergeRel(attrs, ['noopener', ...tokens])}>`;
|
|
75
|
+
});
|
|
76
|
+
if (changed) fs.writeFileSync(file, html);
|
|
77
|
+
files++;
|
|
78
|
+
}
|
|
79
|
+
logger.info(`outbound-link-rel: scanned ${files} html file(s), enforced policy on ${anchors} affiliate/competitor anchor(s)`);
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import type { AstroIntegration, AstroUserConfig } from 'astro';
|
|
5
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
6
|
+
import sitemap, { type SitemapItem } from '@astrojs/sitemap';
|
|
7
|
+
import { outboundLinkRel } from './outboundLinkRel.ts';
|
|
8
|
+
import { guideDateMap } from './guideDates.ts';
|
|
9
|
+
|
|
10
|
+
import { EPOCH } from '../dates/lastmod.ts';
|
|
11
|
+
|
|
12
|
+
export interface SitemapRule {
|
|
13
|
+
match: (parts: string[]) => boolean;
|
|
14
|
+
priority?: number;
|
|
15
|
+
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** astro-pagefind's default export. Passed in: it ships TypeScript source, which plain Node refuses under node_modules. */
|
|
19
|
+
export type PagefindIntegration = (options: { indexConfig: { excludeSelectors: string[]; forceLanguage: string } }) => AstroIntegration;
|
|
20
|
+
|
|
21
|
+
export interface SiteConfigOptions {
|
|
22
|
+
/** Absolute origin, e.g. `https://www.example.com`. */
|
|
23
|
+
site: string;
|
|
24
|
+
/** Site root (pass `new URL('.', import.meta.url)`): holds src/content, src/pages and src/data/outbound-domains.json. */
|
|
25
|
+
root: string | URL;
|
|
26
|
+
redirects?: Record<string, string>;
|
|
27
|
+
lastmod: {
|
|
28
|
+
/** URL segment → content directory, e.g. `{ monument: 'monuments' }`. */
|
|
29
|
+
contentDirMap: Record<string, string>;
|
|
30
|
+
/** src/pages subdirectories whose `.astro` guides carry a `dateModified`. */
|
|
31
|
+
guideDirs: string[];
|
|
32
|
+
/** Extra lookup keys an item's date also feeds; each keeps the latest date it sees. */
|
|
33
|
+
itemKeys?: (urlType: string, item: Record<string, unknown>) => string[];
|
|
34
|
+
/** Extra lookup keys computed from each content directory's latest date. */
|
|
35
|
+
extraKeys?: (collectionMax: Record<string, string>) => Record<string, string | undefined>;
|
|
36
|
+
};
|
|
37
|
+
sitemap?: {
|
|
38
|
+
/** A pathname any predicate accepts is left out; `/search` always is. */
|
|
39
|
+
exclude?: Array<(pathname: string) => boolean>;
|
|
40
|
+
/** Every matching rule sets its fields on the item. */
|
|
41
|
+
rules?: SitemapRule[];
|
|
42
|
+
};
|
|
43
|
+
/** Packages Vite must bundle during SSR. */
|
|
44
|
+
ssrNoExternal?: string[];
|
|
45
|
+
pagefind: PagefindIntegration;
|
|
46
|
+
/** Pagefind index language. */
|
|
47
|
+
language?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A missing date, or a date-only value equal to today, is stamped with the build instant.
|
|
51
|
+
function toDateWithTime(dateStr: string | undefined): string {
|
|
52
|
+
if (!dateStr) return new Date().toISOString();
|
|
53
|
+
if (dateStr.includes('T')) return new Date(dateStr).toISOString();
|
|
54
|
+
const today = new Date().toISOString().split('T')[0];
|
|
55
|
+
if (dateStr === today) return new Date().toISOString();
|
|
56
|
+
return new Date(`${dateStr}T12:00:00Z`).toISOString();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Lookup key → date for the sitemap, plus the latest date overall (the fallback). */
|
|
60
|
+
export function buildLastmodMap(rootDir: string, opts: SiteConfigOptions['lastmod']): { lastmodMap: Map<string, string>; mostRecent: string } {
|
|
61
|
+
const lastmodMap = new Map<string, string>();
|
|
62
|
+
const contentBase = path.join(rootDir, 'src/content');
|
|
63
|
+
let mostRecent = EPOCH;
|
|
64
|
+
const collectionMax: Record<string, string> = {};
|
|
65
|
+
for (const [urlType, dir] of Object.entries(opts.contentDirMap)) {
|
|
66
|
+
const dirPath = path.join(contentBase, dir);
|
|
67
|
+
if (!fs.existsSync(dirPath)) continue;
|
|
68
|
+
let maxForCollection = EPOCH;
|
|
69
|
+
for (const file of fs.readdirSync(dirPath)) {
|
|
70
|
+
if (!file.endsWith('.json')) continue;
|
|
71
|
+
try {
|
|
72
|
+
const content = JSON.parse(fs.readFileSync(path.join(dirPath, file), 'utf-8'));
|
|
73
|
+
if (content.slug && content.dateModified) {
|
|
74
|
+
lastmodMap.set(`${urlType}/${content.slug}`, content.dateModified);
|
|
75
|
+
for (const key of opts.itemKeys?.(urlType, content) ?? []) {
|
|
76
|
+
if (content.dateModified > (lastmodMap.get(key) || '')) lastmodMap.set(key, content.dateModified);
|
|
77
|
+
}
|
|
78
|
+
if (content.dateModified > maxForCollection) maxForCollection = content.dateModified;
|
|
79
|
+
if (content.dateModified > mostRecent) mostRecent = content.dateModified;
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// an unparseable file contributes nothing
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
collectionMax[dir] = maxForCollection;
|
|
86
|
+
}
|
|
87
|
+
for (const dir of Object.values(opts.contentDirMap)) {
|
|
88
|
+
if (collectionMax[dir] && collectionMax[dir] > EPOCH) lastmodMap.set(dir, collectionMax[dir]);
|
|
89
|
+
}
|
|
90
|
+
const pagesDir = path.join(rootDir, 'src/pages');
|
|
91
|
+
for (const dir of opts.guideDirs) {
|
|
92
|
+
let maxForHub = EPOCH;
|
|
93
|
+
for (const [key, date] of guideDateMap(pagesDir, dir)) {
|
|
94
|
+
lastmodMap.set(key, date);
|
|
95
|
+
if (date > maxForHub) maxForHub = date;
|
|
96
|
+
}
|
|
97
|
+
if (maxForHub > EPOCH) lastmodMap.set(dir, maxForHub);
|
|
98
|
+
}
|
|
99
|
+
for (const [key, date] of Object.entries(opts.extraKeys?.(collectionMax) ?? {})) {
|
|
100
|
+
if (date && date > EPOCH) lastmodMap.set(key, date);
|
|
101
|
+
}
|
|
102
|
+
return { lastmodMap, mostRecent };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The sitemap `serialize` hook: trailing slash stripped, exclusions dropped, lastmod looked up, rules applied. */
|
|
106
|
+
export function sitemapSerialize(
|
|
107
|
+
lastmodMap: Map<string, string>,
|
|
108
|
+
mostRecent: string,
|
|
109
|
+
{ exclude = [], rules = [] }: NonNullable<SiteConfigOptions['sitemap']> = {},
|
|
110
|
+
): (item: SitemapItem) => SitemapItem | undefined {
|
|
111
|
+
return (item) => {
|
|
112
|
+
if (item.url.endsWith('/')) item.url = item.url.slice(0, -1);
|
|
113
|
+
const url = new URL(item.url);
|
|
114
|
+
if (url.pathname === '/search') return undefined;
|
|
115
|
+
if (exclude.some((ex) => ex(url.pathname))) return undefined;
|
|
116
|
+
const pathParts = url.pathname.split('/').filter(Boolean);
|
|
117
|
+
const dateModified =
|
|
118
|
+
lastmodMap.get(pathParts.slice(0, 3).join('/')) ||
|
|
119
|
+
lastmodMap.get(pathParts.slice(0, 2).join('/')) ||
|
|
120
|
+
lastmodMap.get(pathParts[0]) ||
|
|
121
|
+
mostRecent;
|
|
122
|
+
item.lastmod = toDateWithTime(dateModified);
|
|
123
|
+
for (const rule of rules) {
|
|
124
|
+
if (!rule.match(pathParts)) continue;
|
|
125
|
+
if (rule.priority !== undefined) item.priority = rule.priority;
|
|
126
|
+
if (rule.changefreq !== undefined) item.changefreq = rule.changefreq as SitemapItem['changefreq'];
|
|
127
|
+
}
|
|
128
|
+
return item;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The Astro config every content site shares: static output, the image service, build and Vite settings, and the
|
|
134
|
+
* outbound-rel → sitemap → Pagefind integrations. The sitemap `lastmod` comes from content `dateModified`, guide
|
|
135
|
+
* pages and the site's extra keys, looked up by the first 3, 2, then 1 path segments.
|
|
136
|
+
*/
|
|
137
|
+
export function defineSiteConfig(opts: SiteConfigOptions): AstroUserConfig {
|
|
138
|
+
const rootDir = opts.root instanceof URL ? fileURLToPath(opts.root) : opts.root;
|
|
139
|
+
const { lastmodMap, mostRecent } = buildLastmodMap(rootDir, opts.lastmod);
|
|
140
|
+
return {
|
|
141
|
+
...(opts.redirects ? { redirects: opts.redirects } : {}),
|
|
142
|
+
site: opts.site,
|
|
143
|
+
base: '/',
|
|
144
|
+
output: 'static',
|
|
145
|
+
trailingSlash: 'never',
|
|
146
|
+
compressHTML: true,
|
|
147
|
+
image: {
|
|
148
|
+
service: {
|
|
149
|
+
entrypoint: process.env.CI ? 'astro/assets/services/noop' : 'astro/assets/services/sharp',
|
|
150
|
+
config: {
|
|
151
|
+
limitInputPixels: false,
|
|
152
|
+
avif: { quality: 65, effort: 6, chromaSubsampling: '4:2:0' },
|
|
153
|
+
// Astro re-encodes from decoded pixels, so the delivered quality is set here, not by the source file.
|
|
154
|
+
webp: { quality: 65, effort: 6 },
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
build: {
|
|
159
|
+
format: 'file',
|
|
160
|
+
inlineStylesheets: 'auto',
|
|
161
|
+
},
|
|
162
|
+
vite: {
|
|
163
|
+
plugins: [tailwindcss()],
|
|
164
|
+
worker: { format: 'es' },
|
|
165
|
+
...(opts.ssrNoExternal?.length ? { ssr: { noExternal: opts.ssrNoExternal } } : {}),
|
|
166
|
+
build: {
|
|
167
|
+
cssMinify: true,
|
|
168
|
+
minify: 'esbuild',
|
|
169
|
+
rollupOptions: {
|
|
170
|
+
external: ['/pagefind/pagefind-ui.js', '/pagefind/pagefind.js'],
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
integrations: [
|
|
175
|
+
outboundLinkRel({ root: rootDir }),
|
|
176
|
+
sitemap({ serialize: sitemapSerialize(lastmodMap, mostRecent, opts.sitemap) }),
|
|
177
|
+
opts.pagefind({
|
|
178
|
+
indexConfig: {
|
|
179
|
+
excludeSelectors: ['nav', 'footer', '.breadcrumb', '[data-pagefind-ignore]'],
|
|
180
|
+
forceLanguage: opts.language ?? 'en',
|
|
181
|
+
},
|
|
182
|
+
}),
|
|
183
|
+
],
|
|
184
|
+
};
|
|
185
|
+
}
|
package/src/dates/lastmod.ts
CHANGED