grok-digest 0.0.0-stage → 0.1.1
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 +121 -2
- package/dist/assets/favicon.svg +1 -0
- package/dist/assets/robots.txt +2 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +71 -0
- package/dist/components/BookmarkCard.astro +61 -0
- package/dist/components/BookmarkFilters.d.ts +6 -0
- package/dist/components/BookmarkFilters.js +32 -0
- package/dist/components/ThemeToggle.d.ts +1 -0
- package/dist/components/ThemeToggle.js +23 -0
- package/dist/components/ui/badge.d.ts +9 -0
- package/dist/components/ui/badge.js +25 -0
- package/dist/components/ui/button.d.ts +10 -0
- package/dist/components/ui/button.js +36 -0
- package/dist/components/ui/card.d.ts +8 -0
- package/dist/components/ui/card.js +16 -0
- package/dist/components/ui/input.d.ts +3 -0
- package/dist/components/ui/input.js +7 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +81 -0
- package/dist/layouts/Layout.astro +45 -0
- package/dist/lib/bookmarks.d.ts +104 -0
- package/dist/lib/bookmarks.js +49 -0
- package/dist/lib/data-files.d.ts +6 -0
- package/dist/lib/data-files.js +14 -0
- package/dist/lib/feed.d.ts +29 -0
- package/dist/lib/feed.js +57 -0
- package/dist/lib/filter.d.ts +10 -0
- package/dist/lib/filter.js +22 -0
- package/dist/lib/load-bookmarks.d.ts +13 -0
- package/dist/lib/load-bookmarks.js +3 -0
- package/dist/lib/search.d.ts +13 -0
- package/dist/lib/search.js +19 -0
- package/dist/lib/theme.d.ts +2 -0
- package/dist/lib/theme.js +2 -0
- package/dist/lib/topics.d.ts +3 -0
- package/dist/lib/topics.js +18 -0
- package/dist/lib/validate.d.ts +18 -0
- package/dist/lib/validate.js +35 -0
- package/dist/pages/index.astro +55 -0
- package/dist/pages/rss/[topic].xml.d.ts +7 -0
- package/dist/pages/rss/[topic].xml.js +15 -0
- package/dist/pages/rss.xml.d.ts +2 -0
- package/dist/pages/rss.xml.js +11 -0
- package/dist/routes/favicon.svg.d.ts +1 -0
- package/dist/routes/favicon.svg.js +2 -0
- package/dist/routes/robots.txt.d.ts +1 -0
- package/dist/routes/robots.txt.js +2 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +2 -0
- package/dist/site.d.ts +4 -0
- package/dist/site.js +15 -0
- package/dist/styles/global.css +96 -0
- package/package.json +92 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kenryu42
|
|
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
CHANGED
|
@@ -1,3 +1,122 @@
|
|
|
1
|
-
#
|
|
1
|
+
# grok-digest
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`grok-digest` renders a Grok Digest site: X bookmarks summarized by Grok, searchable, sorted by topic, with one RSS feed per topic. A site repository holds only its `data/` files, a `package.json`, and a deploy workflow. The page, the feeds, and the schema come from this package.
|
|
4
|
+
|
|
5
|
+
Every deploy installs the newest `grok-digest`, so a release reaches each site the next time its Grok bot pushes a bookmark.
|
|
6
|
+
|
|
7
|
+
## Set up a site
|
|
8
|
+
|
|
9
|
+
Create a public repository and copy the [`template/`](https://github.com/kenryu42/grok-digest/tree/main/template) directory into it (skip `template/data` when the repository already has data). Enable GitHub Pages with **GitHub Actions** as the source, then:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
bun install
|
|
13
|
+
git add bun.lock
|
|
14
|
+
git commit -m "Install grok-digest"
|
|
15
|
+
git push
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The rest is in `template/README.md`. To convert a site that still holds a copy of the page source, follow [MIGRATION.md](https://github.com/kenryu42/grok-digest/blob/main/MIGRATION.md).
|
|
19
|
+
|
|
20
|
+
A site repository contains:
|
|
21
|
+
|
|
22
|
+
| Path | Contents |
|
|
23
|
+
| ------------------------------ | --------------------------------------------------------------------------------- |
|
|
24
|
+
| `data/bookmarks.json` | The bookmarks. The Grok bot appends to it. |
|
|
25
|
+
| `data/processed.json` | Optional. Ids the bot has handled. |
|
|
26
|
+
| `data/removed.json` | Optional. Ids the bot has removed. |
|
|
27
|
+
| `package.json` | Depends on `"grok-digest": "latest"` and maps the scripts to the CLI below. |
|
|
28
|
+
| `bun.lock` | The lockfile. |
|
|
29
|
+
| `.github/workflows/deploy.yml` | Validates the data, builds with the newest `grok-digest`, and publishes to Pages. |
|
|
30
|
+
|
|
31
|
+
## How a site takes new versions
|
|
32
|
+
|
|
33
|
+
`deploy.yml` runs `bun install --frozen-lockfile`, then `bun update grok-digest`. The update moves `grok-digest` to the newest version that `package.json` allows, inside that run only, and commits nothing. With `"latest"`, that is the newest release.
|
|
34
|
+
|
|
35
|
+
To hold a site on one version, set the dependency to that exact version, such as `"grok-digest": "0.1.3"`. `bun update grok-digest` keeps an exact version where it is. Set it back to `"latest"` to follow releases again.
|
|
36
|
+
|
|
37
|
+
If a release fails a site's build, the deploy stops and the page that was live stays live.
|
|
38
|
+
|
|
39
|
+
## CLI
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
grok-digest build Build the site into dist/.
|
|
43
|
+
grok-digest dev Serve the site at http://localhost:4321 and reload on changes.
|
|
44
|
+
grok-digest preview Serve the site built in dist/.
|
|
45
|
+
grok-digest validate [file ...] Check the data files.
|
|
46
|
+
|
|
47
|
+
--data-dir <dir> The folder that holds the data files. Defaults to "data".
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`build`, `dev`, and `preview` read two environment variables. `SITE_URL` is the site's origin, such as `https://<your-username>.github.io`, and becomes Astro's `site`. `BASE_PATH` is the path the site is served under, such as `/<repository-name>`, and becomes Astro's `base`, so links on a `github.io/<repository-name>/` site stay under that path. The deploy workflow sets both from GitHub Pages.
|
|
51
|
+
|
|
52
|
+
`validate` with no file checks `data/bookmarks.json` and whichever of `data/processed.json` and `data/removed.json` exist. It prints one line per file, such as `✓ data/bookmarks.json: 2 bookmarks`, and fails naming the entry and the field of an invalid file.
|
|
53
|
+
|
|
54
|
+
The site needs no Astro config. The package depends on the Astro version it was tested with and runs it.
|
|
55
|
+
|
|
56
|
+
## Data files
|
|
57
|
+
|
|
58
|
+
`grok-digest/schema` exports `SCHEMA_VERSION`, the zod schemas, and `parseBookmarks`:
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
import { SCHEMA_VERSION, bookmarkSchema, parseBookmarks, TOPICS } from "grok-digest/schema";
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`SCHEMA_VERSION` is `1`. The JSON files have no version field. A `bookmarks.json` that already validates is version 1, including files written before this package existed. `posted_at` with milliseconds and a `Z` offset, as the bot writes it, is version 1.
|
|
65
|
+
|
|
66
|
+
| File | Version 1 shape |
|
|
67
|
+
| --------------------- | ---------------------------------------------------------------------- |
|
|
68
|
+
| `data/bookmarks.json` | Array of bookmark objects. The field table is in `template/README.md`. |
|
|
69
|
+
| `data/processed.json` | Array of non-empty id strings. Optional. The page does not read it. |
|
|
70
|
+
| `data/removed.json` | Same shape as `processed.json`. Optional. The page does not read it. |
|
|
71
|
+
|
|
72
|
+
Every site takes every release, so every release reads every data file an earlier release accepted. The policy is in `MIGRATION.md`.
|
|
73
|
+
|
|
74
|
+
## The page
|
|
75
|
+
|
|
76
|
+
The page title is "Grok Digest". The footer reads "Unofficial. Summaries created with Grok." Every page sends `<meta name="robots" content="noindex,nofollow">` and `<meta name="referrer" content="no-referrer">`. `robots.txt` disallows all crawlers. Feeds live at `rss.xml` and `rss/<topic-id>.xml`. Card anchors are `#b-<id>`.
|
|
77
|
+
|
|
78
|
+
Search and topic filters are `?q=` and `?topic=` on the page address. The behavior is documented in `template/README.md`.
|
|
79
|
+
|
|
80
|
+
## Package exports
|
|
81
|
+
|
|
82
|
+
| Export | Role |
|
|
83
|
+
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
84
|
+
| `grok-digest` | Default export, the Astro integration `grokDigest({ dataDir })`, for a site that keeps its own Astro config. |
|
|
85
|
+
| `grok-digest/schema` | `SCHEMA_VERSION`, `bookmarkSchema`, `bookmarksFileSchema`, `stateFileSchema`, `parseBookmarks`, `parseDataFile`, `TOPICS`, `TOPIC_LABELS`, and the `Bookmark`, `BookmarkFileEntry`, and `Topic` types. |
|
|
86
|
+
| `grok-digest` bin | The CLI above. |
|
|
87
|
+
|
|
88
|
+
## Develop
|
|
89
|
+
|
|
90
|
+
You need Bun 1.4 and Node.js 22.12 or later.
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
bun install
|
|
94
|
+
bun run dev # builds the package, then serves template/ at http://localhost:4321
|
|
95
|
+
bun run test
|
|
96
|
+
bun run check # typecheck, oxlint, oxfmt, comment lint, jscpd, knip, tests, starter build
|
|
97
|
+
bun run format
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`bun run check` ends by packing the package with `bun pm pack`, installing the tarball into a copy of `template/` outside the repository, and building it with `SITE_URL=https://example.github.io` and `BASE_PATH=/bm-check`. It checks that the written links stay under that path. This is the same install a site's deploy does.
|
|
101
|
+
|
|
102
|
+
## Publish
|
|
103
|
+
|
|
104
|
+
Releases are cut from GitHub Actions. Open **Actions** > **Prepare Release** > **Run workflow** and choose:
|
|
105
|
+
|
|
106
|
+
| Input | Meaning |
|
|
107
|
+
| --------- | ------------------------------------------------------------------------------ |
|
|
108
|
+
| `bump` | `patch`, `minor`, or `major`, applied to the version in `package.json`. |
|
|
109
|
+
| `version` | Optional. An exact version such as `0.2.0`, without the `v`. Overrides `bump`. |
|
|
110
|
+
| `dry_run` | Run every check on the new version and stop before changing Git. |
|
|
111
|
+
|
|
112
|
+
`prepare-release.yml` refuses a version that is not newer, whose tag exists, or that is already on npm. It sets the new version, runs `bun run check`, commits `release: v<version>` to `main`, pushes the commit and the `v<version>` tag together, and starts `publish.yml` on that tag.
|
|
113
|
+
|
|
114
|
+
`publish.yml` checks the tag again, packs the tarball, publishes that exact tarball to npm with provenance, and creates a GitHub release with generated notes and the tarball attached. Rerunning it for a tag that is already on npm skips the publish and finishes the release.
|
|
115
|
+
|
|
116
|
+
npm accepts the publish through trusted publishing, so the repository holds no npm token. On npmjs.com, the package's **Settings** list `kenryu42/grok-digest` with workflow `publish.yml` as a trusted publisher.
|
|
117
|
+
|
|
118
|
+
Version 0.1.0 was published from a maintainer's machine, because npm offers trusted publishing only for a package that already exists.
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#2b2622"/><path d="M10 7h12v18l-6-4.5L10 25z" fill="#f6f1ea"/></svg>
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { SCHEMA_VERSION } from "./lib/bookmarks.js";
|
|
6
|
+
import { defaultDataFiles, validateFiles } from "./lib/validate.js";
|
|
7
|
+
const USAGE = `Usage: grok-digest <command> [options]
|
|
8
|
+
|
|
9
|
+
Commands:
|
|
10
|
+
build Build the site into dist/.
|
|
11
|
+
dev Serve the site at http://localhost:4321 and reload on changes.
|
|
12
|
+
preview Serve the site built in dist/.
|
|
13
|
+
validate [file ...] Check data files against schema version ${SCHEMA_VERSION}. With no
|
|
14
|
+
file, checks <dir>/bookmarks.json and, when present,
|
|
15
|
+
<dir>/processed.json and <dir>/removed.json.
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--data-dir <dir> The folder that holds the data files. Defaults to "data".
|
|
19
|
+
-h, --help Show this help.
|
|
20
|
+
-v, --version Show the package and schema versions.
|
|
21
|
+
|
|
22
|
+
Environment:
|
|
23
|
+
SITE_URL The site's origin, such as https://<your-username>.github.io.
|
|
24
|
+
BASE_PATH The path the site is served under, such as /<repository-name>.`;
|
|
25
|
+
const packageJson = z
|
|
26
|
+
.object({ version: z.string() })
|
|
27
|
+
.parse(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")));
|
|
28
|
+
const { values, positionals } = parseArgs({
|
|
29
|
+
allowPositionals: true,
|
|
30
|
+
options: {
|
|
31
|
+
"data-dir": { type: "string", default: "data" },
|
|
32
|
+
help: { type: "boolean", short: "h" },
|
|
33
|
+
version: { type: "boolean", short: "v" },
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
const [command, ...files] = positionals;
|
|
37
|
+
const dataDir = values["data-dir"];
|
|
38
|
+
const NODE_ENV_FOR_SITE_COMMAND = {
|
|
39
|
+
build: "production",
|
|
40
|
+
dev: "development",
|
|
41
|
+
preview: "production",
|
|
42
|
+
};
|
|
43
|
+
const isSiteCommand = (name) => Object.hasOwn(NODE_ENV_FOR_SITE_COMMAND, name);
|
|
44
|
+
if (values.version === true) {
|
|
45
|
+
console.log(`grok-digest ${packageJson.version} (schema version ${SCHEMA_VERSION})`);
|
|
46
|
+
}
|
|
47
|
+
else if (values.help === true || command === undefined) {
|
|
48
|
+
console.log(USAGE);
|
|
49
|
+
}
|
|
50
|
+
else if (command === "validate") {
|
|
51
|
+
const cwd = process.cwd();
|
|
52
|
+
const targets = files.length > 0 ? files : await defaultDataFiles({ cwd, dataDir });
|
|
53
|
+
const reports = await validateFiles({ cwd, files: targets });
|
|
54
|
+
for (const report of reports) {
|
|
55
|
+
if (report.ok)
|
|
56
|
+
console.log(`✓ ${report.source}: ${report.summary}`);
|
|
57
|
+
else
|
|
58
|
+
console.error(`✖ ${report.error}`);
|
|
59
|
+
}
|
|
60
|
+
if (reports.some((report) => !report.ok))
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
}
|
|
63
|
+
else if (isSiteCommand(command)) {
|
|
64
|
+
process.env["NODE_ENV"] ??= NODE_ENV_FOR_SITE_COMMAND[command];
|
|
65
|
+
const { runSite } = await import("./site.js");
|
|
66
|
+
await runSite(command, { dataDir });
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
console.error(`Unknown command "${command}".\n\n${USAGE}`);
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { ArrowUpRightIcon } from "lucide-react";
|
|
3
|
+
import { Badge } from "./ui/badge";
|
|
4
|
+
import { Card, CardContent, CardFooter, CardHeader } from "./ui/card";
|
|
5
|
+
import type { Bookmark } from "../lib/bookmarks";
|
|
6
|
+
import { TOPIC_LABELS } from "../lib/topics";
|
|
7
|
+
|
|
8
|
+
type Props = {
|
|
9
|
+
bookmark: Bookmark;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const { bookmark } = Astro.props;
|
|
13
|
+
const { id, url, author, postedAt, topic, summary, text, keyPoints } = bookmark;
|
|
14
|
+
|
|
15
|
+
const dateFormat = new Intl.DateTimeFormat("en", { dateStyle: "medium", timeZone: "UTC" });
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
<li id={`b-${id}`} data-bookmark-id={id}>
|
|
19
|
+
<Card className="gap-4 py-5 shadow-xs ring-foreground/8 [--card-spacing:--spacing(5)] sm:[--card-spacing:--spacing(6)] sm:py-6">
|
|
20
|
+
<CardHeader className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
|
|
21
|
+
<p class="flex min-w-0 flex-wrap items-baseline gap-x-1.5 text-sm">
|
|
22
|
+
<span class="font-medium text-foreground">{author.name}</span>
|
|
23
|
+
<span class="text-muted-foreground">@{author.handle}</span>
|
|
24
|
+
</p>
|
|
25
|
+
<time datetime={postedAt.toISOString().slice(0, 10)} class="shrink-0 text-xs text-muted-foreground tabular-nums">
|
|
26
|
+
{dateFormat.format(postedAt)}
|
|
27
|
+
</time>
|
|
28
|
+
</CardHeader>
|
|
29
|
+
|
|
30
|
+
<CardContent className="flex flex-col gap-4">
|
|
31
|
+
<p class="font-serif text-[1.0625rem] leading-relaxed text-pretty text-foreground sm:text-lg">{summary}</p>
|
|
32
|
+
{keyPoints.length > 0 && (
|
|
33
|
+
<ul class="flex flex-col gap-2 border-l-2 border-border pl-4 text-[0.9375rem] leading-relaxed text-foreground/85">
|
|
34
|
+
{keyPoints.map((point) => (
|
|
35
|
+
<li class="text-pretty">{point}</li>
|
|
36
|
+
))}
|
|
37
|
+
</ul>
|
|
38
|
+
)}
|
|
39
|
+
{text !== "" && (
|
|
40
|
+
<figure class="border-t border-border pt-4">
|
|
41
|
+
<figcaption class="mb-2 text-xs font-medium text-muted-foreground">Original post</figcaption>
|
|
42
|
+
<blockquote class="text-sm leading-relaxed whitespace-pre-wrap text-pretty text-foreground/80">
|
|
43
|
+
{text}
|
|
44
|
+
</blockquote>
|
|
45
|
+
</figure>
|
|
46
|
+
)}
|
|
47
|
+
</CardContent>
|
|
48
|
+
|
|
49
|
+
<CardFooter className="justify-between gap-3 border-t-0 bg-transparent pt-1">
|
|
50
|
+
<Badge variant="secondary">{TOPIC_LABELS[topic]}</Badge>
|
|
51
|
+
<a
|
|
52
|
+
href={url}
|
|
53
|
+
target="_blank"
|
|
54
|
+
rel="noopener noreferrer"
|
|
55
|
+
class="inline-flex items-center gap-1 rounded-sm text-sm font-medium text-foreground underline decoration-border underline-offset-4 transition-colors hover:decoration-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
|
56
|
+
>
|
|
57
|
+
Open on X<ArrowUpRightIcon className="size-3.5" aria-hidden="true" />
|
|
58
|
+
</a>
|
|
59
|
+
</CardFooter>
|
|
60
|
+
</Card>
|
|
61
|
+
</li>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { SearchIcon } from "lucide-react";
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { Button } from "./ui/button.js";
|
|
5
|
+
import { Input } from "./ui/input.js";
|
|
6
|
+
import { filterSearch, parseFilter } from "../lib/filter.js";
|
|
7
|
+
import { matchingIds } from "../lib/search.js";
|
|
8
|
+
import { TOPIC_LABELS, TOPICS } from "../lib/topics.js";
|
|
9
|
+
export function BookmarkFilters({ entries }) {
|
|
10
|
+
const initial = parseFilter(window.location.search);
|
|
11
|
+
const [query, setQuery] = useState(initial.query);
|
|
12
|
+
const [topic, setTopic] = useState(initial.topic);
|
|
13
|
+
const visible = useMemo(() => matchingIds({ entries, query, topic }), [entries, query, topic]);
|
|
14
|
+
const topicsInUse = useMemo(() => TOPICS.filter((t) => entries.some((entry) => entry.topic === t)), [entries]);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
for (const card of document.querySelectorAll("[data-bookmark-id]")) {
|
|
17
|
+
card.hidden = !visible.has(card.dataset["bookmarkId"] ?? "");
|
|
18
|
+
}
|
|
19
|
+
}, [visible]);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const next = `${window.location.pathname}${filterSearch({ query, topic })}${window.location.hash}`;
|
|
22
|
+
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
|
23
|
+
if (current !== next)
|
|
24
|
+
window.history.replaceState(null, "", next);
|
|
25
|
+
}, [query, topic]);
|
|
26
|
+
return (_jsxs("div", { className: "flex flex-col gap-4", children: [_jsxs("div", { className: "relative", children: [_jsx(SearchIcon, { className: "pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { type: "search", value: query, onChange: (event) => setQuery(event.target.value), placeholder: "Search authors, posts, summaries, key points", "aria-label": "Search bookmarks", className: "h-10 rounded-full bg-card pl-9 dark:bg-card" })] }), _jsxs("fieldset", { "aria-label": "Filter by topic", className: "flex min-w-0 flex-wrap gap-2", children: [_jsx(TopicChip, { label: "All", pressed: topic === null, onClick: () => setTopic(null) }), topicsInUse.map((t) => (_jsx(TopicChip, { label: TOPIC_LABELS[t], pressed: topic === t, onClick: () => setTopic(topic === t ? null : t) }, t)))] }), _jsxs("p", { "aria-live": "polite", className: "text-sm text-muted-foreground", children: [visible.size === entries.length
|
|
27
|
+
? `${entries.length} ${entries.length === 1 ? "bookmark" : "bookmarks"}`
|
|
28
|
+
: `Showing ${visible.size} of ${entries.length}`, visible.size === 0 && entries.length > 0 ? ". No bookmarks match your search." : ""] })] }));
|
|
29
|
+
}
|
|
30
|
+
function TopicChip({ label, pressed, onClick }) {
|
|
31
|
+
return (_jsx(Button, { type: "button", size: "sm", variant: pressed ? "default" : "outline", "aria-pressed": pressed, onClick: onClick, className: "rounded-full px-3", children: label }));
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function ThemeToggle(): import("react").JSX.Element;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { MoonIcon, SunIcon } from "lucide-react";
|
|
3
|
+
import { Button } from "./ui/button.js";
|
|
4
|
+
import { DARK_QUERY, THEME_STORAGE_KEY } from "../lib/theme.js";
|
|
5
|
+
function toggleTheme() {
|
|
6
|
+
const root = document.documentElement;
|
|
7
|
+
const dark = !root.classList.contains("dark");
|
|
8
|
+
const systemDark = window.matchMedia(DARK_QUERY).matches;
|
|
9
|
+
try {
|
|
10
|
+
if (dark === systemDark) {
|
|
11
|
+
window.localStorage.removeItem(THEME_STORAGE_KEY);
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
window.localStorage.setItem(THEME_STORAGE_KEY, dark ? "dark" : "light");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
catch { }
|
|
18
|
+
root.classList.toggle("dark", dark);
|
|
19
|
+
root.style.colorScheme = dark ? "dark" : "light";
|
|
20
|
+
}
|
|
21
|
+
export function ThemeToggle() {
|
|
22
|
+
return (_jsxs(Button, { variant: "ghost", size: "icon", onClick: toggleTheme, "aria-label": "Toggle dark mode", title: "Toggle dark mode", children: [_jsx(SunIcon, { className: "hidden dark:block" }), _jsx(MoonIcon, { className: "dark:hidden" })] }));
|
|
23
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { type VariantProps } from "class-variance-authority";
|
|
3
|
+
declare const badgeVariants: (props?: ({
|
|
4
|
+
variant?: "default" | "link" | "outline" | "secondary" | "ghost" | "destructive" | null | undefined;
|
|
5
|
+
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
6
|
+
declare function Badge({ className, variant, asChild, ...props }: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & {
|
|
7
|
+
asChild?: boolean;
|
|
8
|
+
}): React.JSX.Element;
|
|
9
|
+
export { Badge };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { cva } from "class-variance-authority";
|
|
4
|
+
import { cn } from "cn";
|
|
5
|
+
import { Slot } from "radix-ui";
|
|
6
|
+
const badgeVariants = cva("group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", {
|
|
7
|
+
variants: {
|
|
8
|
+
variant: {
|
|
9
|
+
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
|
10
|
+
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
|
11
|
+
destructive: "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
|
12
|
+
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
|
13
|
+
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
|
14
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
defaultVariants: {
|
|
18
|
+
variant: "default",
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
function Badge({ className, variant = "default", asChild = false, ...props }) {
|
|
22
|
+
const Comp = asChild ? Slot.Root : "span";
|
|
23
|
+
return (_jsx(Comp, { "data-slot": "badge", "data-variant": variant, className: cn(badgeVariants({ variant }), className), ...props }));
|
|
24
|
+
}
|
|
25
|
+
export { Badge };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { type VariantProps } from "class-variance-authority";
|
|
3
|
+
declare const buttonVariants: (props?: ({
|
|
4
|
+
variant?: "default" | "link" | "outline" | "secondary" | "ghost" | "destructive" | null | undefined;
|
|
5
|
+
size?: "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null | undefined;
|
|
6
|
+
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
7
|
+
declare function Button({ className, variant, size, asChild, ...props }: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & {
|
|
8
|
+
asChild?: boolean;
|
|
9
|
+
}): React.JSX.Element;
|
|
10
|
+
export { Button };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { cva } from "class-variance-authority";
|
|
4
|
+
import { cn } from "cn";
|
|
5
|
+
import { Slot } from "radix-ui";
|
|
6
|
+
const buttonVariants = cva("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", {
|
|
7
|
+
variants: {
|
|
8
|
+
variant: {
|
|
9
|
+
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
|
10
|
+
outline: "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
|
11
|
+
secondary: "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
|
12
|
+
ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
|
13
|
+
destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
|
14
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
15
|
+
},
|
|
16
|
+
size: {
|
|
17
|
+
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
18
|
+
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
|
19
|
+
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
20
|
+
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
21
|
+
icon: "size-8",
|
|
22
|
+
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
|
23
|
+
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
|
24
|
+
"icon-lg": "size-9",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
defaultVariants: {
|
|
28
|
+
variant: "default",
|
|
29
|
+
size: "default",
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
function Button({ className, variant = "default", size = "default", asChild = false, ...props }) {
|
|
33
|
+
const Comp = asChild ? Slot.Root : "button";
|
|
34
|
+
return (_jsx(Comp, { "data-slot": "button", "data-variant": variant, "data-size": size, className: cn(buttonVariants({ variant, size, className })), ...props }));
|
|
35
|
+
}
|
|
36
|
+
export { Button };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
declare function Card({ className, size, ...props }: React.ComponentProps<"div"> & {
|
|
3
|
+
size?: "default" | "sm";
|
|
4
|
+
}): React.JSX.Element;
|
|
5
|
+
declare function CardHeader({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
|
|
6
|
+
declare function CardContent({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
|
|
7
|
+
declare function CardFooter({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
|
|
8
|
+
export { Card, CardHeader, CardFooter, CardContent };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { cn } from "cn";
|
|
4
|
+
function Card({ className, size = "default", ...props }) {
|
|
5
|
+
return (_jsx("div", { "data-slot": "card", "data-size": size, className: cn("group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", className), ...props }));
|
|
6
|
+
}
|
|
7
|
+
function CardHeader({ className, ...props }) {
|
|
8
|
+
return (_jsx("div", { "data-slot": "card-header", className: cn("group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)", className), ...props }));
|
|
9
|
+
}
|
|
10
|
+
function CardContent({ className, ...props }) {
|
|
11
|
+
return _jsx("div", { "data-slot": "card-content", className: cn("px-(--card-spacing)", className), ...props });
|
|
12
|
+
}
|
|
13
|
+
function CardFooter({ className, ...props }) {
|
|
14
|
+
return (_jsx("div", { "data-slot": "card-footer", className: cn("flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)", className), ...props }));
|
|
15
|
+
}
|
|
16
|
+
export { Card, CardHeader, CardFooter, CardContent };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { cn } from "cn";
|
|
4
|
+
function Input({ className, type, ...props }) {
|
|
5
|
+
return (_jsx("input", { type: type, "data-slot": "input", className: cn("h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", className), ...props }));
|
|
6
|
+
}
|
|
7
|
+
export { Input };
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import react from "@astrojs/react";
|
|
4
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
5
|
+
import { BOOKMARKS_FILE, readJsonFile } from "./lib/data-files.js";
|
|
6
|
+
const PACKAGE_NAME = "grok-digest";
|
|
7
|
+
const BOOKMARKS_MODULE_ID = "virtual:grok-digest/bookmarks";
|
|
8
|
+
const RESOLVED_BOOKMARKS_MODULE_ID = `\0${BOOKMARKS_MODULE_ID}`;
|
|
9
|
+
const SCRIPT_EXTENSION = path.extname(fileURLToPath(import.meta.url));
|
|
10
|
+
const ROUTES = [
|
|
11
|
+
{ pattern: "/", entrypoint: "pages/index.astro" },
|
|
12
|
+
{ pattern: "/rss.xml", entrypoint: `pages/rss.xml${SCRIPT_EXTENSION}` },
|
|
13
|
+
{ pattern: "/rss/[topic].xml", entrypoint: `pages/rss/[topic].xml${SCRIPT_EXTENSION}` },
|
|
14
|
+
{ pattern: "/favicon.svg", entrypoint: `routes/favicon.svg${SCRIPT_EXTENSION}` },
|
|
15
|
+
{ pattern: "/robots.txt", entrypoint: `routes/robots.txt${SCRIPT_EXTENSION}` },
|
|
16
|
+
];
|
|
17
|
+
const RENDERER_PACKAGES = ["@astrojs/react", "react", "react-dom"];
|
|
18
|
+
const PACKAGE_ENTRY = fileURLToPath(import.meta.url);
|
|
19
|
+
const isRendererImport = (id) => RENDERER_PACKAGES.some((name) => id === name || id.startsWith(`${name}/`));
|
|
20
|
+
function replaceInPlace(list, replacement) {
|
|
21
|
+
list.splice(0, list.length, ...replacement);
|
|
22
|
+
}
|
|
23
|
+
function rendererFromPackage() {
|
|
24
|
+
return {
|
|
25
|
+
name: "grok-digest:renderer",
|
|
26
|
+
enforce: "pre",
|
|
27
|
+
async resolveId(id, importer, options) {
|
|
28
|
+
if (!isRendererImport(id))
|
|
29
|
+
return undefined;
|
|
30
|
+
const resolveOptions = { ...options, skipSelf: true };
|
|
31
|
+
const resolvesFromImporter = (await this.resolve(id, importer, resolveOptions)) !== null;
|
|
32
|
+
return resolvesFromImporter ? undefined : this.resolve(id, PACKAGE_ENTRY, resolveOptions);
|
|
33
|
+
},
|
|
34
|
+
configResolved(config) {
|
|
35
|
+
for (const { resolve } of [config, ...Object.values(config.environments)]) {
|
|
36
|
+
replaceInPlace(resolve.dedupe, resolve.dedupe.filter((id) => !isRendererImport(id)));
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
configEnvironment: {
|
|
40
|
+
order: "post",
|
|
41
|
+
handler(_name, config) {
|
|
42
|
+
const include = config.optimizeDeps?.include;
|
|
43
|
+
if (include !== undefined) {
|
|
44
|
+
replaceInPlace(include, include.map((id) => (isRendererImport(id) ? `${PACKAGE_NAME} > ${id}` : id)));
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function bookmarksModule({ file, source }) {
|
|
51
|
+
return {
|
|
52
|
+
name: "grok-digest:bookmarks",
|
|
53
|
+
resolveId: (id) => (id === BOOKMARKS_MODULE_ID ? RESOLVED_BOOKMARKS_MODULE_ID : undefined),
|
|
54
|
+
async load(id) {
|
|
55
|
+
if (id !== RESOLVED_BOOKMARKS_MODULE_ID)
|
|
56
|
+
return undefined;
|
|
57
|
+
this.addWatchFile(file);
|
|
58
|
+
const raw = await readJsonFile({ file, source });
|
|
59
|
+
return `export const source = ${JSON.stringify(source)};\nexport default ${JSON.stringify(raw)};`;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export default function grokDigest({ dataDir = "data" } = {}) {
|
|
64
|
+
return {
|
|
65
|
+
name: PACKAGE_NAME,
|
|
66
|
+
hooks: {
|
|
67
|
+
"astro:config:setup": ({ config, updateConfig, injectRoute }) => {
|
|
68
|
+
const root = fileURLToPath(config.root);
|
|
69
|
+
const file = path.resolve(root, dataDir, BOOKMARKS_FILE);
|
|
70
|
+
const source = path.relative(root, file).split(path.sep).join("/");
|
|
71
|
+
updateConfig({
|
|
72
|
+
integrations: [react()],
|
|
73
|
+
vite: { plugins: [rendererFromPackage(), tailwindcss(), bookmarksModule({ file, source })] },
|
|
74
|
+
});
|
|
75
|
+
for (const { pattern, entrypoint } of ROUTES) {
|
|
76
|
+
injectRoute({ pattern, entrypoint: new URL(entrypoint, import.meta.url) });
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
import "../styles/global.css";
|
|
3
|
+
import { DARK_QUERY, THEME_STORAGE_KEY } from "../lib/theme";
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
title: string;
|
|
7
|
+
description: string;
|
|
8
|
+
feedHref?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const { title, description, feedHref } = Astro.props;
|
|
12
|
+
const favicon = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/favicon.svg`;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<!doctype html>
|
|
16
|
+
<html lang="en">
|
|
17
|
+
<head>
|
|
18
|
+
<meta charset="utf-8" />
|
|
19
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
20
|
+
<meta name="robots" content="noindex,nofollow" />
|
|
21
|
+
<meta name="referrer" content="no-referrer" />
|
|
22
|
+
<meta name="description" content={description} />
|
|
23
|
+
<meta name="color-scheme" content="light dark" />
|
|
24
|
+
<link rel="icon" type="image/svg+xml" href={favicon} />
|
|
25
|
+
{feedHref && <link rel="alternate" type="application/rss+xml" title="Bookmarks" href={feedHref} />}
|
|
26
|
+
<title>{title}</title>
|
|
27
|
+
<script is:inline define:vars={{ storageKey: THEME_STORAGE_KEY, darkQuery: DARK_QUERY }}>
|
|
28
|
+
const media = window.matchMedia(darkQuery);
|
|
29
|
+
const applyTheme = () => {
|
|
30
|
+
let stored = null;
|
|
31
|
+
try {
|
|
32
|
+
stored = window.localStorage.getItem(storageKey);
|
|
33
|
+
} catch {}
|
|
34
|
+
const dark = stored === "dark" || (stored !== "light" && media.matches);
|
|
35
|
+
document.documentElement.classList.toggle("dark", dark);
|
|
36
|
+
document.documentElement.style.colorScheme = dark ? "dark" : "light";
|
|
37
|
+
};
|
|
38
|
+
applyTheme();
|
|
39
|
+
media.addEventListener("change", applyTheme);
|
|
40
|
+
</script>
|
|
41
|
+
</head>
|
|
42
|
+
<body class="min-h-dvh">
|
|
43
|
+
<slot />
|
|
44
|
+
</body>
|
|
45
|
+
</html>
|