@sarimarcus/content-sites-core 0.6.1 → 0.7.0-rc.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 +5 -0
- package/README.md +7 -2
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +2 -0
- package/dist/dates/index.d.ts +1 -0
- package/dist/dates/index.js +2 -0
- package/dist/dom/deterministicId.d.ts +7 -0
- package/dist/dom/deterministicId.js +20 -0
- package/dist/dom/index.d.ts +1 -0
- package/dist/dom/index.js +2 -0
- package/dist/text/fitWithinLength.d.ts +5 -0
- package/dist/text/fitWithinLength.js +7 -0
- package/dist/text/index.d.ts +2 -0
- package/dist/text/index.js +3 -0
- package/dist/text/slugify.d.ts +1 -0
- package/dist/text/slugify.js +8 -0
- package/dist/types/index.d.ts +48 -0
- package/dist/types/index.js +3 -0
- package/dist/url/imageMap.d.ts +8 -0
- package/dist/url/imageMap.js +11 -0
- package/dist/url/index.d.ts +4 -0
- package/dist/url/index.js +5 -0
- package/dist/url/ogImage.d.ts +5 -0
- package/dist/url/ogImage.js +7 -0
- package/dist/url/relatedDedup.d.ts +5 -0
- package/dist/url/relatedDedup.js +41 -0
- package/dist/url/url.d.ts +5 -0
- package/dist/url/url.js +59 -0
- package/package.json +28 -6
- package/src/dom/deterministicId.ts +25 -0
- package/src/dom/index.ts +2 -2
- package/src/text/fitWithinLength.ts +7 -0
- package/src/text/index.ts +3 -0
- package/src/text/slugify.ts +8 -0
- package/src/url/imageMap.ts +14 -0
- package/src/url/index.ts +5 -2
- package/src/url/ogImage.ts +7 -0
- package/src/url/relatedDedup.ts +41 -0
- package/src/url/url.ts +74 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
All packages in the platform release in lockstep; entries are per release version.
|
|
4
4
|
|
|
5
|
+
## 0.7.0-rc.0 (2026-09-30)
|
|
6
|
+
|
|
7
|
+
- Ships compiled `dist/` (ESM + declarations) for every subpath, so code Node loads directly (factory tests, `astro.config.mjs`) can import core.
|
|
8
|
+
- `./url`: `url`, `normalizePagePath`, `toAbsoluteSiteUrl`, `normalizeSiteUrl` (`basePath` defaults to `'/'`), `getOgImagePath`, `normalizeHref`, `extractProseHrefs`, `dedupRelated`, `buildImageMap` / `keyBySlug`. `./dom`: `deterministicId`. New `./text`: `slugify`, `fitWithinLength` (SLA-2747).
|
|
9
|
+
|
|
5
10
|
## 0.6.1 (2026-09-30)
|
|
6
11
|
|
|
7
12
|
- Stable release of 0.6.1-rc.0, unchanged: the Blockquote caption change reviewed by screenshot on Córdoba; every other page identical (SLA-2821).
|
package/README.md
CHANGED
|
@@ -4,9 +4,14 @@ Non-visual code shared by the content sites: generic utilities, Astro config bui
|
|
|
4
4
|
and the type contracts `ui` and `tourism` build on (for example the card item and tag-link shapes that
|
|
5
5
|
today's ui components import from tourism code).
|
|
6
6
|
|
|
7
|
-
Named entry points: `url`, `dates`, `dom`, `config` and `types` (`src/internal` is never exported). No `.astro`
|
|
7
|
+
Named entry points: `url`, `dates`, `dom`, `text`, `config` and `types` (`src/internal` is never exported). No `.astro`
|
|
8
8
|
files. Lowest layer: imports nothing from the other packages. Ownership per path is in
|
|
9
9
|
`.planning/platform/catalogue.json` (owner `core`).
|
|
10
10
|
|
|
11
11
|
Code Node loads directly (config builders imported by `astro.config.mjs`) ships compiled to `dist/` with
|
|
12
|
-
type declarations. Node will not strip types from files under `node_modules`.
|
|
12
|
+
type declarations. Node will not strip types from files under `node_modules`. So every entry point resolves to
|
|
13
|
+
`dist/`: `npm run build -w packages/core` compiles it (`tsconfig.build.json`), and `release-check.mjs` refuses a
|
|
14
|
+
missing or stale `dist/`.
|
|
15
|
+
|
|
16
|
+
Core code never imports `astro:content`, never reads `import.meta.env` or `import.meta.glob` (values and glob
|
|
17
|
+
results come in as parameters), and never names a site or vertical. `validate-packages.mjs` enforces all three.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable per-instance DOM id. Replaces `Math.random()`, which made rebuilds
|
|
3
|
+
* non-byte-identical (SLA-2428). The id only has to be unique within one page,
|
|
4
|
+
* so hash the caller's stable inputs (djb2) and suffix a counter for a genuine
|
|
5
|
+
* same-page collision.
|
|
6
|
+
*/
|
|
7
|
+
export declare function deterministicId(prefix: string, ...parts: (string | null | undefined)[]): string;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Anchored on globalThis, not module scope: the module can be instantiated more
|
|
2
|
+
// than once per build, and the collision counter must not restart when it is.
|
|
3
|
+
const g = globalThis;
|
|
4
|
+
g.__domIdSeen ??= new Map();
|
|
5
|
+
const seen = g.__domIdSeen;
|
|
6
|
+
/**
|
|
7
|
+
* Stable per-instance DOM id. Replaces `Math.random()`, which made rebuilds
|
|
8
|
+
* non-byte-identical (SLA-2428). The id only has to be unique within one page,
|
|
9
|
+
* so hash the caller's stable inputs (djb2) and suffix a counter for a genuine
|
|
10
|
+
* same-page collision.
|
|
11
|
+
*/
|
|
12
|
+
export function deterministicId(prefix, ...parts) {
|
|
13
|
+
const seed = `${prefix}|${parts.map((p) => p ?? '').join('|')}`;
|
|
14
|
+
let h = 5381;
|
|
15
|
+
for (let i = 0; i < seed.length; i++)
|
|
16
|
+
h = ((h * 33) ^ seed.charCodeAt(i)) >>> 0;
|
|
17
|
+
const n = seen.get(seed) ?? 0;
|
|
18
|
+
seen.set(seed, n + 1);
|
|
19
|
+
return `${prefix}-${h.toString(36)}${n ? `-${n}` : ''}`;
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { deterministicId } from './deterministicId.ts';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pick the longest candidate that still fits within maxLen, falling back to the last (shortest) one if
|
|
3
|
+
* none fit. Candidates must be ordered longest/most-descriptive to shortest/most-terse.
|
|
4
|
+
*/
|
|
5
|
+
export declare function fitWithinLength(candidates: string[], maxLen: number): string;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pick the longest candidate that still fits within maxLen, falling back to the last (shortest) one if
|
|
3
|
+
* none fit. Candidates must be ordered longest/most-descriptive to shortest/most-terse.
|
|
4
|
+
*/
|
|
5
|
+
export function fitWithinLength(candidates, maxLen) {
|
|
6
|
+
return candidates.find((c) => c.length <= maxLen) ?? candidates[candidates.length - 1];
|
|
7
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function slugify(value: string): string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Version of the site and vertical definition schemas. The factory validator refuses any other value. */
|
|
2
|
+
export declare const DEFINITION_SCHEMA_VERSION = 1;
|
|
3
|
+
export type CityPlaces = {
|
|
4
|
+
mode: 'city';
|
|
5
|
+
cityName: string;
|
|
6
|
+
lat: number;
|
|
7
|
+
lng: number;
|
|
8
|
+
regionCode: string;
|
|
9
|
+
};
|
|
10
|
+
export type WorldwidePlaces = {
|
|
11
|
+
mode: 'worldwide';
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* A site's identity, as generated into `src/config/site.generated.ts` from its `sites.json` entry.
|
|
15
|
+
* Only fields a site renders from belong here; factory-only registry keys stay in `sites.json`.
|
|
16
|
+
*/
|
|
17
|
+
export interface SiteConfig {
|
|
18
|
+
schemaVersion: typeof DEFINITION_SCHEMA_VERSION;
|
|
19
|
+
key: string;
|
|
20
|
+
siteName: string;
|
|
21
|
+
/** Absolute origin, no trailing slash: `https://<domain>`. */
|
|
22
|
+
url: string;
|
|
23
|
+
language: string;
|
|
24
|
+
locale: string;
|
|
25
|
+
/** The vertical this site belongs to, or null for a one-off site. */
|
|
26
|
+
vertical: string | null;
|
|
27
|
+
analytics: {
|
|
28
|
+
gtmId: string;
|
|
29
|
+
};
|
|
30
|
+
social: {
|
|
31
|
+
twitterHandle?: string;
|
|
32
|
+
};
|
|
33
|
+
places: CityPlaces | WorldwidePlaces;
|
|
34
|
+
}
|
|
35
|
+
/** A vertical manifest, `verticals/<id>/vertical.json` in the factory. Never published. */
|
|
36
|
+
export interface VerticalDefinition {
|
|
37
|
+
schemaVersion: typeof DEFINITION_SCHEMA_VERSION;
|
|
38
|
+
/** Equal to the manifest's directory name. */
|
|
39
|
+
id: string;
|
|
40
|
+
/** The vertical's npm package, or null when it has none yet. */
|
|
41
|
+
package: string | null;
|
|
42
|
+
/** Collections the vertical owns; each must be in every member site's `content_types`. */
|
|
43
|
+
contentTypes: string[];
|
|
44
|
+
/** Dotted paths into a site's `sites.json` entry that every member site must set, e.g. `places.cityName`. */
|
|
45
|
+
requires: string[];
|
|
46
|
+
/** Reserved for SLA-2744; empty until then. */
|
|
47
|
+
routes: [];
|
|
48
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key an eager `import.meta.glob` result by file basename: `/src/assets/x/<slug>.webp` → `<slug>`.
|
|
3
|
+
* The glob itself must stay a literal at the call site (Vite requires it).
|
|
4
|
+
*/
|
|
5
|
+
export declare function buildImageMap<T>(mods: Record<string, {
|
|
6
|
+
default: T;
|
|
7
|
+
}>): Record<string, T>;
|
|
8
|
+
export declare const keyBySlug: typeof buildImageMap;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key an eager `import.meta.glob` result by file basename: `/src/assets/x/<slug>.webp` → `<slug>`.
|
|
3
|
+
* The glob itself must stay a literal at the call site (Vite requires it).
|
|
4
|
+
*/
|
|
5
|
+
export function buildImageMap(mods) {
|
|
6
|
+
return Object.fromEntries(Object.entries(mods).map(([path, mod]) => {
|
|
7
|
+
const slug = path.split('/').pop().replace('.webp', '');
|
|
8
|
+
return [slug, mod.default];
|
|
9
|
+
}));
|
|
10
|
+
}
|
|
11
|
+
export const keyBySlug = buildImageMap;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { url, normalizePagePath, toAbsoluteSiteUrl, normalizeSiteUrl } from './url.ts';
|
|
2
|
+
export { getOgImagePath } from './ogImage.ts';
|
|
3
|
+
export { normalizeHref, extractProseHrefs, dedupRelated } from './relatedDedup.ts';
|
|
4
|
+
export { buildImageMap, keyBySlug } from './imageMap.ts';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Public surface of @sarimarcus/content-sites-core/url.
|
|
2
|
+
export { url, normalizePagePath, toAbsoluteSiteUrl, normalizeSiteUrl } from "./url.js";
|
|
3
|
+
export { getOgImagePath } from "./ogImage.js";
|
|
4
|
+
export { normalizeHref, extractProseHrefs, dedupRelated } from "./relatedDedup.js";
|
|
5
|
+
export { buildImageMap, keyBySlug } from "./imageMap.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The og:image path for a content item: /images/og/{type}/{slug}.jpg.
|
|
3
|
+
* The images are pre-generated by `npm run images:og` into public/images/og/ (factory scripts/og-images).
|
|
4
|
+
*/
|
|
5
|
+
export function getOgImagePath(type, slug) {
|
|
6
|
+
return `/images/og/${type}/${slug}.jpg`;
|
|
7
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function normalizeHref(href: string, basePath?: string): string | null;
|
|
2
|
+
export declare function extractProseHrefs(htmlChunks: (string | undefined | null)[], basePath?: string): Set<string>;
|
|
3
|
+
export declare function dedupRelated<T extends {
|
|
4
|
+
href: string;
|
|
5
|
+
}>(items: T[], proseHrefs: Set<string>, basePath?: string): T[];
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// `null` = skip dedup (external/anchor hrefs are always kept as-is).
|
|
2
|
+
// Exported because callers build taken-href Sets from mixed inputs (prose HTML, cards, url()-applied hrefs)
|
|
3
|
+
// and every member must be in THIS normal form or dedupRelated() silently never matches it.
|
|
4
|
+
// `basePath` must arrive with its trailing slash already stripped.
|
|
5
|
+
export function normalizeHref(href, basePath = '') {
|
|
6
|
+
if (!href)
|
|
7
|
+
return null;
|
|
8
|
+
let h = href;
|
|
9
|
+
if (basePath && h.startsWith(basePath))
|
|
10
|
+
h = h.slice(basePath.length);
|
|
11
|
+
if (!h.startsWith('/'))
|
|
12
|
+
return null;
|
|
13
|
+
h = h.split('#')[0].split('?')[0];
|
|
14
|
+
h = h.replace(/\/+$/, '') || '/';
|
|
15
|
+
return h;
|
|
16
|
+
}
|
|
17
|
+
export function extractProseHrefs(htmlChunks, basePath = '') {
|
|
18
|
+
const hrefs = new Set();
|
|
19
|
+
const re = /<a\b[^>]*\bhref=["']([^"']+)["']/gi;
|
|
20
|
+
const normalizedBase = basePath.replace(/\/+$/, '');
|
|
21
|
+
for (const html of htmlChunks) {
|
|
22
|
+
if (!html)
|
|
23
|
+
continue;
|
|
24
|
+
let m;
|
|
25
|
+
while ((m = re.exec(html))) {
|
|
26
|
+
const n = normalizeHref(m[1], normalizedBase);
|
|
27
|
+
if (n)
|
|
28
|
+
hrefs.add(n);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return hrefs;
|
|
32
|
+
}
|
|
33
|
+
export function dedupRelated(items, proseHrefs, basePath = '') {
|
|
34
|
+
if (items.length === 0 || proseHrefs.size === 0)
|
|
35
|
+
return items;
|
|
36
|
+
const normalizedBase = basePath.replace(/\/+$/, '');
|
|
37
|
+
return items.filter((it) => {
|
|
38
|
+
const n = normalizeHref(it.href, normalizedBase);
|
|
39
|
+
return n === null || !proseHrefs.has(n);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Prepend the site base path to a path, without doubling slashes. */
|
|
2
|
+
export declare function url(path: string, basePath?: string): string;
|
|
3
|
+
export declare function normalizePagePath(pathname: string, basePath?: string): string;
|
|
4
|
+
export declare function toAbsoluteSiteUrl(pathname: string, siteUrl: string, basePath?: string): string;
|
|
5
|
+
export declare function normalizeSiteUrl(input: string, siteUrl: string, basePath?: string): string;
|
package/dist/url/url.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Prepend the site base path to a path, without doubling slashes. */
|
|
2
|
+
export function url(path, basePath = '/') {
|
|
3
|
+
const cleanBase = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath;
|
|
4
|
+
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
|
5
|
+
return `${cleanBase}${cleanPath}`;
|
|
6
|
+
}
|
|
7
|
+
function normalizeBasePath(basePath) {
|
|
8
|
+
if (!basePath || basePath === '/')
|
|
9
|
+
return '';
|
|
10
|
+
return basePath.replace(/\/$/, '');
|
|
11
|
+
}
|
|
12
|
+
export function normalizePagePath(pathname, basePath = '/') {
|
|
13
|
+
const cleanBase = normalizeBasePath(basePath);
|
|
14
|
+
let normalized = pathname || '/';
|
|
15
|
+
if (!normalized.startsWith('/')) {
|
|
16
|
+
normalized = `/${normalized}`;
|
|
17
|
+
}
|
|
18
|
+
normalized = normalized.replace(/\/index\.html$/, '/');
|
|
19
|
+
if (cleanBase && normalized === `${cleanBase}.html`) {
|
|
20
|
+
normalized = `${cleanBase}/`;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
normalized = normalized.replace(/\.html$/, '');
|
|
24
|
+
}
|
|
25
|
+
const isHomepage = normalized === '/' ||
|
|
26
|
+
normalized === cleanBase ||
|
|
27
|
+
normalized === `${cleanBase}/`;
|
|
28
|
+
if (isHomepage) {
|
|
29
|
+
return cleanBase ? `${cleanBase}/` : '/';
|
|
30
|
+
}
|
|
31
|
+
return normalized.replace(/\/$/, '');
|
|
32
|
+
}
|
|
33
|
+
export function toAbsoluteSiteUrl(pathname, siteUrl, basePath = '/') {
|
|
34
|
+
return new URL(normalizePagePath(pathname, basePath), siteUrl.endsWith('/') ? siteUrl : `${siteUrl}/`).toString();
|
|
35
|
+
}
|
|
36
|
+
export function normalizeSiteUrl(input, siteUrl, basePath = '/') {
|
|
37
|
+
if (!input)
|
|
38
|
+
return input;
|
|
39
|
+
if (input.startsWith('/')) {
|
|
40
|
+
const relativeUrl = new URL(`https://internal${input}`);
|
|
41
|
+
const normalizedPath = normalizePagePath(relativeUrl.pathname, basePath);
|
|
42
|
+
return `${normalizedPath}${relativeUrl.search}${relativeUrl.hash}`;
|
|
43
|
+
}
|
|
44
|
+
if (!/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(input)) {
|
|
45
|
+
return input;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const parsedUrl = new URL(input);
|
|
49
|
+
const parsedSiteUrl = new URL(siteUrl);
|
|
50
|
+
if (parsedUrl.origin !== parsedSiteUrl.origin) {
|
|
51
|
+
return input;
|
|
52
|
+
}
|
|
53
|
+
parsedUrl.pathname = normalizePagePath(parsedUrl.pathname, basePath);
|
|
54
|
+
return parsedUrl.toString();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return input;
|
|
58
|
+
}
|
|
59
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sarimarcus/content-sites-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0-rc.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",
|
|
@@ -24,13 +24,35 @@
|
|
|
24
24
|
"CHANGELOG.md",
|
|
25
25
|
"LICENSE"
|
|
26
26
|
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json"
|
|
29
|
+
},
|
|
27
30
|
"exports": {
|
|
28
31
|
"./package.json": "./package.json",
|
|
29
|
-
"./url":
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"./
|
|
32
|
+
"./url": {
|
|
33
|
+
"types": "./dist/url/index.d.ts",
|
|
34
|
+
"default": "./dist/url/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./dates": {
|
|
37
|
+
"types": "./dist/dates/index.d.ts",
|
|
38
|
+
"default": "./dist/dates/index.js"
|
|
39
|
+
},
|
|
40
|
+
"./dom": {
|
|
41
|
+
"types": "./dist/dom/index.d.ts",
|
|
42
|
+
"default": "./dist/dom/index.js"
|
|
43
|
+
},
|
|
44
|
+
"./text": {
|
|
45
|
+
"types": "./dist/text/index.d.ts",
|
|
46
|
+
"default": "./dist/text/index.js"
|
|
47
|
+
},
|
|
48
|
+
"./config": {
|
|
49
|
+
"types": "./dist/config/index.d.ts",
|
|
50
|
+
"default": "./dist/config/index.js"
|
|
51
|
+
},
|
|
52
|
+
"./types": {
|
|
53
|
+
"types": "./dist/types/index.d.ts",
|
|
54
|
+
"default": "./dist/types/index.js"
|
|
55
|
+
}
|
|
34
56
|
},
|
|
35
57
|
"peerDependencies": {
|
|
36
58
|
"astro": "^7.0.6"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
type SeenMap = Map<string, number>;
|
|
2
|
+
|
|
3
|
+
// Anchored on globalThis, not module scope: the module can be instantiated more
|
|
4
|
+
// than once per build, and the collision counter must not restart when it is.
|
|
5
|
+
const g = globalThis as typeof globalThis & { __domIdSeen?: SeenMap };
|
|
6
|
+
g.__domIdSeen ??= new Map();
|
|
7
|
+
const seen: SeenMap = g.__domIdSeen;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Stable per-instance DOM id. Replaces `Math.random()`, which made rebuilds
|
|
11
|
+
* non-byte-identical (SLA-2428). The id only has to be unique within one page,
|
|
12
|
+
* so hash the caller's stable inputs (djb2) and suffix a counter for a genuine
|
|
13
|
+
* same-page collision.
|
|
14
|
+
*/
|
|
15
|
+
export function deterministicId(
|
|
16
|
+
prefix: string,
|
|
17
|
+
...parts: (string | null | undefined)[]
|
|
18
|
+
): string {
|
|
19
|
+
const seed = `${prefix}|${parts.map((p) => p ?? '').join('|')}`;
|
|
20
|
+
let h = 5381;
|
|
21
|
+
for (let i = 0; i < seed.length; i++) h = ((h * 33) ^ seed.charCodeAt(i)) >>> 0;
|
|
22
|
+
const n = seen.get(seed) ?? 0;
|
|
23
|
+
seen.set(seed, n + 1);
|
|
24
|
+
return `${prefix}-${h.toString(36)}${n ? `-${n}` : ''}`;
|
|
25
|
+
}
|
package/src/dom/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
// Public surface of @sarimarcus/content-sites-core/dom.
|
|
2
|
-
export {};
|
|
1
|
+
// Public surface of @sarimarcus/content-sites-core/dom.
|
|
2
|
+
export { deterministicId } from './deterministicId.ts';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pick the longest candidate that still fits within maxLen, falling back to the last (shortest) one if
|
|
3
|
+
* none fit. Candidates must be ordered longest/most-descriptive to shortest/most-terse.
|
|
4
|
+
*/
|
|
5
|
+
export function fitWithinLength(candidates: string[], maxLen: number): string {
|
|
6
|
+
return candidates.find((c) => c.length <= maxLen) ?? candidates[candidates.length - 1];
|
|
7
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key an eager `import.meta.glob` result by file basename: `/src/assets/x/<slug>.webp` → `<slug>`.
|
|
3
|
+
* The glob itself must stay a literal at the call site (Vite requires it).
|
|
4
|
+
*/
|
|
5
|
+
export function buildImageMap<T>(mods: Record<string, { default: T }>): Record<string, T> {
|
|
6
|
+
return Object.fromEntries(
|
|
7
|
+
Object.entries(mods).map(([path, mod]) => {
|
|
8
|
+
const slug = path.split('/').pop()!.replace('.webp', '');
|
|
9
|
+
return [slug, mod.default];
|
|
10
|
+
})
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const keyBySlug = buildImageMap;
|
package/src/url/index.ts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
// Public surface of @sarimarcus/content-sites-core/url.
|
|
2
|
-
export {};
|
|
1
|
+
// Public surface of @sarimarcus/content-sites-core/url.
|
|
2
|
+
export { url, normalizePagePath, toAbsoluteSiteUrl, normalizeSiteUrl } from './url.ts';
|
|
3
|
+
export { getOgImagePath } from './ogImage.ts';
|
|
4
|
+
export { normalizeHref, extractProseHrefs, dedupRelated } from './relatedDedup.ts';
|
|
5
|
+
export { buildImageMap, keyBySlug } from './imageMap.ts';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The og:image path for a content item: /images/og/{type}/{slug}.jpg.
|
|
3
|
+
* The images are pre-generated by `npm run images:og` into public/images/og/ (factory scripts/og-images).
|
|
4
|
+
*/
|
|
5
|
+
export function getOgImagePath(type: string, slug: string): string {
|
|
6
|
+
return `/images/og/${type}/${slug}.jpg`;
|
|
7
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// `null` = skip dedup (external/anchor hrefs are always kept as-is).
|
|
2
|
+
// Exported because callers build taken-href Sets from mixed inputs (prose HTML, cards, url()-applied hrefs)
|
|
3
|
+
// and every member must be in THIS normal form or dedupRelated() silently never matches it.
|
|
4
|
+
// `basePath` must arrive with its trailing slash already stripped.
|
|
5
|
+
export function normalizeHref(href: string, basePath = ''): string | null {
|
|
6
|
+
if (!href) return null;
|
|
7
|
+
let h = href;
|
|
8
|
+
if (basePath && h.startsWith(basePath)) h = h.slice(basePath.length);
|
|
9
|
+
if (!h.startsWith('/')) return null;
|
|
10
|
+
h = h.split('#')[0].split('?')[0];
|
|
11
|
+
h = h.replace(/\/+$/, '') || '/';
|
|
12
|
+
return h;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function extractProseHrefs(htmlChunks: (string | undefined | null)[], basePath = ''): Set<string> {
|
|
16
|
+
const hrefs = new Set<string>();
|
|
17
|
+
const re = /<a\b[^>]*\bhref=["']([^"']+)["']/gi;
|
|
18
|
+
const normalizedBase = basePath.replace(/\/+$/, '');
|
|
19
|
+
for (const html of htmlChunks) {
|
|
20
|
+
if (!html) continue;
|
|
21
|
+
let m: RegExpExecArray | null;
|
|
22
|
+
while ((m = re.exec(html))) {
|
|
23
|
+
const n = normalizeHref(m[1], normalizedBase);
|
|
24
|
+
if (n) hrefs.add(n);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return hrefs;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function dedupRelated<T extends { href: string }>(
|
|
31
|
+
items: T[],
|
|
32
|
+
proseHrefs: Set<string>,
|
|
33
|
+
basePath = '',
|
|
34
|
+
): T[] {
|
|
35
|
+
if (items.length === 0 || proseHrefs.size === 0) return items;
|
|
36
|
+
const normalizedBase = basePath.replace(/\/+$/, '');
|
|
37
|
+
return items.filter((it) => {
|
|
38
|
+
const n = normalizeHref(it.href, normalizedBase);
|
|
39
|
+
return n === null || !proseHrefs.has(n);
|
|
40
|
+
});
|
|
41
|
+
}
|
package/src/url/url.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Prepend the site base path to a path, without doubling slashes. */
|
|
2
|
+
export function url(path: string, basePath = '/'): string {
|
|
3
|
+
const cleanBase = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath;
|
|
4
|
+
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
|
5
|
+
return `${cleanBase}${cleanPath}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function normalizeBasePath(basePath: string): string {
|
|
9
|
+
if (!basePath || basePath === '/') return '';
|
|
10
|
+
return basePath.replace(/\/$/, '');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function normalizePagePath(pathname: string, basePath = '/'): string {
|
|
14
|
+
const cleanBase = normalizeBasePath(basePath);
|
|
15
|
+
let normalized = pathname || '/';
|
|
16
|
+
|
|
17
|
+
if (!normalized.startsWith('/')) {
|
|
18
|
+
normalized = `/${normalized}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
normalized = normalized.replace(/\/index\.html$/, '/');
|
|
22
|
+
|
|
23
|
+
if (cleanBase && normalized === `${cleanBase}.html`) {
|
|
24
|
+
normalized = `${cleanBase}/`;
|
|
25
|
+
} else {
|
|
26
|
+
normalized = normalized.replace(/\.html$/, '');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const isHomepage =
|
|
30
|
+
normalized === '/' ||
|
|
31
|
+
normalized === cleanBase ||
|
|
32
|
+
normalized === `${cleanBase}/`;
|
|
33
|
+
|
|
34
|
+
if (isHomepage) {
|
|
35
|
+
return cleanBase ? `${cleanBase}/` : '/';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return normalized.replace(/\/$/, '');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function toAbsoluteSiteUrl(pathname: string, siteUrl: string, basePath = '/'): string {
|
|
42
|
+
return new URL(
|
|
43
|
+
normalizePagePath(pathname, basePath),
|
|
44
|
+
siteUrl.endsWith('/') ? siteUrl : `${siteUrl}/`,
|
|
45
|
+
).toString();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function normalizeSiteUrl(input: string, siteUrl: string, basePath = '/'): string {
|
|
49
|
+
if (!input) return input;
|
|
50
|
+
|
|
51
|
+
if (input.startsWith('/')) {
|
|
52
|
+
const relativeUrl = new URL(`https://internal${input}`);
|
|
53
|
+
const normalizedPath = normalizePagePath(relativeUrl.pathname, basePath);
|
|
54
|
+
return `${normalizedPath}${relativeUrl.search}${relativeUrl.hash}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(input)) {
|
|
58
|
+
return input;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const parsedUrl = new URL(input);
|
|
63
|
+
const parsedSiteUrl = new URL(siteUrl);
|
|
64
|
+
|
|
65
|
+
if (parsedUrl.origin !== parsedSiteUrl.origin) {
|
|
66
|
+
return input;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
parsedUrl.pathname = normalizePagePath(parsedUrl.pathname, basePath);
|
|
70
|
+
return parsedUrl.toString();
|
|
71
|
+
} catch {
|
|
72
|
+
return input;
|
|
73
|
+
}
|
|
74
|
+
}
|