@hollware/astro 0.0.0 → 1.0.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/README.md +129 -1
- package/dist/index.d.mts +174 -0
- package/dist/index.mjs +223 -0
- package/package.json +43 -4
package/README.md
CHANGED
|
@@ -1,3 +1,131 @@
|
|
|
1
1
|
# @hollware/astro
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Read [Hollware](https://hollware.com) from an Astro site: content loaders for Astro's content
|
|
4
|
+
layer, over a service binding when the site is a Worker on the same Cloudflare account, or over
|
|
5
|
+
HTTP with a read token anywhere else. Published content only, and the latest revision of one entry
|
|
6
|
+
when a page is opened with a preview token (live collections).
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install @hollware/astro
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
On Cloudflare, bind the Hollware Worker in the site's `wrangler.jsonc`:
|
|
13
|
+
|
|
14
|
+
```jsonc
|
|
15
|
+
"services": [{ "binding": "HOLLWARE", "service": "my-hollware", "entrypoint": "Hollware" }]
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
A live collection reads at request time, so a publish shows with no rebuild:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// src/live.config.ts
|
|
22
|
+
import { env } from 'cloudflare:workers'
|
|
23
|
+
import { defineLiveCollection } from 'astro/content/config'
|
|
24
|
+
import { hollwareBinding, hollwareLiveLoader } from '@hollware/astro'
|
|
25
|
+
|
|
26
|
+
const blog = defineLiveCollection({
|
|
27
|
+
loader: hollwareLiveLoader({
|
|
28
|
+
site: 'my-site',
|
|
29
|
+
collection: 'blog',
|
|
30
|
+
locale: 'en',
|
|
31
|
+
reader: () => hollwareBinding(env.HOLLWARE),
|
|
32
|
+
}),
|
|
33
|
+
})
|
|
34
|
+
export const collections = { blog }
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```astro
|
|
38
|
+
---
|
|
39
|
+
import { getLiveEntry, render } from 'astro:content'
|
|
40
|
+
const { entry } = await getLiveEntry('blog', Astro.params.slug!)
|
|
41
|
+
const { Content } = await render(entry!)
|
|
42
|
+
---
|
|
43
|
+
<h1>{entry.data.title}</h1>
|
|
44
|
+
<Content />
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
A build-time collection reads over HTTP with a read token (`hollware token create --read`):
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// src/content.config.ts
|
|
51
|
+
import { defineCollection } from 'astro:content'
|
|
52
|
+
import { hollwareLoader } from '@hollware/astro'
|
|
53
|
+
|
|
54
|
+
const blog = defineCollection({
|
|
55
|
+
loader: hollwareLoader({
|
|
56
|
+
site: 'my-site',
|
|
57
|
+
collection: 'blog',
|
|
58
|
+
locale: 'en',
|
|
59
|
+
url: 'https://cms.example.com',
|
|
60
|
+
token: process.env.HOLLWARE_TOKEN ?? '',
|
|
61
|
+
}),
|
|
62
|
+
})
|
|
63
|
+
export const collections = { blog }
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
| What | Does |
|
|
67
|
+
| -------------------------------------- | ---------------------------------------------------------------------- |
|
|
68
|
+
| `hollwareLoader(options)` | A loader for `defineCollection`: every published entry, with its HTML |
|
|
69
|
+
| `hollwareLiveLoader(options)` | A loader for `defineLiveCollection`: reads at request time |
|
|
70
|
+
| `hollwareBinding(env.HOLLWARE)` | The service binding as a reader, with a clear error when it is missing |
|
|
71
|
+
| `hollwareTranslations(options, group)` | The `{ locale, slug }` of each entry of a translation group |
|
|
72
|
+
|
|
73
|
+
An entry's `id` is its slug, its `data` the frontmatter (give `defineCollection` a `schema` to
|
|
74
|
+
validate it) and its rendered HTML the one Hollware made, so `render(entry)` works. The
|
|
75
|
+
`locale` option picks the locale; make one collection per locale. `getLiveCollection('blog',
|
|
76
|
+
{ tag, limit })` lists the entries without their HTML.
|
|
77
|
+
|
|
78
|
+
The token stays on the server: the loaders run in the build or in a request, and nothing here
|
|
79
|
+
reaches the browser.
|
|
80
|
+
|
|
81
|
+
## Previews
|
|
82
|
+
|
|
83
|
+
"Preview on the site" in the admin opens the entry's page with `?hollware-preview=<token>`: a
|
|
84
|
+
signed link for one entry, valid for one hour. `hollwarePreview()`, a middleware, exchanges the link
|
|
85
|
+
for a cookie: a `303` to the same URL without the parameter (other parameters kept), with the token
|
|
86
|
+
in `hollware_preview` (`HttpOnly`, `SameSite=Lax`, `Path=/`, an hour, and `Secure` on every request
|
|
87
|
+
but `http://localhost` and `http://127.0.0.1`). The token is in a URL once, but for the first
|
|
88
|
+
request's line in the logs; a value not shaped like a token sets no cookie. Every later request
|
|
89
|
+
finds the token in `Astro.locals.hollwarePreview`, and its response is `no-store`:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
// src/middleware.ts
|
|
93
|
+
import { hollwarePreview } from '@hollware/astro'
|
|
94
|
+
export const onRequest = hollwarePreview()
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
// src/env.d.ts
|
|
99
|
+
import type { HollwarePreviewLocals } from '@hollware/astro'
|
|
100
|
+
|
|
101
|
+
declare global {
|
|
102
|
+
namespace App {
|
|
103
|
+
interface Locals extends HollwarePreviewLocals {}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
A live collection reads the latest revision of the entry, a draft included, when `getLiveEntry`
|
|
109
|
+
gets the token in its filter:
|
|
110
|
+
|
|
111
|
+
```astro
|
|
112
|
+
---
|
|
113
|
+
import { refusePreview } from '@hollware/astro'
|
|
114
|
+
const preview = Astro.locals.hollwarePreview
|
|
115
|
+
const { entry, error } = await getLiveEntry('blog', { id: Astro.params.slug!, preview })
|
|
116
|
+
if (error) refusePreview(Astro.locals, error)
|
|
117
|
+
---
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
A token that is expired, forged or for another entry is an `error`, never the published version.
|
|
121
|
+
`refusePreview` makes the response clear the cookie when Hollware says the token expired or is not
|
|
122
|
+
valid; a token for another entry is answered as no entry and keeps it.
|
|
123
|
+
The build-time `hollwareLoader` has no preview: a static page is built once, for everyone, so it
|
|
124
|
+
only ever holds published content.
|
|
125
|
+
|
|
126
|
+
## Links
|
|
127
|
+
|
|
128
|
+
- Docs: <https://hollware.com>
|
|
129
|
+
- Repository: <https://github.com/hollware/hollware>
|
|
130
|
+
- Issues: <https://github.com/hollware/hollware/issues>
|
|
131
|
+
- License: MIT
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { HollwareReader, HollwareReader as HollwareReader$1, hollwareBinding } from "@hollware/client";
|
|
2
|
+
import { DataStore, LiveLoader, Loader, LoaderContext } from "astro/loaders";
|
|
3
|
+
import { MiddlewareHandler } from "astro";
|
|
4
|
+
//#region src/source.d.ts
|
|
5
|
+
/** What names the collection to read: one collection of a site, in one locale. */
|
|
6
|
+
interface SourceOptions {
|
|
7
|
+
/** The key of the site in the Hollware config. */
|
|
8
|
+
readonly site: string;
|
|
9
|
+
/** The collection to read, such as `blog`. */
|
|
10
|
+
readonly collection: string;
|
|
11
|
+
/** The locale to read: an entry's id is its slug in this locale. */
|
|
12
|
+
readonly locale: string;
|
|
13
|
+
}
|
|
14
|
+
/** Where to read from: a reader (a service binding is one), or a Hollware URL and a read token. */
|
|
15
|
+
type ReaderOptions = {
|
|
16
|
+
/**
|
|
17
|
+
* A service binding to the `Hollware` entrypoint, or a function that gives it: a live
|
|
18
|
+
* collection reads at request time, when the binding exists, so it passes
|
|
19
|
+
* `() => env.HOLLWARE`.
|
|
20
|
+
*/
|
|
21
|
+
readonly reader: HollwareReader$1 | (() => HollwareReader$1);
|
|
22
|
+
} | {
|
|
23
|
+
/** The public URL of the Hollware. */
|
|
24
|
+
readonly url: string;
|
|
25
|
+
/** A read token (`hw_read_`, from `hollware token create --read`). */
|
|
26
|
+
readonly token: string;
|
|
27
|
+
/** `fetch`, when not the global one. */
|
|
28
|
+
readonly fetch?: (request: Request) => Promise<Response>;
|
|
29
|
+
};
|
|
30
|
+
/** A published entry as the content layer takes it. */
|
|
31
|
+
interface SourceEntry {
|
|
32
|
+
/** The slug: Astro's id, unique within the locale. */
|
|
33
|
+
readonly id: string;
|
|
34
|
+
/** The frontmatter of the entry. */
|
|
35
|
+
readonly data: Record<string, unknown>;
|
|
36
|
+
/** The HTML Hollware renders from the Markdown: only an entry read alone has it. */
|
|
37
|
+
readonly html?: string;
|
|
38
|
+
/** When the entry last changed, as an ISO date. */
|
|
39
|
+
readonly updatedAt: string;
|
|
40
|
+
}
|
|
41
|
+
/** The same entry in another locale. */
|
|
42
|
+
interface Translation {
|
|
43
|
+
readonly locale: string;
|
|
44
|
+
readonly slug: string;
|
|
45
|
+
}
|
|
46
|
+
/** What a listing narrows to. */
|
|
47
|
+
interface ListOptions {
|
|
48
|
+
readonly tag?: string;
|
|
49
|
+
/** The most entries to return; all of them when not set. */
|
|
50
|
+
readonly limit?: number;
|
|
51
|
+
}
|
|
52
|
+
interface Source {
|
|
53
|
+
list(options?: ListOptions): Promise<SourceEntry[]>;
|
|
54
|
+
/**
|
|
55
|
+
* The entry with this slug and its HTML, or `undefined` when it is not published. With a
|
|
56
|
+
* `preview` token (made in the admin) it is the latest revision of the entry the token names,
|
|
57
|
+
* a draft included; a token that does not hold is an error, never the published version.
|
|
58
|
+
*/
|
|
59
|
+
get(slug: string, options?: {
|
|
60
|
+
readonly preview?: string;
|
|
61
|
+
}): Promise<SourceEntry | undefined>;
|
|
62
|
+
translations(group: string): Promise<Translation[]>;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Reads one collection of a site in one locale, published content only (but for `get` with a
|
|
66
|
+
* preview token). The reader is resolved on
|
|
67
|
+
* each call, so a live loader gets its service binding from the request that makes it.
|
|
68
|
+
*/
|
|
69
|
+
export declare function createSource(options: SourceOptions & ReaderOptions): Source;
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/preview.d.ts
|
|
72
|
+
/**
|
|
73
|
+
* What `hollwarePreview()` puts on `Astro.locals`. Declare it in the site's `src/env.d.ts`:
|
|
74
|
+
*
|
|
75
|
+
* ```ts
|
|
76
|
+
* import type { HollwarePreviewLocals } from '@hollware/astro'
|
|
77
|
+
* declare global {
|
|
78
|
+
* namespace App {
|
|
79
|
+
* interface Locals extends HollwarePreviewLocals {}
|
|
80
|
+
* }
|
|
81
|
+
* }
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
interface HollwarePreviewLocals {
|
|
85
|
+
/** The preview token of the visitor's cookie: pass it to `getLiveEntry('blog', { id, preview })`. */
|
|
86
|
+
hollwarePreview: string | undefined;
|
|
87
|
+
/** Set by `refusePreview`: the response clears the cookie. */
|
|
88
|
+
hollwarePreviewRefused: boolean;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The middleware of previews (T-103), for `src/middleware.ts`: `export const onRequest =
|
|
92
|
+
* hollwarePreview()`, or `sequence(hollwarePreview(), ...)`.
|
|
93
|
+
*
|
|
94
|
+
* - A GET with `?hollware-preview=<token>` (the link of the admin's "Preview on the site") is
|
|
95
|
+
* answered `303` to the same URL without the parameter, with the token in the `hollware_preview`
|
|
96
|
+
* cookie (HttpOnly, SameSite=Lax, an hour, Secure but on http://localhost and
|
|
97
|
+
* http://127.0.0.1), `no-store` and `Referrer-Policy: no-referrer`. The token is not checked:
|
|
98
|
+
* Hollware does, when the page reads. A value not shaped like a token sets no cookie.
|
|
99
|
+
* - Any later request puts the cookie's token on `Astro.locals.hollwarePreview`, and its response
|
|
100
|
+
* is `no-store`.
|
|
101
|
+
* - A page that gets a refusal from Hollware calls `refusePreview(Astro.locals, error)`: when
|
|
102
|
+
* the token expired or is not valid, the response clears the cookie. A token for another entry
|
|
103
|
+
* is answered as no entry and is no refusal.
|
|
104
|
+
*/
|
|
105
|
+
export declare function hollwarePreview(): MiddlewareHandler;
|
|
106
|
+
/**
|
|
107
|
+
* Tells the middleware that Hollware refused the page's token, when `error` (the `error` of
|
|
108
|
+
* `getLiveEntry`) says it expired or is not valid, so the response clears the cookie.
|
|
109
|
+
*/
|
|
110
|
+
export declare function refusePreview(locals: HollwarePreviewLocals, error: unknown): void;
|
|
111
|
+
//#endregion
|
|
112
|
+
//#region src/index.d.ts
|
|
113
|
+
export declare const name = "@hollware/astro";
|
|
114
|
+
/** The options of `hollwareLoader`. */
|
|
115
|
+
export type HollwareLoaderOptions = SourceOptions & ReaderOptions;
|
|
116
|
+
/**
|
|
117
|
+
* A content loader for `defineCollection` in `src/content.config.ts`: it reads the published
|
|
118
|
+
* entries of one collection of a Hollware site at build time (and when the content layer is
|
|
119
|
+
* refreshed), over HTTP with a read token or through any reader. An entry's id is its slug, its
|
|
120
|
+
* data the frontmatter, and its `rendered.html` the HTML Hollware made, so Astro's `render(entry)`
|
|
121
|
+
* works. A schema in `defineCollection` validates the frontmatter.
|
|
122
|
+
*/
|
|
123
|
+
export declare function hollwareLoader(options: HollwareLoaderOptions): Loader;
|
|
124
|
+
/** What a build-time load needs of Astro's `LoaderContext`. */
|
|
125
|
+
export interface LoadContext {
|
|
126
|
+
readonly store: Pick<DataStore, 'set' | 'keys' | 'delete'>;
|
|
127
|
+
readonly parseData: LoaderContext['parseData'];
|
|
128
|
+
readonly generateDigest: LoaderContext['generateDigest'];
|
|
129
|
+
readonly logger: Pick<LoaderContext['logger'], 'info'>;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Stores every published entry of the source with its HTML, its data validated by the schema of
|
|
133
|
+
* the collection, and removes what is no longer published.
|
|
134
|
+
*/
|
|
135
|
+
export declare function loadInto(source: Source, context: LoadContext): Promise<void>;
|
|
136
|
+
/** The filter of `getLiveCollection`. */
|
|
137
|
+
export type HollwareCollectionFilter = ListOptions;
|
|
138
|
+
/**
|
|
139
|
+
* The filter of `getLiveEntry`: the slug, and the preview token of the page's URL
|
|
140
|
+
* (`?hollware-preview=`), which reads the latest revision of its entry, a draft included.
|
|
141
|
+
*/
|
|
142
|
+
export interface HollwareEntryFilter {
|
|
143
|
+
readonly id: string;
|
|
144
|
+
readonly preview?: string | undefined;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* A live loader for `defineLiveCollection` in `src/live.config.ts`: it reads at request time, so
|
|
148
|
+
* a publish shows on the next request with no rebuild. On Workers, give it the service binding
|
|
149
|
+
* through a function, because the binding exists only while a request runs:
|
|
150
|
+
*
|
|
151
|
+
* ```ts
|
|
152
|
+
* import { env } from 'cloudflare:workers'
|
|
153
|
+
* hollwareLiveLoader({
|
|
154
|
+
* site: 'my-site',
|
|
155
|
+
* collection: 'blog',
|
|
156
|
+
* locale: 'en',
|
|
157
|
+
* reader: () => hollwareBinding(env.HOLLWARE),
|
|
158
|
+
* })
|
|
159
|
+
* ```
|
|
160
|
+
*
|
|
161
|
+
* `getLiveEntry('blog', slug)` has the entry with its `rendered.html`; `getLiveEntry('blog', { id:
|
|
162
|
+
* slug, preview })`, with the `hollware-preview` of the page's URL, has its latest revision, a
|
|
163
|
+
* draft included, and the page must answer `Cache-Control: no-store` then; `getLiveCollection('blog',
|
|
164
|
+
* { tag, limit })` lists the entries without it.
|
|
165
|
+
*/
|
|
166
|
+
export declare function hollwareLiveLoader(options: HollwareLoaderOptions): LiveLoader<Record<string, unknown>, HollwareEntryFilter, HollwareCollectionFilter>;
|
|
167
|
+
/**
|
|
168
|
+
* The entries of a translation group of a collection, one per locale: `{ locale, slug }`, to
|
|
169
|
+
* link to. The group is the value of the frontmatter field that `describe_site` names as
|
|
170
|
+
* `translationGroupField`.
|
|
171
|
+
*/
|
|
172
|
+
export declare function hollwareTranslations(options: HollwareLoaderOptions, group: string): Promise<Translation[]>;
|
|
173
|
+
//#endregion
|
|
174
|
+
export { type HollwarePreviewLocals, type HollwareReader, type ListOptions, type ReaderOptions, type Source, type SourceEntry, type SourceOptions, type Translation, hollwareBinding };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { HollwareClientError, clearedPreviewCookie, createClient, exchangeHeaders, exchangePreviewLink, hollwareBinding, isPreviewRefusal, isSecureRequest, previewTokenFromCookie } from "@hollware/client";
|
|
2
|
+
//#region src/source.ts
|
|
3
|
+
/** The largest page the read API gives. */
|
|
4
|
+
const PAGE = 100;
|
|
5
|
+
function readerOf(options) {
|
|
6
|
+
if ("reader" in options) return typeof options.reader === "function" ? options.reader() : options.reader;
|
|
7
|
+
return createClient({
|
|
8
|
+
url: options.url,
|
|
9
|
+
token: options.token,
|
|
10
|
+
fetch: options.fetch
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Reads one collection of a site in one locale, published content only (but for `get` with a
|
|
15
|
+
* preview token). The reader is resolved on
|
|
16
|
+
* each call, so a live loader gets its service binding from the request that makes it.
|
|
17
|
+
*/
|
|
18
|
+
function createSource(options) {
|
|
19
|
+
const { site, collection, locale } = options;
|
|
20
|
+
return {
|
|
21
|
+
async list(listOptions = {}) {
|
|
22
|
+
const reader = readerOf(options);
|
|
23
|
+
const found = [];
|
|
24
|
+
let cursor;
|
|
25
|
+
do {
|
|
26
|
+
const remaining = listOptions.limit === void 0 ? PAGE : listOptions.limit - found.length;
|
|
27
|
+
if (remaining <= 0) break;
|
|
28
|
+
const page = await reader.listEntries(site, collection, {
|
|
29
|
+
locale,
|
|
30
|
+
tag: listOptions.tag,
|
|
31
|
+
limit: Math.min(PAGE, remaining),
|
|
32
|
+
cursor
|
|
33
|
+
});
|
|
34
|
+
for (const entry of page.entries) found.push({
|
|
35
|
+
id: entry.slug,
|
|
36
|
+
data: entry.frontmatter,
|
|
37
|
+
updatedAt: entry.updatedAt
|
|
38
|
+
});
|
|
39
|
+
cursor = page.nextCursor ?? void 0;
|
|
40
|
+
} while (cursor !== void 0);
|
|
41
|
+
return found;
|
|
42
|
+
},
|
|
43
|
+
async get(slug, getOptions = {}) {
|
|
44
|
+
const answer = await readerOf(options).getEntry(site, collection, slug, locale, {
|
|
45
|
+
formats: ["html"],
|
|
46
|
+
...getOptions.preview === void 0 ? {} : { preview: getOptions.preview }
|
|
47
|
+
});
|
|
48
|
+
if (answer.outcome === "not_found" || answer.entry === void 0) return void 0;
|
|
49
|
+
return {
|
|
50
|
+
id: answer.entry.slug,
|
|
51
|
+
data: answer.entry.frontmatter,
|
|
52
|
+
html: answer.entry.html ?? "",
|
|
53
|
+
updatedAt: answer.entry.updatedAt
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
async translations(group) {
|
|
57
|
+
return ((await readerOf(options).getTranslations(site, collection, group)).entries ?? []).map((entry) => ({
|
|
58
|
+
locale: entry.locale,
|
|
59
|
+
slug: entry.slug
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** The error to hand Astro: a refusal of the read API keeps its status in the message. */
|
|
65
|
+
function asError(error) {
|
|
66
|
+
if (error instanceof HollwareClientError) return new Error(`Hollware answered ${error.status} (${error.code}): ${error.message}`, { cause: error });
|
|
67
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/preview.ts
|
|
71
|
+
/**
|
|
72
|
+
* The middleware of previews (T-103), for `src/middleware.ts`: `export const onRequest =
|
|
73
|
+
* hollwarePreview()`, or `sequence(hollwarePreview(), ...)`.
|
|
74
|
+
*
|
|
75
|
+
* - A GET with `?hollware-preview=<token>` (the link of the admin's "Preview on the site") is
|
|
76
|
+
* answered `303` to the same URL without the parameter, with the token in the `hollware_preview`
|
|
77
|
+
* cookie (HttpOnly, SameSite=Lax, an hour, Secure but on http://localhost and
|
|
78
|
+
* http://127.0.0.1), `no-store` and `Referrer-Policy: no-referrer`. The token is not checked:
|
|
79
|
+
* Hollware does, when the page reads. A value not shaped like a token sets no cookie.
|
|
80
|
+
* - Any later request puts the cookie's token on `Astro.locals.hollwarePreview`, and its response
|
|
81
|
+
* is `no-store`.
|
|
82
|
+
* - A page that gets a refusal from Hollware calls `refusePreview(Astro.locals, error)`: when
|
|
83
|
+
* the token expired or is not valid, the response clears the cookie. A token for another entry
|
|
84
|
+
* is answered as no entry and is no refusal.
|
|
85
|
+
*/
|
|
86
|
+
function hollwarePreview() {
|
|
87
|
+
return async (context, next) => {
|
|
88
|
+
const { request, url, locals } = context;
|
|
89
|
+
if (request.method === "GET" || request.method === "HEAD") {
|
|
90
|
+
const exchange = exchangePreviewLink(url);
|
|
91
|
+
if (exchange !== void 0) return new Response(null, {
|
|
92
|
+
status: 303,
|
|
93
|
+
headers: exchangeHeaders(exchange, url)
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const token = previewTokenFromCookie(request.headers.get("cookie"));
|
|
97
|
+
const slot = Object.assign(locals, {
|
|
98
|
+
hollwarePreview: token,
|
|
99
|
+
hollwarePreviewRefused: false
|
|
100
|
+
});
|
|
101
|
+
const response = await next();
|
|
102
|
+
if (token === void 0) return response;
|
|
103
|
+
const answer = new Response(response.body, response);
|
|
104
|
+
answer.headers.set("cache-control", "no-store");
|
|
105
|
+
if (slot.hollwarePreviewRefused) answer.headers.append("set-cookie", clearedPreviewCookie({ secure: isSecureRequest(url) }));
|
|
106
|
+
return answer;
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Tells the middleware that Hollware refused the page's token, when `error` (the `error` of
|
|
111
|
+
* `getLiveEntry`) says it expired or is not valid, so the response clears the cookie.
|
|
112
|
+
*/
|
|
113
|
+
function refusePreview(locals, error) {
|
|
114
|
+
if (isPreviewRefusal(error)) locals.hollwarePreviewRefused = true;
|
|
115
|
+
}
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/index.ts
|
|
118
|
+
const name = "@hollware/astro";
|
|
119
|
+
/**
|
|
120
|
+
* A content loader for `defineCollection` in `src/content.config.ts`: it reads the published
|
|
121
|
+
* entries of one collection of a Hollware site at build time (and when the content layer is
|
|
122
|
+
* refreshed), over HTTP with a read token or through any reader. An entry's id is its slug, its
|
|
123
|
+
* data the frontmatter, and its `rendered.html` the HTML Hollware made, so Astro's `render(entry)`
|
|
124
|
+
* works. A schema in `defineCollection` validates the frontmatter.
|
|
125
|
+
*/
|
|
126
|
+
function hollwareLoader(options) {
|
|
127
|
+
const source = createSource(options);
|
|
128
|
+
return {
|
|
129
|
+
name: `${name}:${options.collection}:${options.locale}`,
|
|
130
|
+
load: (context) => loadInto(source, context)
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Stores every published entry of the source with its HTML, its data validated by the schema of
|
|
135
|
+
* the collection, and removes what is no longer published.
|
|
136
|
+
*/
|
|
137
|
+
async function loadInto(source, context) {
|
|
138
|
+
const { store, logger } = context;
|
|
139
|
+
const seen = /* @__PURE__ */ new Set();
|
|
140
|
+
for (const summary of await source.list()) {
|
|
141
|
+
const entry = await source.get(summary.id);
|
|
142
|
+
if (entry === void 0) continue;
|
|
143
|
+
const data = await context.parseData({
|
|
144
|
+
id: entry.id,
|
|
145
|
+
data: entry.data
|
|
146
|
+
});
|
|
147
|
+
const html = entry.html ?? "";
|
|
148
|
+
store.set({
|
|
149
|
+
id: entry.id,
|
|
150
|
+
data,
|
|
151
|
+
rendered: { html },
|
|
152
|
+
digest: context.generateDigest({
|
|
153
|
+
data,
|
|
154
|
+
html
|
|
155
|
+
})
|
|
156
|
+
});
|
|
157
|
+
seen.add(entry.id);
|
|
158
|
+
}
|
|
159
|
+
for (const id of store.keys()) if (!seen.has(id)) store.delete(id);
|
|
160
|
+
logger.info(`Loaded ${seen.size} published entries`);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* A live loader for `defineLiveCollection` in `src/live.config.ts`: it reads at request time, so
|
|
164
|
+
* a publish shows on the next request with no rebuild. On Workers, give it the service binding
|
|
165
|
+
* through a function, because the binding exists only while a request runs:
|
|
166
|
+
*
|
|
167
|
+
* ```ts
|
|
168
|
+
* import { env } from 'cloudflare:workers'
|
|
169
|
+
* hollwareLiveLoader({
|
|
170
|
+
* site: 'my-site',
|
|
171
|
+
* collection: 'blog',
|
|
172
|
+
* locale: 'en',
|
|
173
|
+
* reader: () => hollwareBinding(env.HOLLWARE),
|
|
174
|
+
* })
|
|
175
|
+
* ```
|
|
176
|
+
*
|
|
177
|
+
* `getLiveEntry('blog', slug)` has the entry with its `rendered.html`; `getLiveEntry('blog', { id:
|
|
178
|
+
* slug, preview })`, with the `hollware-preview` of the page's URL, has its latest revision, a
|
|
179
|
+
* draft included, and the page must answer `Cache-Control: no-store` then; `getLiveCollection('blog',
|
|
180
|
+
* { tag, limit })` lists the entries without it.
|
|
181
|
+
*/
|
|
182
|
+
function hollwareLiveLoader(options) {
|
|
183
|
+
const source = createSource(options);
|
|
184
|
+
return {
|
|
185
|
+
name: `${name}:${options.collection}:${options.locale}`,
|
|
186
|
+
async loadEntry({ filter }) {
|
|
187
|
+
try {
|
|
188
|
+
const preview = filter.preview === "" ? void 0 : filter.preview;
|
|
189
|
+
const entry = await source.get(filter.id, preview === void 0 ? {} : { preview });
|
|
190
|
+
if (entry === void 0) return void 0;
|
|
191
|
+
return {
|
|
192
|
+
id: entry.id,
|
|
193
|
+
data: entry.data,
|
|
194
|
+
rendered: { html: entry.html ?? "" },
|
|
195
|
+
...preview === void 0 ? { cacheHint: { lastModified: new Date(entry.updatedAt) } } : {}
|
|
196
|
+
};
|
|
197
|
+
} catch (error) {
|
|
198
|
+
return { error: asError(error) };
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
async loadCollection({ filter }) {
|
|
202
|
+
try {
|
|
203
|
+
return { entries: (await source.list(filter)).map((entry) => ({
|
|
204
|
+
id: entry.id,
|
|
205
|
+
data: entry.data,
|
|
206
|
+
cacheHint: { lastModified: new Date(entry.updatedAt) }
|
|
207
|
+
})) };
|
|
208
|
+
} catch (error) {
|
|
209
|
+
return { error: asError(error) };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* The entries of a translation group of a collection, one per locale: `{ locale, slug }`, to
|
|
216
|
+
* link to. The group is the value of the frontmatter field that `describe_site` names as
|
|
217
|
+
* `translationGroupField`.
|
|
218
|
+
*/
|
|
219
|
+
function hollwareTranslations(options, group) {
|
|
220
|
+
return createSource(options).translations(group);
|
|
221
|
+
}
|
|
222
|
+
//#endregion
|
|
223
|
+
export { createSource, hollwareBinding, hollwareLiveLoader, hollwareLoader, hollwarePreview, hollwareTranslations, loadInto, name, refusePreview };
|
package/package.json
CHANGED
|
@@ -1,13 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hollware/astro",
|
|
3
|
-
"version": "0.0.0",
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.0-rc.0",
|
|
4
|
+
"description": "Read Hollware from an Astro site: a content loader, over a service binding or HTTP.",
|
|
5
5
|
"homepage": "https://hollware.com",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/hollware/hollware/issues"
|
|
8
|
+
},
|
|
6
9
|
"license": "MIT",
|
|
7
10
|
"repository": {
|
|
8
11
|
"type": "git",
|
|
9
12
|
"url": "git+https://github.com/hollware/hollware.git",
|
|
10
13
|
"directory": "packages/astro"
|
|
11
14
|
},
|
|
12
|
-
"
|
|
13
|
-
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.mts",
|
|
22
|
+
"default": "./dist/index.mjs"
|
|
23
|
+
},
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public",
|
|
28
|
+
"provenance": true
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@hollware/client": "1.0.0-rc.0",
|
|
32
|
+
"@hollware/core": "1.0.0-rc.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "24.19.2",
|
|
36
|
+
"astro": "7.4.0",
|
|
37
|
+
"publint": "0.3.25",
|
|
38
|
+
"typescript": "7.0.2",
|
|
39
|
+
"vite-plus": "1.2.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"astro": "^7.4.0"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=24.11.0"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "vp pack",
|
|
49
|
+
"test": "vp test",
|
|
50
|
+
"publint": "publint --strict"
|
|
51
|
+
}
|
|
52
|
+
}
|