@hagicode/hagilight 0.14.1-dev.152.1.1b8dd12 → 0.15.1-dev.156.1.05ffbd0
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/README.md +58 -0
- package/SEOHead.astro +85 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -49,6 +49,64 @@ The values come from `HAGILIGHT_BUILD_BRANCH` and `HAGILIGHT_BUILD_COMMIT`, then
|
|
|
49
49
|
|
|
50
50
|
`github.head_ref` is set on pull requests and `github.ref_name` on pushes. A shallow clone still reports its commit, because `git rev-parse HEAD` reads the checkout.
|
|
51
51
|
|
|
52
|
+
## SEO head
|
|
53
|
+
|
|
54
|
+
`@hagicode/hagilight/SEOHead` writes the canonical link, the title, description and image tags, and these generated tags. Each appears once, and a tag is left out when its value cannot be determined:
|
|
55
|
+
|
|
56
|
+
- `og:url` is the canonical URL.
|
|
57
|
+
- `og:type` is `website`, or the `ogType` prop.
|
|
58
|
+
- `og:site_name` is the `siteSeo.title`.
|
|
59
|
+
- `og:locale` comes from the `locale` prop, else `Astro.currentLocale`, in `ll_CC` form: `zh-CN` gives `zh_CN`, `zh-Hant` gives `zh_TW`, and a bare `en` gives `en_US`. A tag with no unambiguous region gets no `og:locale`.
|
|
60
|
+
- `twitter:card` is `summary_large_image` when an image resolves, otherwise `summary`.
|
|
61
|
+
|
|
62
|
+
It also writes a default `WebSite` and `Organization` JSON-LD block, built from the site title and the site URL with its base path. The `Organization` name defaults to the site title.
|
|
63
|
+
|
|
64
|
+
```astro
|
|
65
|
+
<SEOHead
|
|
66
|
+
site={site}
|
|
67
|
+
siteSeo={{ title: 'Example Docs' }}
|
|
68
|
+
page={{ url: pageUrl, title, description }}
|
|
69
|
+
locale="zh-CN"
|
|
70
|
+
ogType="article"
|
|
71
|
+
organization={{ name: 'Example Labs', logo: 'https://example.com/logo.png', alternateName: 'EL' }}
|
|
72
|
+
structuredData={pageStructuredData}
|
|
73
|
+
/>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Your own tags win. A managed tag you pass in `head` keeps your value, and the generated twin is not written. Open Graph and Twitter keys match whether you write them as `property` or `name`, so `<meta name="og:type">` also suppresses the generated `og:type`. A default `WebSite` or `Organization` block is skipped when your `structuredData`, or a `script[type="application/ld+json"]` head entry, declares that `@type` at the top level or inside `@graph`. Unparseable JSON-LD does not stop the page and does not suppress a default block.
|
|
77
|
+
|
|
78
|
+
To turn the default structured data off, pass `defaultStructuredData={false}`. Your own `structuredData` is still rendered.
|
|
79
|
+
|
|
80
|
+
### Preview image format
|
|
81
|
+
|
|
82
|
+
Social crawlers cannot read every image format, so `SEOHead` checks the preview image at build time. The image is `page.image`, or `siteSeo.image` when the page declares none. Its format is read from the extension of its path, ignoring case, query and fragment; the image bytes are not read.
|
|
83
|
+
|
|
84
|
+
| Format | Extensions | Result |
|
|
85
|
+
| --- | --- | --- |
|
|
86
|
+
| Crawler-compatible | `.jpg`, `.jpeg`, `.png`, `.gif` | Declared exactly as given. Nothing is fetched or re-encoded. |
|
|
87
|
+
| Convertible | `.avif`, `.webp` | A remote `http://` or `https://` image becomes a JPEG at quality 80, written under the build output. The declared address is that file under the site's own origin. |
|
|
88
|
+
| Unrecognised | anything else, such as `.svg` or no extension | Declared exactly as given, with a build warning. |
|
|
89
|
+
|
|
90
|
+
The output is never larger than 1200×630 and is never enlarged, so a smaller image keeps its size: a 640×400 image stays 640×400, a 1024×768 image becomes 1024×630, and a 1600×1000 image becomes 1200×630.
|
|
91
|
+
|
|
92
|
+
A convertible image given as a site-root path is not converted. It is declared as given, with the same kind of warning. Pages that share one convertible image share one converted file.
|
|
93
|
+
|
|
94
|
+
Convertible images on a remote host need that host in the site's own `image.domains` (or `image.remotePatterns`) in its Astro config:
|
|
95
|
+
|
|
96
|
+
```js
|
|
97
|
+
// astro.config.mjs
|
|
98
|
+
export default defineConfig({
|
|
99
|
+
image: { domains: ['images.example.com'] },
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
A failed conversion fails the build. The unconverted image is never declared in its place. The build fails in these cases:
|
|
104
|
+
|
|
105
|
+
- The host is not permitted: `Hagilight could not convert the SEO image <address>: its host <host> is not permitted. Add <host> to image.domains in the Astro config.`
|
|
106
|
+
- The image cannot be fetched, returns an error status, or cannot be decoded: Astro reports the failure and names the image address.
|
|
107
|
+
|
|
108
|
+
The build warning reads `Hagilight SEO image <address> was kept as declared: its format was not checked for crawler compatibility.`, or, for a site-root convertible image, `... site-root images are not converted, so its format was not checked for crawler compatibility.`
|
|
109
|
+
|
|
52
110
|
## llms.txt
|
|
53
111
|
|
|
54
112
|
`hagilight()` writes `/llms.txt` after the build, following the [llms.txt](https://llmstxt.org/) layout: the site name as the H1, the home page description as a blockquote summary, and one H2 section of links per top-level path segment. It is generated from the built HTML, so it works for plain Astro and Starlight sites alike and needs no content collection.
|
package/SEOHead.astro
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { getImage } from 'astro:assets';
|
|
2
3
|
import {
|
|
4
|
+
buildDefaultStructuredData,
|
|
3
5
|
composeCanonicalSeoHead,
|
|
6
|
+
composeSocialCardHead,
|
|
4
7
|
resolveSeoMetadata,
|
|
5
8
|
serializeJsonLd,
|
|
9
|
+
type DefaultOrganizationOptions,
|
|
6
10
|
type HeadEntry,
|
|
7
11
|
} from '@hagicode/hagilight-core/seo';
|
|
12
|
+
import {
|
|
13
|
+
OG_IMAGE_FORMAT,
|
|
14
|
+
OG_IMAGE_HEIGHT,
|
|
15
|
+
OG_IMAGE_QUALITY,
|
|
16
|
+
OG_IMAGE_WIDTH,
|
|
17
|
+
classifyOgImage,
|
|
18
|
+
} from '@hagicode/hagilight-core/og-image';
|
|
8
19
|
|
|
9
20
|
interface Props {
|
|
10
21
|
page?: {
|
|
@@ -22,6 +33,10 @@ interface Props {
|
|
|
22
33
|
basePath?: string;
|
|
23
34
|
head?: HeadEntry[];
|
|
24
35
|
structuredData?: Record<string, unknown> | Record<string, unknown>[];
|
|
36
|
+
locale?: string;
|
|
37
|
+
ogType?: string;
|
|
38
|
+
organization?: DefaultOrganizationOptions;
|
|
39
|
+
defaultStructuredData?: boolean;
|
|
25
40
|
}
|
|
26
41
|
|
|
27
42
|
const {
|
|
@@ -31,24 +46,91 @@ const {
|
|
|
31
46
|
basePath = import.meta.env.BASE_URL,
|
|
32
47
|
head = [],
|
|
33
48
|
structuredData = [],
|
|
49
|
+
locale = Astro.currentLocale,
|
|
50
|
+
ogType,
|
|
51
|
+
organization,
|
|
52
|
+
defaultStructuredData = true,
|
|
34
53
|
} = Astro.props;
|
|
35
54
|
const site = configuredSite?.toString();
|
|
36
55
|
const currentPageUrl = site
|
|
37
56
|
? new URL(`${Astro.url.pathname}${Astro.url.search}${Astro.url.hash}`, site).href
|
|
38
57
|
: undefined;
|
|
58
|
+
const CONVERTED_IMAGE_OPTIONS = {
|
|
59
|
+
width: OG_IMAGE_WIDTH,
|
|
60
|
+
height: OG_IMAGE_HEIGHT,
|
|
61
|
+
fit: 'cover',
|
|
62
|
+
format: OG_IMAGE_FORMAT,
|
|
63
|
+
quality: OG_IMAGE_QUALITY,
|
|
64
|
+
} as const;
|
|
65
|
+
// getImage returns paths that already include the configured base, and resolveSeoImageUrl adds it again.
|
|
66
|
+
const configuredBase = import.meta.env.BASE_URL;
|
|
67
|
+
|
|
68
|
+
function isAbsoluteHttpUrl(value: string): boolean {
|
|
69
|
+
return /^https?:\/\//iu.test(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function siteRootPath(src: string): string {
|
|
73
|
+
return src.startsWith(configuredBase) ? `/${src.slice(configuredBase.length)}` : src;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Only AVIF and WebP are converted. Anything else is kept as declared, and a failed conversion fails the build.
|
|
77
|
+
async function resolveDeclaredImage(reference: string | undefined): Promise<string | undefined> {
|
|
78
|
+
if (reference === undefined) return undefined;
|
|
79
|
+
const classification = classifyOgImage(reference);
|
|
80
|
+
if (classification === 'compatible') return reference;
|
|
81
|
+
if (classification === 'unrecognised') {
|
|
82
|
+
console.warn(`Hagilight SEO image ${reference} was kept as declared: its format was not checked for crawler compatibility.`);
|
|
83
|
+
return reference;
|
|
84
|
+
}
|
|
85
|
+
if (!isAbsoluteHttpUrl(reference)) {
|
|
86
|
+
console.warn(`Hagilight SEO image ${reference} was kept as declared: site-root images are not converted, so its format was not checked for crawler compatibility.`);
|
|
87
|
+
return reference;
|
|
88
|
+
}
|
|
89
|
+
let image: { src: string };
|
|
90
|
+
try {
|
|
91
|
+
image = await getImage({ ...CONVERTED_IMAGE_OPTIONS, src: reference });
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new Error(`Hagilight could not convert the SEO image ${reference}: ${(error as Error).message}`, { cause: error });
|
|
94
|
+
}
|
|
95
|
+
// Astro returns the remote address unchanged, without an error, when the host is not in image.domains.
|
|
96
|
+
if (isAbsoluteHttpUrl(image.src)) {
|
|
97
|
+
const host = new URL(reference).hostname;
|
|
98
|
+
throw new Error(`Hagilight could not convert the SEO image ${reference}: its host ${host} is not permitted. Add ${host} to image.domains in the Astro config.`);
|
|
99
|
+
}
|
|
100
|
+
return siteRootPath(image.src);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const declaredImage = await resolveDeclaredImage(page.image ?? siteSeo.image);
|
|
39
104
|
const metadata = {
|
|
40
|
-
...resolveSeoMetadata({ pageSeo: page, siteSeo, site, basePath }),
|
|
105
|
+
...resolveSeoMetadata({ pageSeo: { ...page, image: declaredImage }, siteSeo, site, basePath }),
|
|
41
106
|
site,
|
|
42
107
|
};
|
|
43
108
|
const canonicalUrl = page.url?.toString() ?? currentPageUrl;
|
|
44
|
-
const
|
|
109
|
+
const canonicalHead = composeCanonicalSeoHead(head, metadata, {
|
|
45
110
|
canonicalUrl,
|
|
46
111
|
explicitHead: [head],
|
|
47
112
|
});
|
|
113
|
+
const canonicalLink = canonicalHead.find(({ tag, attrs }) => tag === 'link' && attrs?.rel === 'canonical');
|
|
114
|
+
const composedHead = composeSocialCardHead(canonicalHead, {
|
|
115
|
+
url: typeof canonicalLink?.attrs?.href === 'string' ? canonicalLink.attrs.href : undefined,
|
|
116
|
+
type: ogType,
|
|
117
|
+
siteName: siteSeo.title,
|
|
118
|
+
locale,
|
|
119
|
+
image: metadata.image,
|
|
120
|
+
}, [head]);
|
|
48
121
|
const dataValues = Array.isArray(structuredData) ? structuredData : [structuredData];
|
|
122
|
+
const defaultValues = buildDefaultStructuredData({
|
|
123
|
+
siteName: siteSeo.title,
|
|
124
|
+
site,
|
|
125
|
+
basePath,
|
|
126
|
+
enabled: defaultStructuredData,
|
|
127
|
+
organization,
|
|
128
|
+
structuredData: dataValues,
|
|
129
|
+
head,
|
|
130
|
+
});
|
|
49
131
|
---
|
|
50
132
|
|
|
51
133
|
{composedHead.map(({ tag: Tag, attrs, content }) => <Tag {...attrs} set:html={content} />)}
|
|
52
|
-
{dataValues.map((value) => (
|
|
134
|
+
{[...defaultValues, ...dataValues].map((value) => (
|
|
53
135
|
<script type="application/ld+json" set:html={serializeJsonLd(value)} />
|
|
54
136
|
))}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hagicode/hagilight",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.1-dev.156.1.05ffbd0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Astro integrations and components for HagiCode sites",
|
|
6
6
|
"repository": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@astrojs/sitemap": "^3.7.4",
|
|
43
|
-
"@hagicode/hagilight-core": "0.
|
|
43
|
+
"@hagicode/hagilight-core": "0.15.1-dev.156.1.05ffbd0"
|
|
44
44
|
},
|
|
45
45
|
"keywords": [
|
|
46
46
|
"hagicode",
|