linkrel-ssg 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.
- package/README.md +132 -0
- package/package.json +41 -0
- package/src/Builder.ts +393 -0
- package/src/index.ts +1 -0
package/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# `linkrel-ssg`
|
|
2
|
+
|
|
3
|
+
A static site generator based on HTML includes:
|
|
4
|
+
|
|
5
|
+
```html
|
|
6
|
+
<link rel="include" href="./path/to/include.html">
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Motivation
|
|
10
|
+
|
|
11
|
+
I've been writing websites for two decades, and yet to find anything that
|
|
12
|
+
improves on the ergonomics of PHP. That doesn't mean I want to use PHP, but it
|
|
13
|
+
does mean I'm reluctant to use anything that's significantly more complex to
|
|
14
|
+
install or use.
|
|
15
|
+
|
|
16
|
+
I'd also like to generate static sites when possible, as this ensures stable,
|
|
17
|
+
performant sites. But I have been dissatisfied with the assumptions built into
|
|
18
|
+
other static site generators.
|
|
19
|
+
|
|
20
|
+
After two decades without a good low-abstraction tool, I wrote something that:
|
|
21
|
+
|
|
22
|
+
- Is explicit rather than implicit.
|
|
23
|
+
- No magic. All output is generated from explicit code in the source tree.
|
|
24
|
+
- Reuses existing technologies (e.g. DOM parsing and semantics) as much as possible.
|
|
25
|
+
- Uses composition over inheritance.
|
|
26
|
+
- It should be easy to reuse templates but also simple to build a page with arbitrary contents and functionality.
|
|
27
|
+
- Allows writing performant pages.
|
|
28
|
+
- In particular, it should be easy to inline critical code (e.g. CSS that avoids a flash of unstyled content) but also to load shared code when that makes sense.
|
|
29
|
+
|
|
30
|
+
Features are built on semantic HTML that can be written like normal using your editor's HTML language features. In particular, the core functionality is provided by HTML includes, which are a very useful idea but are [not coming to the web any time soon](https://github.com/whatwg/html/issues/2791). In order to use something idiomatic and reasonably future-proof[^1], we use `<link rel="include" …>`.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
import { Builder } from "linkrel-ssg"
|
|
36
|
+
|
|
37
|
+
const builder = new Builder({
|
|
38
|
+
srcRoot: "./src/garron.net/",
|
|
39
|
+
outputDir: "./dist/web/garron.net/"
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Build once
|
|
43
|
+
await builder.build();
|
|
44
|
+
|
|
45
|
+
// Watch for changes and serve locally
|
|
46
|
+
await builder.serve();
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Includes
|
|
50
|
+
|
|
51
|
+
To include part of an HTML page with a fragment, use a `<link>` as follows:
|
|
52
|
+
|
|
53
|
+
```html
|
|
54
|
+
<link rel="include" href="./relative/path/to/file.fragment">
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
This can be used both:
|
|
58
|
+
|
|
59
|
+
- in the `<head>`, and
|
|
60
|
+
- in the `<body>`.
|
|
61
|
+
|
|
62
|
+
Note that:
|
|
63
|
+
|
|
64
|
+
- You can inline CSS by including a `<style>` tag in the fragment.
|
|
65
|
+
|
|
66
|
+
Note that these includes currently:
|
|
67
|
+
|
|
68
|
+
- Are each inlined as a [fragment](https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment), replacing the `<link>` tags that included it.
|
|
69
|
+
- Are recursively evaluated. Each `<link>`'s `href` attribute is resolved relative to the file it appears in.
|
|
70
|
+
- Do not accept any parameters. (To affect the styling of included content, use CSS.)
|
|
71
|
+
- Do not support any kind of customization or interpolation.
|
|
72
|
+
|
|
73
|
+
## Markdown
|
|
74
|
+
|
|
75
|
+
To render a section of Markdown, use a `<pre>` tag as follows:
|
|
76
|
+
|
|
77
|
+
```html
|
|
78
|
+
<pre data-transform="markdown">
|
|
79
|
+
## This is is a [Markdown](https://commonmark.org/) header.
|
|
80
|
+
</pre>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Markdown may contain nested HTML (which is valid Markdown), but it will be round-tripped through the Markdown parser. Make sure that top-level HTML elements in the Markdown source are unindented. For example, this will probably do what you want:
|
|
84
|
+
|
|
85
|
+
```html
|
|
86
|
+
<pre data-transform="markdown">
|
|
87
|
+
Here is some code:
|
|
88
|
+
<p><code>console.log("Hello world!");</code></p>
|
|
89
|
+
</pre>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
By contrast, if you indent all the contents of `<pre>`, then all the contents will be interpreted as a big code block:
|
|
93
|
+
|
|
94
|
+
```html
|
|
95
|
+
<pre data-transform="markdown">
|
|
96
|
+
This line and the following line's source code will be displayed together on the rendered page.
|
|
97
|
+
<p><code>console.log("Hello world!");</code></p>
|
|
98
|
+
</pre>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Nested HTML inside Markdown may itself contain nested Markdown, and so on (arbitrarily nested).
|
|
102
|
+
|
|
103
|
+
## Substitute tags
|
|
104
|
+
|
|
105
|
+
Sometimes tags (e.g. `<title>`) can have special parsing semantics and must be represented as another tag for HTML child elements to be parsed. To process a tag as a generic element and then change its tag name at the end, use `<substitute-tag>`:
|
|
106
|
+
|
|
107
|
+
```html
|
|
108
|
+
<!DOCTYPE html>
|
|
109
|
+
<html>
|
|
110
|
+
<head>
|
|
111
|
+
<meta charset="utf-8">
|
|
112
|
+
<substitute-tag data-tag="title">
|
|
113
|
+
Home | <link rel="include" href="../.common.ssg/head/site-name.fragment">
|
|
114
|
+
</substitute-tag>
|
|
115
|
+
</head>
|
|
116
|
+
</html>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Non-features
|
|
120
|
+
|
|
121
|
+
For now:
|
|
122
|
+
|
|
123
|
+
- No theming. (Bring your own, via CSS. Consider [`minimal-html-style`](https://github.com/lgarron/minimal-html-style).)
|
|
124
|
+
- No automatic collation/indexing/blog management.
|
|
125
|
+
- No automatic reload in dev.
|
|
126
|
+
- No source code highlighting for code snippets.
|
|
127
|
+
|
|
128
|
+
## Performance
|
|
129
|
+
|
|
130
|
+
Performance is currently poor. This is because `linkrel-ssg` is purely implemented in browserless JS using `jsdom`. This has an unfortunate amount of overhead, but avoids the cost of extra abstractions.
|
|
131
|
+
|
|
132
|
+
I'd love to do a rewrite in Rust at some point, but probably not until I need it for sufficient performance on my own website.
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "linkrel-ssg",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A static site generator based on HTML includes.",
|
|
5
|
+
"author": "Lucas Garron <code@garron.net>",
|
|
6
|
+
"license": "MPL-2.0",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://codeberg.org/lgarron/linkrel-ssg"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@types/commonmark": "^0.27.10",
|
|
14
|
+
"@types/js-beautify": "^1.14.3",
|
|
15
|
+
"@types/jsdom": "^28.0.1",
|
|
16
|
+
"@types/serve-handler": "^6.1.4",
|
|
17
|
+
"chokidar": "^5.0.0",
|
|
18
|
+
"commonmark": "^0.31.2",
|
|
19
|
+
"js-beautify": "^1.15.4",
|
|
20
|
+
"jsdom": "^29.0.2",
|
|
21
|
+
"printable-shell-command": "^5.2.0",
|
|
22
|
+
"serve-handler": "^6.1.7"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@biomejs/biome": "^2.4.11",
|
|
26
|
+
"@cubing/dev-config": ">=0.9.5",
|
|
27
|
+
"@types/bun": "^1.3.12",
|
|
28
|
+
"@types/node": "^25.6.0",
|
|
29
|
+
"@typescript/native-preview": "^7.0.0-dev.20260413.1",
|
|
30
|
+
"bun-dx": "^0.1.4"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"./src/"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"prepublishOnly": "make prepublishOnly"
|
|
37
|
+
},
|
|
38
|
+
"patchedDependencies": {
|
|
39
|
+
"bun-types@1.3.12": "patches/bun-types@1.3.12.patch"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/Builder.ts
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cp,
|
|
3
|
+
glob,
|
|
4
|
+
mkdir,
|
|
5
|
+
readFile as nodeReadFile,
|
|
6
|
+
rm,
|
|
7
|
+
writeFile,
|
|
8
|
+
} from "node:fs/promises";
|
|
9
|
+
import { createServer } from "node:http";
|
|
10
|
+
import { dirname, relative, resolve } from "node:path";
|
|
11
|
+
import { cwd, cwd as processCwd } from "node:process";
|
|
12
|
+
import { type FSWatcher, watch } from "chokidar";
|
|
13
|
+
import { HtmlRenderer, Parser } from "commonmark";
|
|
14
|
+
import { html_beautify } from "js-beautify";
|
|
15
|
+
import { JSDOM } from "jsdom";
|
|
16
|
+
import { PrintableShellCommand } from "printable-shell-command";
|
|
17
|
+
import { default as handler } from "serve-handler";
|
|
18
|
+
|
|
19
|
+
const PORT = 1337;
|
|
20
|
+
|
|
21
|
+
export const Test = {
|
|
22
|
+
renderPageString: Symbol("renderPageString"),
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export class BuildFile {
|
|
26
|
+
constructor(
|
|
27
|
+
public builder: Builder,
|
|
28
|
+
public rootRelativePath: string,
|
|
29
|
+
) {}
|
|
30
|
+
|
|
31
|
+
sourcePath(): string {
|
|
32
|
+
return resolve(this.builder.options.srcRoot, this.rootRelativePath);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
outputPath(): string {
|
|
36
|
+
return resolve(this.builder.options.outputDir, this.rootRelativePath);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
toString(): string {
|
|
40
|
+
return this.rootRelativePath;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async trimmedContents(): Promise<string> {
|
|
44
|
+
const readFile = this.builder.options.debug?.readFile ?? nodeReadFile;
|
|
45
|
+
// TODO: Optional trimming?
|
|
46
|
+
return (await readFile(this.sourcePath(), "utf-8")).trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async sourceAsDOM(): Promise<JSDOM> {
|
|
50
|
+
return new JSDOM(await this.trimmedContents());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async sourceAsFragment(): Promise<DocumentFragment> {
|
|
54
|
+
return JSDOM.fragment(await this.trimmedContents());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// TODO: include in graph.
|
|
58
|
+
relativeBuildFile(newPeerRelativePath: string): BuildFile {
|
|
59
|
+
const newRootRelativePath = this.builder.resolve(
|
|
60
|
+
this.rootRelativePath,
|
|
61
|
+
newPeerRelativePath,
|
|
62
|
+
);
|
|
63
|
+
return new BuildFile(this.builder, newRootRelativePath);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface BuilderOptions {
|
|
68
|
+
/** Required. */
|
|
69
|
+
srcRoot: string;
|
|
70
|
+
/** Required. */
|
|
71
|
+
outputDir: string;
|
|
72
|
+
/** Default: `false` */
|
|
73
|
+
debugOutput?: boolean;
|
|
74
|
+
/** Default: `true` */
|
|
75
|
+
parallel?: boolean;
|
|
76
|
+
commonmarkOptions?: {
|
|
77
|
+
/** Default: `false` */
|
|
78
|
+
safe?: boolean;
|
|
79
|
+
/** Default: `"<br>"` */
|
|
80
|
+
softbreak?: string;
|
|
81
|
+
};
|
|
82
|
+
/** Default: `undefined` */
|
|
83
|
+
debug?: {
|
|
84
|
+
readFile?: (rootRelativePath: string, _: "utf-8") => Promise<string>;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Debouncer, but always ensures a final call is made upon/after the last invocation.
|
|
89
|
+
// biome-ignore lint/suspicious/noExplicitAny: Required to write the type.
|
|
90
|
+
function rateLimited<T extends Array<any>>(
|
|
91
|
+
fn: (...t: T) => void,
|
|
92
|
+
milliseconds: number,
|
|
93
|
+
): (...t: T) => void {
|
|
94
|
+
let lastFnInvocation = performance.now();
|
|
95
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
96
|
+
return (...t: T) => {
|
|
97
|
+
if (timeout) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const invoke = () => {
|
|
101
|
+
lastFnInvocation = performance.now();
|
|
102
|
+
timeout = undefined;
|
|
103
|
+
fn(...t);
|
|
104
|
+
};
|
|
105
|
+
const elapsed = performance.now() - lastFnInvocation;
|
|
106
|
+
if (elapsed < milliseconds) {
|
|
107
|
+
timeout = setTimeout(invoke, milliseconds - elapsed);
|
|
108
|
+
} else {
|
|
109
|
+
invoke();
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class Builder {
|
|
115
|
+
buildIndex: number = 0;
|
|
116
|
+
|
|
117
|
+
constructor(public options: BuilderOptions) {}
|
|
118
|
+
|
|
119
|
+
/** Returns the number Markdown sections that were found and replaced. */
|
|
120
|
+
async processMarkdown(
|
|
121
|
+
elementOrFragment: HTMLElement | DocumentFragment,
|
|
122
|
+
): Promise<{ foundAndReplaced: number }> {
|
|
123
|
+
let foundAndReplaced = 0;
|
|
124
|
+
for (const preMarkdownElem of elementOrFragment.querySelectorAll(
|
|
125
|
+
'pre[data-transform="markdown"]',
|
|
126
|
+
)) {
|
|
127
|
+
const innerHTML = preMarkdownElem.innerHTML ?? "";
|
|
128
|
+
const parsed = this.commonmark.parser.parse(innerHTML);
|
|
129
|
+
const htmlText = this.commonmark.writer.render(parsed);
|
|
130
|
+
preMarkdownElem.replaceWith(JSDOM.fragment(htmlText));
|
|
131
|
+
foundAndReplaced++;
|
|
132
|
+
}
|
|
133
|
+
return { foundAndReplaced };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async processIncludes(
|
|
137
|
+
file: BuildFile,
|
|
138
|
+
dom: JSDOM,
|
|
139
|
+
elementOrFragment: HTMLElement | DocumentFragment,
|
|
140
|
+
options?: { watcher?: FSWatcher },
|
|
141
|
+
): Promise<void> {
|
|
142
|
+
for (const linkIncludeElem of elementOrFragment.querySelectorAll(
|
|
143
|
+
'link[rel="include"]',
|
|
144
|
+
)) {
|
|
145
|
+
const src = linkIncludeElem.getAttribute("href");
|
|
146
|
+
if (!src) {
|
|
147
|
+
// console.error("Missing `href`. Ignoring."); // TODO: error recovery
|
|
148
|
+
throw new Error("Missing `href` on an `include` link.");
|
|
149
|
+
}
|
|
150
|
+
const included = file.relativeBuildFile(src);
|
|
151
|
+
|
|
152
|
+
// Explicitly track changes to known dependency files, even if they are outside the source root dir.
|
|
153
|
+
// TODO: check whether we need to filter out duplicates to avoid perf issues with `chokidar`?
|
|
154
|
+
// TODO: It seems this causes an event to be fired just because the file was added. Can we avoid this without losing track of any other changes?
|
|
155
|
+
options?.watcher?.add(resolve(cwd(), included.sourcePath()));
|
|
156
|
+
|
|
157
|
+
if (this.options.debugOutput) {
|
|
158
|
+
console.info(
|
|
159
|
+
`[Include] ${file.rootRelativePath} → ${included.rootRelativePath}`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
const rendered = await this.renderFragment(included, dom);
|
|
163
|
+
linkIncludeElem.replaceWith(rendered);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
procesTagSubstitutions(
|
|
168
|
+
dom: JSDOM,
|
|
169
|
+
elementOrFragment: HTMLElement | DocumentFragment,
|
|
170
|
+
) {
|
|
171
|
+
for (const substituteTagElem of elementOrFragment.querySelectorAll(
|
|
172
|
+
"substitute-tag",
|
|
173
|
+
)) {
|
|
174
|
+
const tag = substituteTagElem.getAttribute("data-tag");
|
|
175
|
+
if (!tag) {
|
|
176
|
+
// console.error("Missing `data-tag` on a `substitute-tag` element."); // TODO: error recovery
|
|
177
|
+
throw new Error("Missing `data-tag` on a `substitute-tag` element.");
|
|
178
|
+
}
|
|
179
|
+
const elem = dom.window.document.createElement(tag);
|
|
180
|
+
elem.append(...substituteTagElem.childNodes);
|
|
181
|
+
substituteTagElem.replaceWith(elem);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// We can't use relative URL resolution like grown-ups, so we cobble together `node`'s APIs with the finesse of Duplo blocks.
|
|
186
|
+
resolve(fromRootRelativePath: string, toPeerRelativePath: string): string {
|
|
187
|
+
const { srcRoot } = this.options;
|
|
188
|
+
return relative(
|
|
189
|
+
srcRoot,
|
|
190
|
+
resolve(srcRoot, dirname(fromRootRelativePath), toPeerRelativePath),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// biome-ignore lint/suspicious/noExplicitAny: Required to express the type.
|
|
195
|
+
async parallelDependingOnOptions<T extends Array<() => Promise<any>>>(
|
|
196
|
+
promiseFns: T,
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
if (this.options.parallel ?? true) {
|
|
199
|
+
await Promise.all(promiseFns.map((promiseFn) => promiseFn()));
|
|
200
|
+
} else {
|
|
201
|
+
for (const promiseFn of promiseFns) {
|
|
202
|
+
await promiseFn();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async renderFragment(file: BuildFile, dom: JSDOM): Promise<DocumentFragment> {
|
|
208
|
+
const fragment = await file.sourceAsFragment();
|
|
209
|
+
await this.processIncludes(file, dom, fragment);
|
|
210
|
+
return fragment;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private async renderPageDOM(
|
|
214
|
+
file: BuildFile,
|
|
215
|
+
options?: { watcher?: FSWatcher },
|
|
216
|
+
): Promise<JSDOM> {
|
|
217
|
+
const dom = await file.sourceAsDOM();
|
|
218
|
+
// Process Markdown before processing includes, to allow interleaving.
|
|
219
|
+
while (
|
|
220
|
+
(await this.processMarkdown(dom.window.document.body))
|
|
221
|
+
.foundAndReplaced !== 0
|
|
222
|
+
) {
|
|
223
|
+
/* no-op */
|
|
224
|
+
}
|
|
225
|
+
await this.parallelDependingOnOptions([
|
|
226
|
+
() => this.processIncludes(file, dom, dom.window.document.head, options),
|
|
227
|
+
() => this.processIncludes(file, dom, dom.window.document.body, options),
|
|
228
|
+
]);
|
|
229
|
+
this.procesTagSubstitutions(dom, dom.window.document.head);
|
|
230
|
+
this.procesTagSubstitutions(dom, dom.window.document.body);
|
|
231
|
+
return dom;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private async renderPageString(
|
|
235
|
+
file: BuildFile,
|
|
236
|
+
options?: { watcher?: FSWatcher },
|
|
237
|
+
): Promise<string> {
|
|
238
|
+
return html_beautify((await this.renderPageDOM(file, options)).serialize());
|
|
239
|
+
}
|
|
240
|
+
[Test.renderPageString](...args: Parameters<Builder["renderPageString"]>) {
|
|
241
|
+
return this.renderPageString(...args);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private async buildFile(
|
|
245
|
+
rootRelativePath: string,
|
|
246
|
+
options?: { watcher?: FSWatcher },
|
|
247
|
+
) {
|
|
248
|
+
const file = new BuildFile(this, rootRelativePath);
|
|
249
|
+
const domString = await this.renderPageString(file, options);
|
|
250
|
+
await writeFile(file.outputPath(), domString, "utf-8");
|
|
251
|
+
console.log(`[Built file] ${file.rootRelativePath}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async buildFiles(
|
|
255
|
+
files: string[],
|
|
256
|
+
options?: {
|
|
257
|
+
watcher?: FSWatcher;
|
|
258
|
+
},
|
|
259
|
+
): Promise<void> {
|
|
260
|
+
const start = performance.now();
|
|
261
|
+
|
|
262
|
+
const buildIndex = ++this.buildIndex;
|
|
263
|
+
console.log("<!---------------->");
|
|
264
|
+
console.log(`<build data-build-id="${buildIndex}">`);
|
|
265
|
+
if (this.options.debugOutput) {
|
|
266
|
+
console.log(`Building files:\n- ${files.join("\n- ")}`);
|
|
267
|
+
}
|
|
268
|
+
await rm(this.options.outputDir, { recursive: true, force: true });
|
|
269
|
+
await mkdir(this.options.outputDir, { recursive: true });
|
|
270
|
+
// TODO: exclude files from `files` so we can run in parallel with building.
|
|
271
|
+
// TODO: exclude fragments?
|
|
272
|
+
await cp(this.options.srcRoot, this.options.outputDir, { recursive: true });
|
|
273
|
+
await this.parallelDependingOnOptions(
|
|
274
|
+
files.map((file) => () => this.buildFile(file, options)),
|
|
275
|
+
);
|
|
276
|
+
console.log(`Ran in ${performance.now() - start}ms`);
|
|
277
|
+
console.log(`</build> <!-- data-build-id=${buildIndex} -->`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async build(options?: { watcher?: FSWatcher }): Promise<void> {
|
|
281
|
+
// Recompile everything for now, because we don't have a dependency graph.
|
|
282
|
+
const files: string[] = [];
|
|
283
|
+
for await (const file of glob("**/*.html", { cwd: this.options.srcRoot })) {
|
|
284
|
+
files.push(file);
|
|
285
|
+
}
|
|
286
|
+
await this.buildFiles(files, options);
|
|
287
|
+
|
|
288
|
+
// TODO: filter these during the file copy
|
|
289
|
+
// TODO: `bun` has a bug, so this doesn't work at all. https://github.com/oven-sh/bun/issues/20507
|
|
290
|
+
// https://github.com/oven-sh/bun/issues/20507
|
|
291
|
+
// for await (const path of glob("**/*.ssg", {
|
|
292
|
+
// cwd: this.options.outputDir,
|
|
293
|
+
// })) {
|
|
294
|
+
// await rm(path, { recursive: true });
|
|
295
|
+
//
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
watch(options?: {
|
|
299
|
+
signal?: AbortSignal;
|
|
300
|
+
buildDoneCallback?: () => void;
|
|
301
|
+
}): void {
|
|
302
|
+
console.info("Watching…");
|
|
303
|
+
const fn = rateLimited(async () => {
|
|
304
|
+
await this.build({ watcher });
|
|
305
|
+
options?.buildDoneCallback?.();
|
|
306
|
+
}, 1000);
|
|
307
|
+
|
|
308
|
+
const cwd = resolve(processCwd(), this.options.srcRoot);
|
|
309
|
+
const watcher = watch(".", {
|
|
310
|
+
// ignored: (path, stats) => stats?.isFile() && !path.endsWith(".js"), // only watch js files
|
|
311
|
+
persistent: true,
|
|
312
|
+
cwd,
|
|
313
|
+
});
|
|
314
|
+
watcher.on("add", fn);
|
|
315
|
+
watcher.on("change", fn);
|
|
316
|
+
watcher.on("unlink", fn);
|
|
317
|
+
options?.signal?.addEventListener("abort", () => {
|
|
318
|
+
watcher.close();
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async serve(options?: {
|
|
323
|
+
signal?: AbortSignal;
|
|
324
|
+
port?: number;
|
|
325
|
+
}): Promise<void> {
|
|
326
|
+
const server = createServer((request, response) => {
|
|
327
|
+
return handler(request, response, { public: this.options.outputDir });
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// TODO: interleave this more cleanly with the build.
|
|
331
|
+
await mkdir(this.options.outputDir, { recursive: true });
|
|
332
|
+
|
|
333
|
+
const port = options?.port ?? PORT;
|
|
334
|
+
const url = new URL("http://localhost/");
|
|
335
|
+
url.port = `${port}`;
|
|
336
|
+
server.listen(port, () => {
|
|
337
|
+
console.log(`Running at ${url}`);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const buildDoneCallback = (() => {
|
|
341
|
+
if (
|
|
342
|
+
// biome-ignore lint/complexity/useLiteralKeys: Record access
|
|
343
|
+
process.env["EXPERIMENTAL_RELOAD_CHROME_MACOS"] === "1"
|
|
344
|
+
) {
|
|
345
|
+
const { origin } = url;
|
|
346
|
+
console.log(
|
|
347
|
+
`\nEXPERIMENTAL_RELOAD_CHROME_MACOS is set. The current Chrome tab (if it begins with \`${origin}\`) will refresh after every build.\n`,
|
|
348
|
+
);
|
|
349
|
+
return () => refreshChrome(origin);
|
|
350
|
+
}
|
|
351
|
+
return () => {};
|
|
352
|
+
})();
|
|
353
|
+
|
|
354
|
+
this.watch({
|
|
355
|
+
...options,
|
|
356
|
+
buildDoneCallback,
|
|
357
|
+
});
|
|
358
|
+
options?.signal?.addEventListener("abort", () => {
|
|
359
|
+
server.close();
|
|
360
|
+
});
|
|
361
|
+
// TODO: ensure that we don't hang the main process after the `AbortSignal` is processed.
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
#commonmarkCached: { parser: Parser; writer: HtmlRenderer } | undefined;
|
|
365
|
+
get commonmark(): { parser: Parser; writer: HtmlRenderer } {
|
|
366
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: Caching pattern
|
|
367
|
+
return (this.#commonmarkCached ??= {
|
|
368
|
+
parser: new Parser(),
|
|
369
|
+
writer: new HtmlRenderer({
|
|
370
|
+
safe: this.options.commonmarkOptions?.safe ?? false,
|
|
371
|
+
softbreak: this.options.commonmarkOptions?.softbreak ?? "<br>",
|
|
372
|
+
}),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function refreshChrome(origin: string): void {
|
|
378
|
+
const validatedOrigin = new URL(origin).origin; // Validation.
|
|
379
|
+
console.log(
|
|
380
|
+
"🔄 Refreshing the current page in Chrome (if " +
|
|
381
|
+
validatedOrigin.toString() +
|
|
382
|
+
").",
|
|
383
|
+
);
|
|
384
|
+
void new PrintableShellCommand("osascript", [
|
|
385
|
+
"-e",
|
|
386
|
+
`tell application "Google Chrome"
|
|
387
|
+
set theURL to get URL of the active tab of its first window
|
|
388
|
+
if theURL starts with ${JSON.stringify(validatedOrigin.toString())} then
|
|
389
|
+
tell the active tab of its first window to reload
|
|
390
|
+
end if
|
|
391
|
+
end tell`,
|
|
392
|
+
]).spawn();
|
|
393
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Builder } from "./Builder";
|