npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, πŸ‘‹, I’m Ryan HefnerΒ  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you πŸ™

Β© 2025 – Pkg Stats / Ryan Hefner

@acets-team/ace

v0.9.5

Published

Our Mission: 🌎 Unite industry leaders, to provide optimal web fundamentals, in a performant, typesafe and beautifully documented library! πŸ™

Downloads

2,128

Readme

Ace Website Framework

Table of Contents

  1. What is Ace?
  2. Ace Mission Statement
  3. Create Ace App
  4. Save state to indexdb
  5. Create API Route
  6. Create Middleware
  7. Path and Search Params
  8. Valibot Helpers
  9. Create a Layout
  10. Create a Route
  11. Call APIs
  12. πŸ™Œ VS Code Extension
  13. Echo
  14. Breakpoints
  15. Scope
  16. Create a 404 Route
  17. Create a Typesafe Anchor
  18. Typesafe Redirects
  19. Add Offline Support
  20. Network Status Hook
  21. πŸ‘©β€πŸ’» Create Desktop Application
  22. Enums
  23. Markdown
  24. Code Highlight Markdown
  25. Bits
  26. Modal Demo
  27. πŸ› οΈ Bind DOM Elements
  28. Form Demo
  29. Magic Link Demo
  30. Create Password Hash
  31. 🟒 Live Data without Browser Refresh
  32. Open Graph Demo
  33. SVG Demo
  34. Add Custom Fonts
  35. Loading Spinner Component
  36. Show Toast Notifications
  37. Tabs Component
  38. Radio Cards Component
  39. Slidshow Component
  40. Ace Config
  41. Ace Plugins
  42. 🚨 When to restart dev?
  43. IF developing multiple Ace projects simultaneously
  44. Environment Variables
  45. Environment Information
  46. Origin Information
  47. Add Tailwind
  48. Turso Demo
  49. AgGrid Demo
  50. Chart.js Demo
  51. Send Brevo Emails
  52. πŸš€ Deploy
  53. Add a custom domain
  54. Resolve www DNS
  55. View Production Logs
  56. VS Code Helpful Info
  57. Error Dictionary

What is Ace?

  • Ace is a set of functions, classes, and types (fundamentals) to aid web developers. We’ve grouped these fundamentals into plugins. To use a plugins fundamentals, set that plugin to true in your ace.config.js! πŸ™
  • Ace plugins:
    1. Solid (optimal DOM updates)
    2. Drizzle (typesafe db updates)
    3. Turso (Fast SQL DB)
    4. Cloudflare (Region: Earth)
    5. AgGrid (Scrollable, filterable & sortable tables)
    6. Charts.js (Evergreen charting library)
    7. Valibot (Small bundle Zod)
    8. Brevo (300 emails a day for free)
    9. Markdown-It (Markdown to HTML)
    10. Highlight.js (Highlight code in Markdown)

Ace Mission Statement

🌎 Unite industry leaders, to provide optimal web fundamentals, in a performant, typesafe and beautifully documented library! πŸ™

Create Ace App!

  • Bash:
    npx create-ace-app@latest
  • 🚨 When opening Create Ace App locally for the first time after an npm run dev, it will take 10-15 seconds to load 😑 b/c Vite is altering code to optimize HMR (so subsequent loads are instant πŸ€“) BUT this slow initial load is no factor in production. To prove this, here's Create Ace App In Production! πŸš€ Deployed to Cloudflare Workers via git push, deploy directions here! Create Ace App in Production

Save state to indexdb

  • βœ… With Ace each piece of state is called an Atom
  • βœ… Atoms are saved @ memory, session storage, local storage (5mb + sync) or indexdb (100's of mb's + async)
  • βœ… An Atom's is prop helps us know how to serialize to and deserialze from the save location
  • βœ… Setting custom onSerialize / onDeserialze @ new Atom() is avaialble!
  1. Define atoms: src/store/atoms.ts
    // Create Ace App's atoms
    
       
    import { Atom } from '@ace/atom'
    import type { ApiName2Data, ChartJsMap } from '@ace/types'
    import type { FinanceSummary, Transaction, ChatMessage } from '@src/lib/types'
    
    
    export const atoms = {
      count: new Atom({ save: 'idb', is: 'number', init: 0 }),
      chatMessage: new Atom({ save: 'idb', is: 'string', init: '' }),
      chatMessages: new Atom<ChatMessage[]>({ save: 'idb', is: 'json', init: [] }),
      transactions: new Atom<Transaction[]>({ save: 'idb', is: 'json', init: [] }),
      financeCategories: new Atom<ChartJsMap[]>({ save: 'idb', is: 'json', init: [] }),
      financeSummary: new Atom<undefined | FinanceSummary>({ save: 'idb', is: 'json' }),
      fortunes: new Atom<ApiName2Data<'apiGetFortune'>[]>({ save: 'idb', is: 'json', init: [] }),
    }
  2. Create a Store: src/store/store.ts
    // To work w/ atoms we put them into a store via createStore() and then get back a useStore() function that can be used in component, route or layout
    // Multiple stores is simple but probably not necessary
    
    
    import { atoms } from './atoms'
    import { createMemo } from 'solid-js'
    import { Atoms2Store } from '@ace/types'
    import { createStore } from '@ace/createStore'
    
    
    export const { useStore, StoreProvider } = createStore({ atoms })
  3. Wrap <App/> with <StoreProvider/> @ src/app.tsx
    // createApp() will generate all our <Route />'s for us and wrap them w/ the providers in the provided array :)
     
    import './app.css'
    import '@ace/tabs.styles.css'
    import '@ace/toast.styles.css'
    import '@ace/pulse.styles.css'
    import '@ace/loading.styles.css'
    import '@ace/tooltip.styles.css'
    import { createApp } from '@ace/createApp'
    import { StoreProvider } from '@src/store/store'
    
    export default createApp([StoreProvider])
  4. useStore() has lots of lovely goodies including:
    • store

      • Solid's store is the accessor to all our store values
      • Each Atom is added into the store and is accessible via it's key. So based on the example atoms above, we can access:
        • store.count
        • store.newsletterForm.name
    • setStore()

      • Solid's setStore() helps us to assign store value, examples:
        • setStore('count', 1)
        • setStore('count', c => c + 1)
        • setStore('newsletterForm', 'name', 'Chris')
        • setStore('newsletterForm', {name: 'Chris', email: '[email protected]'})
    • save()

      • Accepts a store key, and will save to the Atom's save location, example:
        • save('count')
        • save('newsletterForm')
    • set()

      • Calls Solid setStore() and then calls save(), examples:
        • set('count', 1)
        • set('count', c => c + 1)
        • set('newsletterForm', 'name', 'Chris')
        • set('newsletterForm', {name: 'Chris', email: '[email protected]'})
    • copy()

      • Calls Solid's produce() which lets us mutate a copy/draft of the data inside a single callback and then calls save(), examples:
        copy('user', u => {
          u.settings.theme = 'dark'
          u.todos.push('Improve Ace!')
        })
      • 🚨 The entire root object user gets a new reference
      • All nested objects/arrays inside it also get new references
      • This means every reactive computation that depends on any part of user will re-run
      • To avoid this please use sync()
    • sync()

      • Calls Solid's reconcile() which efficiently diffs and merges entire objects or arrays and then calls save(), examples:
        // array example:
        
        store.users = [ // current users
          { _id: '1', name: 'Chris', active: true },
          { _id: '2', name: 'Alex', active: false },
        ]
        
        const newUsers = [ // updated api users
          { _id: '1', name: 'Chris', active: false }, // changed
          { _id: '2', name: 'Alex', active: false },  // identical
        ]
        
        // Only Chris's active property triggers reactive updates
        // key is optional, if undefined the key is 'id'
        sync('users', newUsers, { key: '_id' })
        
        
        // object example:
        
        store.user = {
          _id: '123',
          profile: {
            name: 'Chris',
            stats: { posts: 9, followers: 27 },
          },
        }
        
        const newUser = {
          profile: { stats: { followers: 30 } },
        }
        
        sync('user', newUser)

