@hagicode/hagilight-core 0.1.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.
@@ -0,0 +1,54 @@
1
+ function isRecord(value) {
2
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
3
+ }
4
+ function normalizeLanguageTag(value) {
5
+ if (typeof value !== 'string' || !value.trim())
6
+ return undefined;
7
+ const input = value.trim().replaceAll('_', '-');
8
+ const alias = input.toLowerCase();
9
+ if (['zh', 'zh-cn', 'zh-hans', 'root'].includes(alias))
10
+ return 'zh-CN';
11
+ if (['zh-hant', 'zh-tw', 'zh-hk'].includes(alias))
12
+ return 'zh-Hant';
13
+ try {
14
+ return Intl.getCanonicalLocales(input)[0];
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ function isFooterContext(value) {
21
+ return isRecord(value)
22
+ && typeof value.defaultFeedUrl === 'string'
23
+ && value.defaultFeedUrl !== ''
24
+ && isRecord(value.localeFeedUrls)
25
+ && Array.isArray(value.locales)
26
+ && value.locales.every((entry) => isRecord(entry)
27
+ && typeof entry.lang === 'string'
28
+ && typeof entry.filename === 'string');
29
+ }
30
+ /**
31
+ * Merge generated `hagilightRss()` feed URLs from request locals into Footer
32
+ * link options. Explicit `rssFeedUrl`/`rssLocaleFeedUrl` values win.
33
+ */
34
+ export function resolveRssFooterLinks(locals, locale, links = {}) {
35
+ if (!isRecord(locals) || !Object.hasOwn(locals, 'hagilightRss'))
36
+ return links;
37
+ const rss = locals.hagilightRss;
38
+ if (!isFooterContext(rss)) {
39
+ throw new TypeError('Hagilight RSS Footer context is malformed.');
40
+ }
41
+ const normalizedLocale = normalizeLanguageTag(locale);
42
+ const configuredLocale = rss.locales.find((entry) => entry.filename !== 'en' && normalizeLanguageTag(entry.lang) === normalizedLocale);
43
+ const localeFeedUrl = configuredLocale ? rss.localeFeedUrls[configuredLocale.lang] : undefined;
44
+ if (configuredLocale && (typeof localeFeedUrl !== 'string' || !localeFeedUrl)) {
45
+ throw new TypeError(`Hagilight RSS Footer context has no URL for "${configuredLocale.lang}".`);
46
+ }
47
+ return {
48
+ ...links,
49
+ rssFeedUrl: links.rssFeedUrl ?? rss.defaultFeedUrl,
50
+ ...(typeof localeFeedUrl === 'string' && localeFeedUrl
51
+ ? { rssLocaleFeedUrl: links.rssLocaleFeedUrl ?? localeFeedUrl }
52
+ : {}),
53
+ };
54
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Coordinates which Hagilight integration owns the generated RSS routes when the
3
+ * plain-Astro and Starlight packages are both registered in one Astro project.
4
+ * The registry lives on `globalThis` so that separately installed copies of
5
+ * this module still observe each other.
6
+ */
7
+ export type RssOwnerPackage = 'astro' | 'starlight';
8
+ export interface RssOwnerClaim {
9
+ readonly package: RssOwnerPackage;
10
+ readonly enabled: boolean;
11
+ }
12
+ /** Property key an integration object uses to advertise its RSS ownership claim. */
13
+ export declare const RSS_OWNER: unique symbol;
14
+ /**
15
+ * Record a Starlight RSS claim as soon as the Starlight plugin is created, so a
16
+ * plain-Astro RSS integration earlier in the integration list can defer to it.
17
+ * Returns a callback that withdraws the claim.
18
+ */
19
+ export declare function registerStarlightRssOwner(enabled: boolean): () => void;
20
+ /**
21
+ * Decide whether the plain-Astro RSS integration should generate routes.
22
+ * Consumes pending Starlight claims. Returns `'starlight'` when an enabled
23
+ * Starlight RSS integration owns the routes, otherwise `'astro'`.
24
+ */
25
+ export declare function resolvePlainAstroRssOwner(integrations?: readonly unknown[]): RssOwnerPackage;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Coordinates which Hagilight integration owns the generated RSS routes when the
3
+ * plain-Astro and Starlight packages are both registered in one Astro project.
4
+ * The registry lives on `globalThis` so that separately installed copies of
5
+ * this module still observe each other.
6
+ */
7
+ /** Property key an integration object uses to advertise its RSS ownership claim. */
8
+ export const RSS_OWNER = Symbol.for('@hagicode/hagilight-core/rss-owner');
9
+ const RSS_OWNER_REGISTRY = Symbol.for('@hagicode/hagilight-core/rss-owner-registry');
10
+ function registry() {
11
+ const host = globalThis;
12
+ host[RSS_OWNER_REGISTRY] ??= new Map();
13
+ return host[RSS_OWNER_REGISTRY];
14
+ }
15
+ /**
16
+ * Record a Starlight RSS claim as soon as the Starlight plugin is created, so a
17
+ * plain-Astro RSS integration earlier in the integration list can defer to it.
18
+ * Returns a callback that withdraws the claim.
19
+ */
20
+ export function registerStarlightRssOwner(enabled) {
21
+ if (typeof enabled !== 'boolean') {
22
+ throw new TypeError('Hagilight RSS owner enabled flag must be a boolean.');
23
+ }
24
+ const token = Symbol('starlight-rss-owner');
25
+ const owners = registry();
26
+ owners.set(token, { package: 'starlight', enabled });
27
+ return () => {
28
+ owners.delete(token);
29
+ };
30
+ }
31
+ function isOwnerClaim(value) {
32
+ if (!value || typeof value !== 'object')
33
+ return false;
34
+ const claim = value;
35
+ return (claim.package === 'astro' || claim.package === 'starlight') && typeof claim.enabled === 'boolean';
36
+ }
37
+ function claimOf(integration) {
38
+ if (!integration || typeof integration !== 'object')
39
+ return undefined;
40
+ const claim = integration[RSS_OWNER];
41
+ return isOwnerClaim(claim) ? claim : undefined;
42
+ }
43
+ /**
44
+ * Decide whether the plain-Astro RSS integration should generate routes.
45
+ * Consumes pending Starlight claims. Returns `'starlight'` when an enabled
46
+ * Starlight RSS integration owns the routes, otherwise `'astro'`.
47
+ */
48
+ export function resolvePlainAstroRssOwner(integrations = []) {
49
+ const owners = registry();
50
+ const registered = [...owners.values()];
51
+ const starlightOwners = registered.length > 0
52
+ ? registered
53
+ : integrations.map(claimOf).filter((claim) => claim?.package === 'starlight');
54
+ owners.clear();
55
+ const active = starlightOwners.filter((owner) => owner.enabled);
56
+ if (active.length > 1) {
57
+ throw new Error('Hagilight RSS cannot determine a sole route owner because multiple Starlight RSS integrations are enabled.');
58
+ }
59
+ if (active.length === 1)
60
+ return 'starlight';
61
+ if (starlightOwners.length > 0) {
62
+ throw new Error('Hagilight RSS cannot establish route ownership: Starlight RSS is explicitly disabled while hagilightRss() requests feed generation. Enable Starlight RSS or remove hagilightRss().');
63
+ }
64
+ return 'astro';
65
+ }
package/dist/rss.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ export type RssPublicationDate = Date | string | number | null;
2
+ export interface RssFeedItem {
3
+ title: string;
4
+ link: string;
5
+ description?: string;
6
+ date?: RssPublicationDate;
7
+ pubDate?: RssPublicationDate;
8
+ }
9
+ export interface RssFeedOptions {
10
+ site: string | URL;
11
+ baseUrl?: string | URL;
12
+ language?: string;
13
+ title: string;
14
+ description: string;
15
+ items: readonly RssFeedItem[];
16
+ }
17
+ export type RssLocaleConfig = string | {
18
+ lang?: string;
19
+ label?: string;
20
+ };
21
+ export type RssLocalesInput = Readonly<Record<string, RssLocaleConfig | undefined>>;
22
+ export interface RssLocale {
23
+ /** Starlight-style locale route key, `root` for the default locale. */
24
+ route: string;
25
+ /** Canonical BCP 47 language tag. */
26
+ lang: string;
27
+ /** Feed filename segment: `en` for English, otherwise the canonical language tag. */
28
+ filename: string;
29
+ }
30
+ export interface ResolveRssLocalesOptions {
31
+ requireNonEmpty?: boolean;
32
+ }
33
+ export declare function resolveRssLocales(locales?: RssLocalesInput, { requireNonEmpty }?: ResolveRssLocalesOptions): RssLocale[];
34
+ export declare function generateRssFeed(options: RssFeedOptions): Promise<Response>;
package/dist/rss.js ADDED
@@ -0,0 +1,145 @@
1
+ import rss from '@astrojs/rss';
2
+ const LANGUAGE_TAG_PATTERN = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u;
3
+ function isRecord(value) {
4
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
5
+ }
6
+ export function resolveRssLocales(locales, { requireNonEmpty = false } = {}) {
7
+ if (locales !== undefined && !isRecord(locales)) {
8
+ throw new TypeError('Hagilight RSS locales must be an object.');
9
+ }
10
+ if (requireNonEmpty && (locales === undefined || Object.keys(locales).length === 0)) {
11
+ throw new TypeError('Hagilight RSS locales must contain at least one locale.');
12
+ }
13
+ const configuredLocales = locales === undefined
14
+ ? [['root', { lang: 'en' }]]
15
+ : Object.entries(locales);
16
+ if (configuredLocales.length === 0)
17
+ configuredLocales.push(['root', { lang: 'en' }]);
18
+ const usedFilenames = new Set();
19
+ return configuredLocales.map(([route, config]) => {
20
+ const lang = typeof config === 'string' ? config : config?.lang ?? (route === 'root' ? undefined : route);
21
+ if (typeof lang !== 'string' || !LANGUAGE_TAG_PATTERN.test(lang)) {
22
+ throw new TypeError(`Hagilight RSS locale "${route}" must have a valid language tag.`);
23
+ }
24
+ let normalizedLang;
25
+ try {
26
+ [normalizedLang] = Intl.getCanonicalLocales(lang);
27
+ }
28
+ catch {
29
+ throw new TypeError(`Hagilight RSS locale "${route}" has an invalid language tag "${lang}".`);
30
+ }
31
+ const filename = /^en(?:-us)?$/iu.test(normalizedLang) ? 'en' : normalizedLang;
32
+ const collisionKey = filename.toLowerCase();
33
+ if (usedFilenames.has(collisionKey)) {
34
+ throw new Error(`Hagilight RSS locales collide on the "${filename}" feed filename.`);
35
+ }
36
+ usedFilenames.add(collisionKey);
37
+ return { route, lang: normalizedLang, filename };
38
+ });
39
+ }
40
+ function resolveSite(value) {
41
+ try {
42
+ const site = new URL(value);
43
+ if (!['http:', 'https:'].includes(site.protocol) || site.username || site.password) {
44
+ throw new Error();
45
+ }
46
+ return site;
47
+ }
48
+ catch {
49
+ throw new TypeError('Hagilight RSS requires an absolute Astro site URL; configure `site` in astro.config.mjs.');
50
+ }
51
+ }
52
+ function resolveLanguage(value) {
53
+ if (typeof value !== 'string' || !value.trim()) {
54
+ throw new TypeError('Hagilight RSS language must be a valid language tag.');
55
+ }
56
+ try {
57
+ return Intl.getCanonicalLocales(value)[0];
58
+ }
59
+ catch {
60
+ throw new TypeError(`Hagilight RSS language "${value}" must be a valid language tag.`);
61
+ }
62
+ }
63
+ function resolveBaseUrl(site, baseUrl = '/') {
64
+ let base;
65
+ try {
66
+ base = new URL(baseUrl, site);
67
+ }
68
+ catch {
69
+ throw new TypeError('Hagilight RSS base URL must be a valid path or absolute URL on the configured site.');
70
+ }
71
+ if (base.origin !== site.origin) {
72
+ throw new TypeError('Hagilight RSS base URL must use the configured Astro site origin.');
73
+ }
74
+ base.pathname = `${base.pathname.replace(/\/+$/u, '')}/`;
75
+ base.search = '';
76
+ base.hash = '';
77
+ return base;
78
+ }
79
+ function resolveItemUrl(value, base) {
80
+ if (typeof value !== 'string' || !value.trim()) {
81
+ throw new TypeError('Hagilight RSS items require a non-empty link.');
82
+ }
83
+ let url;
84
+ try {
85
+ url = new URL(value);
86
+ }
87
+ catch {
88
+ url = new URL(value.replace(/^\/+/u, ''), base);
89
+ }
90
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
91
+ throw new TypeError(`Hagilight RSS item link "${value}" must resolve to an absolute HTTP(S) URL.`);
92
+ }
93
+ return url.href;
94
+ }
95
+ function resolvePublicationDate(item, index) {
96
+ const value = item.pubDate ?? item.date;
97
+ if (value === undefined || value === null)
98
+ return undefined;
99
+ const date = value instanceof Date ? value : new Date(value);
100
+ if (Number.isNaN(date.getTime())) {
101
+ throw new TypeError(`Hagilight RSS item ${index + 1} has an invalid publication date.`);
102
+ }
103
+ return date;
104
+ }
105
+ export function generateRssFeed(options) {
106
+ const { site: siteInput, baseUrl = '/', language = 'en', title, description, items, } = (options ?? {});
107
+ const site = resolveSite(siteInput);
108
+ const base = resolveBaseUrl(site, baseUrl);
109
+ const lang = resolveLanguage(language);
110
+ if (typeof title !== 'string' || !title.trim()) {
111
+ throw new TypeError('Hagilight RSS requires a non-empty feed title.');
112
+ }
113
+ if (typeof description !== 'string' || !description.trim()) {
114
+ throw new TypeError('Hagilight RSS requires a non-empty feed description.');
115
+ }
116
+ if (!Array.isArray(items)) {
117
+ throw new TypeError('Hagilight RSS items must be an array supplied by the consumer.');
118
+ }
119
+ const feedItems = items.map((item, index) => {
120
+ if (!isRecord(item)) {
121
+ throw new TypeError(`Hagilight RSS item ${index + 1} must be an object.`);
122
+ }
123
+ const entry = item;
124
+ if (typeof entry.title !== 'string' || !entry.title.trim()) {
125
+ throw new TypeError(`Hagilight RSS item ${index + 1} requires a non-empty title.`);
126
+ }
127
+ if (entry.description !== undefined && typeof entry.description !== 'string') {
128
+ throw new TypeError(`Hagilight RSS item ${index + 1} description must be a string.`);
129
+ }
130
+ const pubDate = resolvePublicationDate(entry, index);
131
+ return {
132
+ title: entry.title,
133
+ ...(entry.description === undefined ? {} : { description: entry.description }),
134
+ link: resolveItemUrl(entry.link, base),
135
+ ...(pubDate === undefined ? {} : { pubDate }),
136
+ };
137
+ });
138
+ return rss({
139
+ title,
140
+ description,
141
+ site: base,
142
+ customData: `<language>${lang}</language>`,
143
+ items: feedItems,
144
+ });
145
+ }
@@ -0,0 +1,12 @@
1
+ import { z } from 'astro/zod';
2
+ /** Frontmatter schema for the optional `seo` object consumed by Hagilight SEO helpers. */
3
+ export declare const seoSchema: z.ZodObject<{
4
+ seo: z.ZodOptional<z.ZodObject<{
5
+ title: z.ZodOptional<z.ZodString>;
6
+ description: z.ZodOptional<z.ZodString>;
7
+ image: z.ZodOptional<z.ZodString & z.ZodType<string, string, z.core.$ZodTypeInternals<string, string>>>;
8
+ author: z.ZodOptional<z.ZodString>;
9
+ publishedDate: z.ZodOptional<z.ZodCoercedDate<unknown>>;
10
+ }, z.core.$strip>>;
11
+ }, z.core.$strip>;
12
+ export type SeoFrontmatter = z.infer<typeof seoSchema>;
@@ -0,0 +1,20 @@
1
+ import { z } from 'astro/zod';
2
+ import { isValidSeoImageReference } from './seo.js';
3
+ const optionalText = z.string().trim().min(1).optional();
4
+ const optionalImage = z.string()
5
+ .trim()
6
+ .min(1)
7
+ .refine(isValidSeoImageReference, {
8
+ message: 'Use an absolute HTTP(S) URL or a site-root path beginning with "/".',
9
+ })
10
+ .optional();
11
+ /** Frontmatter schema for the optional `seo` object consumed by Hagilight SEO helpers. */
12
+ export const seoSchema = z.object({
13
+ seo: z.object({
14
+ title: optionalText,
15
+ description: optionalText,
16
+ image: optionalImage,
17
+ author: optionalText,
18
+ publishedDate: z.coerce.date().optional(),
19
+ }).optional(),
20
+ });
package/dist/seo.d.ts ADDED
@@ -0,0 +1,78 @@
1
+ import type { HeadEntry } from './head.js';
2
+ export type { HeadAttributeValue, HeadEntry } from './head.js';
3
+ export interface SeoFields {
4
+ title?: string;
5
+ description?: string;
6
+ image?: string;
7
+ }
8
+ export interface PageSeo extends SeoFields {
9
+ author?: string;
10
+ publishedDate?: Date | string | number;
11
+ }
12
+ export interface SeoMetadata {
13
+ title?: string;
14
+ description?: string;
15
+ image?: string;
16
+ }
17
+ export interface SeoSiteOptions {
18
+ site?: string | URL;
19
+ basePath?: string;
20
+ }
21
+ export interface ResolveSeoMetadataOptions extends SeoSiteOptions {
22
+ entry?: {
23
+ data?: {
24
+ title?: string;
25
+ description?: string;
26
+ };
27
+ };
28
+ head?: readonly HeadEntry[];
29
+ pageSeo?: SeoFields;
30
+ siteSeo?: SeoFields;
31
+ explicitHead?: readonly (readonly HeadEntry[] | undefined)[];
32
+ body?: string;
33
+ }
34
+ export interface ComposeCanonicalSeoHeadOptions {
35
+ canonicalUrl?: string | URL;
36
+ explicitHead?: readonly (readonly HeadEntry[] | undefined)[];
37
+ }
38
+ export interface ArticleStructuredDataInput {
39
+ url: string | URL;
40
+ title: string;
41
+ description?: string;
42
+ author?: string;
43
+ publishedDate?: Date | string | number;
44
+ modifiedDate?: Date | string | number;
45
+ }
46
+ export interface BreadcrumbItem {
47
+ name: string;
48
+ url: string | URL;
49
+ }
50
+ export interface OrganizationStructuredDataInput {
51
+ name: string;
52
+ url: string | URL;
53
+ logo?: string | URL;
54
+ alternateName?: string;
55
+ }
56
+ export type StructuredData = Record<string, unknown>;
57
+ export declare function resolveAbsoluteHttpUrl(value: unknown, label?: string): URL;
58
+ export declare function resolveCanonicalUrl(pageUrl: string | URL, { site }?: {
59
+ site?: string | URL;
60
+ }): string;
61
+ export declare function extractSeoDescription(body: unknown): string | undefined;
62
+ export declare function resolveSeoMetadata({ entry, head, pageSeo, siteSeo, site, basePath, explicitHead, body, }?: ResolveSeoMetadataOptions): SeoMetadata;
63
+ export declare function isValidSeoImageReference(value: unknown): value is string;
64
+ export declare function resolveSeoImageUrl(value: string, { site, basePath }?: SeoSiteOptions): string;
65
+ export declare function composeSeoHead(head: readonly HeadEntry[], metadata: SeoMetadata, explicitHead?: readonly (readonly HeadEntry[] | undefined)[]): HeadEntry[];
66
+ /**
67
+ * Compose Open Graph/Twitter metadata plus exactly one canonical link for a
68
+ * plain Astro page. An explicit canonical link wins over existing ones, which
69
+ * win over `canonicalUrl`.
70
+ */
71
+ export declare function composeCanonicalSeoHead(head: readonly HeadEntry[], metadata: SeoMetadata & {
72
+ site?: string | URL;
73
+ }, { canonicalUrl, explicitHead }?: ComposeCanonicalSeoHeadOptions): HeadEntry[];
74
+ export declare function buildArticleStructuredData({ url, title, description, author, publishedDate, modifiedDate, }: ArticleStructuredDataInput): StructuredData;
75
+ export declare function buildBreadcrumbStructuredData(items?: readonly BreadcrumbItem[]): StructuredData;
76
+ export declare function buildOrganizationStructuredData({ name, url, logo, alternateName, }: OrganizationStructuredDataInput): StructuredData;
77
+ /** Serialize JSON-LD safely for inline `<script type="application/ld+json">`. */
78
+ export declare function serializeJsonLd(value: unknown): string;