UNPKG

vibelog

Version:

Bring your own content with some vibes ✨

126 lines (110 loc) 19.3 kB
#!/usr/bin/env node import{cac as e}from"cac";import{basename as t,dirname as n,join as r,resolve as i}from"node:path";import{fileURLToPath as a}from"node:url";import{execSync as o}from"node:child_process";import{build as s,dev as c}from"astro";import l from"@astrojs/mdx";import u from"@astrojs/sitemap";import d from"fs-extra";import f from"gray-matter";import{randomBytes as p}from"node:crypto";import{blue as m,dim as h,red as g}from"kleur/colors";import{inspect as _}from"node:util";import{parse as v}from"postcss";import y from"node:assert/strict";import{generateObject as b}from"ai";import{createOllama as x}from"ollama-ai-provider";import{createOpenAI as S}from"@ai-sdk/openai";import{createAnthropic as C}from"@ai-sdk/anthropic";import{createGoogleGenerativeAI as w}from"@ai-sdk/google";import{createOpenRouter as T}from"@openrouter/ai-sdk-provider";import{z as E}from"zod";var D=`0.3.4`,O=`Bring your own content with some vibes ✨`;function ee(e=8){return p(e/2).toString(`hex`)}function k(e){return e.trim().toLowerCase().replace(/[^a-z0-9]+/g,`-`).replace(/\s+/g,`-`).replace(/-+/g,`-`)}var A=class{getTime(){return new Date().toLocaleTimeString()}info(...e){console.info(`${h(this.getTime())} ${m(`[vibelog] ℹ`)}`,...e)}error(...e){console.error(`${h(this.getTime())} ${g(`[vibelog] ❌`)}`,...e)}warn(...e){console.warn(`${h(this.getTime())} ${m(`[vibelog] ⚠️`)}`,...e)}};const j=new A,M=[`vibelog.config.json`,`vibelog.config.js`];function N(e,t){return{site:{...e.site,...t.site}}}async function te(e){let t={site:{}};for(let n of M){let i=r(e,n);if(await d.exists(i))try{if(j.info(`Loading config from ${n}`),n.endsWith(`.js`)||n.endsWith(`.ts`)){let{default:e}=await import(i);return N(t,e)}else{let e=await d.readFile(i,`utf-8`),n=JSON.parse(e);return N(t,n)}}catch(e){j.warn(`Failed to load config from ${n}:`,e)}}return j.info(`No config file found, using defaults`),t}async function ne(){let e=n(a(import.meta.url)),r=i(e,t(e)===`dist`?`..`:`../..`,`template`);if(!await d.exists(r))throw Error(`Template directory not found: ${r}`);return r}var re=class{root;vibelogDir;contentSource;constructor({root:e,contentSource:t}){this.root=e,this.vibelogDir=i(process.cwd(),e,`.vibelog`),this.contentSource=t}async initVibelogDir(){let e=await ne();await d.copy(e,this.vibelogDir),j.info(`Installing deps...`),o(`npm install`,{cwd:this.vibelogDir,stdio:`inherit`,timeout:5*60*1e3}),j.info(`Deps installed successfully`)}async prepare(){if(await d.exists(this.vibelogDir)){j.info(`Using existing ".vibelog" directory`);return}j.info(`Initializing ".vibelog"...`),await this.initVibelogDir()}async fetchContent(){j.info(`Fetching ${this.contentSource.name} content...`);let[{posts:e},n]=await Promise.all([this.contentSource.getPosts(),this.contentSource.getAuthor()]);j.info(`Found ${String(e.length)} posts by ${n.name}`);let a=await te(this.root),o=a.site.title??t(i(process.cwd(),this.root)),s=a.site.description??n.bio,c=`// Auto-generated site configuration export const SITE_TITLE = ${JSON.stringify(o)}; export const SITE_DESCRIPTION = ${JSON.stringify(s)}; `,l=r(this.vibelogDir,`src`,`consts.ts`);await d.writeFile(l,c);let u=r(this.vibelogDir,`src`,`content`,`blog`);await d.ensureDir(u),await d.emptyDir(u),j.info(`Writing blog posts...`);for(let t of e){let e=t.title||`Untitled`,n=t.content.split(` `).find(e=>e.trim().length>0)??``,i=t.slug||k(t.title)||ee(),a=f.stringify(t.content,{title:e,description:n.slice(0,100),date:t.date||new Date().toISOString(),slug:i}),o=r(u,`${i}.md`);await d.writeFile(o,a)}j.info(`Writing author profile...`);let p=f.stringify(n.bio,{name:n.name}),m=r(this.vibelogDir,`src`,`content`,`author.md`);await d.writeFile(m,p),j.info(`Content updated successfully`)}};function P(e){return new re(e)}async function F({vibelogDir:e,outDir:t,site:n}){if(j.info(`Starting production build...`),!await d.exists(e))throw Error(`No ".vibelog" directory found. Please run "vibelog dev" first.`);j.info(`Building with Astro...`);let a=r(e,`dist`);await s({root:e,outDir:a,site:n,integrations:[l(),u()],vite:{logLevel:`warn`}});let o=i(t);await d.remove(o),await d.copy(a,o),j.info(`Production build completed in ${t}`)}let I=function(e){return e.FS=`fs`,e.HACKMD=`hackmd`,e}({}),L=function(e){return e.OPENAI=`openai`,e.ANTHROPIC=`anthropic`,e.OLLAMA=`ollama`,e.GOOGLE=`google`,e.OPENROUTER=`openrouter`,e}({});const R=`fs@./content`,z=`openai@gpt-4o-mini`,B=5566,V=`dist`,H=`https://example.com`,U={CSS_EXPERT:`You are a CSS design expert specializing in color theory and web accessibility. Your task is to transform CSS custom properties (variables) to match requested design themes while: 1. Maintaining excellent color contrast ratios 2. Preserving the existing variable names exactly 3. Keeping RGB format where used 4. Ensuring visual harmony across all colors Always provide a brief description of the theme you created. `,STYLE_RULES:``};var W=class{extractVariables(e){let t=[];try{let n=v(e);n.walkRules(`:root`,e=>{e.walkDecls(e=>{e.prop.startsWith(`--`)&&t.push({name:e.prop,value:e.value})})}),j.info(`Total extracted variables: ${t.length.toString()}`)}catch(e){j.error(`PostCSS parsing failed:`,e)}return t}updateVariables(e,t){try{let n=v(e);return n.walkRules(`:root`,e=>{e.walkDecls(e=>{if(e.prop.startsWith(`--`)){let n=t.find(({name:t})=>t===e.prop);n&&(e.value=n.value)}})}),n.toString()}catch(t){return j.error(`PostCSS rebuild failed:`,t),e}}},G=class{aiProvider;cssParser;constructor({aiProvider:e}){this.aiProvider=e,this.cssParser=new W}createPrompt(e,t){return`Transform these CSS variables to match the theme: "${t}" Current variables: ${e.map(({name:e,value:t})=>`${e}: ${t}`).join(` `)} Return JSON with updated variables and description. `}async transform({originalCss:e,stylePrompt:t=U.CSS_EXPERT}){j.info(`Style prompt:`,t);try{let n=this.cssParser.extractVariables(e);if(n.length===0)return j.error(`No CSS variables found in :root`),{transformedCss:e,description:``};j.info(`Generating styles with ${this.aiProvider.modelId}...`);let r=this.createPrompt(n,t),{variables:i,description:a}=await this.aiProvider.generate(r);j.info(`AI generation completed`),j.info(`Response:`,a);let o=i.map(({name:e,value:t})=>({name:e,value:t})),s=this.cssParser.updateVariables(e,o);return j.info(`Style transformation completed`),{transformedCss:s,description:a}}catch(t){return j.error(`Style transformation failed:`,_(t,{depth:null})),{transformedCss:e,description:``}}}};function K({aiProvider:e}){return new G({aiProvider:e})}function q(){let e=()=>{let e=(e,...t)=>e.reduce((e,n,r)=>e+n+(t[r]??``),``),t=(e,...t)=>e.reduce((e,n,r)=>e+n+(t[r]??``),``);return class extends HTMLElement{cleanup=null;constructor(){super();let n=this.attachShadow({mode:`open`}),r=e` <section class="vibelog-panel" id="vibelog-panel"> <form class="vibelog-form"> <textarea class="vibelog-prompt" placeholder="Describe the vibe you want to create... ✨" rows="3" required autofocus title="Enter your vibe prompt" >Light theme with a calm green tone</textarea> <button type="submit" class="vibelog-button" title="Create vibe">✨</button> </form> <span class="vibelog-dragger" id="vibelog-dragger" title="Drag to move"> ⋮⋮ </span> </section> `,i=t` .vibelog-panel { display: flex; align-items: stretch; gap: 2px; position: fixed; bottom: 32px; right: 32px; z-index: 999; min-width: 30vw; background: var(--vibe-c-bg); color: var(--vibe-c-text-1); border-radius: var(--vibe-border-radius-md); padding: var(--vibe-space-1); box-shadow: var(--vibe-shadow-3); &.dragging { transition: none; } } .vibelog-form { flex: 1; display: flex; gap: var(--vibe-space-1); } .vibelog-prompt { flex: 1; font-size: 14px; line-height: 1.5; padding: var(--vibe-space-1); border: var(--vibe-border-width-thin) solid var(--vibe-c-gray-2); border-radius: var(--vibe-border-radius-md); resize: none; &:focus { outline: none; border-color: var(--vibe-accent); box-shadow: var(--vibe-shadow-1); } } .vibelog-button { font-size: 14px; padding: var(--vibe-space-1) var(--vibe-space-2); border: none; border-radius: var(--vibe-border-radius-md); background: var(--vibe-accent); color: var(--vibe-c-white); min-width: 60px; cursor: pointer; &:disabled { opacity: 0.5; cursor: not-allowed; } } .vibelog-dragger { cursor: move; user-select: none; display: flex; justify-content: center; align-items: center; } `;n.innerHTML=` ${r} <style> ${i} </style> `;let a=n.querySelector(`#vibelog-panel`),o=a?.querySelector(`#vibelog-dragger`),s=a?.querySelector(`.vibelog-form`),c=s?.querySelector(`.vibelog-prompt`),l=s?.querySelector(`.vibelog-button`),u=`vibelog-panel-position`,d=`vibelog-panel-content`,f=(e,t)=>{localStorage.setItem(u,JSON.stringify({x:e,y:t}))},p=()=>{let e=localStorage.getItem(u);return e?JSON.parse(e):null},m=()=>{if(!a)return;let e=p();if(e){let t=a.getBoundingClientRect(),n=window.innerWidth-t.width,r=window.innerHeight-t.height,i=Math.max(0,Math.min(e.x,n)),o=Math.max(0,Math.min(e.y,r));a.style.left=`${i.toString()}px`,a.style.top=`${o.toString()}px`,a.style.right=`auto`,a.style.bottom=`auto`}},h=e=>{localStorage.setItem(d,e)},g=()=>localStorage.getItem(d)??``,_=()=>{if(!c)return;let e=g();if(e){c.value=e;return}c.value=`Light theme with a calm green tone`},v=!1,y=0,b=0,x=0,S=0;function C(e){if(!(!a||!o)&&(e.target===o||o.contains(e.target))){v=!0,a.classList.add(`dragging`),y=e.clientX,b=e.clientY;let t=a.getBoundingClientRect();x=t.left,S=t.top}}function w(){if(!a)return;v=!1,a.classList.remove(`dragging`);let e=a.getBoundingClientRect();f(e.left,e.top)}function T(e){if(e.preventDefault(),!v||!a)return;e.preventDefault();let t=e.clientX-y,n=e.clientY-b,r=x+t,i=S+n,o=a.getBoundingClientRect(),s=window.innerWidth-o.width,c=window.innerHeight-o.height;r=Math.max(0,Math.min(r,s)),i=Math.max(0,Math.min(i,c)),a.style.left=`${r.toString()}px`,a.style.top=`${i.toString()}px`,a.style.right=`auto`,a.style.bottom=`auto`}o?.addEventListener(`mousedown`,C),document.addEventListener(`mousemove`,T),document.addEventListener(`mouseup`,w);async function E(){if(!s||!c||!l)return;let e=c.value.trim();if(e)try{l.textContent=`🚀`,l.disabled=!0;let t=await fetch(`/_vibe/transform`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({prompt:e})});if(!t.ok){let e=await t.text();throw Error(`Error: ${t.status.toString()} - ${e}`)}let{description:n}=await t.json(),r=`> ${e} ${n} `;c.value=r,h(r)}catch(e){console.error(e),l.textContent=`❌`}finally{l.disabled=!1,setTimeout(()=>{l.textContent=`✨`},2e3)}}function D(e){(e.metaKey||e.ctrlKey)&&e.key===`Enter`&&(e.preventDefault(),E().catch(console.error))}c?.addEventListener(`keydown`,D),c?.addEventListener(`input`,()=>{h(c.value)}),s?.addEventListener(`submit`,e=>{e.preventDefault(),E().catch(console.error)}),this.cleanup=()=>{document.removeEventListener(`mousemove`,T),document.removeEventListener(`mouseup`,w)},setTimeout(()=>{m(),_()},0)}disconnectedCallback(){this.cleanup?.()}}};return` // esbuild will inject this function, but I don't know why :( function __name(target, name) { return target; } customElements.define('vibelog-ui', (${e.toString()})()); document.addEventListener('DOMContentLoaded', () => { document.body.appendChild(document.createElement('vibelog-ui')); }); `}var J=class extends Error{constructor(e,t=500){super(e),this.status=t}};function Y(e=1024*1024){return(t,n,r)=>{if(![`POST`,`PUT`,`PATCH`].includes(t.method??``)){r();return}if(t.body){r();return}let i=t.headers[`content-type`]?.split(`;`)[0]??``;if(i!==`application/json`){r();return}let a=[],o=0;t.on(`error`,r).on(`data`,t=>{if(o+=t.length,o>e){r(new J(`Too large`,413));return}a.push(t)}).on(`end`,()=>{try{let e=Buffer.concat(a).toString();t.body=e.trim()?JSON.parse(e):{},r()}catch{r(new J(`Invalid JSON`,400))}})}}function ie(){return(e,t,n,r)=>{let i=e instanceof J?e.status:500;n.writeHead(i,{"Content-Type":`application/json`}),n.end(JSON.stringify({error:e.message||`Internal Server Error`}))}}function ae({vibelogDir:e,styleTransformer:t,server:n}){return(i,a,o)=>{if(i.method!==`POST`)return a.writeHead(405).end();let{prompt:s}=i.body;if(!s)return a.writeHead(400).end();(async()=>{let i=r(e,`src/styles/global.css`),o=await d.readFile(i,`utf-8`),{transformedCss:c,description:l}=await t.transform({originalCss:o,stylePrompt:s});await d.writeFile(i,c),n.ws.send({type:`update`,updates:[{type:`css-update`,path:i,acceptedPath:i,timestamp:Date.now()}]}),a.writeHead(200,{"Content-Type":`application/json`}).end(JSON.stringify({description:l}))})().catch(o)}}function oe({vibelogDir:e,styleTransformer:t}){return{name:`vibelog-dev`,hooks:{"astro:config:setup":({injectScript:e})=>{e(`page`,q())},"astro:server:setup":({server:n,...r})=>{n.middlewares.use(Y()).use(`/_vibe/transform`,ae({vibelogDir:e,styleTransformer:t,server:n,...r})).use(ie())}}}}async function se({root:e,port:t=5e3,styleTransformer:n}){let r=await c({root:e,server:{port:t},site:`http://localhost:${String(t)}`,devToolbar:{enabled:!1},integrations:[oe({vibelogDir:e,styleTransformer:n})]});return r}var ce=class{name=I.FS;constructor(e){this.contentDir=e,j.info(`Content source: FS (${e})`),y(e,`Content directory is required. Use fs@<path-to-content-dir>`)}async getPosts(){if(!await d.exists(this.contentDir))throw Error(`Content directory not found: ${this.contentDir}`);let e=r(this.contentDir,`blog`);if(!await d.exists(e))throw Error(`Blog directory not found: ${e}`);let t=await d.readdir(e),n=t.filter(e=>e.endsWith(`.md`)).map(t=>{let n=r(e,t),{data:i,content:a}=f.read(n);return{id:t.replace(`.md`,``),title:i.title,content:a,slug:i.slug||t.replace(`.md`,``),date:i.date?new Date(i.date).toISOString():new Date().toISOString()}});return{posts:n}}async getAuthor(){let e=r(this.contentDir,`author.md`);if(!await d.exists(e))throw Error(`Author profile not found: ${e}`);let{data:t,content:n}=f.read(e);return{name:t.name||`Unknown Author`,bio:n.trim()||t.bio||``}}};function le(e,t){if(!t)return e;let n=/^#\s+(.+)$/m,r=n.exec(e);if(r){let i=r[1];if(i===t)return e.replace(n,``).replace(/^\n+/,``)}return e}const X=`https://hackmd.io`;var ue=class{name=I.HACKMD;constructor(e){this.username=e,j.info(`Content source: HackMD (${e})`),y(e,`HackMD username is required. Use hackmd@<username>`)}async getPosts(){let e=await fetch(`${X}/api/@${this.username}/overview`);if(!e.ok)throw Error(`Failed to fetch HackMD content: ${e.statusText}`);let{notes:t}=await e.json(),n=await Promise.all(t.filter(e=>e.publishType===`view`&&e.publishedAt).map(async e=>{let t=await fetch(`${X}/${e.id}/download`);if(!t.ok)throw Error(`Failed to fetch the content for note ${e.id}: ${t.statusText}`);let n=await t.text();return{id:e.id,title:e.title,content:le(n,e.title),slug:e.permalink??e.title,date:new Date(e.publishedAt).toISOString()}}));return{posts:n}}async getAuthor(){let e=await fetch(`${X}/info/@${this.username}`);if(!e.ok)throw Error(`Failed to fetch HackMD profile: ${e.statusText}`);let{user:t,team:n}=await e.json();return{name:t?.displayName??n?.name??`Unknown`,bio:t?.biography??n?.description??``}}};const de=E.object({variables:E.array(E.object({name:E.string(),value:E.string()})),description:E.string()});var Z=class{model=null;constructor(e,t){switch(this.name=e,this.modelId=t,j.info(`AI provider: ${e} (${t})`),e){case L.OLLAMA:this.model=x()(t);break;case L.OPENAI:y(process.env.OPENAI_API_KEY,`OPENAI_API_KEY environment variable is required.`),this.model=S({apiKey:process.env.OPENAI_API_KEY})(t);break;case L.ANTHROPIC:y(process.env.ANTHROPIC_API_KEY,`ANTHROPIC_API_KEY environment variable is required.`),this.model=C({apiKey:process.env.ANTHROPIC_API_KEY})(t);break;case L.GOOGLE:y(process.env.GOOGLE_GENERATIVE_AI_API_KEY,`GOOGLE_GENERATIVE_AI_API_KEY environment variable is required.`),this.model=w({apiKey:process.env.GOOGLE_GENERATIVE_AI_API_KEY})(t);break;case L.OPENROUTER:y(process.env.OPENROUTER_API_KEY,`OPENROUTER_API_KEY environment variable is required.`),this.model=T({apiKey:process.env.OPENROUTER_API_KEY})(t);break;default:throw Error(`Unsupported AI provider: ${e}. Supported: ${Object.values(L).join(`, `)}`)}}async generate(e){let{model:t}=this;if(!t)throw Error(`AI model is not initialized. Check provider and model name.`);let{object:n}=await b({model:t,...this.name===L.OPENROUTER&&{mode:`json`},schema:de,messages:[{role:`system`,content:U.CSS_EXPERT},{role:`user`,content:e}],temperature:.1});return n}};function Q(e,t){let[n,r]=e.split(`@`),i=Object.values(t);if(!Object.values(t).includes(n))throw Error(`Unknown provider: ${n}. Supported: ${i.join(`, `)}`);return[n,r]}function fe(e){let[t,n]=Q(e,I);switch(t){case I.HACKMD:return new ue(n);case I.FS:return new ce(n);default:throw Error(`Unsupported content source: ${t}. Supported: ${Object.values(I).join(`, `)}`)}}function pe(e){let[t,n]=Q(e,L);return new Z(t,n)}async function me({content:e,ai:t,port:n,root:r}){j.info(`Starting vibelog dev server...`),j.info(`Project root: ${r}`);try{let i=fe(e),a=pe(t),o=K({aiProvider:a}),s=P({root:r,contentSource:i});await s.prepare(),await s.fetchContent();let c=await se({root:s.vibelogDir,port:parseInt(n),styleTransformer:o});j.info(`Use the vibelog panel to modify styles with AI prompts`),j.info(`Press Ctrl+C to stop`),j.info(`When you are done, run "vibelog build" to generate the production site`);let l=()=>{j.info(`Shutting down...`),c.stop().then(()=>{j.info(`Dev server stopped`),process.exit(0)}).catch(e=>{j.error(`Cleanup failed:`,e),process.exit(1)})};process.on(`SIGINT`,l),process.on(`SIGTERM`,l)}catch(e){j.error(`Failed to start dev server:`,e),process.exit(1)}}async function he({outDir:e,root:t,siteUrl:n}){j.info(`Building production site...`),j.info(`Project root: ${t}`),j.info(`Output: ${e}`),j.info(`Site: ${n}`);try{await F({vibelogDir:i(process.cwd(),t,`.vibelog`),outDir:i(process.cwd(),t,e),site:n}),j.info(`Production build completed in ${e}`)}catch(e){j.error(`Build failed:`,e),process.exit(1)}}const $=e(`vibelog`);$.version(`v${D} - ${O}`).help(),$.option(`-r, --root <dir>`,`Project root directory`,{default:`.`}),$.command(`dev`,`Start development server with content preview`,{allowUnknownOptions:!1}).option(`-c, --content <source>`,`Content source info (name@handle). Supported name: ${Object.values(I).join(`, `)}`,{default:R}).option(`--ai <provider>`,`AI provider info (name@modelId). Supported name: ${Object.values(L).join(`, `)}`,{default:z}).option(`-p, --port <port>`,`Development server port`,{default:B}).example(`vibelog dev --root . --content hackmd@eastsun5566 --ai openai@gpt-4o-mini`).example(`vibelog dev --content fs@./my-content --port 3000`).action(async e=>{await me(e)}),$.command(`build`,`Build production site from dev state`,{allowUnknownOptions:!1}).option(`-d, --out-dir <dir>`,`Output directory`,{default:V}).option(`--site-url <url>`,`Site URL for sitemap`,{default:H}).example(`vibelog build --out-dir public --site-url https://myblog.com`).action(async e=>{await he(e)}),$.parse();