@san-siva/blogkit-md-cli 0.1.50 → 0.1.52

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.
Files changed (99) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +3 -3
  3. package/.next/cache/.previewinfo +1 -1
  4. package/.next/cache/.rscinfo +1 -1
  5. package/.next/cache/.tsbuildinfo +1 -1
  6. package/.next/diagnostics/route-bundle-stats.json +2 -2
  7. package/.next/fallback-build-manifest.json +3 -3
  8. package/.next/prerender-manifest.json +3 -3
  9. package/.next/server/app/[...slug]/page.js +1 -1
  10. package/.next/server/app/[...slug]/page.js.nft.json +1 -1
  11. package/.next/server/app/[...slug]/page_client-reference-manifest.js +1 -1
  12. package/.next/server/app/_global-error.html +1 -1
  13. package/.next/server/app/_global-error.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  15. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  16. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  17. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  18. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  19. package/.next/server/app/_not-found.html +1 -1
  20. package/.next/server/app/_not-found.rsc +1 -1
  21. package/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  22. package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  23. package/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  24. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  25. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  26. package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  27. package/.next/server/app/page.js +1 -1
  28. package/.next/server/app/page.js.nft.json +1 -1
  29. package/.next/server/app/page_client-reference-manifest.js +1 -1
  30. package/.next/server/chunks/ssr/{0dlc_0b_-669._.js → 0dlc_00dxfob._.js} +2 -2
  31. package/.next/server/chunks/ssr/{0dlc_06c72kx._.js → 0dlc_01mfakk._.js} +2 -2
  32. package/.next/server/chunks/ssr/{0dlc_113eo~b._.js → 0dlc_034lzh-._.js} +2 -2
  33. package/.next/server/chunks/ssr/{0dlc_0219ubl._.js → 0dlc_05zbtlo._.js} +2 -2
  34. package/.next/server/chunks/ssr/{0dlc_0pn3q5l._.js → 0dlc_061rz1d._.js} +2 -2
  35. package/.next/server/chunks/ssr/{0dlc_0yb8l3l._.js → 0dlc_068.c_a._.js} +2 -2
  36. package/.next/server/chunks/ssr/{0dlc_05sa3v.._.js → 0dlc_06zh4cf._.js} +2 -2
  37. package/.next/server/chunks/ssr/{0dlc_0gf9l8z._.js → 0dlc_0_a75co._.js} +2 -2
  38. package/.next/server/chunks/ssr/{0dlc_0bhkl55._.js → 0dlc_0a2hf_v._.js} +2 -2
  39. package/.next/server/chunks/ssr/{0dlc_0z7y5fs._.js → 0dlc_0hjv5-5._.js} +2 -2
  40. package/.next/server/chunks/ssr/{0dlc_0ta79~a._.js → 0dlc_0mhhdqc._.js} +2 -2
  41. package/.next/server/chunks/ssr/{0dlc_0xqydor._.js → 0dlc_0sv~z9u._.js} +2 -2
  42. package/.next/server/chunks/ssr/{0dlc_12j4q34._.js → 0dlc_0uuxuhx._.js} +2 -2
  43. package/.next/server/chunks/ssr/{0dlc_0g7g3-8._.js → 0dlc_0wm2d_l._.js} +2 -2
  44. package/.next/server/chunks/ssr/{0dlc_0q1fs5n._.js → 0dlc_111xsg5._.js} +2 -2
  45. package/.next/server/chunks/ssr/{0dlc_0i-79ps._.js → 0dlc_11v.tdn._.js} +2 -2
  46. package/.next/server/chunks/ssr/{0dlc_@san-siva_blogkit_dist_esm_0up7uxr._.js → 0dlc_@san-siva_blogkit_dist_esm_04y5d~m._.js} +2 -2
  47. package/.next/server/chunks/ssr/[root-of-the-server]__06u9sw8._.js +1 -1
  48. package/.next/server/chunks/ssr/[root-of-the-server]__06u9sw8._.js.map +1 -1
  49. package/.next/server/chunks/ssr/{[root-of-the-server]__030~-6p._.js → [root-of-the-server]__0t.geu5._.js} +3 -3
  50. package/.next/server/chunks/ssr/[root-of-the-server]__0t.geu5._.js.map +1 -0
  51. package/.next/server/chunks/ssr/blogkit-md-cli_037q557._.js +6 -0
  52. package/.next/server/chunks/ssr/blogkit-md-cli_037q557._.js.map +1 -0
  53. package/.next/server/chunks/ssr/blogkit-md-cli_0p82t5f._.js +1 -1
  54. package/.next/server/chunks/ssr/blogkit-md-cli_0p82t5f._.js.map +1 -1
  55. package/.next/server/middleware-build-manifest.js +3 -3
  56. package/.next/server/pages/404.html +1 -1
  57. package/.next/server/pages/500.html +1 -1
  58. package/.next/server/server-reference-manifest.js +1 -1
  59. package/.next/server/server-reference-manifest.json +1 -1
  60. package/.next/static/chunks/{0mdj2mmw8o5eg.css → 0uqp7lp30c~08.css} +1 -1
  61. package/.next/static/chunks/{0nirptw7cuebm.js → 0xukhab5vwo26.js} +1 -1
  62. package/.next/trace +1 -1
  63. package/.next/trace-build +1 -1
  64. package/package.json +7 -11
  65. package/src/components/LiveReload.tsx +25 -0
  66. package/src/components/RenderContent.tsx +79 -0
  67. package/src/components/SettingsToolbar.module.scss +40 -0
  68. package/src/components/SettingsToolbar.tsx +45 -0
  69. package/src/components/index.ts +4 -0
  70. package/src/components/useLayoutPreferences.ts +48 -0
  71. package/src/lib/index.ts +10 -0
  72. package/src/lib/layout-preferences.ts +76 -0
  73. package/src/lib/live-reload.ts +56 -0
  74. package/src/lib/markdown-links.ts +51 -0
  75. package/src/lib/resolve-path.ts +47 -0
  76. package/src/package.json +4 -0
  77. package/.next/server/chunks/ssr/[root-of-the-server]__030~-6p._.js.map +0 -1
  78. package/.next/server/chunks/ssr/blogkit-md-cli_110mv-l._.js +0 -6
  79. package/.next/server/chunks/ssr/blogkit-md-cli_110mv-l._.js.map +0 -1
  80. /package/.next/server/chunks/ssr/{0dlc_0b_-669._.js.map → 0dlc_00dxfob._.js.map} +0 -0
  81. /package/.next/server/chunks/ssr/{0dlc_06c72kx._.js.map → 0dlc_01mfakk._.js.map} +0 -0
  82. /package/.next/server/chunks/ssr/{0dlc_113eo~b._.js.map → 0dlc_034lzh-._.js.map} +0 -0
  83. /package/.next/server/chunks/ssr/{0dlc_0219ubl._.js.map → 0dlc_05zbtlo._.js.map} +0 -0
  84. /package/.next/server/chunks/ssr/{0dlc_0pn3q5l._.js.map → 0dlc_061rz1d._.js.map} +0 -0
  85. /package/.next/server/chunks/ssr/{0dlc_0yb8l3l._.js.map → 0dlc_068.c_a._.js.map} +0 -0
  86. /package/.next/server/chunks/ssr/{0dlc_05sa3v.._.js.map → 0dlc_06zh4cf._.js.map} +0 -0
  87. /package/.next/server/chunks/ssr/{0dlc_0gf9l8z._.js.map → 0dlc_0_a75co._.js.map} +0 -0
  88. /package/.next/server/chunks/ssr/{0dlc_0bhkl55._.js.map → 0dlc_0a2hf_v._.js.map} +0 -0
  89. /package/.next/server/chunks/ssr/{0dlc_0z7y5fs._.js.map → 0dlc_0hjv5-5._.js.map} +0 -0
  90. /package/.next/server/chunks/ssr/{0dlc_0ta79~a._.js.map → 0dlc_0mhhdqc._.js.map} +0 -0
  91. /package/.next/server/chunks/ssr/{0dlc_0xqydor._.js.map → 0dlc_0sv~z9u._.js.map} +0 -0
  92. /package/.next/server/chunks/ssr/{0dlc_12j4q34._.js.map → 0dlc_0uuxuhx._.js.map} +0 -0
  93. /package/.next/server/chunks/ssr/{0dlc_0g7g3-8._.js.map → 0dlc_0wm2d_l._.js.map} +0 -0
  94. /package/.next/server/chunks/ssr/{0dlc_0q1fs5n._.js.map → 0dlc_111xsg5._.js.map} +0 -0
  95. /package/.next/server/chunks/ssr/{0dlc_0i-79ps._.js.map → 0dlc_11v.tdn._.js.map} +0 -0
  96. /package/.next/server/chunks/ssr/{0dlc_@san-siva_blogkit_dist_esm_0up7uxr._.js.map → 0dlc_@san-siva_blogkit_dist_esm_04y5d~m._.js.map} +0 -0
  97. /package/.next/static/{Qt8Nt48uI606YFz0N4NF0 → NXYPuKrOZxMlMFHu_1wwd}/_buildManifest.js +0 -0
  98. /package/.next/static/{Qt8Nt48uI606YFz0N4NF0 → NXYPuKrOZxMlMFHu_1wwd}/_clientMiddlewareManifest.js +0 -0
  99. /package/.next/static/{Qt8Nt48uI606YFz0N4NF0 → NXYPuKrOZxMlMFHu_1wwd}/_ssgManifest.js +0 -0
