diff --git a/.transform-log.txt b/.transform-log.txt new file mode 100644 index 0000000..a46fcb9 --- /dev/null +++ b/.transform-log.txt @@ -0,0 +1,19 @@ +[2025-04-27T18:45:25.583Z] + export default {"title":"Blogg","components":[{"component":(await import('/src/lib/components/Hero.svelte')).default,"primer":"kompismoln.se/blog","body":"# Blogg\n","buttons":[{"text":"Berätta mer","url":"/about","primary":true},{"text":"Vad är ?","url":"/tools/nixos","primary":false}]}]}; + +[2025-04-27T18:46:59.727Z] + export default {"title":"Blogg","components":[{"component":"Hero","primer":"kompismoln.se/blog","body":"# Blogg\n","buttons":[{"text":"Berätta mer","url":"/about","primary":true},{"text":"Vad är ?","url":"/tools/nixos","primary":false}]}]}; + +[2025-04-27T18:47:54.121Z] + export default { + 'about': () => import('virtual:content/about'), +'blog': () => import('virtual:content/blog'), +'': () => import('virtual:content/'), +'test': () => import('virtual:content/test'), +'tools': () => import('virtual:content/tools'), +'tools/nixos': () => import('virtual:content/tools/nixos'), +'blog/att-bygga-hemsida': () => import('virtual:content/blog/att-bygga-hemsida'), +'blog/hello-world': () => import('virtual:content/blog/hello-world'), +'blog/hugo-like-sveltekit-ssg': () => import('virtual:content/blog/hugo-like-sveltekit-ssg') + }; + diff --git a/compis/Page.svelte b/compis/Page.svelte index c20d5d2..d8e48bb 100644 --- a/compis/Page.svelte +++ b/compis/Page.svelte @@ -6,10 +6,10 @@ {#if page.component} - + {/if} {#if page.components} {#each page.components as component} - + {/each} {/if} diff --git a/compis/component.loader.ts b/compis/component.loader.ts index 7c6e2ec..4b6d2c2 100644 --- a/compis/component.loader.ts +++ b/compis/component.loader.ts @@ -2,7 +2,8 @@ import type { ComponentMap, ComponentContent, ResolvedComponent, - PageContent + PageContent, + ComponentModule } from './types'; import { createRawSnippet } from 'svelte'; @@ -21,6 +22,12 @@ import { contentTraverser, inferCommonPath, trimKey } from './utils'; */ let componentMap: ComponentMap; +export let contentResolve: (value?: any) => void; + +export const contentReady = new Promise(resolve => { contentResolve = resolve; }); +export const virtualComponentMap: Record = {}; +//contentReady.then(() => console.log(virtualComponentMap)); + /* Reduce keys to friendly component names like 'Hero' or 'Blog/Post', * instead of full paths like: * @@ -38,19 +45,14 @@ export function setComponentMap( componentMap = trimKey(modules, componentRoot.length, '.svelte'.length); } +export function addVirtualComponent(id: string, source: string) { + virtualComponentMap[id] = source; +} + /* How everyone in here should get a component from componentMap */ export const getComponent = async (name: string) => { - if (!Object.keys(componentMap).length) { - throw new Error( - 'Component map is empty. Did you forget to call setComponentMap()?' - ); - } - if (!(name in componentMap)) { - throw new Error(`Component not found: ${name}`); - } - const component = await componentMap[name](); - return component; + return name; }; /* Get module with props in a neat package @@ -63,7 +65,8 @@ export const resolveComponent = async ( content: ComponentContent ): Promise => { const { component: name, ...props } = content; - const { default: component } = await getComponent(name); + + const component = await getComponent(name); return { component, props }; }; @@ -112,7 +115,7 @@ export const resolvePage = async (page: PageContent) => { * Also, what's with "conform"? Who writes that? * * Maybe just pay the price for writing a function that does two things and name it - * validateAndTransformComponent. <- Actually not a bad idead. + * validateAndTransformComponent. <- Actually not a bad idea. */ export const conformComponent = async ( content: ComponentContent diff --git a/compis/content.loader.ts b/compis/content.loader.ts index 2999282..99c4e6b 100644 --- a/compis/content.loader.ts +++ b/compis/content.loader.ts @@ -4,7 +4,6 @@ import fs from 'node:fs/promises'; import { globSync } from 'node:fs'; import path from 'node:path'; -import { redirect } from '@sveltejs/kit'; import type { PageContent } from './types'; import { conformComponent } from './component.loader'; @@ -30,37 +29,32 @@ const filetypes = ['js', 'ts', 'json', 'yaml', 'yml', 'md']; * because this function doesn't run there 🤔 * */ -export const loadPageContent = async (searchPath: string) => { - // This redirect has no effect in production, handle redirects on webserver instead - if (searchPath === config.indexFile) throw redirect(301, '/'); - +export const load = async ({ params }: any) => { // Rename site root to index file - searchPath = searchPath === '' ? config.indexFile : searchPath; - - // Find page or throw - let page = await findPageContent(searchPath); + const searchPath = params.path === '' ? config.indexFile : params.path; - try { - page = await processPage(page); - } catch (error: any) { - throw new Error( - `Failed to process page '${searchPath}': ${error.message || error}` - ); - } + const collectedPage = await collectPage(searchPath); + const processedPage = await processPage(collectedPage); + return processedPage; +}; +/* Find pae and recurse in content tree and + * - Replace all fragments with data from fragment files + */ +export const collectPage = async (searchPath: string): Promise => { + const rawPage = await findPageContent(searchPath) + const page = await contentTraverser({ + obj: rawPage, + filter: (obj) => Object.keys(obj).some((key: string) => key[0] === '_'), + callback: parseFragment + }); return page; -}; +} /* Recurse in content tree and - * - Replace all fragments with data from file * - Validate & transform all components */ export const processPage = async (page: PageContent): Promise => { - page = await contentTraverser({ - obj: page, - filter: (obj) => Object.keys(obj).some((key: string) => key[0] === '_'), - callback: parseFragment - }); page = await contentTraverser({ obj: page, @@ -77,7 +71,8 @@ export const findPageContent = async (searchPath: string) => { for (const ext of filetypes) { const filePath = path.join(config.contentRoot, `${searchPath}.${ext}`); try { - return await parseFile(filePath); + const page = await parseFile(filePath); + return page; } catch (error: any) { if (['ENOENT', 'ERR_MODULE_NOT_FOUND'].includes(error.code)) { continue; @@ -93,9 +88,9 @@ export const findPageContent = async (searchPath: string) => { export const parseFile = async (filePath: string): Promise => { const fileExt = path.extname(filePath); - if (['.js', '.ts'].includes(fileExt)) { - return (await import(/* @vite-ignore */ filePath)).default; - } + //if (['.js', '.ts'].includes(fileExt)) { + // return (await import(/* @vite-ignore */ filePath)).default; + //} const fileContent = await fs.readFile(filePath, 'utf-8'); diff --git a/compis/loaders.spec.ts b/compis/loaders.spec.ts index df3cd38..a87b6a8 100644 --- a/compis/loaders.spec.ts +++ b/compis/loaders.spec.ts @@ -3,12 +3,62 @@ import path from 'node:path'; import { globSync } from 'node:fs'; import { setComponentMap, getComponent } from './component.loader'; import { inferCommonPath } from './utils'; +import { collectPage } from './content.loader'; +import { c } from './schemas'; + setComponentMap({ 'Basic.svelte': () => import('./test/components/Basic.svelte'), 'WithSchema.svelte': () => import('./test/components/WithSchema.svelte') }); +describe('validate content', async () => { + const code = ` + import c from '$lib/components/schemas'; + + export const schema = c.content({ + primer: c.string(), + body: c.markdown(), + buttons: c.array(c.button()).max(2) + }); +`; + it('parses schema', async () => { + expect(c.content({})).toBeDefined(); + expect(c.string()).toBeDefined(); + expect(c.asdf).toBeUndefined(); + expect(c.markdown).toBeTruthy(); + expect(c.smarkdown).toBeFalsy(); + }); +}); + +/* +import getPlugin from './vite'; + +describe('load pages', async () => { + it('collects pages', async () => { + const page = await collectPage('blog'); + console.log(page); + }); +}); + +describe('transform hook playground', async () => { + const plugin = await getPlugin(); + const transform = plugin.transform!.bind(plugin); + + it('play with random code', async () => { + const code = ` + +

Hello

+ `; + const id = '/src/lib/TestComponent.svelte'; + + const result = await transform(code, id); + console.log('TRANSFORM RESULT', result); + // no assertions, just playground + }); +}); describe('components', () => { it('infers component root correctly', () => { const paths = [ @@ -26,9 +76,9 @@ describe('components', () => { }); }), it('retrieves components', async () => { - await expect(getComponent('NotAComponent')).rejects.toThrow( - 'Component not found: NotAComponent' - ); + //await expect(getComponent('NotAComponent')).rejects.toThrow( + // 'Component not found: NotAComponent' + //); const { default: component, schema } = await getComponent('WithSchema'); let result: any; @@ -45,13 +95,11 @@ describe('components', () => { }); }); +*/ describe('playground', () => { it('finds page.yaml in content', () => { - const contentDir = path.resolve(process.cwd(), 'src/lib/content'); - const pattern = path.join(contentDir, '**', 'page.@(yaml|md)'); - const files = globSync(pattern); - const content = Object.fromEntries( - files.map((file) => [path.dirname(path.relative(contentDir, file)), file]) - ); + const c = { test: 'asdff' }; + const r = (new Function('c', `return c.test`))(c); + console.log(r); }); }); diff --git a/compis/schemas.ts b/compis/schemas.ts index 9f1d6ef..4ecb7d4 100644 --- a/compis/schemas.ts +++ b/compis/schemas.ts @@ -1,8 +1,9 @@ +import fs from 'node:fs/promises'; import z from 'zod'; import { browser } from '$app/environment'; import type { ComponentContent, PreparedMarkdown, ParsedHtml } from './types'; - -export { z }; +import { shortHash } from './utils'; +import { addVirtualComponent } from './component.loader'; /** * Provides the following utility schemas: @@ -25,6 +26,74 @@ export { z }; /* Callback for content's transform function */ + +const extensions = { + content: (obj: any) => { + return z + .object({ ...obj, component: z.string() }) + .strict() + }, + + markdown: () => { + const prepare = (val: string): ComponentContent => { + const hash = shortHash(val); + const component = `virtual:${hash}`; + addVirtualComponent(component, val); + + return { component }; + }; + return z.string().transform(prepare).or(c.parsedHtml()); + }, + + parsedHtml: (): z.ZodType => + z.object({ + html: z.string(), + data: z.object({}).passthrough() + }), + + component: (allowed: string[] | null = null) => { + const component = allowed + ? z.enum(allowed as [string, ...string[]]) + : z.string(); + + return z.object({ component }).passthrough(); + }, + + slots: (allowed = null) => z.record(c.component(allowed)), +image: () => + z.object({ + src: z.string(), + alt: z.string() + }), + +link: () => + z.object({ + url: z.string(), + text: z.string(), + blank: z.boolean().optional() + }), + +button: () => + z.object({ + url: z.string(), + text: z.string(), + fill: z.boolean().optional() + }), + +social: () => + z.object({ + url: z.string(), + platform: z.enum([ + 'twitter', + 'facebook', + 'mastodon', + 'instagram', + 'youtube', + 'bluesky', + 'tiktok' + ]) + }), +}; const process = async (content: ComponentContent) => { if (browser) { return content; @@ -42,35 +111,20 @@ const process = async (content: ComponentContent) => { return Object.fromEntries(entries); }; -const content = (obj: any) => { - return z - .object({ ...obj, component: z.string() }) - .strict() - .transform((val) => process(val as ComponentContent)); -}; -const markdown = (options = {}) => { - const prepare = (val: string): PreparedMarkdown => ({ - markdown: val, - options - }); - return z.string().transform(prepare).or(parsedHtml()); -}; +export const getSchema = async (component: string) => { + const code = await fs.readFile(`src/lib/components/${component}.svelte`, 'utf8'); -const parsedHtml = (): z.ZodType => - z.object({ - html: z.string(), - data: z.object({}).passthrough() - }); + const match = code.match(/export\s+const\s+schema\s*=\s*(c\.content\(([\s\S]*?)\));/); -const component = (allowed: string[] | null = null) => { - const component = allowed - ? z.enum(allowed as [string, ...string[]]) - : z.string(); + if (!match) { + return + } + const schemaDefinition = match[1]; - return z.object({ component }).passthrough(); -}; + return ''; -const slots = (allowed = null) => z.record(component(allowed)); +} -export const ze = { markdown, component, slots, content }; +export const c = extensions; +export default c; diff --git a/compis/utils.ts b/compis/utils.ts index 7da7b88..126b1b6 100644 --- a/compis/utils.ts +++ b/compis/utils.ts @@ -89,3 +89,10 @@ export const trimKey = ( ]) ); }; + +export const shortHash = (str: string) => { + let hash = 0 + for (let i = 0; i < str.length; i++) hash = (hash << 5) - hash + str.charCodeAt(i) + return ('0000' + (hash >>> 0).toString(36)).slice(-4) +} + diff --git a/compis/vite.ts b/compis/vite.ts new file mode 100644 index 0000000..cf3bc58 --- /dev/null +++ b/compis/vite.ts @@ -0,0 +1,72 @@ +import { discoverContentPaths, collectPage } from './content.loader'; +import type { Plugin } from 'vite'; +import type { ComponentContent } from './types'; +import { appendFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + + +const logPath = resolve('.transform-log.txt'); +const logFormat = (message: string) => `[${new Date().toISOString()}] ${message}\n`; +const log = (message: string) => appendFileSync(logPath, logFormat(message)); +const componentRoot = '/src/lib/components'; + + +const loadContent = async (path:string) => { + let page = await collectPage(path); + /* + page = await contentTraverser({ + obj: page, + filter: (obj) => 'component' in obj, + callback: conformComponent + }); + */ + return page; +} + +export const conformComponent = async ( + content: ComponentContent +): Promise => { + return content; +}; + +export default async function composably(): Promise{ + + return { + name: 'svelte-composably', + + async load(id) { + if (id === 'virtual:content') { + const entries = discoverContentPaths(); + const code = ` + export default { + ${entries.map(p => `'${p}': () => import('virtual:content/${p}')`).join(',\n')} + }; + `; + return code; + + }; + + if (id.startsWith('virtual:content/')) { + const path = id.slice('virtual:content/'.length); + const page = await loadContent(path); + + let code = ` + export default ${JSON.stringify(page)}; + `; + code = code.replace(/"component":"([^"]+)"/g, (_, path) => + `"component":(await import('${componentRoot}/${path}.svelte')).default`); + + return code; + } + + return null; + }, + + resolveId(source) { + if (source.startsWith('virtual:content')) { + return source; + } + }, + + }; +} diff --git a/package-lock.json b/package-lock.json index cada6f3..e2e6558 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@testing-library/svelte": "^5.2.4", "@types/js-yaml": "^4.0.9", "@types/node": "^22.13.17", + "acorn": "^8.14.1", "animate.css": "^4.1.1", "daisyui": "^5.0.9", "eslint": "^9.18.0", @@ -32,6 +33,7 @@ "hastscript": "^9.0.1", "js-yaml": "^4.1.0", "jsdom": "^26.0.0", + "json5": "^2.2.3", "prettier": "^3.4.2", "prettier-plugin-svelte": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.11", @@ -3925,6 +3927,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/package.json b/package.json index 5f9dd45..528d6e4 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@testing-library/svelte": "^5.2.4", "@types/js-yaml": "^4.0.9", "@types/node": "^22.13.17", + "acorn": "^8.14.1", "animate.css": "^4.1.1", "daisyui": "^5.0.9", "eslint": "^9.18.0", @@ -40,6 +41,7 @@ "hastscript": "^9.0.1", "js-yaml": "^4.1.0", "jsdom": "^26.0.0", + "json5": "^2.2.3", "prettier": "^3.4.2", "prettier-plugin-svelte": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.11", diff --git a/src/lib/components/Footer.svelte b/src/lib/components/Footer.svelte index ef9ff6d..6e030d8 100644 --- a/src/lib/components/Footer.svelte +++ b/src/lib/components/Footer.svelte @@ -1,11 +1,10 @@ diff --git a/src/lib/components/Head.svelte b/src/lib/components/Head.svelte index 514df92..6ae7765 100644 --- a/src/lib/components/Head.svelte +++ b/src/lib/components/Head.svelte @@ -1,12 +1,12 @@ diff --git a/src/lib/components/Hero.svelte b/src/lib/components/Hero.svelte index fdb36a8..236fc7c 100644 --- a/src/lib/components/Hero.svelte +++ b/src/lib/components/Hero.svelte @@ -1,13 +1,10 @@ @@ -65,7 +62,7 @@
- {@html body.html} + {body}
{#if buttons}
diff --git a/src/lib/components/Nav.svelte b/src/lib/components/Nav.svelte index 3f8c84f..f7667ff 100644 --- a/src/lib/components/Nav.svelte +++ b/src/lib/components/Nav.svelte @@ -1,12 +1,11 @@ diff --git a/src/lib/content/index.yaml b/src/lib/content/index.yaml index e3f3e41..d912f66 100644 --- a/src/lib/content/index.yaml +++ b/src/lib/content/index.yaml @@ -5,7 +5,7 @@ components: body: | # NixOS-baserad infrastruktur till er tjänst! En molnbyrå som hjälper människor och företag att - förverkliga [idéer](https://example.com) och hantera sin IT-miljö. + förverkliga idéer och hantera sin IT-miljö. Här hittar du konsulttjänster och decentraliseratde alternativ till Google drive, Dropbox, iCloud, Zoom, AWS, Heroku med flera. buttons: diff --git a/src/routes/[...path]/+layout.svelte b/src/routes/[...path]/+layout.svelte index ac781bd..b3befce 100644 --- a/src/routes/[...path]/+layout.svelte +++ b/src/routes/[...path]/+layout.svelte @@ -10,13 +10,6 @@ import Nav from '$lib/components/Nav.svelte'; import Footer from '$lib/components/Footer.svelte'; - const head = $derived({ - siteName: config.siteName, - pageName: page.data.title, - pagePath: page.url.pathname, - siteUrl: config.siteUrl, - siteDescription: config.siteDescription - }); const nav = { logo: config.logo, @@ -34,7 +27,6 @@ let { children }: LayoutProps = $props(); -