@mzebley/mark-down 1.0.0 → 1.1.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/README.md CHANGED
@@ -9,11 +9,12 @@ This package provides the framework-agnostic `SnippetClient` and supporting type
9
9
  2. [Quick start](#quick-start)
10
10
  3. [Client options](#client-options)
11
11
  4. [Working with snippets](#working-with-snippets)
12
- 5. [SSR and custom fetchers](#ssr-and-custom-fetchers)
12
+ 5. [SSR and custom fetch functions](#ssr-and-custom-fetch-functions)
13
13
  6. [Static sites / CDN usage](#static-sites--cdn-usage)
14
- 7. [Testing & type safety](#testing--type-safety)
15
- 8. [Related packages](#related-packages)
16
- 9. [Roadmap](#roadmap)
14
+ 7. [Zero-build inline mode](#zero-build-inline-mode)
15
+ 8. [Testing & type safety](#testing--type-safety)
16
+ 9. [Related packages](#related-packages)
17
+ 10. [Roadmap](#roadmap)
17
18
 
18
19
  ## Installation
19
20
 
@@ -37,7 +38,7 @@ const client = new SnippetClient({
37
38
  const hero = await client.get("getting-started-welcome");
38
39
  const components = await client.listByType("component");
39
40
 
40
- console.log(hero?.title);
41
+ console.log(hero.title);
41
42
  ```
42
43
 
43
44
  The client lazily loads the manifest when first needed, then fetches Markdown files on demand. Results are cached for the lifetime of the client instance.
@@ -46,25 +47,30 @@ The client lazily loads the manifest when first needed, then fetches Markdown fi
46
47
 
47
48
  `SnippetClient` accepts a single configuration object:
48
49
 
49
- - **`manifest`** (`string | ManifestEntry[] | () => Promise<ManifestEntry[]>`) – where to load the manifest. Pass a URL (default), an in-memory array, or an async factory for advanced scenarios.
50
- - **`fetcher`** (`(input, init) => Promise<Response>`) provide a custom fetch implementation. Useful for SSR environments or when using libraries like Axios.
51
- - **`markdownRenderer`** – a `(markdown: string) => string | Promise<string>` function. Override to swap the default `marked` renderer for something like `remark` or a bespoke pipeline.
52
- - **`resolveSnippetPath`** map manifest entries to final URLs. Override when static assets live in a CDN or custom folder.
50
+ - **`manifest`** (`string | SnippetMeta[] | () => Promise<SnippetMeta[]>`) – where to load the manifest. Provide a URL, an in-memory array, or an async factory.
51
+ - **`base`** (`string`) optional base path prepended to relative snippet paths. The client infers the directory from the manifest URL when omitted.
52
+ - **`fetch`** (`(url: string) => Promise<Response | string>`) inject a custom fetch implementation. Use this for SSR, testing, or advanced caching.
53
+ - **`frontMatter`** (`boolean`, default `true`) toggle YAML front-matter parsing.
54
+ - **`cache`** (`boolean`, default `true`) – enable or disable per-snippet and manifest memoisation.
55
+ - **`verbose`** (`boolean`) – log helpful warnings (for example, slug mismatches) during development.
56
+ - **`render`** (`(markdown: string) => string | Promise<string>`) – override the default `marked` renderer when you need custom HTML output.
53
57
 
54
- All options are optional except `manifest`.
58
+ All options are optional except `manifest`. Results are rendered with `marked` by default; override at the application level if you need a different Markdown pipeline.
55
59
 
56
60
  ## Working with snippets
57
61
 
58
62
  Commonly used APIs:
59
63
 
60
- - `client.get(slug)` – fetch a single snippet. Returns `Promise<Snippet | undefined>`.
61
- - `client.list(filterOrOptions)` – list snippets using predicates, offsets, and limits.
62
- - `client.listByType(type, options?)` – filter by `type`.
63
- - `client.listByGroup(group, options?)` – filter based on the folder-derived `group`.
64
+ - `client.get(slug)` – fetch a single snippet. Throws `SnippetNotFoundError` if the manifest does not include the slug.
65
+ - `client.listAll()` – return a copy of every manifest entry.
66
+ - `client.listByType(type)` / `client.listByGroup(group)` – targeted manifest filters.
67
+ - `client.search({ type, group, tags, tagsMode })` – multi-field search helper with tag matching.
68
+ - `client.getHtml(slug)` – convenience wrapper that resolves directly to HTML.
69
+ - `client.invalidate()` / `client.invalidateSlug(slug)` – clear caches to force refetching.
64
70
 
65
- Metadata is preserved exactly as declared in front matter. Standard keys (`slug`, `title`, etc.) are copied onto `SnippetMeta` and everything else is available through the `extra` bag so you can access custom fields like `snippet.extra.ctaLabel`.
71
+ Metadata is preserved exactly as declared in front matter. Standard keys (`slug`, `title`, etc.) are copied onto `SnippetMeta`, additional properties live inside `extra`, and the resolved `Snippet` includes both rendered HTML and an optional `raw` Markdown string (without front matter) for advanced use cases.
66
72
 
67
- ## SSR and custom fetchers
73
+ ## SSR and custom fetch functions
68
74
 
69
75
  The runtime runs in browsers, Node.js, or edge runtimes. For server-side rendering:
70
76
 
@@ -74,11 +80,16 @@ import { SnippetClient } from "@mzebley/mark-down";
74
80
 
75
81
  const client = new SnippetClient({
76
82
  manifest: () => import("./snippets-index.json"),
77
- fetcher: (input, init) => fetch(input as string, init),
83
+ fetch: (url) => fetch(url).then((response) => {
84
+ if (!response.ok) {
85
+ throw new Error(`Request failed with status ${response.status}`);
86
+ }
87
+ return response;
88
+ }),
78
89
  });
79
90
  ```
80
91
 
81
- You can also pre-seed snippets by passing an array to `manifest` to avoid network requests entirely.
92
+ You can also pre-seed snippets by passing an array to `manifest` to avoid network requests entirely. When `cache` is disabled the client re-fetches both manifest and snippet payloads on every request.
82
93
 
83
94
  ## Static sites / CDN usage
84
95
 
@@ -96,10 +107,112 @@ Need to run mark↓ inside a plain `<script type="module">` context? Use the pre
96
107
 
97
108
  As long as you host `snippets-index.json` (generated by the CLI) alongside your static assets, this bundle works without any additional tooling or manual Buffer shims.
98
109
 
110
+ ## Zero-build inline mode
111
+
112
+ ### Introduction
113
+
114
+ Need Markdown inside plain HTML without a manifest, build step, or Node runtime? mark↓ ships a lightweight inline helper that scans the DOM for raw Markdown, renders it with the same `marked` pipeline used by `SnippetClient`, and progressively enhances the page. Content stays inside the HTML you ship, so crawlers and no-JS users can still read the raw Markdown even before JavaScript runs.
115
+
116
+ ### Installation
117
+
118
+ Install the core package as usual:
119
+
120
+ ```bash
121
+ npm install @mzebley/mark-down
122
+ ```
123
+
124
+ - **Bundlers / ESM:** import the inline helper directly.
125
+
126
+ ```ts
127
+ import { enhanceInlineMarkdown } from "@mzebley/mark-down/inline";
128
+ ```
129
+
130
+ - **Static `<script>` usage:** include the prebuilt UMD bundle that exposes `window.markDownInline`.
131
+
132
+ ```html
133
+ <script src="/node_modules/@mzebley/mark-down/dist/mark-down-inline.umd.js"></script>
134
+ <script>
135
+ document.addEventListener("DOMContentLoaded", () => {
136
+ window.markDownInline.enhanceInlineMarkdown();
137
+ });
138
+ </script>
139
+ ```
140
+
141
+ Host the UMD file yourself or load it from a CDN—no build tooling required.
142
+
143
+ ### Basic usage (happy path)
144
+
145
+ Write plain Markdown directly in your HTML. The helper finds `[data-markdown]` blocks by default.
146
+
147
+ ```html
148
+ <div data-markdown>
149
+ # Hello
150
+
151
+ This is inline markdown with no build step.
152
+ </div>
153
+
154
+ <script src="path/to/mark-down-inline.umd.js"></script>
155
+ <script>
156
+ document.addEventListener("DOMContentLoaded", function () {
157
+ window.markDownInline.enhanceInlineMarkdown();
158
+ });
159
+ </script>
160
+ ```
161
+
162
+ The Markdown text remains in the DOM for SEO while JavaScript enhances it to semantic HTML for users.
163
+
164
+ ### Advanced usage (power path with front matter)
165
+
166
+ You can optionally prepend YAML front matter to provide metadata for each block. Fields are parsed with the same utilities used by the manifest flow.
167
+
168
+ ```html
169
+ <div data-markdown>
170
+ ---
171
+ slug: intro
172
+ title: Introduction
173
+ tags: [hero]
174
+ variant: lead
175
+ ---
176
+
177
+ # Introduction
178
+
179
+ This block has metadata.
180
+ </div>
181
+ ```
182
+
183
+ - `slug` → sets `id="intro"` if no ID exists and `data-slug="intro"`, allowing deep links.
184
+ - `title` → stored in `data-title` for TOC/index generation.
185
+ - `tags` → serialized to `data-tags="hero"` (comma-separated when multiple) for light-weight client-side filtering.
186
+ - `variant` → adds a class like `md-block--lead` so you can theme specific sections.
187
+
188
+ You still get fully rendered HTML, but additional attributes unlock progressive enhancement such as TOCs or custom styling.
189
+
190
+ ### Options
191
+
192
+ Configure the helper per page:
193
+
194
+ ```ts
195
+ enhanceInlineMarkdown({
196
+ selector?: string; // defaults to "[data-markdown]"
197
+ processFrontMatter?: boolean; // defaults to true
198
+ applyMetaToDom?: boolean; // defaults to true
199
+ });
200
+ ```
201
+
202
+ - Use `selector` to target different attributes or classes.
203
+ - Disable `processFrontMatter` when the block contains literal `---` fences that should stay visible.
204
+ - Set `applyMetaToDom` to `false` if you only care about rendered HTML and not DOM metadata.
205
+
206
+ ### SEO & progressive enhancement
207
+
208
+ - Raw Markdown lives directly in your HTML, so crawlers can index the text before scripts execute.
209
+ - Users without JavaScript still see readable Markdown; JavaScript simply upgrades it to semantic HTML, adds IDs, and decorates DOM attributes for richer UX.
210
+ - This inline mode is ideal for small/medium static pages or marketing sites. Larger doc sites should continue using the manifest-driven snippet system for better caching and composition.
211
+
99
212
  ## Testing & type safety
100
213
 
101
214
  - Use `SnippetMeta` and `Snippet` TypeScript types to describe props and state in your application.
102
- - Mock the client by providing a manifest array or by stubbing the `fetcher` function.
215
+ - Mock the client by providing a manifest array or by stubbing the `fetch` option.
103
216
  - Run the workspace tests with `npm run test -- core` to exercise Vitest suites that cover caching, filtering, and Markdown conversion.
104
217
 
105
218
  ## Related packages
package/package.json CHANGED
@@ -1,10 +1,8 @@
1
1
  {
2
2
  "name": "@mzebley/mark-down",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "mark↓ core runtime and shared utilities",
5
5
  "type": "module",
6
- "main": "dist/index.cjs",
7
- "module": "dist/index.mjs",
8
6
  "types": "dist/index.d.ts",
9
7
  "publishConfig": {
10
8
  "access": "public"
@@ -12,17 +10,23 @@
12
10
  "exports": {
13
11
  ".": {
14
12
  "types": "./dist/index.d.ts",
15
- "import": "./dist/index.mjs",
16
- "require": "./dist/index.cjs"
13
+ "import": "./dist/index.js"
17
14
  },
18
15
  "./slug": {
19
16
  "types": "./dist/slug.d.ts",
20
- "import": "./dist/slug.mjs",
21
- "require": "./dist/slug.cjs"
17
+ "import": "./dist/slug.js"
22
18
  },
23
19
  "./browser": {
24
20
  "types": "./dist/browser.d.ts",
25
21
  "import": "./dist/browser.js"
22
+ },
23
+ "./inline": {
24
+ "types": "./dist/inline.d.ts",
25
+ "import": "./dist/inline.js"
26
+ },
27
+ "./angular": {
28
+ "types": "./dist/angular/index.d.ts",
29
+ "import": "./dist/angular/index.js"
26
30
  }
27
31
  },
28
32
  "scripts": {
@@ -30,7 +34,8 @@
30
34
  "dev": "tsup --config tsup.config.ts --watch"
31
35
  },
32
36
  "dependencies": {
33
- "gray-matter": "^4.0.3",
34
- "marked": "^11.1.0"
35
- }
37
+ "marked": "^11.1.0",
38
+ "yaml": "^2.4.1"
39
+ },
40
+ "sideEffects": false
36
41
  }
@@ -0,0 +1,47 @@
1
+ import { Inject, Injectable, InjectionToken, Provider } from "@angular/core";
2
+ import { from, map, Observable, shareReplay } from "rxjs";
3
+ import { SnippetClient } from "../snippet-client";
4
+ import type { Snippet, SnippetClientOptions, SnippetMeta } from "../types";
5
+
6
+ export const SNIPPET_CLIENT = new InjectionToken<SnippetClient>("@mzebley/mark-down/SNIPPET_CLIENT");
7
+ export const SNIPPET_CLIENT_OPTIONS = new InjectionToken<SnippetClientOptions>(
8
+ "@mzebley/mark-down/SNIPPET_CLIENT_OPTIONS"
9
+ );
10
+
11
+ export function provideSnippetClient(options: SnippetClientOptions): Provider[] {
12
+ return [
13
+ { provide: SNIPPET_CLIENT_OPTIONS, useValue: options },
14
+ {
15
+ provide: SNIPPET_CLIENT,
16
+ useFactory: (opts: SnippetClientOptions) => new SnippetClient(opts),
17
+ deps: [SNIPPET_CLIENT_OPTIONS]
18
+ }
19
+ ];
20
+ }
21
+
22
+ @Injectable({ providedIn: "root" })
23
+ export class MarkdownSnippetService {
24
+ constructor(@Inject(SNIPPET_CLIENT) private readonly client: SnippetClient) {}
25
+
26
+ get(slug: string): Observable<Snippet> {
27
+ return from(this.client.get(slug)).pipe(shareReplay({ bufferSize: 1, refCount: true }));
28
+ }
29
+
30
+ listAll(): Observable<SnippetMeta[]> {
31
+ return from(this.client.listAll()).pipe(shareReplay({ bufferSize: 1, refCount: true }));
32
+ }
33
+
34
+ listByGroup(group: string): Observable<SnippetMeta[]> {
35
+ return from(this.client.listByGroup(group)).pipe(shareReplay({ bufferSize: 1, refCount: true }));
36
+ }
37
+
38
+ listByType(type: string): Observable<SnippetMeta[]> {
39
+ return from(this.client.listByType(type)).pipe(shareReplay({ bufferSize: 1, refCount: true }));
40
+ }
41
+
42
+ html(slug: string): Observable<string> {
43
+ return this.get(slug).pipe(map((snippet) => snippet.html));
44
+ }
45
+ }
46
+
47
+ export type { Snippet, SnippetClientOptions, SnippetMeta } from "../types";
package/src/errors.ts ADDED
@@ -0,0 +1,19 @@
1
+ export class SnippetNotFoundError extends Error {
2
+ readonly slug: string;
3
+
4
+ constructor(slug: string) {
5
+ super(`Snippet with slug '${slug}' was not found in the manifest.`);
6
+ this.name = "SnippetNotFoundError";
7
+ this.slug = slug;
8
+ }
9
+ }
10
+
11
+ export class ManifestLoadError extends Error {
12
+ readonly cause?: unknown;
13
+
14
+ constructor(message: string, cause?: unknown) {
15
+ super(message);
16
+ this.name = "ManifestLoadError";
17
+ this.cause = cause instanceof Error ? cause : cause ? new Error(String(cause)) : undefined;
18
+ }
19
+ }
@@ -0,0 +1,111 @@
1
+ import { parse as parseYaml } from "yaml";
2
+ import { ManifestLoadError } from "./errors";
3
+ import type { SnippetMeta } from "./types";
4
+
5
+ export interface FrontMatterResult {
6
+ content: string;
7
+ meta: Partial<SnippetMeta>;
8
+ extra: Record<string, unknown>;
9
+ slug?: string;
10
+ hasFrontMatter: boolean;
11
+ }
12
+
13
+ const FRONT_MATTER_PATTERN = /^(?:\uFEFF)?[ \t\r\n]*---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n?/;
14
+
15
+ export function parseFrontMatter(raw: string): FrontMatterResult {
16
+ const match = FRONT_MATTER_PATTERN.exec(raw);
17
+ if (!match) {
18
+ return { content: raw, meta: {}, extra: {}, hasFrontMatter: false };
19
+ }
20
+
21
+ const yamlSection = match[1];
22
+ let data: unknown;
23
+ try {
24
+ data = parseYaml(yamlSection) ?? {};
25
+ } catch (error) {
26
+ throw new ManifestLoadError("Failed to parse snippet front-matter.", error);
27
+ }
28
+
29
+ if (!isRecord(data)) {
30
+ return { content: raw.slice(match[0].length), meta: {}, extra: {}, hasFrontMatter: true };
31
+ }
32
+
33
+ const { known, extra } = splitFrontMatter(data);
34
+
35
+ return {
36
+ content: raw.slice(match[0].length),
37
+ meta: known.meta,
38
+ extra,
39
+ slug: known.slug,
40
+ hasFrontMatter: true
41
+ };
42
+ }
43
+
44
+ function splitFrontMatter(
45
+ data: Record<string, unknown>
46
+ ): { known: { meta: Partial<SnippetMeta>; slug?: string }; extra: Record<string, unknown> } {
47
+ const meta: Partial<SnippetMeta> = {};
48
+ const extra: Record<string, unknown> = {};
49
+ let slug: string | undefined;
50
+
51
+ for (const [key, value] of Object.entries(data)) {
52
+ switch (key) {
53
+ case "slug":
54
+ slug = typeof value === "string" ? value : undefined;
55
+ break;
56
+ case "title":
57
+ if (typeof value === "string") {
58
+ meta.title = value;
59
+ }
60
+ break;
61
+ case "type":
62
+ if (typeof value === "string") {
63
+ meta.type = value;
64
+ }
65
+ break;
66
+ case "order":
67
+ if (typeof value === "number") {
68
+ meta.order = value;
69
+ }
70
+ break;
71
+ case "tags":
72
+ meta.tags = normalizeTags(value);
73
+ break;
74
+ case "group":
75
+ if (typeof value === "string" || value === null) {
76
+ meta.group = value;
77
+ }
78
+ break;
79
+ case "draft":
80
+ if (typeof value === "boolean") {
81
+ meta.draft = value;
82
+ }
83
+ break;
84
+ default:
85
+ extra[key] = value;
86
+ break;
87
+ }
88
+ }
89
+
90
+ return { known: { meta, slug }, extra };
91
+ }
92
+
93
+ function normalizeTags(value: unknown): string[] | undefined {
94
+ if (!value) {
95
+ return undefined;
96
+ }
97
+ if (Array.isArray(value)) {
98
+ return value.map((item) => String(item));
99
+ }
100
+ if (typeof value === "string") {
101
+ return value
102
+ .split(",")
103
+ .map((tag) => tag.trim())
104
+ .filter(Boolean);
105
+ }
106
+ return undefined;
107
+ }
108
+
109
+ function isRecord(candidate: unknown): candidate is Record<string, unknown> {
110
+ return Boolean(candidate) && typeof candidate === "object" && !Array.isArray(candidate);
111
+ }
package/src/inline.ts ADDED
@@ -0,0 +1,80 @@
1
+ import { parseFrontMatter, type FrontMatterResult } from "./front-matter";
2
+ import { renderMarkdown } from "./markdown";
3
+
4
+ export interface InlineMarkdownOptions {
5
+ selector?: string;
6
+ processFrontMatter?: boolean;
7
+ applyMetaToDom?: boolean;
8
+ }
9
+
10
+ const DEFAULT_SELECTOR = "[data-markdown]";
11
+
12
+ export function enhanceInlineMarkdown(options: InlineMarkdownOptions = {}): void {
13
+ if (typeof document === "undefined") {
14
+ return;
15
+ }
16
+
17
+ const selector = options.selector ?? DEFAULT_SELECTOR;
18
+ const processFrontMatter = options.processFrontMatter !== false;
19
+ const applyMetaToDom = options.applyMetaToDom !== false;
20
+
21
+ const elements = Array.from(document.querySelectorAll<HTMLElement>(selector));
22
+ for (const element of elements) {
23
+ if (element.dataset.markdownProcessed === "true") {
24
+ continue;
25
+ }
26
+ processElement(element, { processFrontMatter, applyMetaToDom });
27
+ }
28
+ }
29
+
30
+ function processElement(
31
+ element: HTMLElement,
32
+ options: { processFrontMatter: boolean; applyMetaToDom: boolean }
33
+ ): void {
34
+ const raw = element.textContent ?? "";
35
+ let frontMatter: FrontMatterResult | undefined;
36
+
37
+ if (options.processFrontMatter) {
38
+ try {
39
+ frontMatter = parseFrontMatter(raw);
40
+ } catch (error) {
41
+ console.warn("[mark↓ inline] Failed to parse front matter for element:", error);
42
+ frontMatter = undefined;
43
+ }
44
+ }
45
+
46
+ const body = frontMatter?.content ?? raw;
47
+ const html = renderMarkdown(body);
48
+
49
+ element.innerHTML = html;
50
+ element.dataset.markdownProcessed = "true";
51
+
52
+ if (options.applyMetaToDom && frontMatter?.hasFrontMatter) {
53
+ applyMetaAttributes(element, frontMatter);
54
+ }
55
+ }
56
+
57
+ function applyMetaAttributes(element: HTMLElement, frontMatter: FrontMatterResult): void {
58
+ const { slug, meta, extra } = frontMatter;
59
+
60
+ if (slug) {
61
+ if (!element.id) {
62
+ element.id = slug;
63
+ }
64
+ element.dataset.slug = slug;
65
+ }
66
+
67
+ if (meta.title) {
68
+ element.dataset.title = meta.title;
69
+ }
70
+
71
+ if (meta.tags?.length) {
72
+ element.dataset.tags = meta.tags.join(",");
73
+ }
74
+
75
+ const variant = typeof extra.variant === "string" ? extra.variant.trim() : "";
76
+ if (variant) {
77
+ element.classList.add(`md-block--${variant}`);
78
+ }
79
+
80
+ }
@@ -0,0 +1,5 @@
1
+ import { marked } from "marked";
2
+
3
+ export function renderMarkdown(markdown: string): string {
4
+ return marked.parse(markdown);
5
+ }