@gusnips/vite 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/LICENSE +21 -0
- package/README.md +123 -0
- package/dist/escape.d.ts +6 -0
- package/dist/escape.d.ts.map +1 -0
- package/dist/escape.js +6 -0
- package/dist/escape.js.map +1 -0
- package/dist/head.d.ts +110 -0
- package/dist/head.d.ts.map +1 -0
- package/dist/head.js +183 -0
- package/dist/head.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/node.d.ts +57 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +104 -0
- package/dist/node.js.map +1 -0
- package/dist/og.d.ts +70 -0
- package/dist/og.d.ts.map +1 -0
- package/dist/og.js +60 -0
- package/dist/og.js.map +1 -0
- package/dist/preset.d.ts +37 -0
- package/dist/preset.d.ts.map +1 -0
- package/dist/preset.js +43 -0
- package/dist/preset.js.map +1 -0
- package/dist/render.d.ts +36 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +50 -0
- package/dist/render.js.map +1 -0
- package/dist/sitemap.d.ts +82 -0
- package/dist/sitemap.d.ts.map +1 -0
- package/dist/sitemap.js +91 -0
- package/dist/sitemap.js.map +1 -0
- package/package.json +105 -0
- package/src/escape.ts +8 -0
- package/src/head.test.ts +258 -0
- package/src/head.ts +288 -0
- package/src/index.ts +59 -0
- package/src/node.test.ts +102 -0
- package/src/node.ts +140 -0
- package/src/og.test.ts +53 -0
- package/src/og.ts +103 -0
- package/src/preset.ts +78 -0
- package/src/render.test.ts +67 -0
- package/src/render.ts +75 -0
- package/src/sitemap.test.ts +124 -0
- package/src/sitemap.ts +159 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gustavo Salomé
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# @gusnips/vite
|
|
2
|
+
|
|
3
|
+
Turn a Vite + React SPA's public routes into real HTML files at build time — a real `<head>`, a
|
|
4
|
+
real body, one file per page.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
bun add -d @gusnips/vite
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { pageFile } from "@gusnips/vite";
|
|
12
|
+
|
|
13
|
+
pageFile("/pricing"); // → "pricing.html"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Why bother
|
|
17
|
+
|
|
18
|
+
A Vite SPA ships one `index.html` and draws everything else with JavaScript. Nothing that reads a
|
|
19
|
+
link for a living runs that bundle — not a search crawler, not an LLM, not the thing that draws
|
|
20
|
+
the preview card when someone pastes your link in a chat. A shipped `<div id="root"></div>` is a
|
|
21
|
+
page that can be listed and never quoted.
|
|
22
|
+
|
|
23
|
+
So the build renders each public route to its own file. This is the part every app doing that
|
|
24
|
+
ends up writing.
|
|
25
|
+
|
|
26
|
+
## Baking a page
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { bakeHead } from "@gusnips/vite";
|
|
30
|
+
|
|
31
|
+
const html = bakeHead(template, {
|
|
32
|
+
title: "Pricing — Example",
|
|
33
|
+
description: "What it costs.",
|
|
34
|
+
canonical: "https://example.com/pricing",
|
|
35
|
+
body: { route: "/pricing", html: rendered },
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`bakeHead` rewrites the title, the description, the canonical, the Open Graph and Twitter tags,
|
|
40
|
+
`<html lang>`, the `hreflang` set, and injects the rendered markup into `<div id="root">`.
|
|
41
|
+
|
|
42
|
+
Four rules in there are load-bearing:
|
|
43
|
+
|
|
44
|
+
- **`canonical: null` strips the tag, and takes `og:image` and `og:url` with it.** For the 404
|
|
45
|
+
shell, which is served for every address that does not exist. Blanking is not stripping: an
|
|
46
|
+
empty canonical is a claim about `""`, and an empty `og:url` is a card pointing at your front
|
|
47
|
+
page. The image is the half people miss — one codebase set it before it branched on the shell,
|
|
48
|
+
so its `404.html` advertised a card at `/og/__not-found__.png`, a file that has never existed.
|
|
49
|
+
Every share of a dead link unfurled broken, and nothing in a browser showed it.
|
|
50
|
+
- **`og:locale` is not optional on a non-English page.** Leave it out and the spec does not
|
|
51
|
+
default it to "unknown" — it defaults to `en_US`. A Portuguese page with a Portuguese
|
|
52
|
+
`og:title` then tells every share crawler the card is English. `ogLocale("pt-BR")` gives you
|
|
53
|
+
the underscore spelling it actually wants.
|
|
54
|
+
- **`body` carries the route, not just the markup.** A static host answers every address it does
|
|
55
|
+
not publish with the nearest `404.html`, and that file has the not-found page rendered _into_
|
|
56
|
+
it. So "does the root have children" is the wrong question: a route served from the shell finds
|
|
57
|
+
a full root, hydrates, and React reconciles two different pages. It recovers by throwing the
|
|
58
|
+
tree away and logging — a page that works, and a bug nobody sees. The marker is what the
|
|
59
|
+
browser entry compares against before it decides.
|
|
60
|
+
- **`alternates` must be reciprocal.** A crawler ignores the whole set unless every address in it
|
|
61
|
+
points back at the others, which is why you pass the full list to every page rather than "the
|
|
62
|
+
other two".
|
|
63
|
+
|
|
64
|
+
## Rendering
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { renderTree } from "@gusnips/vite/render";
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Behind its own subpath, because it is the one thing here that loads React. A repo that only wants
|
|
71
|
+
a sitemap installs no renderer.
|
|
72
|
+
|
|
73
|
+
It uses `prerender` from `react-dom/static`, never `renderToString`. With `lazy()` routes behind
|
|
74
|
+
a `<Suspense>`, **`renderToString` renders the fallback** — it will write a loading screen into
|
|
75
|
+
every file and pass any gate that only asks whether the root has children. It also answers `""`
|
|
76
|
+
for a basename mismatch with no error and no warning, so `assertRendered` checks every route for
|
|
77
|
+
non-empty output. And React 19 hoists in-tree `<title>` and `<meta>` to the front of the server
|
|
78
|
+
stream, which lands them inside your body when you are filling one `<div>` rather than assembling
|
|
79
|
+
a document — so those get stripped, and a render error is rethrown so a broken page fails the
|
|
80
|
+
build instead of shipping.
|
|
81
|
+
|
|
82
|
+
## Flat files
|
|
83
|
+
|
|
84
|
+
That `pageFile` at the top writes `pricing.html`, not `pricing/index.html`. Nested routes keep
|
|
85
|
+
their folders — `/guides/errors` → `guides/errors.html`.
|
|
86
|
+
|
|
87
|
+
The directory form looks tidier and is a trap. Cloudflare Pages serves it at `/pricing/` and
|
|
88
|
+
answers `/pricing` with a 308, so every address your app advertises in its own canonical and its
|
|
89
|
+
own sitemap would be a redirect rather than a page. A flat file answers both, 200 either way.
|
|
90
|
+
|
|
91
|
+
## Sitemap, robots, OG cards
|
|
92
|
+
|
|
93
|
+
`sitemapFor(origin, pages)` walks your page registry; `robotsTxt({ origin, sitemaps })` generates
|
|
94
|
+
the file, listing every sitemap in one, because a crawler only reads the one at the origin root —
|
|
95
|
+
a subdirectory app cannot ship its own.
|
|
96
|
+
|
|
97
|
+
`sitemapXml` takes `priority` **pre-formatted, as a string**. Ranking pages is a decision that
|
|
98
|
+
belongs to whoever walks the registry: one codebase reads a field off its own registry, another
|
|
99
|
+
gives the front door 1.0 and every guide 0.8 flat, because ranking one guide above another would
|
|
100
|
+
be a guess about a reader.
|
|
101
|
+
|
|
102
|
+
`writeOgCards` walks the registry and calls a renderer you supply, and `fitText` does the
|
|
103
|
+
text-fitting maths. When copy does not fit, it **records the overflow and refuses to write**
|
|
104
|
+
rather than appending an ellipsis — an ellipsis makes every string "fit", so copy that outgrew
|
|
105
|
+
its column has no failing case and ships.
|
|
106
|
+
|
|
107
|
+
`loadTemplate` refuses a `dist/index.html` that is already a rendered page. `dist/index.html` is
|
|
108
|
+
both the file every page is baked _from_ and the home page's own output, so a second pass over a
|
|
109
|
+
written `dist/` reads a finished page as its blank and nests one render inside another. `vite
|
|
110
|
+
build` empties `dist/` and normally makes that impossible — a restored build cache and a hand-run
|
|
111
|
+
of the script both route around it.
|
|
112
|
+
|
|
113
|
+
## The vite preset
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { webPreset } from "@gusnips/vite/preset";
|
|
117
|
+
|
|
118
|
+
export default defineConfig(webPreset({/* … */}));
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Its own subpath too, since it pulls in the React and Tailwind plugins.
|
|
122
|
+
|
|
123
|
+
MIT · part of [frontkit](https://github.com/gusnips/frontkit)
|
package/dist/escape.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Escaping shared by the head baker and the sitemap writer. Its own file so neither has to
|
|
2
|
+
* import the other, and so both stay free of every dependency this package has. */
|
|
3
|
+
/** `&` first, or the ampersands introduced by the later rules get escaped twice. */
|
|
4
|
+
export declare const escapeAttr: (s: string) => string;
|
|
5
|
+
export declare const escapeRegex: (s: string) => string;
|
|
6
|
+
//# sourceMappingURL=escape.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"escape.d.ts","sourceRoot":"","sources":["../src/escape.ts"],"names":[],"mappings":"AAAA;oFACoF;AAEpF,oFAAoF;AACpF,eAAO,MAAM,UAAU,GAAI,GAAG,MAAM,KAAG,MACuD,CAAC;AAE/F,eAAO,MAAM,WAAW,GAAI,GAAG,MAAM,KAAG,MAAkD,CAAC"}
|
package/dist/escape.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Escaping shared by the head baker and the sitemap writer. Its own file so neither has to
|
|
2
|
+
* import the other, and so both stay free of every dependency this package has. */
|
|
3
|
+
/** `&` first, or the ampersands introduced by the later rules get escaped twice. */
|
|
4
|
+
export const escapeAttr = (s) => s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
5
|
+
export const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
6
|
+
//# sourceMappingURL=escape.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"escape.js","sourceRoot":"","sources":["../src/escape.ts"],"names":[],"mappings":"AAAA;oFACoF;AAEpF,oFAAoF;AACpF,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,CAAS,EAAU,EAAE,CAC9C,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;AAE/F,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,CAAS,EAAU,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,qBAAqB,EAAE,MAAM,CAAC,CAAC"}
|
package/dist/head.d.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** The empty root a template must carry — `bakeHead` fills it, and refuses a filled one. */
|
|
2
|
+
export declare const EMPTY_ROOT = "<div id=\"root\"></div>";
|
|
3
|
+
/** One `<link rel="alternate" hreflang>` — this page's address in another language. */
|
|
4
|
+
export interface Alternate {
|
|
5
|
+
/** A BCP-47 tag, or `x-default` for the address a crawler should show when it has no reason
|
|
6
|
+
* to prefer one. */
|
|
7
|
+
hreflang: string;
|
|
8
|
+
href: string;
|
|
9
|
+
}
|
|
10
|
+
export interface HeadTags {
|
|
11
|
+
/** `<title>`, and the share title unless overridden. */
|
|
12
|
+
title: string;
|
|
13
|
+
/** `name="description"`, and the share description unless overridden. */
|
|
14
|
+
description: string;
|
|
15
|
+
/**
|
|
16
|
+
* The page's own URL, for `<link rel="canonical">` and the share tags.
|
|
17
|
+
*
|
|
18
|
+
* `null` means this page has no canonical URL and must not claim one — the 404 shell, which
|
|
19
|
+
* is served for every address that does not exist and would otherwise tell a crawler that
|
|
20
|
+
* all of them are the front page.
|
|
21
|
+
*/
|
|
22
|
+
canonical: string | null;
|
|
23
|
+
ogTitle?: string;
|
|
24
|
+
ogDescription?: string;
|
|
25
|
+
/** Absolute URL of the share card. Omit where the template carries no `og:image` tag at all
|
|
26
|
+
* — setting one there is a build error, by design. */
|
|
27
|
+
image?: string;
|
|
28
|
+
/** Markup inserted before `</head>` — structured data, and nothing else so far. Emitted here
|
|
29
|
+
* because its only reader is a crawler. */
|
|
30
|
+
headExtra?: string;
|
|
31
|
+
/** Keep this page out of every index. Pairs with `canonical: null`. */
|
|
32
|
+
noindex?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* `<html lang>`, when this page is not in the template's language, and the `og:locale` that
|
|
35
|
+
* goes with it.
|
|
36
|
+
*
|
|
37
|
+
* A crawler and a screen reader both read `lang`, and neither runs the bundle that would
|
|
38
|
+
* otherwise set it — so a Portuguese file whose `<html>` still says `en` is announced in the
|
|
39
|
+
* wrong voice and indexed as the wrong language.
|
|
40
|
+
*/
|
|
41
|
+
lang?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Every language this page exists in, INCLUDING this one, plus `x-default`.
|
|
44
|
+
*
|
|
45
|
+
* Reciprocal by contract: a crawler ignores the whole set unless each address in it points
|
|
46
|
+
* back at the others, which is why the caller passes the full list to every page rather than
|
|
47
|
+
* "the other two".
|
|
48
|
+
*/
|
|
49
|
+
alternates?: readonly Alternate[];
|
|
50
|
+
/**
|
|
51
|
+
* The rendered page, injected into `<div id="root">`, and the route it is a render OF.
|
|
52
|
+
*
|
|
53
|
+
* The head alone was never enough. A shipped `<div id="root"></div>` is a page whose entire
|
|
54
|
+
* content is a description tag — it can be listed, and it can never be read, quoted or
|
|
55
|
+
* answered from.
|
|
56
|
+
*
|
|
57
|
+
* The two travel together because markup alone is not enough to hydrate against. A static
|
|
58
|
+
* host answers every unpublished address with the nearest `404.html`, and that file has a
|
|
59
|
+
* rendered body in it — so a route served from the shell would find a full root and hydrate
|
|
60
|
+
* the not-found page into a page that is not it. The marker is what the browser entry
|
|
61
|
+
* compares its own route against before deciding to hydrate or to mount fresh.
|
|
62
|
+
*/
|
|
63
|
+
body?: {
|
|
64
|
+
route: string;
|
|
65
|
+
html: string;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A BCP-47 tag in Open Graph's spelling: underscore, and a TERRITORY it will not infer.
|
|
70
|
+
*
|
|
71
|
+
* `og:locale` wants `language_TERRITORY` and quietly ignores anything else, which is the same
|
|
72
|
+
* outcome as omitting it — the `en_US` default. So a Portuguese page with a Portuguese
|
|
73
|
+
* `og:title` and no `og:locale` tells every share crawler the card is English. `pt-BR` already
|
|
74
|
+
* carries its territory; `en` and `es` do not, so one is chosen here rather than left to a
|
|
75
|
+
* crawler. `es_ES` is not a claim that the copy is peninsular Spanish — it is the most widely
|
|
76
|
+
* recognised Spanish value, and the tag's job is to be understood, not precise about dialect.
|
|
77
|
+
*/
|
|
78
|
+
export declare function ogLocale(tag: string): string;
|
|
79
|
+
/**
|
|
80
|
+
* One page's `<head>` and body, written into the built template.
|
|
81
|
+
*
|
|
82
|
+
* Every tag is SET rather than appended, and `setMeta` throws on a tag the template does not
|
|
83
|
+
* carry — so the failure mode of editing `index.html` is a red build, never a page quietly
|
|
84
|
+
* shipping somebody else's description.
|
|
85
|
+
*/
|
|
86
|
+
export declare function bakeHead(template: string, tags: HeadTags): string;
|
|
87
|
+
export interface RenderedChecks {
|
|
88
|
+
/** The BCP-47 tag this file must be marked with — the same `lang` passed to `bakeHead`. */
|
|
89
|
+
lang?: string;
|
|
90
|
+
/** Bytes the page must have gained over the template before it counts as rendered.
|
|
91
|
+
* 500 is the floor both donor builds ran with. */
|
|
92
|
+
minGrowth?: number;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* What every written file must be true of before the build is allowed to pass.
|
|
96
|
+
*
|
|
97
|
+
* The regression this exists for is a root that renders to nothing. A router whose location
|
|
98
|
+
* does not match its routes yields empty markup with no error and no warning — it looks fine
|
|
99
|
+
* in every browser and is invisible to everything that reads a link. Checking the bytes we
|
|
100
|
+
* actually wrote is the only thing that catches it, and it needs no browser, so it gates the
|
|
101
|
+
* BUILD rather than sitting in a test suite.
|
|
102
|
+
*
|
|
103
|
+
* The loading-screen check is the half a size floor misses. With `lazy()` routes behind one
|
|
104
|
+
* `<Suspense fallback={<Spinner />}>`, a render that resolves nothing still produces a
|
|
105
|
+
* plausible body: one donor shipped a 1,174-byte spinner with a perfect title and an element
|
|
106
|
+
* inside the root. A file whose body is a spinner is WORSE than an empty one, because every
|
|
107
|
+
* cheap check passes.
|
|
108
|
+
*/
|
|
109
|
+
export declare function assertRendered(file: string, html: string, template: string, checks?: RenderedChecks): void;
|
|
110
|
+
//# sourceMappingURL=head.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"head.d.ts","sourceRoot":"","sources":["../src/head.ts"],"names":[],"mappings":"AAiBA,4FAA4F;AAC5F,eAAO,MAAM,UAAU,4BAA0B,CAAC;AA+BlD,uFAAuF;AACvF,MAAM,WAAW,SAAS;IACxB;yBACqB;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,QAAQ;IACvB,wDAAwD;IACxD,KAAK,EAAE,MAAM,CAAC;IACd,yEAAyE;IACzE,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;;OAMG;IACH,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;2DACuD;IACvD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;gDAC4C;IAC5C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uEAAuE;IACvE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,SAAS,SAAS,EAAE,CAAC;IAClC;;;;;;;;;;;;OAYG;IACH,IAAI,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;CACxC;AAED;;;;;;;;;GASG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAG5C;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,GAAG,MAAM,CAqFjE;AAED,MAAM,WAAW,cAAc;IAC7B,2FAA2F;IAC3F,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;uDACmD;IACnD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,EAChB,MAAM,GAAE,cAAmB,GAC1B,IAAI,CAqCN"}
|
package/dist/head.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The head a crawler sees, baked at build time.
|
|
3
|
+
*
|
|
4
|
+
* A Vite SPA ships one `index.html` and draws everything else with JavaScript. Nothing that
|
|
5
|
+
* reads a link for a living runs that bundle — not Google's first pass, not Bing, not an LLM
|
|
6
|
+
* crawler, not whatever draws the preview card in Slack. So the build rewrites the template
|
|
7
|
+
* once per route and writes a real file per page, with a real `<head>` AND a real body.
|
|
8
|
+
*
|
|
9
|
+
* Pure string work, on purpose: no `node:` import anywhere in this file, so every branch is
|
|
10
|
+
* unit-testable without a filesystem. The reading and writing lives in `node.ts`.
|
|
11
|
+
*/
|
|
12
|
+
// The `/contract` subpath, not the barrel: the barrel reaches `react-dom/client` through
|
|
13
|
+
// `hydrate.ts`, and a build script asking for one string should not pull the browser renderer
|
|
14
|
+
// into a Node process. That file imports nothing at all.
|
|
15
|
+
import { PRERENDERED_ROUTE_ATTR } from "@gusnips/react/contract";
|
|
16
|
+
import { escapeAttr, escapeRegex } from "./escape.js";
|
|
17
|
+
/** The empty root a template must carry — `bakeHead` fills it, and refuses a filled one. */
|
|
18
|
+
export const EMPTY_ROOT = '<div id="root"></div>';
|
|
19
|
+
/** `[^>]*` on both sides tolerates the attributes being split across lines, as Vite emits them. */
|
|
20
|
+
const metaRe = (selector) => new RegExp(`(<meta[^>]*${escapeRegex(selector)}[^>]*content=")[^"]*(")`);
|
|
21
|
+
/**
|
|
22
|
+
* Replace the `content` of one `<meta …>`. Throws when the tag is missing, so an edit to
|
|
23
|
+
* `index.html` fails the build instead of silently shipping the front door's description to
|
|
24
|
+
* every crawler.
|
|
25
|
+
*/
|
|
26
|
+
function setMeta(html, selector, value) {
|
|
27
|
+
const re = metaRe(selector);
|
|
28
|
+
if (!re.test(html))
|
|
29
|
+
throw new Error(`prerender: <meta ${selector}> not found in index.html`);
|
|
30
|
+
return html.replace(re, `$1${escapeAttr(value)}$2`);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The same, for a tag a template is allowed not to carry.
|
|
34
|
+
*
|
|
35
|
+
* Only the `twitter:` pair and `name="title"` get this. X reads the `og:` tags when the
|
|
36
|
+
* `twitter:` ones are absent, and no crawler reads `name="title"` at all — so a template that
|
|
37
|
+
* omits them is correct, and demanding them would break an app that never had them. A template
|
|
38
|
+
* that DOES carry one and gets a stale value is still a bug, which is why they are written
|
|
39
|
+
* rather than ignored. Everything a crawler actually depends on goes through `setMeta`.
|
|
40
|
+
*/
|
|
41
|
+
function setMetaIfPresent(html, selector, value) {
|
|
42
|
+
const re = metaRe(selector);
|
|
43
|
+
return re.test(html) ? html.replace(re, `$1${escapeAttr(value)}$2`) : html;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A BCP-47 tag in Open Graph's spelling: underscore, and a TERRITORY it will not infer.
|
|
47
|
+
*
|
|
48
|
+
* `og:locale` wants `language_TERRITORY` and quietly ignores anything else, which is the same
|
|
49
|
+
* outcome as omitting it — the `en_US` default. So a Portuguese page with a Portuguese
|
|
50
|
+
* `og:title` and no `og:locale` tells every share crawler the card is English. `pt-BR` already
|
|
51
|
+
* carries its territory; `en` and `es` do not, so one is chosen here rather than left to a
|
|
52
|
+
* crawler. `es_ES` is not a claim that the copy is peninsular Spanish — it is the most widely
|
|
53
|
+
* recognised Spanish value, and the tag's job is to be understood, not precise about dialect.
|
|
54
|
+
*/
|
|
55
|
+
export function ogLocale(tag) {
|
|
56
|
+
const TERRITORY = { en: "en_US", es: "es_ES" };
|
|
57
|
+
return TERRITORY[tag] ?? tag.replace("-", "_");
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* One page's `<head>` and body, written into the built template.
|
|
61
|
+
*
|
|
62
|
+
* Every tag is SET rather than appended, and `setMeta` throws on a tag the template does not
|
|
63
|
+
* carry — so the failure mode of editing `index.html` is a red build, never a page quietly
|
|
64
|
+
* shipping somebody else's description.
|
|
65
|
+
*/
|
|
66
|
+
export function bakeHead(template, tags) {
|
|
67
|
+
const shareTitle = tags.ogTitle ?? tags.title;
|
|
68
|
+
const shareDescription = tags.ogDescription ?? tags.description;
|
|
69
|
+
let html = template.replace(/<title>[^<]*<\/title>/, `<title>${escapeAttr(tags.title)}</title>`);
|
|
70
|
+
html = setMeta(html, 'name="description"', tags.description);
|
|
71
|
+
html = setMeta(html, 'property="og:title"', shareTitle);
|
|
72
|
+
html = setMeta(html, 'property="og:description"', shareDescription);
|
|
73
|
+
html = setMetaIfPresent(html, 'name="title"', tags.title);
|
|
74
|
+
html = setMetaIfPresent(html, 'property="twitter:title"', shareTitle);
|
|
75
|
+
html = setMetaIfPresent(html, 'property="twitter:description"', shareDescription);
|
|
76
|
+
if (tags.image !== undefined) {
|
|
77
|
+
html = setMeta(html, 'property="og:image"', tags.image);
|
|
78
|
+
html = setMetaIfPresent(html, 'property="twitter:image"', tags.image);
|
|
79
|
+
}
|
|
80
|
+
if (tags.canonical === null) {
|
|
81
|
+
// Strip rather than blank: an empty canonical is a claim about "" and an empty og:url is a
|
|
82
|
+
// share card pointing at the origin root. The image is left alone — it is a picture, not a
|
|
83
|
+
// claim about this address, and a dead link that still unfurls the brand card is fine.
|
|
84
|
+
html = html
|
|
85
|
+
.replace(/\s*<link rel="canonical"[^>]*>/, "")
|
|
86
|
+
.replace(/\s*<meta property="(?:og|twitter):url"[^>]*>/g, "");
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
html = setMeta(html, 'property="og:url"', tags.canonical);
|
|
90
|
+
html = setMetaIfPresent(html, 'property="twitter:url"', tags.canonical);
|
|
91
|
+
html = html.replace(/(<link rel="canonical" href=")[^"]*(")/, `$1${escapeAttr(tags.canonical)}$2`);
|
|
92
|
+
}
|
|
93
|
+
if (tags.alternates?.length) {
|
|
94
|
+
const links = tags.alternates
|
|
95
|
+
.map((alt) => ` <link rel="alternate" hreflang="${escapeAttr(alt.hreflang)}" href="${escapeAttr(alt.href)}" />`)
|
|
96
|
+
.join("\n");
|
|
97
|
+
html = html.replace("</head>", `${links}\n </head>`);
|
|
98
|
+
}
|
|
99
|
+
if (tags.lang !== undefined) {
|
|
100
|
+
// `og:locale` is not the same claim as `<html lang>` and is read by different machines.
|
|
101
|
+
// APPENDED rather than set, because a Vite template carries no such tag — but one that
|
|
102
|
+
// does would end up with two, and a crawler reading the first would get the template's
|
|
103
|
+
// language on every page. So drop whatever is there before writing this page's own.
|
|
104
|
+
html = html.replace(/\s*<meta property="og:locale(?::alternate)?"[^>]*>/g, "");
|
|
105
|
+
const alternates = (tags.alternates ?? [])
|
|
106
|
+
.filter((alt) => alt.hreflang !== "x-default" && alt.hreflang !== tags.lang)
|
|
107
|
+
.map((alt) => ` <meta property="og:locale:alternate" content="${escapeAttr(ogLocale(alt.hreflang))}" />`);
|
|
108
|
+
html = html.replace("</head>", ` <meta property="og:locale" content="${escapeAttr(ogLocale(tags.lang))}" />\n${alternates.length ? `${alternates.join("\n")}\n` : ""} </head>`);
|
|
109
|
+
}
|
|
110
|
+
if (tags.noindex)
|
|
111
|
+
html = html.replace("</head>", ` <meta name="robots" content="noindex" />\n </head>`);
|
|
112
|
+
if (tags.headExtra)
|
|
113
|
+
html = html.replace("</head>", ` ${tags.headExtra}\n </head>`);
|
|
114
|
+
if (tags.lang !== undefined) {
|
|
115
|
+
const re = /(<html[^>]*\blang=")[^"]*(")/;
|
|
116
|
+
if (!re.test(html))
|
|
117
|
+
throw new Error("prerender: <html lang> not found in index.html");
|
|
118
|
+
html = html.replace(re, `$1${escapeAttr(tags.lang)}$2`);
|
|
119
|
+
}
|
|
120
|
+
if (tags.body !== undefined) {
|
|
121
|
+
// Empty on purpose in the template, and it must STAY empty there: a second bake over an
|
|
122
|
+
// already-filled root would nest one render inside another.
|
|
123
|
+
if (!html.includes(EMPTY_ROOT))
|
|
124
|
+
throw new Error(`prerender: ${EMPTY_ROOT} not found in index.html`);
|
|
125
|
+
html = html.replace(EMPTY_ROOT, `<div id="root" ${PRERENDERED_ROUTE_ATTR}="${escapeAttr(tags.body.route)}">${tags.body.html}</div>`);
|
|
126
|
+
}
|
|
127
|
+
return html;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* What every written file must be true of before the build is allowed to pass.
|
|
131
|
+
*
|
|
132
|
+
* The regression this exists for is a root that renders to nothing. A router whose location
|
|
133
|
+
* does not match its routes yields empty markup with no error and no warning — it looks fine
|
|
134
|
+
* in every browser and is invisible to everything that reads a link. Checking the bytes we
|
|
135
|
+
* actually wrote is the only thing that catches it, and it needs no browser, so it gates the
|
|
136
|
+
* BUILD rather than sitting in a test suite.
|
|
137
|
+
*
|
|
138
|
+
* The loading-screen check is the half a size floor misses. With `lazy()` routes behind one
|
|
139
|
+
* `<Suspense fallback={<Spinner />}>`, a render that resolves nothing still produces a
|
|
140
|
+
* plausible body: one donor shipped a 1,174-byte spinner with a perfect title and an element
|
|
141
|
+
* inside the root. A file whose body is a spinner is WORSE than an empty one, because every
|
|
142
|
+
* cheap check passes.
|
|
143
|
+
*/
|
|
144
|
+
export function assertRendered(file, html, template, checks = {}) {
|
|
145
|
+
function fail(why) {
|
|
146
|
+
throw new Error(`prerender: ${file} ${why}`);
|
|
147
|
+
}
|
|
148
|
+
// Measured against the template rather than by matching the root's closing tag — the body is
|
|
149
|
+
// thousands of nested `</div>`s and no regex should be asked to find the right one.
|
|
150
|
+
// Everything this file has over the shell it was baked from is the page.
|
|
151
|
+
const floor = checks.minGrowth ?? 500;
|
|
152
|
+
const grew = html.length - template.length;
|
|
153
|
+
if (grew < floor)
|
|
154
|
+
fail(`is only ${String(grew)} bytes bigger than the shell — nothing rendered`);
|
|
155
|
+
if (/<title>\s*<\/title>/.test(html))
|
|
156
|
+
fail("has an empty <title>");
|
|
157
|
+
if (checks.lang !== undefined && !html.includes(`<html lang="${checks.lang}"`))
|
|
158
|
+
fail(`is not marked as ${checks.lang}`);
|
|
159
|
+
// A `%NAME%` the HTML transform never filled in. Three inner characters minimum, because two
|
|
160
|
+
// is the shape of percent-encoding: `/caf%C3%A9` contains `%C3%`, and an accented slug is not
|
|
161
|
+
// a broken build.
|
|
162
|
+
const placeholder = /%[A-Z][A-Z0-9_]{2,}%/.exec(html);
|
|
163
|
+
if (placeholder)
|
|
164
|
+
fail(`still carries an unsubstituted placeholder, ${placeholder[0]}`);
|
|
165
|
+
// What the root ACTUALLY opens with. Sliced rather than matched in one pattern, because a
|
|
166
|
+
// regex that skips React's `<!--$-->` Suspense markers with `(?:<!--.*?-->|\s)*` can backtrack
|
|
167
|
+
// across the whole document — it will happily skip 40 KB of real page to find a
|
|
168
|
+
// `role="status"` further down and report a perfectly good file as a spinner. It did exactly
|
|
169
|
+
// that in the donor before this was rewritten as a slice.
|
|
170
|
+
const opened = /<div id="root"[^>]*>/.exec(html);
|
|
171
|
+
if (!opened)
|
|
172
|
+
fail("has no root element at all");
|
|
173
|
+
const start = opened.index + opened[0].length;
|
|
174
|
+
const head = html
|
|
175
|
+
.slice(start, start + 600)
|
|
176
|
+
.replace(/<!--.*?-->/g, "")
|
|
177
|
+
.trimStart();
|
|
178
|
+
if (!/^<[a-z]/.test(head))
|
|
179
|
+
fail("has no element inside its root");
|
|
180
|
+
if (/^<[^>]*role="status"/.test(head))
|
|
181
|
+
fail("rendered the loading screen, not the page — something suspended and never resolved");
|
|
182
|
+
}
|
|
183
|
+
//# sourceMappingURL=head.js.map
|
package/dist/head.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"head.js","sourceRoot":"","sources":["../src/head.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The build-time half of a prerendered Vite + React SPA.
|
|
3
|
+
*
|
|
4
|
+
* A Vite SPA ships one `index.html` and draws the rest with JavaScript. Nothing that reads a
|
|
5
|
+
* link for a living runs that bundle — not a search crawler, not an LLM, not the thing that
|
|
6
|
+
* draws the preview card in a chat app. So the build renders every public route to a real file
|
|
7
|
+
* with a real `<head>` and a real body, and this is the part every app doing that shares.
|
|
8
|
+
*
|
|
9
|
+
* Four repos wrote it independently and each learned something the others had not. What is
|
|
10
|
+
* here is the merge; every non-obvious rule carries the reason it exists.
|
|
11
|
+
*
|
|
12
|
+
* Two things live behind their own subpath, because each drags a dependency this barrel would
|
|
13
|
+
* otherwise force on everyone: the vite config preset at `@gusnips/vite/preset` (the React and
|
|
14
|
+
* Tailwind plugins) and `renderTree` at `@gusnips/vite/render` (React itself). Nothing here
|
|
15
|
+
* imports React or vite, so a prerender script, an OG generator and a repo that only wants a
|
|
16
|
+
* sitemap all install exactly what they use.
|
|
17
|
+
*/
|
|
18
|
+
export { assertRendered, bakeHead, EMPTY_ROOT, ogLocale, type Alternate, type HeadTags, type RenderedChecks, } from "./head.ts";
|
|
19
|
+
export { loadRenderer, loadTemplate, writeDist, writeOgCards, type OgCard, type WriteOgCardsOptions, } from "./node.ts";
|
|
20
|
+
export { describeOverflow, fitText, OG_CANVAS, type FitOptions, type FitResult, type OgOverflow, } from "./og.ts";
|
|
21
|
+
export type { PageRenderer } from "./render.ts";
|
|
22
|
+
export { ogImagePath, pageFile, pageSlug, robotsTxt, siteOrigin, sitemapFor, sitemapXml, type PublicPage, type RobotsOptions, type SitemapEntry, } from "./sitemap.ts";
|
|
23
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,EACL,cAAc,EACd,QAAQ,EACR,UAAU,EACV,QAAQ,EACR,KAAK,SAAS,EACd,KAAK,QAAQ,EACb,KAAK,cAAc,GACpB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,SAAS,EACT,YAAY,EACZ,KAAK,MAAM,EACX,KAAK,mBAAmB,GACzB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,gBAAgB,EAChB,OAAO,EACP,SAAS,EACT,KAAK,UAAU,EACf,KAAK,SAAS,EACd,KAAK,UAAU,GAChB,MAAM,SAAS,CAAC;AAKjB,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EACL,WAAW,EACX,QAAQ,EACR,QAAQ,EACR,SAAS,EACT,UAAU,EACV,UAAU,EACV,UAAU,EACV,KAAK,UAAU,EACf,KAAK,aAAa,EAClB,KAAK,YAAY,GAClB,MAAM,cAAc,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The build-time half of a prerendered Vite + React SPA.
|
|
3
|
+
*
|
|
4
|
+
* A Vite SPA ships one `index.html` and draws the rest with JavaScript. Nothing that reads a
|
|
5
|
+
* link for a living runs that bundle — not a search crawler, not an LLM, not the thing that
|
|
6
|
+
* draws the preview card in a chat app. So the build renders every public route to a real file
|
|
7
|
+
* with a real `<head>` and a real body, and this is the part every app doing that shares.
|
|
8
|
+
*
|
|
9
|
+
* Four repos wrote it independently and each learned something the others had not. What is
|
|
10
|
+
* here is the merge; every non-obvious rule carries the reason it exists.
|
|
11
|
+
*
|
|
12
|
+
* Two things live behind their own subpath, because each drags a dependency this barrel would
|
|
13
|
+
* otherwise force on everyone: the vite config preset at `@gusnips/vite/preset` (the React and
|
|
14
|
+
* Tailwind plugins) and `renderTree` at `@gusnips/vite/render` (React itself). Nothing here
|
|
15
|
+
* imports React or vite, so a prerender script, an OG generator and a repo that only wants a
|
|
16
|
+
* sitemap all install exactly what they use.
|
|
17
|
+
*/
|
|
18
|
+
export { assertRendered, bakeHead, EMPTY_ROOT, ogLocale, } from "./head.js";
|
|
19
|
+
export { loadRenderer, loadTemplate, writeDist, writeOgCards, } from "./node.js";
|
|
20
|
+
export { describeOverflow, fitText, OG_CANVAS, } from "./og.js";
|
|
21
|
+
export { ogImagePath, pageFile, pageSlug, robotsTxt, siteOrigin, sitemapFor, sitemapXml, } from "./sitemap.js";
|
|
22
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,EACL,cAAc,EACd,QAAQ,EACR,UAAU,EACV,QAAQ,GAIT,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,SAAS,EACT,YAAY,GAGb,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,gBAAgB,EAChB,OAAO,EACP,SAAS,GAIV,MAAM,SAAS,CAAC;AAMjB,OAAO,EACL,WAAW,EACX,QAAQ,EACR,QAAQ,EACR,SAAS,EACT,UAAU,EACV,UAAU,EACV,UAAU,GAIX,MAAM,cAAc,CAAC"}
|
package/dist/node.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { OgOverflow } from "./og.ts";
|
|
2
|
+
import type { PageRenderer } from "./render.ts";
|
|
3
|
+
/**
|
|
4
|
+
* The built `index.html`, and a refusal to bake one twice.
|
|
5
|
+
*
|
|
6
|
+
* `dist/index.html` is BOTH the template and the front page's destination, so a second run over
|
|
7
|
+
* a `dist/` the prerender has already touched would read a finished page as its blank shell and
|
|
8
|
+
* nest one render inside another. `vite build` empties `dist/` and normally makes this
|
|
9
|
+
* impossible; what does not is a restored build cache, or somebody running the script directly
|
|
10
|
+
* to debug it. Caught here, by name, rather than as a missing-root error three frames down.
|
|
11
|
+
*/
|
|
12
|
+
export declare function loadTemplate(distDir: string): Promise<string>;
|
|
13
|
+
/**
|
|
14
|
+
* The app compiled for the server, imported out of the BUILT bundle.
|
|
15
|
+
*
|
|
16
|
+
* The prerender is a script rather than a Vite plugin for exactly this reason: it needs the SSR
|
|
17
|
+
* bundle, and a plugin running in `closeBundle` is inside the build that would have to have
|
|
18
|
+
* produced it. So the entry is a path on disk, written by `vite build --ssr src/entry-server.tsx`,
|
|
19
|
+
* and nothing in the source tree references it.
|
|
20
|
+
*/
|
|
21
|
+
export declare function loadRenderer<Context = unknown>(entryFile: string): Promise<PageRenderer<Context>>;
|
|
22
|
+
/** Write one file under `dist`, creating the folders a nested route needs. */
|
|
23
|
+
export declare function writeDist(distDir: string, file: string, contents: string): Promise<void>;
|
|
24
|
+
/** One laid-out share card: the bytes, and anything that did not fit. */
|
|
25
|
+
export interface OgCard {
|
|
26
|
+
png: Uint8Array;
|
|
27
|
+
/** Copy the layout could not hold. A single one refuses the whole run — see
|
|
28
|
+
* {@link writeOgCards}. */
|
|
29
|
+
overflow?: readonly OgOverflow[];
|
|
30
|
+
}
|
|
31
|
+
export interface WriteOgCardsOptions<Page> {
|
|
32
|
+
pages: readonly Page[];
|
|
33
|
+
/** Where the cards land — `public/og` in both donors, so they are committed beside the
|
|
34
|
+
* favicons and served as static files. */
|
|
35
|
+
outDir: string;
|
|
36
|
+
/** The file name for one page, inside `outDir`. `(page) => \`${pageSlug(page.path)}.png\``. */
|
|
37
|
+
file: (page: Page) => string;
|
|
38
|
+
/** Lay one card out and rasterize it. The card's art is the product's; this only drives it. */
|
|
39
|
+
card: (page: Page) => OgCard | Promise<OgCard>;
|
|
40
|
+
/** Lay every card out and report, without writing anything. */
|
|
41
|
+
check?: boolean;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Walk a page registry and write one share card per page.
|
|
45
|
+
*
|
|
46
|
+
* Every card is laid out BEFORE any is written, and a single line that does not fit refuses the
|
|
47
|
+
* whole run. That order is the point: a card that cannot hold its copy is a product decision,
|
|
48
|
+
* not something to resolve with an ellipsis — an ellipsis makes every string "fit", so
|
|
49
|
+
* overgrown copy has no failing case and ships a card missing the end of the one line the card
|
|
50
|
+
* exists to carry. The reader who finds out is someone else's link unfurl.
|
|
51
|
+
*
|
|
52
|
+
* Overflow is collected rather than thrown on the first card, so one run names every bad one:
|
|
53
|
+
* copy lands per locale in batches, and a build that dies on the first of six sends its
|
|
54
|
+
* operator round the loop six times.
|
|
55
|
+
*/
|
|
56
|
+
export declare function writeOgCards<Page>({ pages, outDir, file, card, check, }: WriteOgCardsOptions<Page>): Promise<string[]>;
|
|
57
|
+
//# sourceMappingURL=node.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node.d.ts","sourceRoot":"","sources":["../src/node.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAE1C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD;;;;;;;;GAQG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAUnE;AAWD;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAAC,OAAO,GAAG,OAAO,EAClD,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC,CAOhC;AAED,8EAA8E;AAC9E,wBAAsB,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAI9F;AAED,yEAAyE;AACzE,MAAM,WAAW,MAAM;IACrB,GAAG,EAAE,UAAU,CAAC;IAChB;gCAC4B;IAC5B,QAAQ,CAAC,EAAE,SAAS,UAAU,EAAE,CAAC;CAClC;AAED,MAAM,WAAW,mBAAmB,CAAC,IAAI;IACvC,KAAK,EAAE,SAAS,IAAI,EAAE,CAAC;IACvB;+CAC2C;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,+FAA+F;IAC/F,IAAI,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,MAAM,CAAC;IAC7B,+FAA+F;IAC/F,IAAI,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC/C,+DAA+D;IAC/D,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,EACvC,KAAK,EACL,MAAM,EACN,IAAI,EACJ,IAAI,EACJ,KAAa,GACd,EAAE,mBAAmB,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAsB/C"}
|