@mzebley/mark-down 1.0.0

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/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@mzebley/mark-down",
3
+ "version": "1.0.0",
4
+ "description": "mark↓ core runtime and shared utilities",
5
+ "type": "module",
6
+ "main": "dist/index.cjs",
7
+ "module": "dist/index.mjs",
8
+ "types": "dist/index.d.ts",
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.mjs",
16
+ "require": "./dist/index.cjs"
17
+ },
18
+ "./slug": {
19
+ "types": "./dist/slug.d.ts",
20
+ "import": "./dist/slug.mjs",
21
+ "require": "./dist/slug.cjs"
22
+ },
23
+ "./browser": {
24
+ "types": "./dist/browser.d.ts",
25
+ "import": "./dist/browser.js"
26
+ }
27
+ },
28
+ "scripts": {
29
+ "build": "tsup --config tsup.config.ts",
30
+ "dev": "tsup --config tsup.config.ts --watch"
31
+ },
32
+ "dependencies": {
33
+ "gray-matter": "^4.0.3",
34
+ "marked": "^11.1.0"
35
+ }
36
+ }
package/src/browser.ts ADDED
@@ -0,0 +1,141 @@
1
+ installBufferShim();
2
+
3
+ export * from "./index";
4
+
5
+ function installBufferShim() {
6
+ const globalRef = globalThis as typeof globalThis & { Buffer?: BufferConstructor };
7
+
8
+ if (typeof globalRef.Buffer !== "undefined") {
9
+ return;
10
+ }
11
+
12
+ const textEncoder = new TextEncoder();
13
+ const textDecoder = new TextDecoder();
14
+
15
+ class BrowserBuffer extends Uint8Array {
16
+ constructor(value: number | ArrayBufferLike | ArrayBufferView | ArrayLike<number>) {
17
+ if (typeof value === "number") {
18
+ super(value);
19
+ return;
20
+ }
21
+
22
+ if (isArrayBufferLike(value)) {
23
+ super(new Uint8Array(value));
24
+ return;
25
+ }
26
+
27
+ if (ArrayBuffer.isView(value)) {
28
+ super(
29
+ new Uint8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength))
30
+ );
31
+ return;
32
+ }
33
+
34
+ super(Array.from(value));
35
+ }
36
+
37
+ override toString(encoding: BufferEncoding = "utf-8") {
38
+ if (encoding !== "utf-8" && encoding !== "utf8") {
39
+ throw new Error(`Unsupported encoding '${encoding}' in browser Buffer shim`);
40
+ }
41
+ return textDecoder.decode(this);
42
+ }
43
+ }
44
+
45
+ const from = (value: string | ArrayLike<number> | BufferSource, encoding: BufferEncoding = "utf-8") => {
46
+ if (typeof value === "string") {
47
+ if (encoding !== "utf-8" && encoding !== "utf8") {
48
+ throw new Error(`Unsupported encoding '${encoding}' in browser Buffer shim`);
49
+ }
50
+ return new BrowserBuffer(textEncoder.encode(value));
51
+ }
52
+
53
+ if (isArrayBufferLike(value)) {
54
+ return new BrowserBuffer(new Uint8Array(value));
55
+ }
56
+
57
+ if (ArrayBuffer.isView(value)) {
58
+ return new BrowserBuffer(value);
59
+ }
60
+
61
+ if (typeof (value as ArrayLike<number>).length === "number") {
62
+ return new BrowserBuffer(Array.from(value as ArrayLike<number>));
63
+ }
64
+
65
+ throw new TypeError("Unsupported input passed to Buffer.from in browser shim");
66
+ };
67
+
68
+ const alloc = (size: number, fill?: number | string) => {
69
+ if (size < 0) {
70
+ throw new RangeError("Invalid Buffer size");
71
+ }
72
+ const buffer = new BrowserBuffer(size);
73
+ if (typeof fill === "number") {
74
+ buffer.fill(fill);
75
+ } else if (typeof fill === "string") {
76
+ if (!fill.length) {
77
+ buffer.fill(0);
78
+ } else {
79
+ const pattern = textEncoder.encode(fill);
80
+ for (let i = 0; i < buffer.length; i++) {
81
+ buffer[i] = pattern[i % pattern.length];
82
+ }
83
+ }
84
+ } else {
85
+ buffer.fill(0);
86
+ }
87
+ return buffer;
88
+ };
89
+
90
+ const concat = (buffers: ArrayLike<Uint8Array>, totalLength?: number) => {
91
+ const sanitized = Array.from(buffers, (buffer) =>
92
+ buffer instanceof BrowserBuffer ? buffer : new BrowserBuffer(buffer)
93
+ );
94
+ const length = totalLength ?? sanitized.reduce((acc, current) => acc + current.length, 0);
95
+ const result = new BrowserBuffer(length);
96
+ let offset = 0;
97
+ for (const buffer of sanitized) {
98
+ result.set(buffer, offset);
99
+ offset += buffer.length;
100
+ }
101
+ return result;
102
+ };
103
+
104
+ const byteLength = (value: string | ArrayBuffer | ArrayBufferView) => {
105
+ if (typeof value === "string") {
106
+ return textEncoder.encode(value).length;
107
+ }
108
+ if (value instanceof ArrayBuffer) {
109
+ return value.byteLength;
110
+ }
111
+ if (ArrayBuffer.isView(value)) {
112
+ return value.byteLength;
113
+ }
114
+ throw new TypeError("Unable to determine byte length for provided value");
115
+ };
116
+
117
+ Object.defineProperties(BrowserBuffer, {
118
+ from: { value: from },
119
+ isBuffer: { value: (candidate: unknown) => candidate instanceof BrowserBuffer },
120
+ alloc: { value: alloc },
121
+ concat: { value: concat },
122
+ byteLength: { value: byteLength }
123
+ });
124
+
125
+ BrowserBuffer.prototype.valueOf = function valueOf() {
126
+ return this;
127
+ };
128
+
129
+ globalRef.Buffer = BrowserBuffer as unknown as BufferConstructor;
130
+
131
+ if (typeof window !== "undefined" && typeof (window as typeof globalThis).Buffer === "undefined") {
132
+ (window as typeof globalThis & { Buffer?: BufferConstructor }).Buffer = globalRef.Buffer;
133
+ }
134
+ }
135
+
136
+ function isArrayBufferLike(value: unknown): value is ArrayBufferLike {
137
+ if (value instanceof ArrayBuffer) {
138
+ return true;
139
+ }
140
+ return typeof SharedArrayBuffer !== "undefined" && value instanceof SharedArrayBuffer;
141
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./types";
2
+ export * from "./snippet-client";
3
+ export { normalizeSlug } from "./slug";
package/src/slug.ts ADDED
@@ -0,0 +1,21 @@
1
+ const NON_ALPHANUMERIC = /[^a-z0-9]+/gi;
2
+ const LEADING_TRAILING_DASH = /^-+|-+$/g;
3
+
4
+ export function normalizeSlug(input: string): string {
5
+ const value = input?.trim();
6
+ if (!value) {
7
+ throw new Error("Cannot normalize an empty slug");
8
+ }
9
+
10
+ const normalized = value
11
+ .toLowerCase()
12
+ .replace(NON_ALPHANUMERIC, "-")
13
+ .replace(/-{2,}/g, "-")
14
+ .replace(LEADING_TRAILING_DASH, "");
15
+
16
+ if (!normalized) {
17
+ throw new Error(`Slug '${input}' does not contain any alphanumeric characters`);
18
+ }
19
+
20
+ return normalized;
21
+ }
@@ -0,0 +1,215 @@
1
+ import matter from "gray-matter";
2
+ import { marked } from "marked";
3
+ import type {
4
+ ListOptions,
5
+ ManifestSource,
6
+ ResponseLike,
7
+ Snippet,
8
+ SnippetClientOptions,
9
+ SnippetFilter,
10
+ SnippetMeta
11
+ } from "./types";
12
+
13
+ const OPTIONAL_FIELDS: Array<keyof SnippetMeta> = [
14
+ "title",
15
+ "order",
16
+ "type",
17
+ "tags",
18
+ "draft"
19
+ ];
20
+
21
+ export class SnippetClient {
22
+ private readonly manifestSource: ManifestSource;
23
+ private readonly fetcher: (input: string) => Promise<ResponseLike>;
24
+ private readonly markdownRenderer: (markdown: string) => Promise<string> | string;
25
+ private readonly resolveSnippetPath: (meta: SnippetMeta) => string;
26
+ private manifestPromise?: Promise<SnippetMeta[]>;
27
+ private snippetCache = new Map<string, Promise<Snippet>>();
28
+
29
+ constructor(options: SnippetClientOptions) {
30
+ this.manifestSource = options.manifest;
31
+ this.fetcher = options.fetcher ?? defaultFetch;
32
+ this.markdownRenderer = options.markdownRenderer ?? marked.parse;
33
+ this.resolveSnippetPath = options.resolveSnippetPath ?? ((meta) => meta.path);
34
+ }
35
+
36
+ async get(slug: string): Promise<Snippet | undefined> {
37
+ const manifest = await this.loadManifest();
38
+ const entry = manifest.find((snippet) => snippet.slug === slug);
39
+ if (!entry) {
40
+ return undefined;
41
+ }
42
+
43
+ return this.loadSnippet(entry);
44
+ }
45
+
46
+ async list(filter?: SnippetFilter | ListOptions, options?: ListOptions): Promise<SnippetMeta[]> {
47
+ const manifest = await this.loadManifest();
48
+ let predicate: SnippetFilter | undefined;
49
+ let finalOptions: ListOptions = {};
50
+
51
+ if (typeof filter === "function") {
52
+ predicate = filter;
53
+ finalOptions = options ?? {};
54
+ } else if (filter) {
55
+ finalOptions = filter;
56
+ } else if (options) {
57
+ finalOptions = options;
58
+ }
59
+
60
+ let items = manifest;
61
+ const filters: SnippetFilter[] = [];
62
+ if (predicate) {
63
+ filters.push(predicate);
64
+ }
65
+ if (finalOptions.filter) {
66
+ filters.push(finalOptions.filter);
67
+ }
68
+ if (filters.length) {
69
+ items = items.filter((entry) => filters.every((fn) => fn(entry)));
70
+ }
71
+
72
+ const offset = finalOptions.offset ?? 0;
73
+ const limit = finalOptions.limit;
74
+ if (offset > 0) {
75
+ items = items.slice(offset);
76
+ }
77
+ if (typeof limit === "number") {
78
+ items = items.slice(0, limit);
79
+ }
80
+
81
+ return [...items];
82
+ }
83
+
84
+ listByType(type: string, options?: ListOptions): Promise<SnippetMeta[]> {
85
+ return this.list((entry) => entry.type === type, options);
86
+ }
87
+
88
+ listByGroup(group: string, options?: ListOptions): Promise<SnippetMeta[]> {
89
+ return this.list((entry) => entry.group === group, options);
90
+ }
91
+
92
+ private loadSnippet(meta: SnippetMeta): Promise<Snippet> {
93
+ const cached = this.snippetCache.get(meta.slug);
94
+ if (cached) {
95
+ return cached;
96
+ }
97
+
98
+ const promise = this.fetchSnippet(meta);
99
+ this.snippetCache.set(meta.slug, promise);
100
+ return promise;
101
+ }
102
+
103
+ private async fetchSnippet(meta: SnippetMeta): Promise<Snippet> {
104
+ const snippetPath = this.resolveSnippetPath(meta);
105
+ const response = await this.fetcher(snippetPath);
106
+ if (!response.ok) {
107
+ throw new Error(`Failed to fetch snippet '${meta.slug}' (status ${response.status})`);
108
+ }
109
+
110
+ const rawContent = await response.text();
111
+ const parsed = matter(rawContent);
112
+ const frontMatter = sanitizeFrontMatter(parsed.data ?? {});
113
+ const mergedMeta = mergeFrontMatter(meta, frontMatter.meta);
114
+
115
+ const html = await this.markdownRenderer(parsed.content);
116
+
117
+ const baseExtra = mergedMeta.extra ?? {};
118
+ return {
119
+ ...mergedMeta,
120
+ extra: {
121
+ ...baseExtra,
122
+ ...frontMatter.extra
123
+ },
124
+ markdown: parsed.content,
125
+ html
126
+ };
127
+ }
128
+
129
+ private loadManifest(): Promise<SnippetMeta[]> {
130
+ if (!this.manifestPromise) {
131
+ this.manifestPromise = this.resolveManifest();
132
+ }
133
+ return this.manifestPromise;
134
+ }
135
+
136
+ private async resolveManifest(): Promise<SnippetMeta[]> {
137
+ if (Array.isArray(this.manifestSource)) {
138
+ return this.manifestSource;
139
+ }
140
+
141
+ if (typeof this.manifestSource === "function") {
142
+ const result = await this.manifestSource();
143
+ return Array.isArray(result) ? result : Promise.reject(new Error("Manifest function must return an array"));
144
+ }
145
+
146
+ const raw = await this.fetchText(this.manifestSource);
147
+ const manifest = JSON.parse(raw);
148
+ if (!Array.isArray(manifest)) {
149
+ throw new Error("Manifest must be an array");
150
+ }
151
+ return manifest;
152
+ }
153
+
154
+ private async fetchText(path: string): Promise<string> {
155
+ const response = await this.fetcher(path);
156
+ if (!response.ok) {
157
+ throw new Error(`Failed to fetch manifest from ${path} (status ${response.status})`);
158
+ }
159
+ return response.text();
160
+ }
161
+ }
162
+
163
+ function sanitizeFrontMatter(data: Record<string, unknown>) {
164
+ const meta: Partial<SnippetMeta> = {};
165
+ const extra: Record<string, unknown> = {};
166
+
167
+ for (const [key, value] of Object.entries(data)) {
168
+ if (OPTIONAL_FIELDS.includes(key as keyof SnippetMeta)) {
169
+ if (key === "tags") {
170
+ meta.tags = normalizeTags(value);
171
+ } else {
172
+ (meta as Record<string, unknown>)[key] = value;
173
+ }
174
+ } else {
175
+ extra[key] = value;
176
+ }
177
+ }
178
+
179
+ return { meta, extra };
180
+ }
181
+
182
+ function normalizeTags(value: unknown): string[] | undefined {
183
+ if (!value) {
184
+ return undefined;
185
+ }
186
+ if (Array.isArray(value)) {
187
+ return value.map((tag) => String(tag));
188
+ }
189
+ if (typeof value === "string") {
190
+ return value
191
+ .split(",")
192
+ .map((tag) => tag.trim())
193
+ .filter(Boolean);
194
+ }
195
+ return undefined;
196
+ }
197
+
198
+ function mergeFrontMatter(base: SnippetMeta, overrides: Partial<SnippetMeta>): SnippetMeta {
199
+ const merged: SnippetMeta = { ...base };
200
+ for (const field of OPTIONAL_FIELDS) {
201
+ const overrideValue = overrides[field];
202
+ if (overrideValue !== undefined && merged[field] === undefined) {
203
+ merged[field] = overrideValue as never;
204
+ }
205
+ }
206
+ return merged;
207
+ }
208
+
209
+ async function defaultFetch(input: string): Promise<ResponseLike> {
210
+ const runtimeFetch = (globalThis as typeof globalThis & { fetch?: typeof fetch }).fetch;
211
+ if (!runtimeFetch) {
212
+ throw new Error("No global fetch implementation found. Provide a custom fetcher.");
213
+ }
214
+ return runtimeFetch(input);
215
+ }
package/src/types.ts ADDED
@@ -0,0 +1,46 @@
1
+ export interface SnippetMeta {
2
+ slug: string;
3
+ title?: string;
4
+ order?: number | null;
5
+ type?: string;
6
+ tags?: string[];
7
+ draft?: boolean;
8
+ path: string;
9
+ group: string;
10
+ extra?: Record<string, unknown>;
11
+ }
12
+
13
+ export interface Snippet extends SnippetMeta {
14
+ markdown: string;
15
+ html: string;
16
+ }
17
+
18
+ export type SnippetFilter = (snippet: SnippetMeta) => boolean;
19
+
20
+ export interface ListOptions {
21
+ filter?: SnippetFilter;
22
+ limit?: number;
23
+ offset?: number;
24
+ }
25
+
26
+ export type ManifestSource =
27
+ | string
28
+ | SnippetMeta[]
29
+ | (() => Promise<SnippetMeta[]> | SnippetMeta[]);
30
+
31
+ export interface SnippetClientOptions {
32
+ /** Where to load the manifest; defaults to `/snippets-index.json`. */
33
+ manifest: ManifestSource;
34
+ /** Custom fetch to use in Node environments. Falls back to global `fetch`. */
35
+ fetcher?: (input: string) => Promise<ResponseLike>;
36
+ /** Allows overriding markdown → HTML rendering. Defaults to `marked`. */
37
+ markdownRenderer?: (markdown: string) => Promise<string> | string;
38
+ /** Customize how snippet URLs are resolved; defaults to the manifest `path`. */
39
+ resolveSnippetPath?: (meta: SnippetMeta) => string;
40
+ }
41
+
42
+ export interface ResponseLike {
43
+ ok: boolean;
44
+ status: number;
45
+ text(): Promise<string>;
46
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist"
5
+ },
6
+ "include": ["src/**/*"]
7
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { defineConfig } from "tsup";
2
+
3
+ export default defineConfig([
4
+ {
5
+ entry: {
6
+ index: "src/index.ts",
7
+ slug: "src/slug.ts"
8
+ },
9
+ format: ["esm", "cjs"],
10
+ dts: true,
11
+ sourcemap: true,
12
+ clean: true,
13
+ target: "es2020",
14
+ outExtension({ format }) {
15
+ return {
16
+ js: format === "cjs" ? ".cjs" : ".mjs"
17
+ };
18
+ }
19
+ },
20
+ {
21
+ entry: {
22
+ browser: "src/browser.ts"
23
+ },
24
+ format: ["esm"],
25
+ dts: true,
26
+ sourcemap: true,
27
+ clean: false,
28
+ target: "es2020",
29
+ platform: "browser",
30
+ splitting: false,
31
+ outExtension() {
32
+ return {
33
+ js: ".js"
34
+ };
35
+ }
36
+ }
37
+ ]);