Create API Route

  1. Example: src/api/apiUpdateEmail.ts
    // to the API constructor, the first arg is the path param and the 2nd is the api function name which can be called on the FE or BE
    
    
    import { API } from '@ace/api'
    import { eq } from 'drizzle-orm'
    import { db, users } from '@src/lib/db'
    import { sessionB4 } from '@src/auth/authB4'
    import { updateEmailParser } from '@src/parsers/updateEmailParser'
    
    
    export const POST = new API('/api/update-email', 'apiUpdateEmail')
      .b4([sessionB4])
      .body(updateEmailParser)
      .resolve(async (scope) => {
        await db
          .update(users)
          .set({ email: scope.body.email }) // typesafe b/c updateEmailParser
          .where(eq(users.id, scope.event.locals.session.userId)) // typesafe b/c sessionB4
    
        return scope.success('Updated!')
      })
  2. Create middleware functions: src/auth/authB4.ts
    // Ace middleware is different then express middleware and for that reason we use a different name we call them b4 (before) functions b/c they run before your API resolve function
    // If a b4 returns anything that response is given as the Response
    // To persist data from one b4 to the next or from a b4 to the resolve, place data into `event.locals` and update the functions generic type 🚨 By setting the generic type this lets downstream b4's or resolves know the type of your persisted data, so then in the resolve for example we'd have typesafety for `scope.event.locals.session` as seen in the api example above
    
    
    import type { B4 } from '@ace/types'
    import { getSession } from './getSession'
    import type { Session } from '@src/lib/types'
    
    
    export const sessionB4: B4<{ session: Session }> = async (scope) => {
      scope.event.locals.session = await getSession(scope)
    }
    
    export const adminB4: B4<{ session: Session }> = async (scope) => {
      if (!scope.event.locals.session.isAdmin) throw new Error('Unauthorized') // ❌
    }
  3. Create a parser: src/parsers/updateEmailParser.ts
    import { object } from 'valibot'
    import { vParse } from '@ace/vParse'
    import { vEmail } from '@ace/vEmail'
    
    
    export const updateEmailParser = vParse(
      object({
        email: vEmail('Please provide a valid email'),
      })
    )

Path and Search Params

🚨 Important

  • .pathParams() & .searchParams() @ new Route() & new API() work the same way!
  • IF params are valid THEN scope.pathParams and/or scope.searchParams will be set w/ their typesafe parsed values!
  • And when working w/ new API() you may also pass a parser to .body() which if valid will be available @ scope.body

