meowpress 0.3.0-alpha.2 → 0.3.0-alpha.4

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.
@@ -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
- return explicitDate;
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
- return new Intl.DateTimeFormat("en-CA", {
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
- }).format(date);
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
- files.push(...(await collectFiles(entryPath)));
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
- if (CONTENT_EXTENSIONS.has(entry.name.slice(entry.name.lastIndexOf(".")))) {
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
- return (prefix ?? "")
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
- directory: resolve(normalized.directory),
206
+ ...normalized,
207
+ directory,
182
208
  slugPrefix: normalizeSlugPrefix(normalized.slugPrefix) || undefined,
183
209
  };
184
210
  });
185
211
  }
186
- function duplicateContentError(kind, slug) {
187
- return new Error(`Duplicate ${kind} slug "${slug}" while merging content sources.`);
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
- async function loadSectionFromSource(source, kind, options) {
190
- const sectionDirectory = resolve(source.directory, `${kind}s`);
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
- sourceFiles = await collectFiles(sectionDirectory);
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 = resolve(source.directory, directory);
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/fonts.js CHANGED
@@ -7,7 +7,7 @@ export const FONT_DIRECTORY = "fonts";
7
7
  export const FONT_EXTERNAL = `./${FONT_DIRECTORY}/*`;
8
8
  /**
9
9
  * Resolve a package file from the site first (where a host installs its theme's fonts), then from
10
- * this package (which carries the default Geist).
10
+ * this package (which carries the default Journal fonts).
11
11
  */
12
12
  function resolveFontFile(specifier) {
13
13
  for (const from of [process.cwd(), dirname(fileURLToPath(import.meta.url))]) {
@@ -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. */
@@ -2,6 +2,6 @@ export { collectContentAssets, collectAssetFiles, isDraftAsset, loadSection, nor
2
2
  export { buildSite, meowSite, type ResolvedSiteConfig, type SiteConfig } from "./build.js";
3
3
  export { aboutPage, agentMarkdown, homePage, listPage, llmText, postPage, skillPage, skillsIndexPage, thoughtPage, } from "./site.js";
4
4
  export { DEFAULT_SITE, type SiteAbout, type SiteIdentity, type SiteProfileLink } from "./identity.js";
5
- export { blogFonts, blogTheme, blogTokens, blogOptions } from "./theme.js";
5
+ export { blogFonts, blogTheme } from "./theme.js";
6
6
  export type { SiteFont } from "./fonts.js";
7
7
  export { loadSkills, type LoadSkillsOptions, type Skill, type SkillFile, type SkillScope, } from "./skills.js";
package/dist/src/index.js CHANGED
@@ -2,5 +2,5 @@ export { collectContentAssets, collectAssetFiles, isDraftAsset, loadSection, nor
2
2
  export { buildSite, meowSite } from "./build.js";
3
3
  export { aboutPage, agentMarkdown, homePage, listPage, llmText, postPage, skillPage, skillsIndexPage, thoughtPage, } from "./site.js";
4
4
  export { DEFAULT_SITE } from "./identity.js";
5
- export { blogFonts, blogTheme, blogTokens, blogOptions } from "./theme.js";
5
+ export { blogFonts, blogTheme } from "./theme.js";
6
6
  export { loadSkills, } from "./skills.js";
@@ -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
+ }
@@ -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