@@ -0,0 +1,79 @@
1
+ import { BlogHeader, Callout } from '@san-siva/blogkit';
2
+ import { MarkdownSections, readMarkdownFile } from '@san-siva/blogkit-md';
3
+ import Link from 'next/link';
4
+ import type { ReactNode } from 'react';
5
+
6
+ import { SettingsToolbar } from './SettingsToolbar';
7
+
8
+ type MarkdownResult = Awaited<ReturnType<typeof readMarkdownFile>>;
9
+
10
+ type LinkItem = { href: string; label: string };
11
+
12
+ /**
13
+ * Rendered above the content. The CLI passes its live-reload client; a
14
+ * statically exported site leaves it out, having no SSE endpoint to listen to.
15
+ */
16
+ type Shared = { liveReload?: ReactNode };
17
+
18
+ type Props = Shared &
19
+ (
20
+ | { kind: 'file'; result: MarkdownResult; fallbackTitle?: string }
21
+ | { kind: 'directory'; title: string; links: LinkItem[] }
22
+ );
23
+
24
+ export const RenderContent = (props: Props) => {
25
+ const { liveReload } = props;
26
+
27
+ if (props.kind === 'directory') {
28
+ const { title, links } = props;
29
+ return (
30
+ <SettingsToolbar>
31
+ {liveReload}
32
+ <BlogHeader
33
+ title={[title]}
34
+ desc={[`${links.length} markdown file${links.length === 1 ? '' : 's'}`]}
35
+ />
36
+ {links.length === 0 ? (
37
+ <Callout type="warning">
38
+ No markdown files found in this directory.
39
+ </Callout>
40
+ ) : (
41
+ <ul>
42
+ {links.map(({ href, label }) => (
43
+ <li key={href}>
44
+ <Link href={href}>{label}</Link>
45
+ </li>
46
+ ))}
47
+ </ul>
48
+ )}
49
+ </SettingsToolbar>
50
+ );
51
+ }
52
+
53
+ const { result, fallbackTitle } = props;
54
+
55
+ if (!result.success) {
56
+ return (
57
+ <SettingsToolbar>
58
+ {liveReload}
59
+ <Callout type="warning">{result.error}</Callout>
60
+ </SettingsToolbar>
61
+ );
62
+ }
63
+
64
+ const { rendered, title, description } = result;
65
+ const resolvedTitle = title ?? fallbackTitle;
66
+
67
+ return (
68
+ <SettingsToolbar>
69
+ {liveReload}
70
+ {resolvedTitle && (
71
+ <BlogHeader
72
+ title={[resolvedTitle]}
73
+ desc={description ? [description] : []}
74
+ />
75
+ )}
76
+ <MarkdownSections rendered={rendered} />
77
+ </SettingsToolbar>
78
+ );
79
+ };
@@ -0,0 +1,40 @@
1
+ @use '@san-siva/stylekit/styles/index.module.scss' as styles;
2
+
3
+ .wrapper {
4
+ position: fixed;
5
+ bottom: 0;
6
+ right: 0;
7
+ z-index: 10;
8
+ width: 200px;
9
+ height: 200px;
10
+ display: flex;
11
+ align-items: flex-end;
12
+ justify-content: flex-end;
13
+
14
+ &:hover .toolbar {
15
+ opacity: 1;
16
+ }
17
+ }
18
+
19
+ .toolbar {
20
+ -webkit-backdrop-filter: blur(19.6px);
21
+ backdrop-filter: blur(19.6px);
22
+ background-color: hsla(0, 0%, 97%, 0.82);
23
+ border: 1px solid rgba(0, 0, 0, 0.1);
24
+ border-radius: styles.$border-radius--1;
25
+ padding: styles.space(1) styles.space(2);
26
+ margin: styles.space(3);
27
+ opacity: 0;
28
+ transition: opacity 0.3s ease-in-out;
29
+ // min-contents is not a CSS value, so this rule was dropped and the
30
+ // toolbar shrank to the wrapper, wrapping its labels. Size to the widest
31
+ // label instead, and don't let the flex parent squeeze it back down.
32
+ width: max-content;
33
+ flex: none;
34
+ white-space: nowrap;
35
+ display: flex;
36
+ flex-direction: column;
37
+ align-items: center;
38
+ justify-content: center;
39
+ gap: styles.space(1);
40
+ }
@@ -0,0 +1,45 @@
1
+ 'use client';
2
+
3
+ import { Blog, CheckList, type CheckListItem } from '@san-siva/blogkit';
4
+ import type { ReactNode } from 'react';
5
+
6
+ import { useLayoutPreferences } from './useLayoutPreferences';
7
+
8
+ import styles from './SettingsToolbar.module.scss';
9
+
10
+ type Properties = {
11
+ children: ReactNode;
12
+ };
13
+
14
+ export const SettingsToolbar = ({ children }: Properties) => {
15
+ const {
16
+ preferences: { increasedWidthMode, isTocEnabled },
17
+ toggle,
18
+ } = useLayoutPreferences();
19
+
20
+ const widthModeToggle: CheckListItem[] = [
21
+ {
22
+ id: 'increased-width-mode',
23
+ children: <p>Wide layout</p>,
24
+ isChecked: increasedWidthMode,
25
+ onClick: () => toggle('increasedWidthMode'),
26
+ },
27
+ {
28
+ id: 'enable-toc',
29
+ children: <p>Table of contents</p>,
30
+ isChecked: isTocEnabled,
31
+ onClick: () => toggle('isTocEnabled'),
32
+ },
33
+ ];
34
+
35
+ return (
36
+ <Blog increasedWidthMode={increasedWidthMode} isTocEnabled={isTocEnabled}>
37
+ <div className={styles.wrapper}>
38
+ <div className={styles.toolbar}>
39
+ <CheckList items={widthModeToggle} />
40
+ </div>
41
+ </div>
42
+ {children}
43
+ </Blog>
44
+ );
45
+ };
@@ -0,0 +1,4 @@
1
+ export { RenderContent } from './RenderContent';
2
+ export { SettingsToolbar } from './SettingsToolbar';
3
+ export type { LayoutPreferencesState } from './useLayoutPreferences';
4
+ export { useLayoutPreferences } from './useLayoutPreferences';
@@ -0,0 +1,48 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+
5
+ import {
6
+ defaultPreferences,
7
+ type LayoutPreferences,
8
+ readPreferences,
9
+ writePreferences,
10
+ } from '../lib/layout-preferences';
11
+
12
+ export type LayoutPreferencesState = {
13
+ preferences: LayoutPreferences;
14
+ /** Flip one preference and persist the result. */
15
+ toggle: (key: keyof LayoutPreferences) => void;
16
+ };
17
+
18
+ /**
19
+ * Layout preferences that survive a reload of the current tab, independently
20
+ * of any other tab (see the storage choice in lib/layout-preferences).
21
+ *
22
+ * Stored values are read on mount rather than during render: the server has
23
+ * no storage, so reading during render would make the first client render
24
+ * disagree with the server HTML and trip hydration.
25
+ */
26
+ export const useLayoutPreferences = (): LayoutPreferencesState => {
27
+ const [preferences, setPreferences] =
28
+ useState<LayoutPreferences>(defaultPreferences);
29
+ // Only a real toggle is worth storing: without this, the restore below
30
+ // would write the defaults straight back into a tab nobody has touched.
31
+ const hasToggled = useRef(false);
32
+
33
+ useEffect(() => {
34
+ setPreferences(readPreferences());
35
+ }, []);
36
+
37
+ useEffect(() => {
38
+ if (hasToggled.current) writePreferences(preferences);
39
+ }, [preferences]);
40
+
41
+ return {
42
+ preferences,
43
+ toggle: key => {
44
+ hasToggled.current = true;
45
+ setPreferences(current => ({ ...current, [key]: !current[key] }));
46
+ },
47
+ };
48
+ };
@@ -0,0 +1,10 @@
1
+ export type { LayoutPreferences } from './layout-preferences';
2
+ export {
3
+ defaultPreferences,
4
+ parsePreferences,
5
+ readPreferences,
6
+ STORAGE_KEY,
7
+ writePreferences,
8
+ } from './layout-preferences';
9
+ export type { LinkItem } from './markdown-links';
10
+ export { collectMarkdownLinks } from './markdown-links';
@@ -0,0 +1,76 @@
1
+ export type LayoutPreferences = {
2
+ /** Widen the article column beyond the default reading width. */
3
+ increasedWidthMode: boolean;
4
+ /** Show the table of contents alongside the article. */
5
+ isTocEnabled: boolean;
6
+ };
7
+
8
+ export const STORAGE_KEY = 'blogkit-md:layout-preferences';
9
+
10
+ export const defaultPreferences: LayoutPreferences = {
11
+ increasedWidthMode: false,
12
+ isTocEnabled: true,
13
+ };
14
+
15
+ /**
16
+ * sessionStorage, not localStorage: it is scoped to the individual tab, so a
17
+ * refresh keeps that tab's choice while every tab keeps its own layout and a
18
+ * newly opened one starts from the defaults.
19
+ */
20
+ const storage = (): Storage | undefined => globalThis.sessionStorage;
21
+
22
+ const booleanOr = (value: unknown, fallback: boolean): boolean =>
23
+ typeof value === 'boolean' ? value : fallback;
24
+
25
+ /**
26
+ * Coerce a stored payload into preferences, falling back per field so a
27
+ * partial or half-corrupt value still contributes what it can.
28
+ */
29
+ export const parsePreferences = (stored: string | null): LayoutPreferences => {
30
+ if (!stored) return defaultPreferences;
31
+
32
+ let parsed: unknown;
33
+ try {
34
+ parsed = JSON.parse(stored);
35
+ } catch {
36
+ return defaultPreferences;
37
+ }
38
+
39
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
40
+ return defaultPreferences;
41
+ }
42
+
43
+ const { increasedWidthMode, isTocEnabled } = parsed as Record<
44
+ keyof LayoutPreferences,
45
+ unknown
46
+ >;
47
+
48
+ return {
49
+ increasedWidthMode: booleanOr(
50
+ increasedWidthMode,
51
+ defaultPreferences.increasedWidthMode
52
+ ),
53
+ isTocEnabled: booleanOr(isTocEnabled, defaultPreferences.isTocEnabled),
54
+ };
55
+ };
56
+
57
+ /**
58
+ * Read this tab's stored preferences. Storage can be missing (server render)
59
+ * or throw (private mode, blocked site data); both fall back to the defaults.
60
+ */
61
+ export const readPreferences = (): LayoutPreferences => {
62
+ try {
63
+ return parsePreferences(storage()?.getItem(STORAGE_KEY) ?? null);
64
+ } catch {
65
+ return defaultPreferences;
66
+ }
67
+ };
68
+
69
+ /** Persist this tab's preferences, ignoring unavailable or full storage. */
70
+ export const writePreferences = (preferences: LayoutPreferences): void => {
71
+ try {
72
+ storage()?.setItem(STORAGE_KEY, JSON.stringify(preferences));
73
+ } catch {
74
+ // Storage unavailable — preferences stay until this tab is closed.
75
+ }
76
+ };
@@ -0,0 +1,56 @@
1
+ // Enough failed reconnects (~3s apart in Chrome) to ride out a server
2
+ // replacement — kill + 400ms port release + next start boot — with margin.
3
+ export const MAX_RETRIES = 30;
4
+
5
+ export type LiveReloadHooks = {
6
+ /** Soft refresh — re-fetch server components for the current route. */
7
+ refresh: () => void;
8
+ /** Full page reload — a different server may own the port now. */
9
+ reload: () => void;
10
+ /** Stop listening for events. */
11
+ close: () => void;
12
+ };
13
+
14
+ /**
15
+ * The reconnect state machine behind LiveReload. The SSE server greets every
16
+ * connection with 'connected' and broadcasts 'reload' on file changes:
17
+ *
18
+ * - 'reload' (or any other payload) → soft refresh.
19
+ * - 'connected' after the stream previously dropped → another server took
20
+ * over this port (e.g. a folder server superseding a file server), so the
21
+ * page shape may have completely changed → full reload.
22
+ * - The first successful connect never reloads, even if attempts before it
23
+ * failed — otherwise a server still booting would cause a reload loop.
24
+ * - The retry budget is per-outage: it resets on every successful connect
25
+ * and closes the stream once an outage exhausts it.
26
+ */
27
+ export function createLiveReloadHandlers({
28
+ refresh,
29
+ reload,
30
+ close,
31
+ }: LiveReloadHooks) {
32
+ let retries = 0;
33
+ let everConnected = false;
34
+ let dropped = false;
35
+
36
+ return {
37
+ onMessage(data: string): void {
38
+ if (data === 'connected') {
39
+ const replaced = everConnected && dropped;
40
+ everConnected = true;
41
+ dropped = false;
42
+ retries = 0;
43
+ if (replaced) {
44
+ close();
45
+ reload();
46
+ }
47
+ return;
48
+ }
49
+ refresh();
50
+ },
51
+ onError(): void {
52
+ dropped = true;
53
+ if (++retries >= MAX_RETRIES) close();
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,51 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export type LinkItem = { href: string; label: string };
5
+
6
+ /**
7
+ * Recursively collect a link for every .md file under `root`. Hrefs are built
8
+ * relative to `base` (the served MARKDOWN_DIR) so they stay valid no matter
9
+ * which subdirectory is being listed; labels are relative to `root` so a
10
+ * subdirectory listing isn't prefixed with its own name on every line.
11
+ */
12
+ export async function collectMarkdownLinks(
13
+ root: string,
14
+ base: string
15
+ ): Promise<LinkItem[]> {
16
+ const links: LinkItem[] = [];
17
+
18
+ const walk = async (current: string) => {
19
+ let entries;
20
+ try {
21
+ entries = await readdir(current, { withFileTypes: true });
22
+ } catch {
23
+ return;
24
+ }
25
+ for (const entry of entries) {
26
+ const fullPath = path.join(current, entry.name);
27
+ if (entry.isDirectory()) {
28
+ await walk(fullPath);
29
+ } else if (entry.name.endsWith('.md')) {
30
+ const href =
31
+ '/' +
32
+ path
33
+ .relative(base, fullPath)
34
+ .replace(/\.md$/, '')
35
+ .split(path.sep)
36
+ .map(encodeURIComponent)
37
+ .join('/');
38
+ const label = path
39
+ .relative(root, fullPath)
40
+ .replace(/\.md$/, '')
41
+ .split(path.sep)
42
+ .join(' / ');
43
+ links.push({ href, label });
44
+ }
45
+ }
46
+ };
47
+
48
+ await walk(root);
49
+ links.sort((a, b) => a.label.localeCompare(b.label));
50
+ return links;
51
+ }
@@ -0,0 +1,47 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Resolve a URL slug to a path inside `dir`, rejecting traversal attempts:
6
+ * `..` segments, separators smuggled inside a segment (also via URL encoding),
7
+ * and anything that resolves outside `dir`.
8
+ */
9
+ export const resolveSafePath = (
10
+ slug: string[],
11
+ dir: string | undefined
12
+ ): string | null => {
13
+ if (!dir) return null;
14
+
15
+ const safeSlug = slug
16
+ .map(part => decodeURIComponent(part))
17
+ .filter(p => p && p !== '..' && !p.includes('/') && !p.includes('\\'));
18
+ if (safeSlug.length !== slug.length) return null;
19
+
20
+ const target = path.resolve(dir, safeSlug.join(path.sep));
21
+ // Defense-in-depth: check '..' as a whole segment so children whose own
22
+ // name starts with dots (e.g. '..drafts') aren't mistaken for traversal.
23
+ const relative = path.relative(dir, target);
24
+ if (
25
+ relative === '..' ||
26
+ relative.startsWith('..' + path.sep) ||
27
+ path.isAbsolute(relative)
28
+ ) {
29
+ return null;
30
+ }
31
+ return target;
32
+ };
33
+
34
+ /** When the slug names an existing directory inside `dir`, return its path; else null. */
35
+ export const resolveDirectory = async (
36
+ slug: string[],
37
+ dir: string | undefined
38
+ ): Promise<string | null> => {
39
+ const target = resolveSafePath(slug, dir);
40
+ if (!target) return null;
41
+ try {
42
+ const stats = await stat(target);
43
+ return stats.isDirectory() ? target : null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ };
@@ -0,0 +1,4 @@
1
+ {
2
+ "//": "Tests run as ESM so tsx resolves packages that only define an 'import' export condition (e.g. @san-siva/blogkit-md). Scoped here to keep the CJS bin build untouched.",
3
+ "type": "module"
4
+ }