βœ… Examples:

  1. Optional path params:
    import { vString } from '@ace/vString'
    import { object, optional } from 'valibot'
    
    export default new Route('/unsubscribe/:email?')
      .pathParams(vParse(object({ email: optional(vString()) })))
  2. Required path params:
    export default new Route('/magic-link/:token')
      .pathParams(vParse(object({ token: vString('Please provide a token') })))
  3. Optional search params:
    export const GET = new API('/api/test', 'apiTest')
      .searchParams(vParse(object({ amount: optional(vNum()) })))
      .resolve(async (scope) => {
        return scope.success(scope.searchParams)
      })
  4. Required search & body params:
    export const POST = new API('/api/test', 'apiTest')
      .body(vParse(object({ when: vDate() })))
      .searchParams(vParse(object({ allGood: vBool() })))
      .resolve(async ({ success, body, searchParams }) => {
        return success({ body, searchParams })
      })
  5. All valibot helpers that can be used w/in vParse(object(())

Parser

  • In Ace a Parser is a function that validates & also optionally parses data
  • This function accepts an input, will throw if invalid, and return data if valid that's potentially but not required to be parsed (your call) (all vParse helpers below validate + parse)
  • Within a Parser function any prefered schema library may be used πŸ™Œ
  • We reccomend Valibot!
  • To create a valibot parser use vParse + one/many of these helpers:
    • { token: vString('Please provide a token') }
    • { modal: vBool('Please provide a modal param') }
    • { email: vEmail('Please provide a valid email') }
    • { value: vNum('Please provide a value as a number') }
    • { voicePart: vEnums(new Enums(['Bass', 'Alto', 'Soprano'])) }
    • { passportIssuedDate: vDate({error: 'Please provide date passport issued'}) }
  • Any of the helpers can be wrapped in valibot's optional() btw!
  • Example:
    vParse(object({ amount: optional(vNum()), allGood: vBool() }))
  • vBool()
    • Parses 1 to true
    • Parses 0 to false
    • Parses true of any case to true
    • Parses false of any case to false
  • vDate()
    • Can receive a data as a Date | iso string or ms number
    • Parses based on requested to prop
    • @param props.to - Optional, defaults to iso, what type of date are we parsing to, options are 'date' | 'ms' | 'sec' | 'iso'
    • props.includeIsoTime - Optional, defaults to true, when true => 2025-08-01T00:50:28.809Z when false => 2025-08-01
    • @param props.error - The error to return when the input is invalid
  • 🚨 Body Parsers or parsers that validate request bodies are typically in their own file, b/c they are used on the FE & on the BE. If this parser was exported from the API file above and we imported it into a FE component, then there is a risk that API content could be in our FE build. So to avoid this, when we have code that works in both environments, it's best practice to put the export in it's own file (or a file that is as a whole good in both environments like src/lib/vars.ts), and ensure the imports into this file are optimal in both environments, example:
    // src/parsers/updateEmailParser.ts
    import { object } from 'valibot'
    import { vParse } from '@ace/vParse'
    import { vEmail } from '@ace/vEmail'
    
    
    export const updateEmailParser = vParse(
      object({
        email: vEmail('Please provide a valid email'),
      })
    )
    
    // src/api/apiUpdateEmail.ts
    import { API } from '@ace/api'
    import { eq } from 'drizzle-orm'
    import { db, users } from '@src/lib/db'
    import { sessionB4 } from '@src/auth/authB4'
    import { updateEmailParser } from '@src/parsers/updateEmailParser'
    
    
    export const POST = new API('/api/update-email', 'apiUpdateEmail')
      .b4([sessionB4])
      .body(updateEmailParser)
      .resolve(async (scope) => {
        await db
          .update(users)
          .set({ email: scope.body.email })
          .where(eq(users.id, scope.event.locals.session.userId))
    
        return scope.success()
      })
    
    // FE component
    const {store} = useStore()
    
    const onSubmit = createOnSubmit(({ event }) => {
      apiUpdateEmail({
        body: kParse(updateEmailParser, { email: store.newsletterForm.email }),
        onSuccess() {
          event.currentTarget.reset()
          showToast({ type: 'success', value: 'Updated!' })
        }
      })
    })
  • kParse()
    • The k stands for keys
    • We pass to kParse() the input object (data to give to the parser) & the parser
    • At compile-time kParse() reads the parser and gives us autocomplete hints for the keys this parser requires
    • So w/ the example above, our IDE will error till email is a key in the input object
    • At run-time the parser will validate the input object completely
    • This helps us build our input object correctly
  • createOnSubmit()
  • Parsers for path and/or search params can be in their own file but are typically inline b/c they are only needed in that one place (most often but sometimes shared so then a file like above is ideal), example:
    export const POST = new API('/api/test', 'apiTest')
      .searchParams(vParse(object({ ready: vBool() })))
      .resolve(({ success, searchParams }) => {
        return success({ searchParams })
      })

Create a Layout

  1. Example: src/app/RootLayout.tsx
    // one to many layouts may surround a route
    // <ServiceWorker /> is fantastic if you'd love your site to function (simply or fully) w/o internet!
    
    
    import { Nav } from '@src/Nav/Nav'
    import { Layout } from '@ace/layout'
    import { ServiceWorker } from '@ace/serviceWorker'
    
    export default new Layout()
      .component(({children}) => {
        return <>
          <Nav />
          {children}
          <ServiceWorker />
        </>
      })

Create a Route

  1. Example: src/app/Deposits/Deposits.tsx
    // 🚨 Vite prefers css paths to be relative so don't use @src there please
    
    import './Deposits.css'
    import { Show } from 'solid-js'
    import { Route } from '@ace/route'
    import { Title } from '@solidjs/meta'
    import RootLayout from '../RootLayout'
    import { Users } from '@src/Users/Users'
    import { getUsersSources } from '@src/lib/vars'
    import { loadSession } from '@src/auth/loadSession'
    import { AuthLoading } from '@src/auth/AuthLoading'
    import { isEmployee, useStore } from '@src/store/store'
    import { AuthHeroTitle } from '@src/auth/AuthHeroTitle'
    import type { ScopeComponent } from '@ace/scopeComponent'
    
    
    export default new Route('/deposits')
      .layouts([RootLayout])
      .component((scope) => {
        loadSession()
    
        return <>
          <Title>❀️ Deposits</Title>
    
          <main class="deposit">
            <AuthHeroTitle />
    
            <div class="main-content">
              <Show when={scope.bits.get('apiGetSession') === false} fallback={<AuthLoading />}>
                <Loaded scope={scope} />
              </Show>
            </div>
          </main>
        </>
      })
    
    
    function Loaded({ scope }: { scope: ScopeComponent }) {
      const {store} = useStore()
    
      return <>
        <Show when={isEmployee(store)}>
          <div class="h-title">❀️ Made Deposit</div>
          <Users scope={scope} source={getUsersSources.keys.deposits}></Users>
        </Show>
      </>
    }
  2. As seen above w/ isEmployee(store), when computed properties are used in multiple components: src/store/store.ts:
    import { atoms } from './atoms'
    import { createMemo } from 'solid-js'
    import { Atoms2Store } from '@ace/types'
    import { partners } from '@src/lib/vars'
    import { createStore } from '@ace/createStore'
    
    
    export const { useStore, StoreProvider } = createStore({ atoms })
    
    
    // computed properties that are accessed in multiple modules
    export const isEmployee = (store: Atoms2Store<typeof atoms>) => createMemo(() => store.apiGetSession?.isAdmin || store.apiGetSession?.partner === partners.keys.globus)
    
    export const isAdmin = (store: Atoms2Store<typeof atoms>) => createMemo(() => store.apiGetSession?.isAdmin)

Call APIs

  1. With Ace, we call APIs via API functions! πŸ™Œ
    function UpdateEmail() {
      const {store, refBind} = useStore()
    
      const onSubmit = createOnSubmit(({ event }) => {
        apiUpdateEmail({ // API Function! ❀️
          body: kParse(updateEmailParser, { email: store.newsletterForm.email }),
          onSuccess() {
            event.currentTarget.reset()
            showToast({ type: 'success', value: 'Updated!' }) // from @ace/toast
          }
        })
      })
    
      return <>
        <form ref={refFormReset()} onSubmit={onSubmit} class="update-email">
            <input ref={refBind('newsletterForm', 'email')} name="email" type="email" placeholder="Please enter email" />
            <Messages name="email" />
            <Submit label="Update" bitKey="apiUpdateEmail" $button={{ class: 'brand' }} />
        </form>
      </>
    }
  2. onSuccess()
    • On FE, IF no errors AND onSuccess provided THEN onSuccess will be called w/ response.data, which is set on the BE via return scope.success({ example: true })
  3. onResponse()
    • On FE, IF no errors AND onResponse provided THEN onResponse will be called w/ Response, which is set on the BE via return new Response('example')
  4. onError()
    • On FE, IF response.error is truthy THEN onError() OR defaultOnError() will be called w/ response.error, which is set on the BE via return scope.error('πŸ›') OR throw new Error('❌')
    • defaultOnError() => showErrorToast(error.message)
  5. queryType
    • If you specify a queryType then we'll use Solid's query() function. query() accomplishes the following:
      • Deduping on the server for the lifetime of the request
      • Provides a back/forward cache for browser navigation for up to 5 minutes. Any user based navigation or link click bypasses this cache
    • 🚨 set the queryType to stream when you'd love this api call to happen while the component is rendering & this request does NOT set cookies. On refresh the request will start on the BE and on SPA navigation (on anchor click) the request will start on the FE
      import './Home.css'
      import { Route } from '@ace/route'
      import { useStore } from '@src/store/store'
      import { Title, Meta } from '@solidjs/meta'
      import { MarkdownItStatic } from '@ace/markdownItStatic'
      import { apiGetFinances, apiGetCashFlow, apiGetTransactions } from '@ace/apis'
      
      
      export default new Route('/')
        .layouts([RootLayout])
        .component(() => {
          const {sync} = useStore()
      
          apiGetTransactions({ // api's load simultaneously btw ❀️
            queryType: 'stream',
            onSuccess: (d) => sync('transactions', d)
          })
      
          apiGetFinances({ // on refresh => requests start on the BE πŸ€“
            queryType: 'stream',
            onSuccess(d) {
              sync('financeSummary', d.summary)
              sync('financeCategories', d.categories)
            }
          })
      
          return <>
            <Title>🏑 Home · Create Ace App</Title>
            <Meta property="og:title" content="🏑 Home · Create Ace App" />
            <Meta property="og:type" content="website" />
            <Meta property="og:url" content={buildOrigin} />
            <Meta property="og:image" content={buildOrigin + '/og/home.webp'} />
      
            <main class="home">
              <Welcome />
      
              <section class="summaries">
                <Summary key="balance" label="πŸ’Έ Total Balance"  />
                <Summary key="monthlyExpenses" label="πŸ“‰ Monthly Expenses" />
                <Summary key="monthlyIncome" label="πŸ“ˆ Monthly Income" />
              </section>
      
              <section class="vizs">
                <Categories />
                <Transactions/>
              </section>
      
              <MarkdownItStatic content={mdAppInfo} />
      
              <Nav showRefresh={true} />
            </main>
          </>
        })
    • 🚨 set the queryType to direct when this api call does not happen while the component is rendering but does happen after, like based on some user interaction like an onClick
      import { Show } from 'solid-js'
      import { Submit } from '@ace/submit'
      import { apiSignOut } from '@ace/apis'
      import { scope } from '@ace/scopeComponent'
      import { useStore } from '@src/store/store'
      import { goSignIn } from '@src/auth/goSignIn'
      import { createOnSubmit } from '@ace/createOnSubmit'
      
      
      export function SignOut() {
        const {set, store} = useStore()
      
        const signOut = createOnSubmit(() => {
          apiSignOut({ // API Function! ❀️
            queryType: 'direct',
            onSuccess () {
              set('apiGetSession', undefined)
              goSignIn(scope)
            }
          })
        })
      
        return <>
          <Show when={store.apiGetSession?.userId}>
            <form onSubmit={signOut}>
              <Submit label="Sign Out" bitKey="apiSignOut" $button={{class: 'brand'}} />
            </form>
          </Show>
        </>
      }
    • 🚨 set the queryType to maySetCookies when you'd love this api call to happen while the component is rendering & this request DOES set cookies. maySetCookies ensures that this request will always start on the FE. Explanation: The way a server tells a browser about cookies is w/ the Set-Cookie header. We may not update HTTP headers after a Response is given to the FE, and during streaming the response is already w/ the FE. stream is the most performant option, so to avoid this option as much as possible we recommend redirecting to api's that set cookies
      import { apiGetSession } from '@ace/apis'
      import { useStore } from '@src/store/store'
      
      
      export function loadSession() {
        const {set} = useStore()
      
        apiGetSession({ // API Function! ❀️
          queryType: 'maySetCookies',
          onSuccess: (d) => set('apiGetSession', d),
          onError: () => set('apiGetSession', undefined),
        })
      }
  • 🚨 See VS Code Extension to see how to add links to Ace API's right above API Function calls!
  • See Echo to see how updating API data in Ace works
  • queryKey:
    • An optional prop that can be passed to api functions
    • The unique identifier Solid's query() uses for all it's magic like deduplication
    • IF queryType is set AND queryKey is undefined THEN the queryKey is set as the api function name (ex: apiGetUsers)
    • Typically being undefined is ideal unless there is a query or search param that is sent in this request and we wanna dedup based on that id (ex: ['apiGetUser', id])
    • So a queryKey can be undefined (typical), can be a string (uncommon), or an array (sometimes super helpful)

VS Code Extension

  • Provides links to Ace API's right above API Function calls! πŸ™Œ Ace for Vs Code Extension
  • In VS Code or any fork like VsCodium:
    • Extensions Search: ace-vs-code
    • The name of the package is Ace for VS Code and the author is acets-team
  • & please feel free click here to see additional vs code helpful info!

Echo

  • How does this Echo idea work?
    1. An API endpoint updates some data
    2. The API gathers the updated data consistently (same function every time)
    3. The API echos the updated data to the FE w/in the Response
    4. & if using Ace Live Server, the API echos the updated data to all subscribers
  • πŸ€“ Kitchen Sink Example:
    • src/lib/vars.ts
      export const streams = new Enums(['amsterdamRegistration']) // the streams our Ace Live Server supports
    • src/lib/types.d.ts
      import type { ApiName2Data } from '@ace/types'
      
      export type LiveAmsterdamRegistrationData = { // subscribers will receive a toast message & the updated registration
        message: string,
        registration: ApiName2Data<'apiGetAmsterdamRegistration'>
      }
    • src/db/dbGetAmsterdamRegistration.ts
      // our consistent (same function every time) way to get complex data (complex b/c multiple joins) 
      
      import { eq, type SQL } from 'drizzle-orm'
      import { amsterdamRegistrations, db, roommateOptions, users, voiceParts } from "./db";
      
      
      export async function dbGetAmsterdamRegistration(where: SQL) {
        const [registration] = await db
          .select({
            id: amsterdamRegistrations.id,
            email: users.email, 
            phone: amsterdamRegistrations.phone,
            gender: amsterdamRegistrations.gender,
            voicePart: voiceParts.name,
            emergencyContact: amsterdamRegistrations.emergencyContact,
            physicalLimitations: amsterdamRegistrations.physicalLimitations,
            dietaryLimitations: amsterdamRegistrations.dietaryLimitations,
      
            roommateOption: roommateOptions.name,
            roommateName: amsterdamRegistrations.roommateName,
            singleAgree: amsterdamRegistrations.roommateSingleAgree,
      
            nameOnPassport: amsterdamRegistrations.name,
            passportNumber: amsterdamRegistrations.passportNumber,
            passportIssuedDate: amsterdamRegistrations.passportIssuedDate,
            passportExpiredDate: amsterdamRegistrations.passportExpiredDate,
            passportAuthority: amsterdamRegistrations.passportAuthority,
            nationality: amsterdamRegistrations.nationality,
          })
          .from(amsterdamRegistrations)
          .leftJoin(voiceParts, eq(amsterdamRegistrations.voicePartId, voiceParts.id))
          .leftJoin(roommateOptions, eq(amsterdamRegistrations.roommateOptionId, roommateOptions.id))
          .leftJoin(users, eq(users.id, amsterdamRegistrations.userId))
          .where(where)
          .limit(1)
      
        return registration
      }
    • src/api/apiGetAmsterdamRegistration.ts
      import { API } from '@ace/api'
      import { eq } from 'drizzle-orm'
      import { vNum } from '@ace/vNum'
      import { vParse } from '@ace/vParse'
      import { object, optional } from 'valibot'
      import { sessionB4 } from '@src/auth/authB4'
      import { amsterdamRegistrations } from '@src/db/db'
      import { dbGetAmsterdamRegistration } from '@src/db/dbGetAmsterdamRegistration'
      
      
      export const GET = new API('/api/get-amsterdam-registration/:registrationId?', 'apiGetAmsterdamRegistration')
        .b4([sessionB4])
        .pathParams(vParse(object({ registrationId: optional(vNum()) })))
        .resolve(async (scope) => {
          const where = scope.pathParams.registrationId // IF pathParam defined
            ? eq(amsterdamRegistrations.id, scope.pathParams.registrationId) // get registration by pathParams.registrationId
            : eq(amsterdamRegistrations.userId, scope.event.locals.session.userId) // get registration by current session user id
      
          const registration = await dbGetAmsterdamRegistration(where)
      
          return scope.success(registration)
        })
    • src/api/apiUpsertAmsterdamRegistration.ts
      import { API } from '@ace/api'
      import { streams } from '@src/lib/vars'
      import { sessionB4 } from '@src/auth/authB4'
      import { eq, type InferInsertModel } from 'drizzle-orm'
      import { db, amsterdamRegistrations } from '@src/db/db'
      import type { LiveAmsterdamRegistrationData } from '@src/lib/types'
      import { dbGetAmsterdamRegistration } from '@src/db/dbGetAmsterdamRegistration'
      import { upsertAmsterdamRegistrationParser } from '@src/parsers/upsertAmsterdamRegistrationParser'
      
      
      export const POST = new API('/api/upsert-amsterdam-registration', 'apiUpsertAmsterdamRegistration')
        .b4([sessionB4])
        .body(upsertAmsterdamRegistrationParser)
        .resolve(async (scope) => {
          if (!process.env.LIVE_SECRET) throw new Error('!process.env.LIVE_SECRET')
      
          const user = await db.query.users.findFirst({ where: (u, { eq }) => eq(u.id, scope.event.locals.session.userId) })
          if (!user) throw new Error('Please sign in')
      
          const voicePart = await db.query.voiceParts.findFirst({ where: (v, { eq }) => eq(v.name, scope.body.voicePart) })
          if (!voicePart) throw new Error('Please include a valid voice part')
      
          const roommateOption = await db.query.roommateOptions.findFirst({ where: (r, { eq }) => eq(r.name, scope.body.roommateOption) })
          if (!roommateOption) throw new Error('Please include a valid roommate option')
      
          const existingRegistration = await db.query.amsterdamRegistrations.findFirst({
            where: (row, { eq }) => eq(row.userId, user.id)
          })
      
          const upsert: InferInsertModel<typeof amsterdamRegistrations> = { // ❀️ define 1 shape for insert AND update
            userId: user.id,
            phone: scope.body.phone,
            gender: scope.body.gender,
            voicePartId: voicePart.id,
            emergencyContact: scope.body.emergencyContact,
            physicalLimitations: scope.body.physicalLimitations || null,
            dietaryLimitations: scope.body.dietaryLimitations || null,
            roommateOptionId: roommateOption.id,
            roommateName: scope.body.roommateName || null,
            roommateSingleAgree: scope.body.roommateSingleAgree ? 1 : 0,
            name: scope.body.name,
            passportNumber: scope.body.passportNumber,
            passportIssuedDate: new Date(scope.body.passportIssuedDate),
            passportExpiredDate: new Date(scope.body.passportExpiredDate),
            passportAuthority: scope.body.passportAuthority,
            nationality: scope.body.nationality,
          }
      
          let data: undefined | LiveAmsterdamRegistrationData
      
          if (existingRegistration) { // πŸ”„ update
            await db
              .update(amsterdamRegistrations)
              .set(upsert)
              .where(eq(amsterdamRegistrations.id, existingRegistration.id))
      
            data = { // data is for Ace Live Server
              message: `${scope.body.name} updated their Amsterdam Registration!`,
              registration: await dbGetAmsterdamRegistration(eq(amsterdamRegistrations.id, existingRegistration.id)),
            }
          } else { // ✨ create
            const [resultSet] = await db
              .insert(amsterdamRegistrations)
              .values(upsert)
              .returning()
      
            data = { // data is for Ace Live Server
              message: `${scope.body.name} created their Amsterdam Registration!`,
              registration: await dbGetAmsterdamRegistration(eq(amsterdamRegistrations.id, resultSet.id))
            }
          }
      
          await scope.liveEvent({ // broadcast (echo) live event to all subscribers of the amsterdamRegistration stream
            data,
            stream: streams.keys.amsterdamRegistration,
            requestInit: { headers: { LIVE_SECRET: process.env.LIVE_SECRET } }
          })
      
          return scope.success(`Registration ${existingRegistration ? 'updated' : 'saved'}!`)
        })
    • Component.tsx
      import { showToast } from '@ace/toast'
      import { onCleanup, onMount } from 'solid-js'
      import { LiveAmsterdamRegistrationData } from '@src/lib/types'
      
      const {sync} = useStore()
      
      onMount(() => {
        const ws = scope.liveSubscribe({ stream: streams.keys.amsterdamRegistration }) // πŸ”” subscribe to amsterdamRegistration stream
      
        ws.addEventListener('message', async (event) => { // πŸ’Œ onMessage
          const data: LiveAmsterdamRegistrationData = JSON.parse(event.data) // parse data
          showToast({ type: 'success', value: data.message }) // show notfication
          sync('apiGetAmsterdamRegistration', data.registration) // update FE data
        })
      
        onCleanup(() => scope.liveUnsubscribe(ws)) // πŸ”• on leave => unsubscribe
      })
    • Ace Live Server > index.ts
      import { jwtValidate } from '@ace/jwtValidate'
      import { createLiveWorker, createLiveDurableObject, readCookie } from '@ace/liveServer'
      
      
      export default createLiveWorker() satisfies ExportedHandler<Env>
      
      
      export const LiveDurableObject = createLiveDurableObject({
        onValidateEvent(request) { // only our server can post events
          if (request.headers.get('LIVE_SECRET') !== process.env.LIVE_SECRET) {
            return new Response('Unauthorized', { status: 400 })
          }
        },
        async onValidateSubscribe(request) { // only our signed in customers can subscribe (we may share cookies if we put our live server on a subdomain of the app server (ex: live.example.com)
          const jwt = readCookie(request, 'aceJWT')
          const res = await jwtValidate({ jwt })
          if (!res.isValid) return new Response('Unauthorized', { status: 400 })
        }
      })
  • Why doesen't Ace use revalidate()?
    • Solid's revalidate() relies on signals created by query() and createAsync()
    • To update state with Ace, we don't update query signals, we update state signals
  • Why doesen't Ace use Single Flight Mutations?
    • The Response that comes from a Single Flight Mutation is framework specific & not a JSON shape we define
    • & b/c of how RPC functions are created, the Request URL may also be difficult to predict
    • We love defined & typesafe, URLs & Responses

Breakpoints

BE Breakpoints βœ…

  1. @ BE code, place a debugger w/in your code and/or an if (condition) debugger (as seen in screenshot below)
  2. Refresh site and now in your editor you may watch variables & see the call stack! Ace BE Breakpoint Example

FE Breakpoints βœ…

  1. @ FE code, place a debugger w/in your code and/or an if (condition) debugger (as seen in screenshot below)
  2. In browser navigate to Inpect > Sources
  3. Refresh site and now in your browser you may watch variables & see the call stack! Ace FE Breakpoint Example

Scope

βœ… ScopeBE

  • Available @:
    • B4 Functions, example:
      export const sessionB4: B4<{ session: Session }> = async (scope) => {
        scope.event.locals.session = await getSession(scope)
      }
    • API > .resolve() Functions, example:
      import { API } from '@ace/api'
      import { eq } from 'drizzle-orm'
      import { db, users } from '@src/lib/db'
      import { sessionB4 } from '@src/auth/authB4'
      import { updateEmailParser } from '@src/parsers/updateEmailParser'
      
      
      export const POST = new API('/api/update-email', 'apiUpdateEmail')
        .b4([sessionB4])
        .body(updateEmailParser)
        .resolve(async (scope) => {
          await db
            .update(users)
            .set({ email: scope.body.email })
            .where(eq(users.id, scope.event.locals.session.userId))
      
          return scope.success()
        })
  • Features:
    • scope.respond()
      • Builds a Response based on the provided props
      • scope.success(), scope.error() & scope.go() each use scope.respond()
      • The option to return a custom Response from an API or B4 is available too btw
      • Props:
        {
          path?: T_Path, // redirect path string
          data?: T_Data, // object
          status?: number,
          error?: AceError,
          headers?: HeadersInit,
          pathParams?: RoutePath2PathParams<T_Path>, // object (for redirect)
          searchParams?: RoutePath2SearchParams<T_Path>, // object (for redirect)
        }
    • scope.success()
      • Creates a success Response w/ simple options
      • For all options please call scope.respond()
      • Props: (data?: T_Data, status = 200)
    • scope.error()
      • Creates a error Response w/ simple options
      • For all options please call scope.respond()
      • Props: (message: string, status = 400)
    • scope.go()
      • Creates a redirect Response w/ simple options
      • For all options please call scope.respond()
      • Props: (path: T_Path, params?: { pathParams?: RoutePath2PathParams<T_Path>, searchParams?: RoutePath2SearchParams<T_Path> })
    • scope.setCookie()
      • Set a cookie
      • Props: (name: string, value: string, options?: CookieSerializeOptions)
    • scope.getCookie()
      • Get a cookie value by name
      • Props: (name: string)
    • scope.clearCookie()
      • Delete a cookie by name
      • Props: (name: string)
    • scope.liveEvent()
    • scope.requestUrlOrigin
      • The origin of the current HTTP request URL

βœ… ScopeComponent

  • Available @:
    • Any component via import { scope } from '@ace/scopeComponent'
    • Layout > .component(), example:
      import { Nav } from '@src/Nav/Nav'
      import { Layout } from '@ace/layout'
      
      export default new Layout()
        .component(({ children }) => {
          return <>
            <Nav />
            {children}
          </>
        })
    • Route > .component(), example:
      import { Route } from '@ace/route'
      
      export default new Route('/')
        .component((scope) => {
          return <>
            <Show when={scope.bits.get('apiExample') === false} fallback={<Loading />}>
              <Loaded />
            </Show>
          </>
        })
    • Route404 > .component(), example:
      import { Route404 } from '@ace/route404'
      
      export default new Route404()
        .component((scope) => {
          return <>
            <h1>{scope.location.pathname}</h1>
          </>
        })
  • Features:
    • scope.pathParams
      • Path params as an object
      • If using in a createEffect() THEN use scope.PathParams()
    • scope.PathParams()
      • Reactive path params that can be used in a createEffect()
      • If not using in a createEffect() THEN use scope.pathParams
    • scope.searchParams
      • Search params as an object
      • If using in a createEffect() THEN use scope.SearchParams()
    • scope.SearchParams()
      • Reactive search params that can be used in a createEffect()
      • If not using in a createEffect() THEN use scope.searchParams
    • scope.location
      • Location as an object
      • If using in a createEffect() THEN use scope.Location()
    • scope.Location()
      • Reactive location that can be used in a createEffect()
      • IF not using in a createEffect() THEN use scope.location
    • scope.liveSubscribe()
      • Helpful when you'd love to create a ws connection to an Ace Live Server
      • Example:
        const ws = scope.liveSubscribe({ stream: 'example' })
              
        ws.addEventListener('message', event => {
          console.log(event.data)
        })
              
        ws.addEventListener('close', () => {
          console.log('ws closed')
        })
    • scope.go()
      • Frontend redirect w/ simple options
      • For all possible options please use scope.Go()
      • Props:
        /**
         * @param path - Redirect to this path, as defined @ new Route()
         * @param params.pathParams - Path params
         * @param params.searchParams - Search params
         */
    • scope.Go()
      • Frontend redirect w/ simple options
      • For all possible options please use scope.Go()
      • Props:
        /**
         * @param path - Redirect to this path, as defined @ new Route()
         * @param pathParams - Path params
         * @param searchParams - Search params
         * @param replace - Optional, defaults to false, when true this redirect will clear out a history stack entry 
         * @param scroll - Optional, defaults to true, if you'd like to scroll to the top of the page when done redirecting
         * @param state - Optional, defaults to an empty object, must be an object that is serializable, available @ the other end via `fe.getLocation().state`
         */
    • scope.children
      • Get the children for a layout
      • IF not a layout OR no children THEN undefined
    • scope.GET()
      • Call api GET method w/ typesafe autocomplete
      • Props:
        /**
        * @param path - As defined @ `new API()`
        * @param options.bitKey - `Bits` are `boolean signals`, they live in a `map`, so they each have a `bitKey` to help us identify them, the provided bitKey will have a value of true while this api is loading
        * @param options.pathParams - Path params
        * @param options.searchParams - Search params
        * @param options.manualBitOff - Optional, defaults to false, set to true when you don't want the bit to turn off in this function but to turn off in yours, helpful if you want additional stuff to happen afte api call then say we done
        */
    • scope.POST()
      • Call api POST method w/ typesafe autocomplete
      • Props:
        /**
        * @param path - As defined @ `new API()`
        * @param options.bitKey - `Bits` are `boolean signals`, they live in a `map`, so they each have a `bitKey` to help us identify them, the provided bitKey will have a value of true while this api is loading
        * @param options.pathParams - Path params
        * @param options.searchParams - Search params
        * @param options.body - Request body
        * @param options.manualBitOff - Optional, defaults to false, set to true when you don't want the bit to turn off in this function but to turn off in yours, helpful if you want additional stuff to happen afte api call then say we done
        */
    • scope.PUT()
      • Call api PUT method w/ typesafe autocomplete
      • Props:
        /**
        * @param path - As defined @ `new API()`
        * @param options.bitKey - `Bits` are `boolean signals`, they live in a `map`, so they each have a `bitKey` to help us identify them, the provided bitKey will have a value of true while this api is loading
        * @param options.pathParams - Path params
        * @param options.searchParams - Search params
        * @param options.body - Request body
        * @param options.manualBitOff - Optional, defaults to false, set to true when you don't want the bit to turn off in this function but to turn off in yours, helpful if you want additional stuff to happen afte api call then say we done
        */
    • scope.DELETE()
      • Call api DELETE method w/ typesafe autocomplete
      • Props:
        /**
        * @param path - As defined @ `new API()`
        * @param options.bitKey - `Bits` are `boolean signals`, they live in a `map`, so they each have a `bitKey` to help us identify them, the provided bitKey will have a value of true while this api is loading
        * @param options.pathParams - Path params
        * @param options.searchParams - Search params
        * @param options.body - Request body
        * @param options.manualBitOff - Optional, defaults to false, set to true when you don't want the bit to turn off in this function but to turn off in yours, helpful if you want additional stuff to happen afte api call then say we done
        */

Create a 404 Route

import './Unknown.css'
import { A } from '@ace/a'
import { Title } from '@solidjs/meta'
import { Route404 } from '@ace/route404'
import RootLayout from '@src/app/RootLayout'


export default new Route404()
  .layouts([RootLayout]) // zero to many layouts available @ Route or Route404!
  .component(({location}) => {
    return <>
      <Title>πŸ˜… 404</Title>

      <main class="not-found">
        <div class="code">404 πŸ˜…</div>
        <div class="message">Oops! We can't find that page.</div>
        <div class="path">{location.pathname}</div>
        <A path="/" class="brand">🏑 Go Back Home</A>
      </main>
    </>
  })

Create a Typesafe Anchor

  • Path, pathParams & searchParams = typesafe
  • So IF path or search params are defined for this path as required THEN they'll be required here via TS IDE autocomplete intellisense
  • By default & configurable this component toggles an active class based on the current route πŸ₯³
<A path="/about">Learn More</A>

Typesafe Redirects

  1. Redirect @ B4!
    import type { B4 } from '@ace/types'
    
    export const redirectB4: B4 = async (scope) => {
      return scope.go('/') // return or throw both work :)
    }
  2. Redirect @ API > .resolve()
    // scope.go() and scope.error() are shortcuts to scope.respond()
    // IF scope.go() does not have all the options you want (ex: custom status or headers) please use scope.respond({ path: '/', status: abc, headers: xyz })
    
    
    import { API } from '@ace/api'
    import { vParse } from '@ace/vParse'
    import { vEnums } from '@ace/vEnums'
    import { elements } from 'src/lib/vars'
    import { object, picklist } from 'valibot'
    
    
    export const GET = new API('/api/element/:element', 'apiElement')
      .pathParams(vParse(object({ element: vEnums(elements) })))
      .resolve(async (scope) => {
        return scope.pathParams.element === 'fire' // ✨ Typesafe API Path Params
          ? scope.error('πŸ”₯')
          : scope.go('/') // πŸ’« Typesafe API Redirects (throwing scope.go() here works too)
      })
  3. Redirect @ Route > .component()!
    // IF scope.go() does not have all the options you want (ex: custom scroll or replace) please use scope.Go({ path: '/example', replace: true })
    // 🚨 Throw the `go()` or `Go()`, THROWING thankfully ends the inference loop of defining and returning a route πŸ™Œ
    
    
    import { Route } from '@ace/route'
    
    export default new Route('/')
      .component(scope => {
        throw scope.go('/deposits')
      })

Add Offline Support

  1. Create /public/sw.js
    // @ts-check
    
    import { swAddOffLineSupport } from './.ace/swAddOffLineSupport.js'
    
    const packageDotJsonVersion = ''
    
    swAddOffLineSupport({ cacheName: `offline-cache-v-${packageDotJsonVersion}` })
  2. 🚨 To align our app version (in package.json) w/ your cache version then:
    1. Ensure package.json version is defined
    2. Ensure /public/sw.js has const packageDotJsonVersion = ''
    3. Run in bash: ace sw to place your package.json version into your sw.js file πŸ₯³
    4. Update package.json scripts to run ace sw automatically
      {
        "scripts": {
          "dev": "ace build local && ace sw && vinxi dev",
          "build": "ace build prod && ace sw && vinxi build",
        },
      }
  3. @ src/entry-server.tsx add @ace/sw.styles.css?raw AND @ace/swRegister?raw. The ?raw is a Vite thing, it gives us the import as a string, and then we inline it for an optimal Lighthouse score!
    // @refresh reload
    import swRegister from '@ace/swRegister?raw'
    import swStyles from '@ace/sw.styles.css?raw'
    import { createHandler, StartServer } from '@solidjs/start/server'
    
    export default createHandler(() => (
      <StartServer
        document={({ assets, children, scripts }) => (
          <html lang="en">
            <head>
              <meta charset="utf-8" />
              <style>{swStyles}</style>
              <link rel="icon" href="/favicon.ico" />
              <link rel="manifest" href="/manifest.json" />
              <meta name="viewport" content="width=device-width, initial-scale=1.0" />
              <meta name="description" content="Create Ace App is a showcase for what is possible w/ Ace!" />
    
              {/* enhance how the site looks when added to an iOS device's home screen */}
              <meta name="apple-mobile-web-app-capable" content="yes" /> {/* tells iOS Safari that the web application should be run in full-screen mode without the standard browser chrome (address bar, toolbar) when launched from a home screen icon */}
              <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> {/* sets the appearance of the iOS status bar (time, battery icons) when running in full-screen mode */}
    
              {assets}
            </head>
            <body>
              <div id="app">{children}</div>
              <script>{swRegister}</script>
              {scripts}
            </body>
          </html>
        )}
      />
    ))
  • How it works:
    • On every GET request we first go to the server, and if that response is ok then the service worker will store the response in cache
    • When offline we'll still try to fetch, and when it fails we'll check cache, and if cache has a response we'll give it
    • The styling is b/c service workers register when you go to a page that has a service worker, but it won't install and activate till the first refresh after it's been registered, so we do this immediately after registration, and to not let the user know this is happening we have the app be 0 opacity till after the refresh. The refresh only happens once in the lifetime of the customer on the app w/ this cache version (app version).

Network Status Hook

  • Helpful if you'd love to alter things based on their network (wifi) connection
  • IF hook called one or many times THEN still only 1 event is added to window to watch network status & then this value is shared w/ all hooks
    import { Route } from '@ace/route'
    import { showToast } from '@ace/toast'
    import { useNetworkStatus } from '@ace/useNetworkStatus'
    
    export default new Route('/')
      .component(() => {
        const status = useNetworkStatus() // Accessor<'online' | 'offline'>
          
        return <>
          <button type="button" onClick={() => showToast({ type: 'info', value: status() })}>Click here</button>
        </>
      })

Create Desktop Application

βœ… npx create-ace-app@latest is built w/ the following directions done btw! Create Ace App

  1. Please follow the Add Offline Support directions to ensure you register the service worker correctly!
    • Offline support is a lovely app feature but if you don't want it, just don't call swAddOffLineSupport() @ /public/sw.js
  2. For free in Figma create a 512x512 icon for your app
  3. For free @ Progressier create a manifest.json and icon suite
  4. Add the generated manifest.json and icons to your /public folder
  5. @ src/entry-server.tsx > <head> add <link rel="manifest" href="/manifest.json" />
  6. App install is now ready! (works on localhost too btw!) Create Desktop Application

Markdown

<AceMarkdown /> βœ…

  • Ideal for SEO
  • Supports .md files & markdown Preview @ VsCodium βœ… Ace Markdown Example
  • Example:
    // How `.md?raw` works:
      // at build-time, the markdown file is minified & bundled as a string literal 
      // so at run-time, there's no file I/O b/c the markdown is an in memory string constant 
    
    // 🚨 IF not highlighting code THEN `registerHljs` AND `hljsMarkdownItOptions` are not necessary
    
    import { AceMarkdown } from '@ace/aceMarkdown'
    import { Example } from '@src/Example/Example'
    import mdAppInfo from '@src/md/mdAppInfo.md?raw'
    import { registerHljs } from '@src/init/registerHljs'
    import { hljsMarkdownItOptions } from '@ace/hljsMarkdownItOptions'
    
    <AceMarkdown content={mdAppInfo} components={[Example]} registerHljs={registerHljs} markdownItOptions={{ highlight: hljsMarkdownItOptions }} />
  • Requires: npm i markdown-it -D
  • Converts markdown to html AND supports Directives, added via comments that start with <!--{, end with }--> and include JSON w/in
  • Directives:
    • $info:
      • Example: <!--{ "$info": true, "$interpolate": true, "title": "What is Ace?", "slug": "what-is-ace" }-->
      • IF $info.$interpolate is true THEN vars from $info can be placed @ markdown, component props, tab label & tab content
    • $component:
      • Adds a Solid component into markdown πŸ™Œ
      • Example: <!--{ "$component": "Example", "title": "{title}" }-->
      • THEN pass the component function to AceMarkdown, example: <AceMarkdown content={mdWhatIsAce} components={[Example]} />
    • ❀️ $tabs:
      • Example: <!--{ "$tabs": ["TS", "JS"] }-->
      • The value in the directive @ $tabs is the tab labels, so for this example there would be 2 labels, one is TS and the other is JS
      • Place the tab content below the Directive
      • 🚨 Add --- at the end of each tab content to let us know where each tab content ends
      • IF the number of content items does not match the number of labels THEN we'll throw an error
      • βœ… Tab content may include, markdown, or components
      • W/in the Directive these additional TabsProps can be provided of { name?: string, variant?: 'underline' | 'pill' | 'classic', $div?: JSX.HTMLAttributes<HTMLDivElement> }
        • Example: <!--{ "$tabs": ["TS", "JS"], "variant": "underline" }-->
  • Props:
    {
      /** Ace Markdown Content, example: `import mdWhatIsAce from '@src/md/mdWhatIsAce.md?raw'`, then provide `mdWhatIsAce` here  */
      content: string,
      /** Components that are in the AceMarkdown, just passing the function names is perfect */
      components?: (() => JSX.Element)[],
      /** Helpful when you'd love the markdownIt instance, example: `const [md, setMD] = createSignal<MarkdownIt>()` and then pass `setMD` here */
      setMD?: Setter<markdownit | undefined>
      /** Optional, requested options will be merged w/ the `defaultMarkdownOptions` */
      markdownItOptions?: MarkdownItOptions
      /** Optional, props passed to inner wrapper div */
      $div?: JSX.HTMLAttributes<HTMLDivElement>,
      /** Optional, to enable code highlighting pass a function here that registers highlight languages */
      registerHljs?: () => void,
    }

parseMarkdownFolders() βœ…

  • Helpful when we have a folder of .md files and we'd love to get typesafe info about all of the markdowns w/in the folder
  • Example: We've got a folder for posts and we wanna show each post title & link to it w/in a component
    • Ace Markdown Directive:
      <!--{ "$info": true, "title": "What is Ace?", "slug": "what-is-ace" }-->
    • ace.config.js
      export const config = {
        mdFolders: [
          { id: 'mdFolder', path: 'src/md' },
          { id: 'contentFolder', path: 'src/content' },
        ],
      }
    • ./src/parsers/mdParser
      export const mdParser = vParse(object({ 
        $info: vBool(),
        slug: vString(),
        title: vString()
      }))
    • Component
      import { parseMarkdownFolders } from '@ace/parseMarkdownFolders'
      
      const mdFolder = parseMarkdownFolders('mdFolder', mdParser)
      const contentFolder = parseMarkdownFolders('contentFolder', parser)
      
      console.log({
        mdFolder, contentFolder, // { path: string, raw: string, info: T }[]
        whatIsAce: mdFolder.find(md => md.info.slug === 'what-is-ace')
      })

<MarkdownItStatic /> βœ…

  • Ideal for SEO
  • Does not support <AceMarkdown /> directives
  • Supports .md files & markdown Preview @ VsCodium βœ…
  • Install: npm i markdown-it -D
  • Example:
    // 🚨 IF not highlighting code THEN `registerHljs` AND `hljsMarkdownItOptions` are not necessary
    
    import mdAppInfo from '@src/md/mdAppInfo.md?raw'
    import { registerHljs } from '@src/init/registerHljs'
    import { MarkdownItStatic } from '@ace/markdownItStatic'
    import { hljsMarkdownItOptions } from '@ace/hljsMarkdownItOptions'
    
    <MarkdownItStatic content={mdAppInfo} registerHljs={registerHljs}  options={{ highlight: hljsMarkdownItOptions }} />
  • Props:
    {
      /** Content to render from markdown to html, can also pass content later by updating the passed in content prop or `md()?.render()` */
      content: string,
      /** Helpful when you'd love the markdownIt instance, example: `const [md, setMD] = createSignal<MarkdownIt>()` and then pass `setMD` here */
      setMD?: Setter<markdownit | undefined>
      /** Optional, requested options will be merged w/ the `defaultMarkdownOptions` */
      markdownItOptions?: MarkdownItOptions
      /** Optional, props passed to inner wrapper div */
      $div?: JSX.HTMLAttributes<HTMLDivElement>,
      /** Optional, to enable code highlighting pass a function here that registers highlight languages */
      registerHljs?: () => void
    }

<MarkdownItDynamic /> βœ…

  • Ideal for dynamic markdown (from DB) or FE alterable markdown (from textarea)
  • Does not support <AceMarkdown /> directives
  • Install: npm i markdown-it -D
  • Example:
    // 🚨 IF not highlighting code THEN `registerHljs` AND `hljsMarkdownItOptions` are not necessary
    
    import { registerHljs } from '@src/init/registerHljs'
    import { MarkdownItDynamic } from '@ace/markdownItDynamic'
    import { hljsMarkdownItOptions } from '@ace/hljsMarkdownItOptions'
    
    <MarkdownItDynamic content={() => store.buildStats} registerHljs={registerHljs} options={{ highlight: hljsMarkdownItOptions }} $div={{ class: 'markdown' }} />
  • Props:
    {
      /** Content to render from markdown to html, can also pass content later by updating the passed in content prop or `md()?.render()` */
      content: Accessor<string | undefined>
      /** Helpful when you'd love the markdownIt instance, example: `const [md, setMD] = createSignal<MarkdownIt>()` and then pass `setMD` here */
      setMD?: Setter<markdownit | undefined>
      /** Optional, requested options will be merged w/ the `defaultMarkdownOptions` */
      markdownItOptions?: MarkdownItOptions
      /** Optional, props passed to inner wrapper div */
      $div?: JSX.HTMLAttributes<HTMLDivElement>,
      /** Optional, to enable code highlighting pass a function here that registers highlight languages */
      registerHljs?: () => void
    }

defaultMarkdownOptions βœ…

  • Requested markdownItOptions @ <AceMarkdown />, <MarkdownItStatic /> & <MarkdownItDynamic /> will be merged w/ these defaultMarkdownOptions
    import type { Options as MarkdownItOptions } from 'markdown-it'
    
    const defaultMarkdownOptions: MarkdownItOptions = {
      html: true,
      linkify: true,
      typographer: true
    }

Code Highlight Markdo