meowpress 0.3.0-alpha.2 → 0.3.0-alpha.3
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 +60 -27
- package/dist/src/build.d.ts +19 -13
- package/dist/src/build.js +64 -42
- package/dist/src/cli.js +105 -14
- package/dist/src/content.d.ts +9 -1
- package/dist/src/content.js +86 -22
- package/dist/src/identity.d.ts +6 -0
- package/dist/src/output.d.ts +7 -0
- package/dist/src/output.js +111 -0
- package/dist/src/preview.d.ts +1 -0
- package/dist/src/preview.js +61 -0
- package/dist/src/site.d.ts +3 -2
- package/dist/src/site.js +37 -12
- package/dist/src/skills.d.ts +1 -0
- package/dist/src/skills.js +13 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,30 +1,75 @@
|
|
|
1
1
|
# Meowpress
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Meowpress builds a static blog from Markdown and MDX, with a built-in theme for posts, short notes
|
|
4
|
+
and public skills. A project can supply its own theme or page map through `meowpress/build`.
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
## Build and preview a site
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
Use Bun and install this version of `meowpress` with compatible `meowcss` and `meowjsx` peers.
|
|
9
|
+
When trying a workspace build, install the archives from `bun run build` and `bun run pack` in your
|
|
10
|
+
project. From that project, add your first post and build the site.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
```sh
|
|
13
|
+
mkdir -p content/posts
|
|
14
|
+
printf '# First post\n\nHello from Meowpress.\n' > content/posts/first.md
|
|
15
|
+
bunx --no-install meowpress build
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Meowpress builds `dist/index.html` and `dist/posts/first/index.html`. Put short notes in
|
|
19
|
+
`content/thoughts/` and public skills in `skills/<name>/SKILL.md`. `llms.txt` and `agents.md`
|
|
20
|
+
describe the generated site. The default theme includes `404.html` for static hosts that serve a
|
|
21
|
+
custom error page.
|
|
22
|
+
|
|
23
|
+
Run `bunx --no-install meowpress preview` to serve the built files at `http://127.0.0.1:4173`.
|
|
24
|
+
Run the build again after editing content. Deploy the `dist/` directory to a static host.
|
|
25
|
+
|
|
26
|
+
## Configure an existing repository
|
|
13
27
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
28
|
+
Place `meowpress.config.ts` in the project root when your content lives outside the default
|
|
29
|
+
folders or when you want to change the site name. This example uses `blog/` for posts, `notes/`
|
|
30
|
+
for short notes and `blog/assets/images/` for the homepage image.
|
|
17
31
|
|
|
18
32
|
```ts
|
|
19
33
|
import { meowSite } from "meowpress/build";
|
|
20
34
|
|
|
21
35
|
export default meowSite({
|
|
22
|
-
content:
|
|
23
|
-
|
|
24
|
-
|
|
36
|
+
content: {
|
|
37
|
+
directory: ".",
|
|
38
|
+
postsDirectory: "blog",
|
|
39
|
+
thoughtsDirectory: "notes",
|
|
40
|
+
assetsDirectory: "blog/assets",
|
|
41
|
+
},
|
|
42
|
+
site: {
|
|
43
|
+
name: "My site",
|
|
44
|
+
images: { hero: "images/cover.svg" },
|
|
45
|
+
},
|
|
25
46
|
});
|
|
26
47
|
```
|
|
27
48
|
|
|
49
|
+
The directory names in `content` are relative to its `directory`. Posts from `blog/` still have
|
|
50
|
+
URLs under `/posts/`, and notes still have URLs under `/thoughts/`. Meowpress copies `posts/assets/`
|
|
51
|
+
to `/posts/assets/`. Set `postAssetsDirectory` to change the source of those files. Posts and assets
|
|
52
|
+
whose paths contain a file or folder name starting with `_` are treated as drafts.
|
|
53
|
+
|
|
54
|
+
Pass a project path after `build` or `preview` to run from another directory. The project root
|
|
55
|
+
defaults to the current directory and is the base for relative config paths and output.
|
|
56
|
+
The CLI looks for `meowpress.config.ts`, then `site.config.ts`. Use `--config <file>` to choose a
|
|
57
|
+
file explicitly. `meowpress` and `meowpress --config ./site.config.ts` also build, and `preview`
|
|
58
|
+
accepts `--port <port>`.
|
|
59
|
+
|
|
60
|
+
Builds replace the generated output after they succeed. Meowpress reuses a nonempty default
|
|
61
|
+
`dist/` only when it contains the previous build's `agents.md` and `llms.txt`. If another tool
|
|
62
|
+
owns `dist/`, set `outputDirectory` to a separate directory and reserve it for generated files.
|
|
63
|
+
|
|
64
|
+
`includeProjectSkills: true` opts in to publishing `.agents/skills/`. Set `projectRootDirectory`
|
|
65
|
+
to choose the root for those skills. Set `notFound: false` to omit the built-in `404.html`, or
|
|
66
|
+
supply `notFound: { title, message, homeLabel }` to change its copy.
|
|
67
|
+
|
|
68
|
+
## Build from code
|
|
69
|
+
|
|
70
|
+
`meowSite()` resolves configuration and `buildSite()` writes the site. Both are exported from
|
|
71
|
+
`meowpress/build`. Use trusted local configuration and MDX, which execute code during a build.
|
|
72
|
+
|
|
28
73
|
## Pages a consumer owns
|
|
29
74
|
|
|
30
75
|
A consumer that supplies `pages` owns the whole site map. The provider receives the content that was
|
|
@@ -64,7 +109,6 @@ export default meowSite({
|
|
|
64
109
|
applies to the built-in pages as well, so a renamed stylesheet never leaves a page linking a file
|
|
65
110
|
that was not written.
|
|
66
111
|
|
|
67
|
-
Directories in the configuration are provided explicitly by the application; the default theme resolves from this package and does not depend on the startup directory.
|
|
68
112
|
The whole theme can be replaced with `theme: defineTheme(...)`; all static pages output that theme's root attributes and default options.
|
|
69
113
|
The build writes `theme.cssText` to a temporary CSS file, then lets Bun handle the other assets, and finally delivers through a stylesheet link,
|
|
70
114
|
so the theme applies without client-side JavaScript.
|
|
@@ -80,19 +124,8 @@ Install the listed packages in the site; nothing depends on an external font ser
|
|
|
80
124
|
Default illustrations use in-package vectors and CSS and require no business images. The `hero`, `feed` and `about` keys of `site.images`
|
|
81
125
|
can explicitly set decorative image paths, for example `images/cover.svg`; paths are relative to the content root's `assets/`, the library handles each page depth,
|
|
82
126
|
and assets are delivered along the existing asset build flow. When no About image is set, the first letter of the site name is used.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
## Verification and build
|
|
87
|
-
|
|
88
|
-
After installing `meowpress` and its `meowcss` and `meowjsx` dependencies, run in the consumer directory:
|
|
89
|
-
|
|
90
|
-
```sh
|
|
91
|
-
bunx --no-install meowpress --config ./site.config.ts
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
The CLI requires Bun, and the output location is determined by `outputDirectory`. Font packages resolve from the startup directory first, then from
|
|
95
|
-
this package, which carries the default Geist, so consumers are not required to copy source, install workspace-root dependencies or access an external font service.
|
|
127
|
+
Font packages resolve from the project root first, then from this package, which carries the
|
|
128
|
+
default Geist.
|
|
96
129
|
|
|
97
130
|
## Independent verification
|
|
98
131
|
|
package/dist/src/build.d.ts
CHANGED
|
@@ -1,28 +1,32 @@
|
|
|
1
1
|
import { type ThemeDefinition } from "meowcss";
|
|
2
2
|
import { type SiteFont } from "./fonts.js";
|
|
3
|
-
import { type SiteIdentity } from "./identity.js";
|
|
3
|
+
import { type SiteIdentity, type SiteNotFound } from "./identity.js";
|
|
4
4
|
import { type ContentSourceInput, type MdxComponent } from "./content.js";
|
|
5
5
|
import { type SitePageProvider } from "./pages.js";
|
|
6
6
|
export { sitePageMarkdownFile } from "./pages.js";
|
|
7
7
|
export type { SitePage, SitePageContext, SitePageProvider } from "./pages.js";
|
|
8
|
-
export type { ContentItem } from "./content.js";
|
|
8
|
+
export type { ContentItem, ContentSource, ContentSourceInput } from "./content.js";
|
|
9
9
|
export type { SiteFont } from "./fonts.js";
|
|
10
|
-
export type { SiteIdentity } from "./identity.js";
|
|
10
|
+
export type { SiteIdentity, SiteNotFound } from "./identity.js";
|
|
11
11
|
export interface SiteConfig {
|
|
12
|
-
/**
|
|
13
|
-
content
|
|
14
|
-
/**
|
|
15
|
-
skillsDirectory
|
|
16
|
-
/** Static output directory for the generated site. */
|
|
17
|
-
outputDirectory
|
|
12
|
+
/** Roots containing posts, thoughts and optional assets. Defaults to `content/`. */
|
|
13
|
+
content?: ContentSourceInput | readonly ContentSourceInput[];
|
|
14
|
+
/** Root used to discover public `skills/`. Defaults to the current project directory. */
|
|
15
|
+
skillsDirectory?: string;
|
|
16
|
+
/** Static output directory for the generated site. Defaults to `dist/`. */
|
|
17
|
+
outputDirectory?: string;
|
|
18
18
|
/** Defaults to this package's own Blog theme. */
|
|
19
19
|
theme?: ThemeDefinition;
|
|
20
|
-
/**
|
|
20
|
+
/** Include project `.agents/skills/` in the public build. Defaults to false. */
|
|
21
|
+
includeProjectSkills?: boolean;
|
|
22
|
+
/** Alternate root for `.agents/skills/` when `includeProjectSkills` is enabled. */
|
|
21
23
|
projectRootDirectory?: string;
|
|
22
24
|
/** Site name, description, repository links and About copy; unset fields use neutral defaults. */
|
|
23
25
|
site?: Partial<SiteIdentity>;
|
|
24
|
-
/** Consumer-supplied pages
|
|
26
|
+
/** Consumer-supplied pages replace the built-in site map. */
|
|
25
27
|
pages?: SitePageProvider;
|
|
28
|
+
/** Copy for the built-in root `404.html`, or false to omit it. */
|
|
29
|
+
notFound?: SiteNotFound | false;
|
|
26
30
|
/**
|
|
27
31
|
* Markdown that opens `llms.txt` and `llms-full.txt` after the site summary: what an agent must
|
|
28
32
|
* know before following any link, such as how to install and set up what the site documents.
|
|
@@ -43,14 +47,16 @@ export interface ResolvedSiteConfig {
|
|
|
43
47
|
skillsDirectory: string;
|
|
44
48
|
outputDirectory: string;
|
|
45
49
|
theme: ThemeDefinition;
|
|
50
|
+
includeProjectSkills?: boolean;
|
|
46
51
|
projectRootDirectory?: string;
|
|
47
52
|
site: SiteIdentity;
|
|
48
53
|
pages?: SitePageProvider;
|
|
54
|
+
notFound?: SiteNotFound | false;
|
|
49
55
|
agentGuide?: string;
|
|
50
56
|
mdxComponents: Record<string, MdxComponent>;
|
|
51
57
|
themeStylesheet: string;
|
|
52
58
|
fonts: readonly SiteFont[];
|
|
53
59
|
}
|
|
54
60
|
/** Define a site config in the app package without taking on the build pipeline. */
|
|
55
|
-
export declare function meowSite(config
|
|
56
|
-
export declare function buildSite(input
|
|
61
|
+
export declare function meowSite(config?: SiteConfig): ResolvedSiteConfig;
|
|
62
|
+
export declare function buildSite(input?: SiteConfig | ResolvedSiteConfig): Promise<void>;
|
package/dist/src/build.js
CHANGED
|
@@ -2,28 +2,35 @@ import { removeLegacyIsFallbacks } from "meowcss/bundle";
|
|
|
2
2
|
import { blogFonts, blogTheme } from "./theme.js";
|
|
3
3
|
import { FONT_EXTERNAL, publishFonts } from "./fonts.js";
|
|
4
4
|
import { DEFAULT_SITE } from "./identity.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { constants } from "node:fs";
|
|
6
|
+
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
7
8
|
import { collectContentAssets, isDraftAsset, loadSection, normalizeContentSources, } from "./content.js";
|
|
8
|
-
import { aboutPage, agentMarkdown, DEFAULT_THEME_STYLESHEET, homePage, listPage, llmFullText, llmText, postPage, skillPage, skillsIndexPage, thoughtPage, } from "./site.js";
|
|
9
|
+
import { aboutPage, agentMarkdown, DEFAULT_THEME_STYLESHEET, homePage, listPage, llmFullText, llmText, notFoundPage, postPage, skillPage, skillsIndexPage, thoughtPage, } from "./site.js";
|
|
9
10
|
import { loadSkills } from "./skills.js";
|
|
10
11
|
import { sitePageFile, sitePageMarkdownFile } from "./pages.js";
|
|
12
|
+
import { publishSite, validateBuildDestination, validateOutputDirectory } from "./output.js";
|
|
11
13
|
export { sitePageMarkdownFile } from "./pages.js";
|
|
12
14
|
/** Define a site config in the app package without taking on the build pipeline. */
|
|
13
|
-
export function meowSite(config) {
|
|
15
|
+
export function meowSite(config = {}) {
|
|
16
|
+
const themeStylesheet = config.themeStylesheet ?? DEFAULT_THEME_STYLESHEET;
|
|
17
|
+
if (basename(themeStylesheet) !== themeStylesheet || !themeStylesheet.endsWith(".css"))
|
|
18
|
+
throw new Error(`Expected a theme stylesheet file name: ${themeStylesheet}`);
|
|
14
19
|
return {
|
|
15
|
-
content: normalizeContentSources(config.content),
|
|
16
|
-
skillsDirectory: resolve(config.skillsDirectory),
|
|
17
|
-
outputDirectory: resolve(config.outputDirectory),
|
|
20
|
+
content: normalizeContentSources(config.content ?? "content"),
|
|
21
|
+
skillsDirectory: resolve(config.skillsDirectory ?? "."),
|
|
22
|
+
outputDirectory: resolve(config.outputDirectory ?? "dist"),
|
|
18
23
|
theme: config.theme ?? blogTheme,
|
|
24
|
+
includeProjectSkills: config.includeProjectSkills,
|
|
19
25
|
projectRootDirectory: config.projectRootDirectory
|
|
20
26
|
? resolve(config.projectRootDirectory)
|
|
21
27
|
: undefined,
|
|
22
28
|
site: { ...DEFAULT_SITE, ...config.site },
|
|
23
29
|
pages: config.pages,
|
|
30
|
+
notFound: config.notFound,
|
|
24
31
|
agentGuide: config.agentGuide,
|
|
25
32
|
mdxComponents: config.mdxComponents ?? {},
|
|
26
|
-
themeStylesheet
|
|
33
|
+
themeStylesheet,
|
|
27
34
|
fonts: config.fonts ?? (config.theme === undefined || config.theme === blogTheme ? blogFonts : []),
|
|
28
35
|
};
|
|
29
36
|
}
|
|
@@ -33,25 +40,23 @@ async function writePage(generatedDirectory, pathname, html) {
|
|
|
33
40
|
await writeFile(target, html, "utf8");
|
|
34
41
|
return target;
|
|
35
42
|
}
|
|
36
|
-
async function
|
|
37
|
-
const assets = await collectContentAssets(sources, { directory
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
await
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
await cp(asset.sourcePath, target, { force: true });
|
|
54
|
-
}));
|
|
43
|
+
async function writeAssets(destination, sources, directory) {
|
|
44
|
+
const assets = await collectContentAssets(sources, { directory });
|
|
45
|
+
for (const asset of assets) {
|
|
46
|
+
if (isDraftAsset(asset.sourcePath, asset.assetRoot))
|
|
47
|
+
continue;
|
|
48
|
+
const path = directory === "assets" ? asset.relativePath : join(directory, asset.relativePath);
|
|
49
|
+
const target = join(destination, path);
|
|
50
|
+
await mkdir(dirname(target), { recursive: true });
|
|
51
|
+
try {
|
|
52
|
+
await copyFile(asset.sourcePath, target, constants.COPYFILE_EXCL);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error.code !== "EEXIST")
|
|
56
|
+
throw error;
|
|
57
|
+
throw new Error(`Content asset would replace a generated file: ${path}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
55
60
|
}
|
|
56
61
|
async function buildStaticHtmlFromEntrypoints(entrypoints, generatedDirectory, outputDirectory) {
|
|
57
62
|
const result = await Bun.build({
|
|
@@ -60,13 +65,12 @@ async function buildStaticHtmlFromEntrypoints(entrypoints, generatedDirectory, o
|
|
|
60
65
|
outdir: outputDirectory,
|
|
61
66
|
target: "browser",
|
|
62
67
|
minify: true,
|
|
68
|
+
throw: false,
|
|
63
69
|
// Published font files stay separate, so subsets load by unicode-range instead of inline.
|
|
64
70
|
external: [FONT_EXTERNAL],
|
|
65
71
|
});
|
|
66
|
-
if (!result.success)
|
|
67
|
-
|
|
68
|
-
throw new Error("Unable to build site static HTML");
|
|
69
|
-
}
|
|
72
|
+
if (!result.success)
|
|
73
|
+
throw new Error(["Unable to build site static HTML", ...result.logs.map((log) => log.message)].join("\n"));
|
|
70
74
|
// Bun writes legacy :-webkit-any()/:-moz-any() copies of every :is() rule; Safari misreads them.
|
|
71
75
|
for (const output of result.outputs)
|
|
72
76
|
if (output.path.endsWith(".css"))
|
|
@@ -111,21 +115,36 @@ async function writeContentPages(generatedDirectory, posts, thoughts, theme, sit
|
|
|
111
115
|
}
|
|
112
116
|
return entrypoints;
|
|
113
117
|
}
|
|
114
|
-
export async function buildSite(input) {
|
|
118
|
+
export async function buildSite(input = {}) {
|
|
115
119
|
const config = meowSite(input);
|
|
116
|
-
|
|
120
|
+
await validateOutputDirectory(config);
|
|
121
|
+
await validateBuildDestination(config.outputDirectory);
|
|
122
|
+
const outputParent = dirname(config.outputDirectory);
|
|
123
|
+
await mkdir(outputParent, { recursive: true });
|
|
124
|
+
const workspace = await mkdtemp(join(outputParent, ".meowpress-build-"));
|
|
125
|
+
try {
|
|
126
|
+
const outputDirectory = join(workspace, "output");
|
|
127
|
+
const result = await writeSite({ ...config, outputDirectory }, join(workspace, "source"));
|
|
128
|
+
await validateBuildDestination(config.outputDirectory);
|
|
129
|
+
await publishSite(outputDirectory, config.outputDirectory);
|
|
130
|
+
console.log(`Built ${result} into ${config.outputDirectory}.`);
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
await rm(workspace, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async function writeSite(config, generatedDirectory) {
|
|
117
137
|
const [posts, thoughts, skills] = await Promise.all([
|
|
118
138
|
loadSection(config.content, "post", { components: config.mdxComponents }),
|
|
119
139
|
loadSection(config.content, "thought", { components: config.mdxComponents }),
|
|
120
140
|
config.pages
|
|
121
141
|
? Promise.resolve([])
|
|
122
142
|
: loadSkills(config.skillsDirectory, {
|
|
143
|
+
includeProjectSkills: config.includeProjectSkills,
|
|
123
144
|
projectRootDirectory: config.projectRootDirectory,
|
|
124
145
|
}),
|
|
125
146
|
]);
|
|
126
147
|
const timeline = [...posts, ...thoughts].sort((left, right) => (right.date?.getTime() ?? 0) - (left.date?.getTime() ?? 0));
|
|
127
|
-
await rm(config.outputDirectory, { recursive: true, force: true });
|
|
128
|
-
await rm(generatedDirectory, { recursive: true, force: true });
|
|
129
148
|
await mkdir(config.outputDirectory, { recursive: true });
|
|
130
149
|
await mkdir(generatedDirectory, { recursive: true });
|
|
131
150
|
// A consumer that supplies its own pages owns the whole site map; the package's
|
|
@@ -150,6 +169,9 @@ export async function buildSite(input) {
|
|
|
150
169
|
writePage(generatedDirectory, "thoughts/index.html", listPage("thought", thoughts, config.theme, config.site, config.themeStylesheet)),
|
|
151
170
|
writePage(generatedDirectory, "skills/index.html", skillsIndexPage(skills, config.theme, config.site, config.themeStylesheet)),
|
|
152
171
|
])), ...(await writeContentPages(generatedDirectory, posts, thoughts, config.theme, config.site, config.themeStylesheet)));
|
|
172
|
+
if (config.notFound !== false) {
|
|
173
|
+
entrypoints.push(await writePage(generatedDirectory, "404.html", notFoundPage(config.notFound ?? {}, config.theme, config.site, config.themeStylesheet)));
|
|
174
|
+
}
|
|
153
175
|
for (const skill of skills) {
|
|
154
176
|
const skillPath = skill.scope === "public" ? `skills/${skill.slug}` : `skills/project/${skill.slug}`;
|
|
155
177
|
entrypoints.push(await writePage(generatedDirectory, `${skillPath}/index.html`, skillPage(skill, config.theme, config.site, config.themeStylesheet)));
|
|
@@ -159,11 +181,11 @@ export async function buildSite(input) {
|
|
|
159
181
|
// root, next to the published font directory its rules reference.
|
|
160
182
|
const fontFaces = await publishFonts(config.fonts, config.outputDirectory);
|
|
161
183
|
await writeFile(join(generatedDirectory, config.themeStylesheet), fontFaces ? `${fontFaces}\n${config.theme.cssText}` : config.theme.cssText, "utf8");
|
|
162
|
-
await
|
|
184
|
+
await writeAssets(generatedDirectory, config.content, "assets");
|
|
163
185
|
await buildStaticHtmlFromEntrypoints(entrypoints, generatedDirectory, config.outputDirectory);
|
|
164
|
-
await copyPostAssets(config.outputDirectory, config.content);
|
|
165
186
|
if (pages)
|
|
166
187
|
await writeConsumerMarkdown(config.outputDirectory, pages);
|
|
188
|
+
await writeAssets(config.outputDirectory, config.content, "posts/assets");
|
|
167
189
|
const full = pages ? llmFullText(config.site, pages, config.agentGuide) : undefined;
|
|
168
190
|
await Promise.all([
|
|
169
191
|
writeFile(join(config.outputDirectory, "llm.txt"), llmText(posts, thoughts, config.site, pages, config.agentGuide), "utf8"),
|
|
@@ -171,10 +193,10 @@ export async function buildSite(input) {
|
|
|
171
193
|
...(full === undefined
|
|
172
194
|
? []
|
|
173
195
|
: [writeFile(join(config.outputDirectory, "llms-full.txt"), full, "utf8")]),
|
|
174
|
-
writeFile(join(config.outputDirectory, "agents.md"), agentMarkdown(posts, thoughts, config.site, pages
|
|
196
|
+
writeFile(join(config.outputDirectory, "agents.md"), agentMarkdown(posts, thoughts, config.site, pages, config.content.some((source) => typeof source !== "string" &&
|
|
197
|
+
(source.postsDirectory !== undefined || source.thoughtsDirectory !== undefined))), "utf8"),
|
|
175
198
|
]);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
: `Built ${posts.length} posts and ${thoughts.length} thoughts into site dist/.`);
|
|
199
|
+
return pages
|
|
200
|
+
? `${posts.length} content items and ${pages.length} site pages`
|
|
201
|
+
: `${posts.length} posts and ${thoughts.length} thoughts`;
|
|
180
202
|
}
|
package/dist/src/cli.js
CHANGED
|
@@ -1,22 +1,113 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
2
3
|
import { resolve } from "node:path";
|
|
3
4
|
import { pathToFileURL } from "node:url";
|
|
4
|
-
import { buildSite } from "./build.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
import { buildSite, meowSite } from "./build.js";
|
|
6
|
+
import { hasBuiltSite, validateOutputDirectory } from "./output.js";
|
|
7
|
+
import { serveSite } from "./preview.js";
|
|
8
|
+
const USAGE = [
|
|
9
|
+
"Usage: meowpress [--config <file>]",
|
|
10
|
+
" meowpress build [root] [--config <file>]",
|
|
11
|
+
" meowpress preview [root] [--config <file>] [--port <port>]",
|
|
12
|
+
].join("\n");
|
|
13
|
+
const CONFIG_FILES = ["meowpress.config.ts", "site.config.ts"];
|
|
14
|
+
function parseArgs(args) {
|
|
15
|
+
const command = args[0] === "preview" ? "preview" : "build";
|
|
16
|
+
const hasCommand = args[0] === command;
|
|
17
|
+
let root;
|
|
18
|
+
let config;
|
|
19
|
+
let port;
|
|
20
|
+
for (let index = hasCommand ? 1 : 0; index < args.length; index++) {
|
|
21
|
+
const arg = args[index];
|
|
22
|
+
if (arg === "--config" && config === undefined) {
|
|
23
|
+
const value = args[++index];
|
|
24
|
+
if (!value || value.startsWith("-"))
|
|
25
|
+
throw new Error("Missing file for --config");
|
|
26
|
+
config = value;
|
|
27
|
+
}
|
|
28
|
+
else if (arg === "--port" && command === "preview" && port === undefined) {
|
|
29
|
+
const value = args[++index];
|
|
30
|
+
port = Number(value);
|
|
31
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
32
|
+
throw new Error("Port must be an integer between 1 and 65535");
|
|
33
|
+
}
|
|
34
|
+
else if (hasCommand && !arg.startsWith("-") && root === undefined) {
|
|
35
|
+
root = arg;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
throw new Error(`Unexpected argument: ${arg}\n${USAGE}`);
|
|
39
|
+
}
|
|
10
40
|
}
|
|
11
|
-
return resolve(
|
|
41
|
+
return { command, root: resolve(root ?? "."), config, port };
|
|
42
|
+
}
|
|
43
|
+
async function loadConfig(config) {
|
|
44
|
+
for (const candidate of config === undefined ? CONFIG_FILES : [config]) {
|
|
45
|
+
const configPath = resolve(candidate);
|
|
46
|
+
try {
|
|
47
|
+
if (!(await stat(configPath)).isFile())
|
|
48
|
+
throw new Error(`Site config is not a file: ${configPath}`);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error.code !== "ENOENT")
|
|
52
|
+
throw error;
|
|
53
|
+
if (config !== undefined)
|
|
54
|
+
throw new Error(`Site config not found: ${configPath}`);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const module = (await import(pathToFileURL(configPath).href));
|
|
58
|
+
const site = module.default ?? module.site;
|
|
59
|
+
if (typeof site !== "object" || site === null || Array.isArray(site))
|
|
60
|
+
throw new Error(`Expected a default site config object or a named site export in ${configPath}`);
|
|
61
|
+
return site;
|
|
62
|
+
}
|
|
63
|
+
return {};
|
|
12
64
|
}
|
|
13
65
|
export async function runSiteCli(args = Bun.argv.slice(2)) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
66
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
67
|
+
console.log(USAGE);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const options = parseArgs(args);
|
|
71
|
+
if (options.root !== process.cwd()) {
|
|
72
|
+
if (!(await stat(options.root).catch(() => undefined))?.isDirectory())
|
|
73
|
+
throw new Error(`Project root is not a directory: ${options.root}`);
|
|
74
|
+
// Trusted config can call meowSite while loading, so it and its imports need the selected cwd.
|
|
75
|
+
const child = Bun.spawn([
|
|
76
|
+
process.execPath,
|
|
77
|
+
import.meta.path,
|
|
78
|
+
options.command,
|
|
79
|
+
...(options.config ? ["--config", options.config] : []),
|
|
80
|
+
...(options.port ? ["--port", String(options.port)] : []),
|
|
81
|
+
], { cwd: options.root, stdin: "inherit", stdout: "inherit", stderr: "inherit" });
|
|
82
|
+
const interrupt = () => child.kill("SIGINT");
|
|
83
|
+
const terminate = () => child.kill("SIGTERM");
|
|
84
|
+
process.once("SIGINT", interrupt);
|
|
85
|
+
process.once("SIGTERM", terminate);
|
|
86
|
+
try {
|
|
87
|
+
process.exitCode = await child.exited;
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
process.off("SIGINT", interrupt);
|
|
91
|
+
process.off("SIGTERM", terminate);
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const config = await loadConfig(options.config);
|
|
96
|
+
if (options.command === "preview") {
|
|
97
|
+
const resolved = meowSite(config);
|
|
98
|
+
await validateOutputDirectory(resolved);
|
|
99
|
+
if (!(await hasBuiltSite(resolved.outputDirectory))) {
|
|
100
|
+
throw new Error(`Built site not found at ${resolved.outputDirectory}. Run meowpress build first.`);
|
|
101
|
+
}
|
|
102
|
+
const server = await serveSite(resolved.outputDirectory, options.port);
|
|
103
|
+
console.log(`Meowpress preview: ${server.url}`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
19
106
|
await buildSite(config);
|
|
20
107
|
}
|
|
21
|
-
if (import.meta.main)
|
|
22
|
-
await runSiteCli()
|
|
108
|
+
if (import.meta.main) {
|
|
109
|
+
await runSiteCli().catch((error) => {
|
|
110
|
+
console.error(error instanceof Error ? error.message : error);
|
|
111
|
+
process.exitCode = 1;
|
|
112
|
+
});
|
|
113
|
+
}
|
package/dist/src/content.d.ts
CHANGED
|
@@ -4,6 +4,14 @@ export type Frontmatter = Record<string, unknown>;
|
|
|
4
4
|
/** A content root containing `posts/`, `thoughts/`, and optional asset folders. */
|
|
5
5
|
export interface ContentSource {
|
|
6
6
|
directory: string;
|
|
7
|
+
/** Markdown and MDX posts, relative to the source root. Defaults to "posts". */
|
|
8
|
+
postsDirectory?: string;
|
|
9
|
+
/** Markdown and MDX thoughts, relative to the source root. Defaults to "thoughts". */
|
|
10
|
+
thoughtsDirectory?: string;
|
|
11
|
+
/** Assets for site pages and styles, relative to the source root. Defaults to "assets". */
|
|
12
|
+
assetsDirectory?: string;
|
|
13
|
+
/** Assets copied to /posts/assets/, relative to the source root. Defaults to "posts/assets". */
|
|
14
|
+
postAssetsDirectory?: string;
|
|
7
15
|
/** Namespace slugs and asset paths from this source to avoid collisions. */
|
|
8
16
|
slugPrefix?: string;
|
|
9
17
|
}
|
|
@@ -47,7 +55,7 @@ export interface CollectContentAssetsOptions {
|
|
|
47
55
|
directory?: string;
|
|
48
56
|
duplicate?: DuplicateContentPolicy;
|
|
49
57
|
}
|
|
50
|
-
export declare function normalizeContentSources(input: ContentSourceInput | readonly ContentSourceInput[]): ContentSource[];
|
|
58
|
+
export declare function normalizeContentSources(input: ContentSourceInput | readonly ContentSourceInput[], baseDirectory?: string): ContentSource[];
|
|
51
59
|
export declare function loadSection(sources: ContentSourceInput | readonly ContentSourceInput[], kind: ContentKind, options?: LoadSectionOptions): Promise<ContentItem[]>;
|
|
52
60
|
export declare function withoutLeadingHeading(html: string): string;
|
|
53
61
|
export declare function collectAssetFiles(directory: string): Promise<string[]>;
|
package/dist/src/content.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { compile, run } from "@mdx-js/mdx";
|
|
2
2
|
import remarkGfm from "remark-gfm";
|
|
3
|
-
import { readdir, readFile, stat } from "node:fs/promises";
|
|
4
|
-
import { relative, resolve, sep } from "node:path";
|
|
3
|
+
import { lstat, readdir, readFile, stat } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { parse as parseYaml } from "yaml";
|
|
6
6
|
import { Fragment, h } from "meowjsx/runtime";
|
|
7
7
|
import { renderToHtml } from "meowjsx/web";
|
|
@@ -42,16 +42,24 @@ function parseDate(value) {
|
|
|
42
42
|
function makeDateKey(value, date) {
|
|
43
43
|
const text = asText(value);
|
|
44
44
|
const explicitDate = text?.match(/^\d{4}-\d{2}-\d{2}/)?.[0];
|
|
45
|
-
if (explicitDate)
|
|
46
|
-
|
|
45
|
+
if (explicitDate) {
|
|
46
|
+
const parsed = new Date(`${explicitDate}T00:00:00Z`);
|
|
47
|
+
if (!Number.isNaN(parsed.getTime()) && parsed.toISOString().startsWith(explicitDate)) {
|
|
48
|
+
return explicitDate;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
47
51
|
if (!date)
|
|
48
52
|
return "undated";
|
|
49
|
-
|
|
53
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
50
54
|
timeZone: "Asia/Shanghai",
|
|
51
55
|
year: "numeric",
|
|
52
56
|
month: "2-digit",
|
|
53
57
|
day: "2-digit",
|
|
54
|
-
}).
|
|
58
|
+
}).formatToParts(date);
|
|
59
|
+
const year = parts.find((part) => part.type === "year").value;
|
|
60
|
+
const month = parts.find((part) => part.type === "month").value;
|
|
61
|
+
const day = parts.find((part) => part.type === "day").value;
|
|
62
|
+
return `${year.padStart(4, "0")}-${month}-${day}`;
|
|
55
63
|
}
|
|
56
64
|
function makeTimeLabel(value, date) {
|
|
57
65
|
const text = asText(value);
|
|
@@ -126,14 +134,14 @@ async function renderMdx(source, components) {
|
|
|
126
134
|
return renderToHtml(component({ components: { ...MDX_COMPONENTS, ...components } }));
|
|
127
135
|
}
|
|
128
136
|
async function renderSource(source, sourcePath, components) {
|
|
129
|
-
if (sourcePath.endsWith(".md")) {
|
|
137
|
+
if (sourcePath.toLowerCase().endsWith(".md")) {
|
|
130
138
|
return Bun.markdown.html(source, {
|
|
131
139
|
tagFilter: true,
|
|
132
140
|
});
|
|
133
141
|
}
|
|
134
142
|
return renderMdx(source, components);
|
|
135
143
|
}
|
|
136
|
-
async function collectFiles(directory) {
|
|
144
|
+
async function collectFiles(directory, assetRoots, skipNestedAssets) {
|
|
137
145
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
138
146
|
const files = [];
|
|
139
147
|
for (const entry of entries) {
|
|
@@ -141,10 +149,13 @@ async function collectFiles(directory) {
|
|
|
141
149
|
continue;
|
|
142
150
|
const entryPath = resolve(directory, entry.name);
|
|
143
151
|
if (entry.isDirectory()) {
|
|
144
|
-
|
|
152
|
+
if (assetRoots.has(entryPath) || (skipNestedAssets && entry.name === "assets"))
|
|
153
|
+
continue;
|
|
154
|
+
files.push(...(await collectFiles(entryPath, assetRoots, skipNestedAssets)));
|
|
145
155
|
continue;
|
|
146
156
|
}
|
|
147
|
-
|
|
157
|
+
const extension = entry.name.slice(entry.name.lastIndexOf(".")).toLowerCase();
|
|
158
|
+
if (entry.isFile() && CONTENT_EXTENSIONS.has(extension)) {
|
|
148
159
|
files.push(entryPath);
|
|
149
160
|
}
|
|
150
161
|
}
|
|
@@ -164,39 +175,90 @@ function makeSlug(sourcePath, sectionDirectory) {
|
|
|
164
175
|
.join("/");
|
|
165
176
|
}
|
|
166
177
|
function normalizeSlugPrefix(prefix) {
|
|
167
|
-
|
|
178
|
+
const normalized = (prefix ?? "")
|
|
168
179
|
.replaceAll("\\", "/")
|
|
169
180
|
.replace(/^\/+|\/+$/g, "")
|
|
170
181
|
.replaceAll(/\/+/g, "/");
|
|
182
|
+
if (normalized.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
183
|
+
throw new Error(`Content slug prefix must not contain "." or "..": ${prefix}`);
|
|
184
|
+
}
|
|
185
|
+
return normalized;
|
|
171
186
|
}
|
|
172
187
|
function withSlugPrefix(prefix, slug) {
|
|
173
188
|
const normalizedPrefix = normalizeSlugPrefix(prefix);
|
|
174
189
|
return normalizedPrefix ? `${normalizedPrefix}/${slug}` : slug;
|
|
175
190
|
}
|
|
176
|
-
export function normalizeContentSources(input) {
|
|
191
|
+
export function normalizeContentSources(input, baseDirectory = process.cwd()) {
|
|
177
192
|
const sources = Array.isArray(input) ? input : [input];
|
|
178
193
|
return sources.map((source) => {
|
|
179
194
|
const normalized = typeof source === "string" ? { directory: source } : source;
|
|
195
|
+
const directory = resolve(baseDirectory, normalized.directory);
|
|
196
|
+
for (const mapping of [
|
|
197
|
+
normalized.postsDirectory,
|
|
198
|
+
normalized.thoughtsDirectory,
|
|
199
|
+
normalized.assetsDirectory,
|
|
200
|
+
normalized.postAssetsDirectory,
|
|
201
|
+
]) {
|
|
202
|
+
if (mapping !== undefined)
|
|
203
|
+
sourceDirectory(directory, mapping);
|
|
204
|
+
}
|
|
180
205
|
return {
|
|
181
|
-
|
|
206
|
+
...normalized,
|
|
207
|
+
directory,
|
|
182
208
|
slugPrefix: normalizeSlugPrefix(normalized.slugPrefix) || undefined,
|
|
183
209
|
};
|
|
184
210
|
});
|
|
185
211
|
}
|
|
186
|
-
function
|
|
187
|
-
|
|
212
|
+
function sourceDirectory(root, path) {
|
|
213
|
+
const target = resolve(root, path);
|
|
214
|
+
const local = relative(root, target);
|
|
215
|
+
if (isAbsolute(path) || local === ".." || local.startsWith(`..${sep}`) || isAbsolute(local)) {
|
|
216
|
+
throw new Error(`Content directory must stay inside its source root: ${path}`);
|
|
217
|
+
}
|
|
218
|
+
return target;
|
|
188
219
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
let sourceFiles;
|
|
220
|
+
/** Check every segment below a source root before walking a configured directory. */
|
|
221
|
+
async function isSourceDirectory(root, target) {
|
|
192
222
|
try {
|
|
193
|
-
|
|
223
|
+
let path = root;
|
|
224
|
+
if (!(await lstat(path)).isDirectory())
|
|
225
|
+
return false;
|
|
226
|
+
for (const segment of relative(root, target).split(sep).filter(Boolean)) {
|
|
227
|
+
path = resolve(path, segment);
|
|
228
|
+
if (!(await lstat(path)).isDirectory())
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
return true;
|
|
194
232
|
}
|
|
195
233
|
catch (error) {
|
|
196
234
|
if (error.code === "ENOENT")
|
|
197
|
-
return
|
|
235
|
+
return false;
|
|
198
236
|
throw error;
|
|
199
237
|
}
|
|
238
|
+
}
|
|
239
|
+
function assetDirectory(source, directory) {
|
|
240
|
+
const mapping = directory === "assets"
|
|
241
|
+
? source.assetsDirectory
|
|
242
|
+
: directory === "posts/assets"
|
|
243
|
+
? source.postAssetsDirectory
|
|
244
|
+
: undefined;
|
|
245
|
+
return sourceDirectory(source.directory, mapping ?? directory);
|
|
246
|
+
}
|
|
247
|
+
function duplicateContentError(kind, slug) {
|
|
248
|
+
return new Error(`Duplicate ${kind} slug "${slug}" while merging content sources.`);
|
|
249
|
+
}
|
|
250
|
+
async function loadSectionFromSource(source, kind, options) {
|
|
251
|
+
const mapping = kind === "post" ? source.postsDirectory : source.thoughtsDirectory;
|
|
252
|
+
const sectionDirectory = sourceDirectory(source.directory, mapping ?? `${kind}s`);
|
|
253
|
+
if (!(await isSourceDirectory(source.directory, sectionDirectory)))
|
|
254
|
+
return [];
|
|
255
|
+
const assetRoots = new Set([
|
|
256
|
+
assetDirectory(source, "assets"),
|
|
257
|
+
assetDirectory(source, "posts/assets"),
|
|
258
|
+
]);
|
|
259
|
+
if (assetRoots.has(sectionDirectory))
|
|
260
|
+
return [];
|
|
261
|
+
const sourceFiles = await collectFiles(sectionDirectory, assetRoots, mapping !== undefined);
|
|
200
262
|
const items = [];
|
|
201
263
|
for (const sourcePath of sourceFiles) {
|
|
202
264
|
if (isDraft(sourcePath, sectionDirectory, kind))
|
|
@@ -278,7 +340,7 @@ async function collectFilesForAssets(directory) {
|
|
|
278
340
|
if (entry.isDirectory()) {
|
|
279
341
|
files.push(...(await collectFilesForAssets(entryPath)));
|
|
280
342
|
}
|
|
281
|
-
else {
|
|
343
|
+
else if (entry.isFile()) {
|
|
282
344
|
files.push(entryPath);
|
|
283
345
|
}
|
|
284
346
|
}
|
|
@@ -289,7 +351,9 @@ export async function collectContentAssets(sources, options = {}) {
|
|
|
289
351
|
const duplicatePolicy = options.duplicate ?? "first-wins";
|
|
290
352
|
const assets = new Map();
|
|
291
353
|
for (const source of normalizeContentSources(sources)) {
|
|
292
|
-
const assetRoot =
|
|
354
|
+
const assetRoot = assetDirectory(source, directory);
|
|
355
|
+
if (!(await isSourceDirectory(source.directory, assetRoot)))
|
|
356
|
+
continue;
|
|
293
357
|
const sourceFiles = await collectAssetFiles(assetRoot);
|
|
294
358
|
for (const sourcePath of sourceFiles) {
|
|
295
359
|
const localPath = relative(assetRoot, sourcePath).split(sep).join("/");
|
package/dist/src/identity.d.ts
CHANGED
|
@@ -14,6 +14,12 @@ export interface SiteAbout {
|
|
|
14
14
|
linksHeading?: string;
|
|
15
15
|
links?: readonly SiteProfileLink[];
|
|
16
16
|
}
|
|
17
|
+
/** Optional copy for the built-in 404 page. */
|
|
18
|
+
export interface SiteNotFound {
|
|
19
|
+
title?: string;
|
|
20
|
+
message?: string;
|
|
21
|
+
homeLabel?: string;
|
|
22
|
+
}
|
|
17
23
|
/** Site-owned identity and copy. The application supplies it; the library keeps neutral defaults. */
|
|
18
24
|
export interface SiteIdentity {
|
|
19
25
|
/** Display name used in header, footer and document titles. */
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ResolvedSiteConfig } from "./build.js";
|
|
2
|
+
export declare function validateOutputDirectory(config: ResolvedSiteConfig): Promise<void>;
|
|
3
|
+
/** Both agent indexes have been emitted by Meowpress since before the conventional CLI. */
|
|
4
|
+
export declare function hasBuiltSite(directory: string): Promise<boolean>;
|
|
5
|
+
export declare function validateBuildDestination(directory: string): Promise<void>;
|
|
6
|
+
/** Move a complete build into place and preserve any previous build if that move fails. */
|
|
7
|
+
export declare function publishSite(staged: string, destination: string): Promise<void>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { lstat, mkdtemp, readdir, realpath, rename, rm } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { normalizeContentSources } from "./content.js";
|
|
4
|
+
function isMissing(error) {
|
|
5
|
+
return error.code === "ENOENT";
|
|
6
|
+
}
|
|
7
|
+
async function existingPath(path) {
|
|
8
|
+
try {
|
|
9
|
+
return await lstat(path);
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (!isMissing(error))
|
|
13
|
+
throw error;
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Resolve symlinked ancestors even if the output directory has not been created yet. */
|
|
18
|
+
async function filesystemPath(path) {
|
|
19
|
+
try {
|
|
20
|
+
return await realpath(path);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (!isMissing(error))
|
|
24
|
+
throw error;
|
|
25
|
+
return join(await filesystemPath(dirname(path)), basename(path));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function containsPath(directory, path) {
|
|
29
|
+
const fromDirectory = relative(directory, path);
|
|
30
|
+
return (!isAbsolute(fromDirectory) && fromDirectory !== ".." && !fromDirectory.startsWith(`..${sep}`));
|
|
31
|
+
}
|
|
32
|
+
export async function validateOutputDirectory(config) {
|
|
33
|
+
const project = process.cwd();
|
|
34
|
+
const sources = normalizeContentSources(config.content);
|
|
35
|
+
const sourceRoots = [
|
|
36
|
+
project,
|
|
37
|
+
config.skillsDirectory,
|
|
38
|
+
...sources.map((source) => source.directory),
|
|
39
|
+
];
|
|
40
|
+
if (config.projectRootDirectory)
|
|
41
|
+
sourceRoots.push(config.projectRootDirectory);
|
|
42
|
+
const inputs = sources.flatMap((source) => [
|
|
43
|
+
resolve(source.directory, source.postsDirectory ?? "posts"),
|
|
44
|
+
resolve(source.directory, source.thoughtsDirectory ?? "thoughts"),
|
|
45
|
+
resolve(source.directory, source.assetsDirectory ?? "assets"),
|
|
46
|
+
resolve(source.directory, source.postAssetsDirectory ?? "posts/assets"),
|
|
47
|
+
]);
|
|
48
|
+
inputs.push(join(config.skillsDirectory, "skills"), join(project, ".agents"), join(project, ".git"));
|
|
49
|
+
if (config.includeProjectSkills)
|
|
50
|
+
inputs.push(join(config.projectRootDirectory ?? config.skillsDirectory, ".agents/skills"));
|
|
51
|
+
const [output, roots, directories] = await Promise.all([
|
|
52
|
+
filesystemPath(config.outputDirectory),
|
|
53
|
+
Promise.all(sourceRoots.map(filesystemPath)),
|
|
54
|
+
Promise.all(inputs.map(filesystemPath)),
|
|
55
|
+
]);
|
|
56
|
+
if (roots.some((root) => containsPath(output, root)) ||
|
|
57
|
+
directories.some((input) => containsPath(output, input) || containsPath(input, output))) {
|
|
58
|
+
throw new Error(`Output directory overlaps project files: ${config.outputDirectory}`);
|
|
59
|
+
}
|
|
60
|
+
const info = await existingPath(config.outputDirectory);
|
|
61
|
+
if (info && !info.isDirectory())
|
|
62
|
+
throw new Error(`Site output must be a directory: ${config.outputDirectory}`);
|
|
63
|
+
}
|
|
64
|
+
/** Both agent indexes have been emitted by Meowpress since before the conventional CLI. */
|
|
65
|
+
export async function hasBuiltSite(directory) {
|
|
66
|
+
const markers = await Promise.all(["agents.md", "llms.txt"].map((name) => existingPath(join(directory, name))));
|
|
67
|
+
return markers.every((marker) => marker?.isFile());
|
|
68
|
+
}
|
|
69
|
+
export async function validateBuildDestination(directory) {
|
|
70
|
+
if (directory === resolve("dist") &&
|
|
71
|
+
(await existingPath(directory)) &&
|
|
72
|
+
(await readdir(directory)).length &&
|
|
73
|
+
!(await hasBuiltSite(directory))) {
|
|
74
|
+
throw new Error(`Refusing to replace existing files in ${directory}. Move them or choose another outputDirectory.`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Move a complete build into place and preserve any previous build if that move fails. */
|
|
78
|
+
export async function publishSite(staged, destination) {
|
|
79
|
+
const existing = await existingPath(destination);
|
|
80
|
+
if (!existing) {
|
|
81
|
+
await rename(staged, destination);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!existing.isDirectory())
|
|
85
|
+
throw new Error(`Site output must be a directory: ${destination}`);
|
|
86
|
+
const backupDirectory = await mkdtemp(join(dirname(destination), ".meowpress-previous-"));
|
|
87
|
+
const backup = join(backupDirectory, basename(destination));
|
|
88
|
+
let restoreFailed = false;
|
|
89
|
+
try {
|
|
90
|
+
await rename(destination, backup);
|
|
91
|
+
try {
|
|
92
|
+
await rename(staged, destination);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
try {
|
|
96
|
+
await rename(backup, destination);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
restoreFailed = true;
|
|
100
|
+
throw new Error(`Could not replace ${destination}. Previous output remains in ${backup}.`, {
|
|
101
|
+
cause: error,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
if (!restoreFailed)
|
|
109
|
+
await rm(backupDirectory, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function serveSite(outputDirectory: string, port?: number): Promise<Bun.Server<undefined>>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { join, resolve, sep } from "node:path";
|
|
3
|
+
async function statIfPresent(path) {
|
|
4
|
+
try {
|
|
5
|
+
return await stat(path);
|
|
6
|
+
}
|
|
7
|
+
catch (error) {
|
|
8
|
+
const code = error.code;
|
|
9
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
10
|
+
return undefined;
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export async function serveSite(outputDirectory, port = 4173) {
|
|
15
|
+
if (!(await statIfPresent(outputDirectory))?.isDirectory()) {
|
|
16
|
+
throw new Error(`Built site not found at ${outputDirectory}. Run meowpress build first.`);
|
|
17
|
+
}
|
|
18
|
+
const root = await realpath(outputDirectory);
|
|
19
|
+
const isInside = (path) => path === root || path.startsWith(root + sep);
|
|
20
|
+
async function fileResponse(path, method, status) {
|
|
21
|
+
if (!(await statIfPresent(path))?.isFile())
|
|
22
|
+
return undefined;
|
|
23
|
+
const target = await realpath(path);
|
|
24
|
+
if (!isInside(target))
|
|
25
|
+
return undefined;
|
|
26
|
+
const file = Bun.file(target);
|
|
27
|
+
return new Response(method === "HEAD" ? null : file, {
|
|
28
|
+
status,
|
|
29
|
+
headers: { "Content-Type": file.type, "Cache-Control": "no-store" },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return Bun.serve({
|
|
33
|
+
hostname: "127.0.0.1",
|
|
34
|
+
port,
|
|
35
|
+
async fetch(request) {
|
|
36
|
+
if (request.method !== "GET" && request.method !== "HEAD")
|
|
37
|
+
return new Response("Method not allowed", { status: 405, headers: { Allow: "GET, HEAD" } });
|
|
38
|
+
const url = new URL(request.url);
|
|
39
|
+
let pathname;
|
|
40
|
+
try {
|
|
41
|
+
pathname = decodeURIComponent(url.pathname);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return new Response("Invalid path", { status: 400 });
|
|
45
|
+
}
|
|
46
|
+
if (pathname.includes("\0"))
|
|
47
|
+
return new Response("Invalid path", { status: 400 });
|
|
48
|
+
const target = resolve(root, `.${pathname}`);
|
|
49
|
+
if (!isInside(target))
|
|
50
|
+
return new Response("Forbidden", { status: 403 });
|
|
51
|
+
if (!pathname.endsWith("/") && (await statIfPresent(target))?.isDirectory()) {
|
|
52
|
+
url.pathname += "/";
|
|
53
|
+
return Response.redirect(url, 301);
|
|
54
|
+
}
|
|
55
|
+
const path = pathname.endsWith("/") ? join(target, "index.html") : target;
|
|
56
|
+
return ((await fileResponse(path, request.method, 200)) ??
|
|
57
|
+
(await fileResponse(join(root, "404.html"), request.method, 404)) ??
|
|
58
|
+
new Response(request.method === "HEAD" ? null : "Page not found", { status: 404 }));
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
package/dist/src/site.d.ts
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import { type ThemeDefinition } from "meowcss";
|
|
2
2
|
import { type ContentItem } from "./content.js";
|
|
3
|
-
import { type SiteIdentity } from "./identity.js";
|
|
3
|
+
import { type SiteIdentity, type SiteNotFound } from "./identity.js";
|
|
4
4
|
import { type SitePage } from "./pages.js";
|
|
5
5
|
import type { Skill } from "./skills.js";
|
|
6
6
|
/** The name of the theme stylesheet the package writes by default. */
|
|
7
7
|
export declare const DEFAULT_THEME_STYLESHEET = "blog-theme.css";
|
|
8
8
|
export declare function homePage(items: ContentItem[], theme?: ThemeDefinition, site?: SiteIdentity, stylesheet?: string): string;
|
|
9
9
|
export declare function listPage(kind: "post" | "thought", items: ContentItem[], theme?: ThemeDefinition, site?: SiteIdentity, stylesheet?: string): string;
|
|
10
|
+
export declare function notFoundPage(copy: SiteNotFound, theme: ThemeDefinition, site: SiteIdentity, stylesheet: string): string;
|
|
10
11
|
export declare function postPage(item: ContentItem, posts: ContentItem[], thoughts: ContentItem[], theme?: ThemeDefinition, site?: SiteIdentity, stylesheet?: string): string;
|
|
11
12
|
export declare function thoughtPage(item: ContentItem, theme?: ThemeDefinition, site?: SiteIdentity, stylesheet?: string): string;
|
|
12
13
|
export declare function aboutPage(theme?: ThemeDefinition, site?: SiteIdentity, stylesheet?: string): string;
|
|
13
14
|
export declare function skillsIndexPage(skills: Skill[], theme?: ThemeDefinition, site?: SiteIdentity, stylesheet?: string): string;
|
|
14
15
|
export declare function skillPage(skill: Skill, theme?: ThemeDefinition, site?: SiteIdentity, stylesheet?: string): string;
|
|
15
|
-
export declare function agentMarkdown(posts: ContentItem[], thoughts: ContentItem[], site?: SiteIdentity, pages?: readonly SitePage[]): string;
|
|
16
|
+
export declare function agentMarkdown(posts: ContentItem[], thoughts: ContentItem[], site?: SiteIdentity, pages?: readonly SitePage[], mappedSections?: boolean): string;
|
|
16
17
|
export declare function llmText(posts: ContentItem[], thoughts: ContentItem[], site?: SiteIdentity, pages?: readonly SitePage[], agentGuide?: string): string;
|
|
17
18
|
/**
|
|
18
19
|
* Every page's Markdown in one file, after the same summary and guide as llms.txt. Pages without
|
package/dist/src/site.js
CHANGED
|
@@ -35,7 +35,7 @@ function SiteFooter({ site }) {
|
|
|
35
35
|
" \u00B7 ",
|
|
36
36
|
h("a", { href: "/agents.md" }, "agents.md"))));
|
|
37
37
|
}
|
|
38
|
-
function Document({ title, description, activePath, styleHref, children, theme, site, }) {
|
|
38
|
+
function Document({ title, description, activePath, styleHref, baseHref, children, theme, site, }) {
|
|
39
39
|
const documentTitle = title === site.name ? site.name : `${title} · ${site.name}`;
|
|
40
40
|
return (h("html", { lang: "en", ...themeAttributes(theme) },
|
|
41
41
|
h("head", null,
|
|
@@ -44,6 +44,7 @@ function Document({ title, description, activePath, styleHref, children, theme,
|
|
|
44
44
|
h("meta", { name: "description", content: description ?? site.description }),
|
|
45
45
|
h("meta", { name: "theme-color", media: "(prefers-color-scheme: light)", content: "#faf8f3" }),
|
|
46
46
|
h("meta", { name: "theme-color", media: "(prefers-color-scheme: dark)", content: "#191816" }),
|
|
47
|
+
baseHref ? h("base", { href: baseHref }) : null,
|
|
47
48
|
h("link", { rel: "stylesheet", href: styleHref }),
|
|
48
49
|
h("title", null, documentTitle)),
|
|
49
50
|
h("body", { "data-site": site.id },
|
|
@@ -56,20 +57,20 @@ function pageHtml(props) {
|
|
|
56
57
|
return `<!doctype html>\n${renderToHtml(h(Document, { ...props }))}`;
|
|
57
58
|
}
|
|
58
59
|
function dateLabel(dateKey) {
|
|
59
|
-
|
|
60
|
+
const date = new Date(`${dateKey}T00:00:00Z`);
|
|
61
|
+
if (Number.isNaN(date.getTime()))
|
|
60
62
|
return { date: "Undated", weekday: "" };
|
|
61
|
-
const
|
|
63
|
+
const weekday = new Intl.DateTimeFormat("en-US", {
|
|
64
|
+
timeZone: "UTC",
|
|
65
|
+
weekday: "short",
|
|
66
|
+
}).format(date);
|
|
62
67
|
return {
|
|
63
68
|
date: new Intl.DateTimeFormat("en-US", {
|
|
64
|
-
timeZone: "
|
|
69
|
+
timeZone: "UTC",
|
|
65
70
|
month: "long",
|
|
66
71
|
day: "numeric",
|
|
67
72
|
}).format(date),
|
|
68
|
-
weekday:
|
|
69
|
-
timeZone: "Asia/Shanghai",
|
|
70
|
-
weekday: "short",
|
|
71
|
-
year: "numeric",
|
|
72
|
-
}).format(date),
|
|
73
|
+
weekday: `${date.getUTCFullYear()} ${weekday}`,
|
|
73
74
|
};
|
|
74
75
|
}
|
|
75
76
|
function itemUrl(item) {
|
|
@@ -282,6 +283,26 @@ export function listPage(kind, items, theme = blogTheme, site = DEFAULT_SITE, st
|
|
|
282
283
|
h("div", { className: isPost ? "post-list" : "thought-list" }, items.length === 0 ? (h("p", { className: "empty-state" }, "No public content yet.")) : (items.map((item) => isPost ? h(PostCard, { item: item }) : h(ThoughtCard, { item: item, linked: true })))))),
|
|
283
284
|
});
|
|
284
285
|
}
|
|
286
|
+
export function notFoundPage(copy, theme, site, stylesheet) {
|
|
287
|
+
const title = copy.title ?? "Page not found";
|
|
288
|
+
const message = copy.message ?? "This page may have moved or been removed.";
|
|
289
|
+
return pageHtml({
|
|
290
|
+
site,
|
|
291
|
+
theme,
|
|
292
|
+
title,
|
|
293
|
+
description: message,
|
|
294
|
+
styleHref: styleHref(0, stylesheet),
|
|
295
|
+
// Static hosts can serve 404.html at the missing URL, including deep paths.
|
|
296
|
+
baseHref: "/",
|
|
297
|
+
children: (h("main", { className: "listing" },
|
|
298
|
+
h("header", { className: "listing-head" },
|
|
299
|
+
h("span", { className: "kind" }, "404"),
|
|
300
|
+
h("h1", null, title),
|
|
301
|
+
h("p", null, message)),
|
|
302
|
+
h("div", { className: "prose" },
|
|
303
|
+
h("a", { href: "/" }, copy.homeLabel ?? "Back to home")))),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
285
306
|
function PostNavigation({ item, posts }) {
|
|
286
307
|
const index = posts.findIndex((post) => post.slug === item.slug);
|
|
287
308
|
const older = index >= 0 ? posts[index + 1] : undefined;
|
|
@@ -673,7 +694,7 @@ export function skillPage(skill, theme = blogTheme, site = DEFAULT_SITE, stylesh
|
|
|
673
694
|
h("script", { dangerouslySetInnerHTML: { __html: SKILL_BROWSER_SCRIPT } }))),
|
|
674
695
|
});
|
|
675
696
|
}
|
|
676
|
-
export function agentMarkdown(posts, thoughts, site = DEFAULT_SITE, pages) {
|
|
697
|
+
export function agentMarkdown(posts, thoughts, site = DEFAULT_SITE, pages, mappedSections = false) {
|
|
677
698
|
const siteMap = pages
|
|
678
699
|
? pages.map((page) => `- [${page.title}](${sitePageAgentUrl(page)})`)
|
|
679
700
|
: [
|
|
@@ -694,8 +715,12 @@ export function agentMarkdown(posts, thoughts, site = DEFAULT_SITE, pages) {
|
|
|
694
715
|
"",
|
|
695
716
|
"## Content model",
|
|
696
717
|
"",
|
|
697
|
-
|
|
698
|
-
|
|
718
|
+
mappedSections
|
|
719
|
+
? "- Thoughts are trimmed, directly presentable notes."
|
|
720
|
+
: `- \`${site.contentDirectory}/thoughts/\` stores trimmed, directly presentable short thoughts.`,
|
|
721
|
+
mappedSections
|
|
722
|
+
? "- Posts are full articles. Post files whose name or path segment starts with `_` are drafts."
|
|
723
|
+
: `- \`${site.contentDirectory}/posts/\` stores full posts; files whose name or path segment starts with \`_\` are drafts and are not published or rendered.`,
|
|
699
724
|
"- Content files support `.md` and `.mdx`, and may start with YAML frontmatter.",
|
|
700
725
|
"- Frontmatter currently has no required fields; `title`, `date`, `created_at`, `category` and `tags` are recognized by the site.",
|
|
701
726
|
"",
|
package/dist/src/skills.d.ts
CHANGED
|
@@ -36,6 +36,7 @@ export interface Skill {
|
|
|
36
36
|
relations: SkillRelation[];
|
|
37
37
|
}
|
|
38
38
|
export interface LoadSkillsOptions {
|
|
39
|
+
includeProjectSkills?: boolean;
|
|
39
40
|
projectRootDirectory?: string;
|
|
40
41
|
}
|
|
41
42
|
export declare function loadSkills(rootDirectory: string, options?: LoadSkillsOptions): Promise<Skill[]>;
|
package/dist/src/skills.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdir, readFile } from "node:fs/promises";
|
|
1
|
+
import { lstat, readdir, readFile } from "node:fs/promises";
|
|
2
2
|
import { extname, posix, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
4
4
|
import { parse as parseYaml } from "yaml";
|
|
@@ -90,7 +90,7 @@ async function collectFiles(directory) {
|
|
|
90
90
|
const path = resolve(directory, entry.name);
|
|
91
91
|
if (entry.isDirectory())
|
|
92
92
|
files.push(...(await collectFiles(path)));
|
|
93
|
-
else
|
|
93
|
+
else if (entry.isFile())
|
|
94
94
|
files.push(path);
|
|
95
95
|
}
|
|
96
96
|
return files;
|
|
@@ -295,15 +295,21 @@ async function skillDirectories(rootDirectory, options = {}) {
|
|
|
295
295
|
base: "skills",
|
|
296
296
|
scope: "public",
|
|
297
297
|
},
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
298
|
+
...(options.includeProjectSkills
|
|
299
|
+
? [
|
|
300
|
+
{
|
|
301
|
+
root: resolve(projectRootDirectory, ".agents/skills"),
|
|
302
|
+
base: ".agents/skills",
|
|
303
|
+
scope: "project",
|
|
304
|
+
},
|
|
305
|
+
]
|
|
306
|
+
: []),
|
|
303
307
|
];
|
|
304
308
|
const directories = [];
|
|
305
309
|
for (const candidate of candidates) {
|
|
306
310
|
try {
|
|
311
|
+
if ((await lstat(candidate.root)).isSymbolicLink())
|
|
312
|
+
continue;
|
|
307
313
|
const entries = await readdir(candidate.root, { withFileTypes: true });
|
|
308
314
|
directories.push(...entries
|
|
309
315
|
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
|