@refrakt-md/svelte 0.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/package.json +35 -0
- package/src/Renderer.svelte +69 -0
- package/src/ThemeShell.svelte +41 -0
- package/src/context.ts +28 -0
- package/src/index.ts +8 -0
- package/src/route-rules.ts +31 -0
- package/src/serialize.ts +24 -0
- package/src/theme.ts +16 -0
- package/src/types.ts +9 -0
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@refrakt-md/svelte",
|
|
3
|
+
"description": "Svelte renderer for refrakt.md content",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/refrakt-md/refrakt.git",
|
|
10
|
+
"directory": "packages/svelte"
|
|
11
|
+
},
|
|
12
|
+
"bugs": "https://github.com/refrakt-md/refrakt/issues",
|
|
13
|
+
"homepage": "https://github.com/refrakt-md/refrakt",
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"svelte": "src/index.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./src/index.ts",
|
|
21
|
+
"svelte": "./src/index.ts",
|
|
22
|
+
"default": "./src/index.ts"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src"
|
|
27
|
+
],
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@markdoc/markdoc": "0.4.0",
|
|
30
|
+
"@refrakt-md/types": "0.1.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"svelte": "^5.0.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export type { SerializedTag, RendererNode } from './types.js';
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<script lang="ts">
|
|
6
|
+
import type { Component } from 'svelte';
|
|
7
|
+
import type { SerializedTag, RendererNode } from './types.js';
|
|
8
|
+
import { getComponent, getElementOverrides } from './context.js';
|
|
9
|
+
import Renderer from './Renderer.svelte';
|
|
10
|
+
|
|
11
|
+
let { node, overrides }: { node: RendererNode; overrides?: Record<string, Component<any>> } = $props();
|
|
12
|
+
|
|
13
|
+
function isTag(n: unknown): n is SerializedTag {
|
|
14
|
+
return n !== null && typeof n === 'object' && (n as any).$$mdtype === 'Tag';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Filter out attributes that shouldn't be rendered as HTML attributes */
|
|
18
|
+
function htmlAttrs(attrs: Record<string, any>): Record<string, any> {
|
|
19
|
+
const result: Record<string, any> = {};
|
|
20
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
21
|
+
if (v === undefined || v === null || v === false) continue;
|
|
22
|
+
result[k] = v === true ? '' : String(v);
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const globalOverrides = getElementOverrides();
|
|
28
|
+
const merged = overrides
|
|
29
|
+
? { ...globalOverrides, ...overrides }
|
|
30
|
+
: globalOverrides;
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
{#if Array.isArray(node)}
|
|
34
|
+
{#each node as child}
|
|
35
|
+
<Renderer node={child} overrides={merged} />
|
|
36
|
+
{/each}
|
|
37
|
+
{:else if node === null || node === undefined}
|
|
38
|
+
<!-- empty -->
|
|
39
|
+
{:else if typeof node === 'string'}
|
|
40
|
+
{node}
|
|
41
|
+
{:else if typeof node === 'number'}
|
|
42
|
+
{String(node)}
|
|
43
|
+
{:else if isTag(node)}
|
|
44
|
+
{@const Component = node.attributes?.typeof ? getComponent(node.attributes.typeof) : undefined}
|
|
45
|
+
{@const ElementOverride = !Component && merged?.[node.name] ? merged[node.name] : undefined}
|
|
46
|
+
{#if Component}
|
|
47
|
+
<Component tag={node}>
|
|
48
|
+
{#each node.children as child}
|
|
49
|
+
<Renderer node={child} overrides={merged} />
|
|
50
|
+
{/each}
|
|
51
|
+
</Component>
|
|
52
|
+
{:else if ElementOverride}
|
|
53
|
+
<ElementOverride tag={node}>
|
|
54
|
+
{#each node.children as child}
|
|
55
|
+
<Renderer node={child} overrides={merged} />
|
|
56
|
+
{/each}
|
|
57
|
+
</ElementOverride>
|
|
58
|
+
{:else}
|
|
59
|
+
<svelte:element this={node.name} {...htmlAttrs(node.attributes)}>
|
|
60
|
+
{#each node.children as child}
|
|
61
|
+
{#if node.attributes?.['data-codeblock'] && typeof child === 'string'}
|
|
62
|
+
{@html child}
|
|
63
|
+
{:else}
|
|
64
|
+
<Renderer node={child} overrides={merged} />
|
|
65
|
+
{/if}
|
|
66
|
+
{/each}
|
|
67
|
+
</svelte:element>
|
|
68
|
+
{/if}
|
|
69
|
+
{/if}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { SvelteTheme } from './theme.js';
|
|
3
|
+
import { setRegistry, setElementOverrides } from './context.js';
|
|
4
|
+
import { setContext } from 'svelte';
|
|
5
|
+
import { matchRouteRule } from './route-rules.js';
|
|
6
|
+
|
|
7
|
+
interface PageData {
|
|
8
|
+
title: string;
|
|
9
|
+
description: string;
|
|
10
|
+
regions: Record<string, { name: string; mode: string; content: any[] }>;
|
|
11
|
+
renderable: any;
|
|
12
|
+
pages: Array<{ url: string; title: string; draft: boolean }>;
|
|
13
|
+
url: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let { theme, page }: { theme: SvelteTheme; page: PageData } = $props();
|
|
17
|
+
|
|
18
|
+
// Wire theme into Svelte context
|
|
19
|
+
setRegistry(theme.components);
|
|
20
|
+
if (theme.elements) setElementOverrides(theme.elements);
|
|
21
|
+
setContext('pages', page.pages);
|
|
22
|
+
|
|
23
|
+
// Pick layout via route rules (reactive so layout updates on client-side navigation)
|
|
24
|
+
const layoutName = $derived(matchRouteRule(page.url, theme.manifest.routeRules));
|
|
25
|
+
const Layout = $derived(theme.layouts[layoutName] ?? theme.layouts['default']);
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<svelte:head>
|
|
29
|
+
{#if page.title}<title>{page.title}</title>{/if}
|
|
30
|
+
{#if page.description}<meta name="description" content={page.description} />{/if}
|
|
31
|
+
</svelte:head>
|
|
32
|
+
|
|
33
|
+
{#if Layout}
|
|
34
|
+
<Layout
|
|
35
|
+
title={page.title}
|
|
36
|
+
description={page.description}
|
|
37
|
+
regions={page.regions}
|
|
38
|
+
renderable={page.renderable}
|
|
39
|
+
pages={page.pages}
|
|
40
|
+
/>
|
|
41
|
+
{/if}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { getContext, setContext } from 'svelte';
|
|
2
|
+
import type { Component } from 'svelte';
|
|
3
|
+
|
|
4
|
+
export type ComponentRegistry = Record<string, Component<any>>;
|
|
5
|
+
export type ElementOverrides = Record<string, Component<any>>;
|
|
6
|
+
|
|
7
|
+
const REGISTRY_KEY = Symbol('refrakt-registry');
|
|
8
|
+
const ELEMENT_OVERRIDES_KEY = Symbol('refrakt-element-overrides');
|
|
9
|
+
|
|
10
|
+
/** Set the component registry in Svelte context. Call during component init (e.g. in a layout). */
|
|
11
|
+
export function setRegistry(registry: ComponentRegistry): void {
|
|
12
|
+
setContext(REGISTRY_KEY, registry);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Look up a component by its typeof name from the context registry. */
|
|
16
|
+
export function getComponent(typeName: string): Component<any> | undefined {
|
|
17
|
+
return getContext<ComponentRegistry>(REGISTRY_KEY)?.[typeName];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Set theme-global element overrides in Svelte context. */
|
|
21
|
+
export function setElementOverrides(overrides: ElementOverrides): void {
|
|
22
|
+
setContext(ELEMENT_OVERRIDES_KEY, overrides);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Get theme-global element overrides from Svelte context. */
|
|
26
|
+
export function getElementOverrides(): ElementOverrides | undefined {
|
|
27
|
+
return getContext<ElementOverrides>(ELEMENT_OVERRIDES_KEY);
|
|
28
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { default as Renderer } from './Renderer.svelte';
|
|
2
|
+
export { default as ThemeShell } from './ThemeShell.svelte';
|
|
3
|
+
export type { SerializedTag, RendererNode } from './types.js';
|
|
4
|
+
export { serialize, serializeTree } from './serialize.js';
|
|
5
|
+
export { setRegistry, getComponent, setElementOverrides, getElementOverrides } from './context.js';
|
|
6
|
+
export type { ComponentRegistry, ElementOverrides } from './context.js';
|
|
7
|
+
export type { SvelteTheme } from './theme.js';
|
|
8
|
+
export { matchRouteRule } from './route-rules.js';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { RouteRule } from '@refrakt-md/types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Match a URL against an ordered list of route rules.
|
|
5
|
+
* Returns the layout name from the first matching rule, or 'default' if none match.
|
|
6
|
+
*
|
|
7
|
+
* Pattern syntax:
|
|
8
|
+
* - "docs/**" matches /docs/anything and /docs/nested/deep
|
|
9
|
+
* - "blog/*" matches /blog/post but not /blog/nested/deep
|
|
10
|
+
* - "**" matches everything (catch-all)
|
|
11
|
+
* - "about" matches /about exactly
|
|
12
|
+
*/
|
|
13
|
+
export function matchRouteRule(url: string, rules: RouteRule[]): string {
|
|
14
|
+
const normalized = url.startsWith('/') ? url.slice(1) : url;
|
|
15
|
+
|
|
16
|
+
for (const rule of rules) {
|
|
17
|
+
if (matchPattern(normalized, rule.pattern)) {
|
|
18
|
+
return rule.layout;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return 'default';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function matchPattern(url: string, pattern: string): boolean {
|
|
25
|
+
const regexStr = pattern
|
|
26
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape regex special chars (except * and ?)
|
|
27
|
+
.replace(/\*\*/g, '{{GLOBSTAR}}')
|
|
28
|
+
.replace(/\*/g, '[^/]*')
|
|
29
|
+
.replace(/\{\{GLOBSTAR\}\}/g, '.*');
|
|
30
|
+
return new RegExp(`^${regexStr}$`).test(url);
|
|
31
|
+
}
|
package/src/serialize.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import Markdoc from '@markdoc/markdoc';
|
|
2
|
+
import type { RenderableTreeNode, RenderableTreeNodes } from '@markdoc/markdoc';
|
|
3
|
+
const { Tag } = Markdoc;
|
|
4
|
+
|
|
5
|
+
/** Convert a Markdoc Tag instance to a plain serializable object */
|
|
6
|
+
export function serialize(node: RenderableTreeNode): unknown {
|
|
7
|
+
if (node === null || node === undefined) return node;
|
|
8
|
+
if (typeof node === 'string' || typeof node === 'number') return node;
|
|
9
|
+
if (Tag.isTag(node)) {
|
|
10
|
+
return {
|
|
11
|
+
$$mdtype: 'Tag',
|
|
12
|
+
name: node.name,
|
|
13
|
+
attributes: node.attributes,
|
|
14
|
+
children: node.children.map(serialize),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
return node;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Convert a Markdoc tree (single node or array) to serializable POJOs */
|
|
21
|
+
export function serializeTree(tree: RenderableTreeNodes): unknown {
|
|
22
|
+
if (Array.isArray(tree)) return tree.map(serialize);
|
|
23
|
+
return serialize(tree);
|
|
24
|
+
}
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ThemeManifest } from '@refrakt-md/types';
|
|
2
|
+
import type { Component } from 'svelte';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A resolved Svelte theme: the manifest plus live component references.
|
|
6
|
+
* This is the runtime contract between a theme package and the Svelte adapter.
|
|
7
|
+
*/
|
|
8
|
+
export interface SvelteTheme {
|
|
9
|
+
manifest: ThemeManifest;
|
|
10
|
+
/** Layout name → Svelte component */
|
|
11
|
+
layouts: Record<string, Component<any>>;
|
|
12
|
+
/** typeof name → Svelte component (the component registry) */
|
|
13
|
+
components: Record<string, Component<any>>;
|
|
14
|
+
/** HTML element name → Svelte component (element-level overrides) */
|
|
15
|
+
elements?: Record<string, Component<any>>;
|
|
16
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** A serialized Markdoc Tag (plain object, not a class instance) */
|
|
2
|
+
export interface SerializedTag {
|
|
3
|
+
$$mdtype: 'Tag';
|
|
4
|
+
name: string;
|
|
5
|
+
attributes: Record<string, any>;
|
|
6
|
+
children: RendererNode[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type RendererNode = SerializedTag | string | number | null | undefined | RendererNode[];
|