@san-siva/blogkit-md-cli 0.1.51 → 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.
- package/.next/BUILD_ID +1 -1
- package/.next/build-manifest.json +3 -3
- package/.next/cache/.previewinfo +1 -1
- package/.next/cache/.rscinfo +1 -1
- package/.next/cache/.tsbuildinfo +1 -1
- package/.next/diagnostics/route-bundle-stats.json +2 -2
- package/.next/fallback-build-manifest.json +3 -3
- package/.next/prerender-manifest.json +3 -3
- package/.next/server/app/[...slug]/page.js +1 -1
- package/.next/server/app/[...slug]/page.js.nft.json +1 -1
- package/.next/server/app/[...slug]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +1 -1
- package/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/page.js +1 -1
- package/.next/server/app/page.js.nft.json +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/chunks/ssr/{0dlc_0b_-669._.js → 0dlc_00dxfob._.js} +2 -2
- package/.next/server/chunks/ssr/{0dlc_06c72kx._.js → 0dlc_01mfakk._.js} +2 -2
- package/.next/server/chunks/ssr/{0dlc_113eo~b._.js → 0dlc_034lzh-._.js} +2 -2
- package/.next/server/chunks/ssr/{0dlc_0219ubl._.js → 0dlc_05zbtlo._.js} +2 -2
- package/.next/server/chunks/ssr/{0dlc_0pn3q5l._.js → 0dlc_061rz1d._.js} +2 -2
- package/.next/server/chunks/ssr/{0dlc_0yb8l3l._.js → 0dlc_068.c_a._.js} +2 -2
- package/.next/server/chunks/ssr/{0dlc_05sa3v.._.js → 0dlc_06zh4cf._.js} +2 -2
- package/.next/server/chunks/ssr/{0dlc_0gf9l8z._.js → 0dlc_0_a75co._.js} +2 -2
- package/.next/server/chunks/ssr/{0dlc_0bhkl55._.js → 0dlc_0a2hf_v._.js} +2 -2
- package/.next/server/chunks/ssr/{0dlc_0z7y5fs._.js → 0dlc_0hjv5-5._.js} +2 -2
- package/.next/server/chunks/ssr/{0dlc_0ta79~a._.js → 0dlc_0mhhdqc._.js} +2 -2
- package/.next/server/chunks/ssr/{0dlc_0xqydor._.js → 0dlc_0sv~z9u._.js} +2 -2
- package/.next/server/chunks/ssr/{0dlc_12j4q34._.js → 0dlc_0uuxuhx._.js} +2 -2
- package/.next/server/chunks/ssr/{0dlc_0g7g3-8._.js → 0dlc_0wm2d_l._.js} +2 -2
- package/.next/server/chunks/ssr/{0dlc_0q1fs5n._.js → 0dlc_111xsg5._.js} +2 -2
- package/.next/server/chunks/ssr/{0dlc_0i-79ps._.js → 0dlc_11v.tdn._.js} +2 -2
- package/.next/server/chunks/ssr/{0dlc_@san-siva_blogkit_dist_esm_0up7uxr._.js → 0dlc_@san-siva_blogkit_dist_esm_04y5d~m._.js} +2 -2
- package/.next/server/chunks/ssr/[root-of-the-server]__06u9sw8._.js +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__06u9sw8._.js.map +1 -1
- package/.next/server/chunks/ssr/{[root-of-the-server]__030~-6p._.js → [root-of-the-server]__0t.geu5._.js} +3 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__0t.geu5._.js.map +1 -0
- package/.next/server/chunks/ssr/blogkit-md-cli_037q557._.js +6 -0
- package/.next/server/chunks/ssr/blogkit-md-cli_037q557._.js.map +1 -0
- package/.next/server/chunks/ssr/blogkit-md-cli_0p82t5f._.js +1 -1
- package/.next/server/chunks/ssr/blogkit-md-cli_0p82t5f._.js.map +1 -1
- package/.next/server/middleware-build-manifest.js +3 -3
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.js +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/{0mdj2mmw8o5eg.css → 0uqp7lp30c~08.css} +1 -1
- package/.next/static/chunks/{0nirptw7cuebm.js → 0xukhab5vwo26.js} +1 -1
- package/.next/trace +1 -1
- package/.next/trace-build +1 -1
- package/package.json +10 -3
- package/src/components/LiveReload.tsx +25 -0
- package/src/components/RenderContent.tsx +79 -0
- package/src/components/SettingsToolbar.module.scss +40 -0
- package/src/components/SettingsToolbar.tsx +45 -0
- package/src/components/index.ts +4 -0
- package/src/components/useLayoutPreferences.ts +48 -0
- package/src/lib/index.ts +10 -0
- package/src/lib/layout-preferences.ts +76 -0
- package/src/lib/live-reload.ts +56 -0
- package/src/lib/markdown-links.ts +51 -0
- package/src/lib/resolve-path.ts +47 -0
- package/src/package.json +4 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__030~-6p._.js.map +0 -1
- package/.next/server/chunks/ssr/blogkit-md-cli_110mv-l._.js +0 -6
- package/.next/server/chunks/ssr/blogkit-md-cli_110mv-l._.js.map +0 -1
- /package/.next/server/chunks/ssr/{0dlc_0b_-669._.js.map → 0dlc_00dxfob._.js.map} +0 -0
- /package/.next/server/chunks/ssr/{0dlc_06c72kx._.js.map → 0dlc_01mfakk._.js.map} +0 -0
- /package/.next/server/chunks/ssr/{0dlc_113eo~b._.js.map → 0dlc_034lzh-._.js.map} +0 -0
- /package/.next/server/chunks/ssr/{0dlc_0219ubl._.js.map → 0dlc_05zbtlo._.js.map} +0 -0
- /package/.next/server/chunks/ssr/{0dlc_0pn3q5l._.js.map → 0dlc_061rz1d._.js.map} +0 -0
- /package/.next/server/chunks/ssr/{0dlc_0yb8l3l._.js.map → 0dlc_068.c_a._.js.map} +0 -0
- /package/.next/server/chunks/ssr/{0dlc_05sa3v.._.js.map → 0dlc_06zh4cf._.js.map} +0 -0
- /package/.next/server/chunks/ssr/{0dlc_0gf9l8z._.js.map → 0dlc_0_a75co._.js.map} +0 -0
- /package/.next/server/chunks/ssr/{0dlc_0bhkl55._.js.map → 0dlc_0a2hf_v._.js.map} +0 -0
- /package/.next/server/chunks/ssr/{0dlc_0z7y5fs._.js.map → 0dlc_0hjv5-5._.js.map} +0 -0
- /package/.next/server/chunks/ssr/{0dlc_0ta79~a._.js.map → 0dlc_0mhhdqc._.js.map} +0 -0
- /package/.next/server/chunks/ssr/{0dlc_0xqydor._.js.map → 0dlc_0sv~z9u._.js.map} +0 -0
- /package/.next/server/chunks/ssr/{0dlc_12j4q34._.js.map → 0dlc_0uuxuhx._.js.map} +0 -0
- /package/.next/server/chunks/ssr/{0dlc_0g7g3-8._.js.map → 0dlc_0wm2d_l._.js.map} +0 -0
- /package/.next/server/chunks/ssr/{0dlc_0q1fs5n._.js.map → 0dlc_111xsg5._.js.map} +0 -0
- /package/.next/server/chunks/ssr/{0dlc_0i-79ps._.js.map → 0dlc_11v.tdn._.js.map} +0 -0
- /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
- /package/.next/static/{XTb_O8v4YVLwgVp851CWK → NXYPuKrOZxMlMFHu_1wwd}/_buildManifest.js +0 -0
- /package/.next/static/{XTb_O8v4YVLwgVp851CWK → NXYPuKrOZxMlMFHu_1wwd}/_clientMiddlewareManifest.js +0 -0
- /package/.next/static/{XTb_O8v4YVLwgVp851CWK → 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,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
|
+
};
|
package/src/lib/index.ts
ADDED
|
@@ -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
|
+
};
|
package/src/package.json
ADDED