PHP

★ A Laravel package to crawl and index content of your sites

[AdSense-A]

The newly released spatie/laravel-site-search package can crawl and index the content of one or more sites. You can think of it as a private Google search for your sites. Like most Spatie packages, it is highly customizable: you have total control over what content gets crawled and indexed.

To see the package in action, head over to the search page of this very blog.

In this post, I'd like to introduce the package to you and highlight some implementation and testing details. Let's dig in!

Are you a visual learner?

In this stream on YouTube, I'll demo the package and dive into its source code. All questions are welcome in the chat.

Why we created this package?

In our ecosystem, there already are several options to create a search index. Let's compare them with our new package.

Laravel Scout is an excellent package to add search capabilities for Eloquent models. In most cases, this is very useful if you want to provide a structured search. For example, if you have a Product model, Scout can help build up a search index to search the properties of these products.

The main difference between Scout and laravel-site-search is that laravel-site-search is not tied to Eloquent models. Like Google, it will crawl your entire site and index all content that is there.

Another nice indexing option is Algolia Docsearch. It can add search capabilities to open-source documentation for free.

Our laravel-site-search package may be used to index non-open-source stuff as well. Where Docsearch makes basic assumptions on how the content is structured, our package tries to make a best effort to index all kinds of content.

You could also opt to use Meilisearch Doc Scraper, which you can use for non-open-source content. It's written in Python, so it's not that easy to integrate with a PHP app.

Our package is, of course, written in PHP and can be customized very easily; you can even add custom properties.

So summarised, our package can be used for all kinds of content, and it can be easily customized when installed in a Laravel app.

Crawling your first site

First, you must follow the installation instructions. This involves installing the package and installing Meilisearch. The docs even mention how you can install and run Meilisearch on a Forge provisioned server.

After you've installed the package, you can run this command to define a site that needs to be indexed.

php artisan site-search:create-index

This command will ask for a name for your index and the URL of your site that should be crawled. Of course, you could run that command multiple times to create multiple indexes.

After that, you should run this command to start a queued job. You should probably schedule that command to run every couple of hours so that the index is kept in sync with your site's latest content.

php artisan site-search:crawl

That job that is started by that command will:

create a new Meilisearch index
crawl your website using multiple concurrent connections to improve performance
transform crawled content to something that can be put in the search index
mark that new Meilisearch index as the active one
delete the old Meilisearch index

Finally, you can use Search to perform a query on your index.

use SpatieSiteSearchSearch;

$searchResults = Search::onIndex($indexName)
->query('your query')
->get();

This is how you could render the results in a Blade view:

<ul>
@foreach($searchResults->hits as $hit)
<li>
<a href="{{ $hit->url }}">
<div>{{ $hit->url }}</div>
<div>{{ $hit->title() }}</div>
<div>{!! $hit->highlightedSnippet() !!}</div>
</a>
</li>
@endforeach
</ul>

That is basically how you can use the package. On the search page of this very blog, you can see the package in action. I've also open-sourced my blog, so on GitHub, you'll be able to see the Livewire component and Blade view that power the search page.

Customizing what gets crawled and indexed

In most cases, you don't want to index all content that is available on your site. A few examples of this are menu structures or list pages (e.g. a list with blog posts with links to the detail pages of those posts).

We've made it easy to ignore such content. In the config file there's an option ignore_content_on_urls. Your homepage probably contains no unique content but rather links to pages where the full content is.

You can ignore the content on the homepage by adding /. We'll still crawl the homepage but not put any of its content in the index.

/*
* When crawling your site, we will ignore content that is on these URLs.
*
* All links on these URLs will still be followed and crawled.
*
* You may use `*` as a wildcard.
*/
'ignore_content_on_urls' => [
'/'
],

You can also ignore content based on CSS selectors. There's an option ignore_content_by_css_selector in the config file that lets you specify any CSS selection.

If your menu structure is in a nav element, you can add nav. You could also introduce a data attribute that you could slap on any content you don't want in your index.

So with this configuration:

/*
* When indexing your site, we will ignore any content to the search index
* that is selected by these CSS selectors.
*
* All links inside such content will still be crawled, so it's safe
* it's safe to add a selector for your menu structure.
*/
'ignore_content_by_css_selector' => [
'nav',
'[data-no-index]',
],

... this div won't get indexed:

<div>
This will get indexed
</div>
<div data-no-index>
This won't get indexed but <a href="/other-page">this link</a> will still be followed.
</div>

Using a search profile

For a lot of users, the above config options will be enough. If you want to control what gets indexed and crawled programmatically, you can use a search profile.

A search profile determines which pages get crawled and what content gets indexed. In the site-search config file, you'll win the default_profile key that the SpatieSiteSearchProfilesDefaultSearchProfile::class is being use by default.

This default profile will instruct the indexing process:

to crawl each page of your site
to only index any page that had 200 as the status code of its response
to not index a page if the response had a header site-search-do-not-index

By default, the crawling process will respect the robots.txt of your site.

If you want to customize the crawling and indexing behaviour, you could opt to extend SpatieSiteSearchProfilesDefaultSearchProfile or create your own class that implements the SpatieSiteSearchProfilesSearchProfile interface. This is how that interface looks like.

namespace SpatieSiteSearchProfiles;

use PsrHttpMessageResponseInterface;
use PsrHttpMessageUriInterface;
use SpatieSiteSearchIndexersIndexer;

interface SearchProfile
{
public function shouldCrawl(UriInterface $url, ResponseInterface $response): bool;
public function shouldIndex(UriInterface $url, ResponseInterface $response): bool;
public function useIndexer(UriInterface $url, ResponseInterface $response): ?Indexer;
public function configureCrawler(Crawler $crawler): void;
}

Indexing extra properties

Only the page title, URL, description, and some content are added to the search index by default. However, you can add any extra property you want.

You do this by using a custom indexer and override the extra method.

class YourIndexer extends SpatieSiteSearchIndexersDefaultIndexer
{
public function extra() : array{
return [
'authorName' => $this->functionThatExtractsAuthorName()
]
}

public function functionThatExtractsAuthorName()
{
// add logic here to extract the username using
// the `$response` property that's set on this class
}
}

The extra properties will be available on a search result hit.

$searchResults = SearchIndexQuery::onIndex('my-index')->search('your query')->get();

$firstHit = $searchResults->hits->first();

$firstHit->authorName; // returns the author name

Let's take a look at the tests

When writing tests, I usually prefer to write feature tests. They give me the highest confidence that everything is working correctly.

In the case of this package, a proper feature test would encompass crawling and indexing a site, then perform a query to the built-up search index, and verify if the results are correct.

In our test suite, we do precisely that. Let's first take a look at the test itself.

it('can crawl and index all pages', function () {
Server::activateRoutes('chain');

dispatch(new CrawlSiteJob($this->siteSearchConfig));

waitForMeilisearch($this->siteSearchConfig);

$searchResults = Search::onIndex($this->siteSearchConfig->name)
->query('here')
->get();

expect(hitUrls($searchResults))->toEqual([
'http://localhost:8181/',
'http://localhost:8181/2',
'http://localhost:8181/3',
]);
});

The site that we're going to crawl is not a real site. The used crawl_url in $this->siteSearchConfig is set to localhost:8181. This site is served by a Lumen application, that is booted whenever the tests run.

The first line of our test is Server::activateRoutes('chain'). This will make our Lumen application load and use a certain routes file. In this case, we will let our Lumen app use the chain.php routes file. This is what that routes file looks like:

$router->get('/', fn () => view('chain/1'));
$router->get('2', fn () => view('chain/2'));
$router->get('3', fn () => view('chain/3'));

So basically, our Lumen app now is a mini-site that serves a couple of chained pages.

In the following lines of our test, we're dispatching the job that will crawl and indexed that site.


// in our test

dispatch(new CrawlSiteJob($this->siteSearchConfig));

waitForMeilisearch($this->siteSearchConfig);

That waitForMeilisearch also deserves a bit of explanation. When something is being saved in a Meilisearch index, that bit of info won't be indexed immediately. Meilisearch needs a bit of time to process everything. Our tests need to wait on that because otherwise, our test may randomly fail because sometimes our exceptions would run before the indexing is complete.

Luckily, Meilisearch has an API that can determine whether all updates to an index are processed. Here's the implementation of waitForMeilisearch. We simply wait for Meilisearch's processing to be done.

function waitForMeilisearch(SiteSearchConfig $siteSearchConfig): void
{
$indexName = $siteSearchConfig->refresh()->index_name;

while (MeiliSearchDriver::make($siteSearchConfig)->isProcessing($indexName)) {
sleep(1);
}
}

After Meilisearch has done its work, we will perform a query against the Meilisearch index and expect certain URLs to be returned.

// in our test

$searchResults = Search::onIndex($this->siteSearchConfig->name)
->query('here')
->get();

expect(hitUrls($searchResults))->toEqual([
'http://localhost:8181/',
'http://localhost:8181/2',
'http://localhost:8181/3',
]);

With that Lumen test server a waitForMeilisearch function, we can test most functionalities of the package. Here's the test that makes sure the ignore_content_on_urls option is working.

When crawling the same chain as above but add ignore_content_on_urls to the pages to ignore, we expect that / and /3 are in the index.

it('can be configured not to index certain urls', function () {
Server::activateRoutes('chain');

config()->set('site-search.ignore_content_on_urls', [
'/2',
]);

dispatch(new CrawlSiteJob($this->siteSearchConfig));

waitForMeilisearch($this->siteSearchConfig);

$searchResults = Search::onIndex($this->siteSearchConfig->name)
->query('here')
->get();

expect(hitUrls($searchResults))->toEqual([
'http://localhost:8181/',
'http://localhost:8181/3',
]);
});

This kind of test gives me a lot of confidence that everything in the package is working correctly. If you want to see more tests, head over to the test suite on GitHub.

In closing

I hope that you like this little tour of the package. There are a lot of options not mentioned in this blog post: You can create synonyms, extra properties can be added, and much more.

We spent a lot of time making every aspect of the crawling and indexing behaviour customizable. Discover all the options in our extensive docs.

This isn't the first package that our team has made. Our website has an open source section that lists every package that our team has made. I'm pretty sure that there's something there for your next project. Currently, all of our package combined are being downloaded 10 million times a month.

Our team doesn't only create open-source, but also paid digital products, such as Ray, Mailcoach and Flare. Our team also creates premium video courses, such as Laravel Beyond CRUD, Testing Laravel, Laravel Package Training and Event Sourcing in Laravel. If you want to support our open source efforts, consider picking up one of our paid products.

(more…)

By , ago
PHP

★ Replacing Keytar with Electron's safeStorage in Ray

[AdSense-A]

Ray is an app we built at Spatie to make debugging your applications easier and faster. Being web developers, we naturally decided to write this app in Electron, which enabled us to move from nothing to a working prototype to a released product on 3 separate platforms within a matter of weeks.

About 9 months ago, Alex added a much requested feature that allows you to connect to remote servers and receive their Ray outputs securely over SSH.

To save the credentials to a server, we needed to find a secure way to save the password or private key passphrase. We quickly settled on node-keytar, a native Node.js module that leverages your system's keychain (Keychain/libsecret/Credential Vault/…) to safely store passwords, hidden from other applications and users.

Using a native Node module brought one major disadvantage: we couldn't easily build for other platforms anymore without running the actual build on that platform. There are some solutions provided by electron-builder, Keytar, and other packages, but these all came with their own layer of overhead. We eventually decided to run the build on all platforms separately using the GitHub Actions CI.

Three weeks ago, on September 21, 2021, Electron 15 was released, and somewhat hidden in the release notes we found a mention to a newly added string encryption API: safeStorage (PR/docs). Similarly to Keytar, Electron's safeStorage also uses the system's keychain to securely encrypt strings, but without the need for an extra dependency.

We jumped at the idea of simplifying our build process and removing a dependency, and wrote this simple implementation using safeStorage and electron-store, with an external API inspired by Keytar:

import { safeStorage } from 'electron';
import Store from 'electron-store';

const store = new Store<Record<string, string>>({
name: 'ray-encrypted',
watch: true,
encryptionKey: 'this_only_obfuscates',
});

export default {
setPassword(key: string, password: string) {
const buffer = safeStorage.encryptString(password);
store.set(key, buffer.toString('latin1'));
},

deletePassword(key: string) {
store.delete(key);
},

getCredentials(): Array<{ account: string; password: string }> {
return Object.entries(store.store).reduce((credentials, [account, buffer]) => {
return [...credentials, { account, password: safeStorage.decryptString(Buffer.from(buffer, 'latin1')) }];
}, [] as Array<{ account: string; password: string }>);
},
};

The only downside is that any previously saved passwords and passphrases saved using Keytar are now inaccessible for the app, but you can still find them by opening your system's keychain application and looking for any mentions of ray, ssh_password_, or private_key_. We also hope Ray wasn't the only spot you saved your server passwords.

Upon opening the servers overlay, existing users will receive a notification that their credentials need to be entered again. Ray will save which servers have not had their credentials updated yet, and will display this to the user. We used electron-store's migrations feature for this:

migrations: {
'>=1.18.0': (store) => {
store.set(
'servers',
store.get('servers').map((server) => ({ ...server, needsCredentialsUpdate: true }))
);
},
},
(more…)

By , ago
PHP

★ Making 1Password understand where your change password page is located

[AdSense-A]

A few days ago, a new version of 1Password was released that is able to detect where a user can reset his or her password.

This is how it looks like in 1Password:

When you click that "Change password" item, 1Password will open up a tab in your browser on the right page at Oh Dear to change the password.

This is pretty convenient if you ask me.

1Password knows where the change password page is located using the "Well-Known URL for Changing Passwords" specification. This specification tells that a request to <your-domain>/.well-known/change-password should redirect to the change password pass on your site.

So, behind the scenes, 1Password simply requests /.well-known/change-password and checks if a redirect is made.

In Laravel, you can easily create such a redirect in a routes file.

Route::redirect('/.well-known/change-password', '/url-of-your-change-password-page)

I was tempted to use a closure and the route name, but this code would make the routes uncacheable.

// do not use this if you want to use route caching
Route::get('.well-known/change-password', fn() => redirect()->route('profile.show'));

I can highly recommend adding a /.well-known/change-password redirect to your projects.

(more…)

By , ago
PHP

★ Asserting valid and invalid requests in Laravel

[AdSense-A]

Testing a Laravel project is one of the most pleasant experiences I've ever had: there's a clean testing API, a very powerful layer added on top of testing frameworks; all while keeping the simplicity and eloquence you'd expect from a Laravel project.

Here's a great example of Laravel's powerful simplicity. Recently, an improved way to test whether a request has validation errors or not was added. You can now use assertValid and assertInvalid instead of assertSessionHasErrors or assertJsonValidationErrors:

public function test_post_validation()
{
$this
->post(
action(UpdatePostController::class),
[
'title' => null,
'date' => '2021-01-01',
'author' => 'Brent',
'body' => null,
],
)
->assertValid(['date', 'author'])
->assertInvalid(['title', 'body']);
}

It's even possible to check for specific validation errors:

public function test_post_validation()
{
$this
->post(/* … */)
->assertInvalid([
'title' => 'required',
'body' => 'required',
]);
}

It's these kinds of little details that make testing a Laravel project so much fun!

If you want to up your testing game, check out our complete course about Testing Laravel. It teaches you how to test a Laravel application, from a beginner to master level.

(more…)

By , ago
PHP

How Livewire works (a deep dive)

The experience of using Livewire seems magical. It’s as if your front-end HTML can call your PHP code and everything just works. A lot goes into making this magic happen. Read more

By , ago
PHP

How to start and stop polling in React and Livewire

[AdSense-A]

Last week, support for Telegram notifications was added to both Flare and Oh Dear. Let's take a look at how both these services connect to Telegram. We'll focus on how polling is used in the connection flow and how polling can be stopped once the necessary data has been received.

Explaining our use case

Flare is an exception tracker made for Laravel applications. Under the hood, it is built using Laravel / Inertia / React.

Let's take a look at how people can connect to Telegram. At the Telegram notification preferences at Flare, users find the instructions. A bot needs to be invited to the Telegram room where notifications should be sent to. The bot is started by messaging a token.

Here's how that looks like.

animation

Don't bother trying to use that token. It is randomly generated, based on some secret user data, and has a very short lifespan.

Whenever a command is sent to the @FlareAppBot, Telegram will forward the command to Flare via a webhook. That webhook also contains a chatId. This id identifies the unique chat room / channel used in Telegram. Flare will use this id to send notifications to the correct room on Telegram.

In the animation above, you see that a confirmation message is shown in Telegram as soon a token is accepted. That confirmation message is sent in the request on our server that handles that webhook.

But, you've maybe also noticed that the UI on Flare itself also changed. A message "Your Telegram chat has been connected successfully" is displayed. Let's take a look at how our UI knows that a chatId was sent.

The request that handles the webhook from Telegram will put the incoming chatId in the cache. The cache key name will be generated based on something user specific, so no chatId can be stolen by other users.

Let's now focus on getting that chatId from the cache to the UI. If you're only interested in Livewire, you can skip the following paragraph on React.

Start and stop polling in React

The Flare UI is constantly polling an endpoint that takes a look if something is in the cache.

Here's how that controller looks like:

namespace AppHttpAppControllersNotificationDestinations;

use AppDomainNotificationTelegramActionsTelegramInviteToken;

class TelegramNewChatIdController
{
    public function __invoke()
    {
        $token = new TelegramInviteToken(current_user());

        $chatId = '';

        $cacheKey = $token->cacheKey();

        if (cache()->has($cacheKey)) {
            $chatId = cache()->get($cacheKey);
            
            cache()->forget($cacheKey);
        }

        return response()->json(['new_chat_id' => $chatId]);
    }
}

If we open up the browser's dev tools, we see that we're constantly polling for that chatId.

animation

When a new chatId is received, the UI is updated with that confirmation message. Also, notice that after a chatId is received, we stop polling. Let's take a look at the part of the React component that is responsible for that behaviour.

if (telegramChatId) {
    return (
        <>
            <Alert>
                Your Telegram chat has been connected successfully!{' '}
                {!hasInitialTelegramChatId.current &&
                    'Please submit this form to save your settings and start receiving notifications.'}
            </Alert>

            <input type="hidden" name="configuration[chat_id]" value={telegramChatId} />
        </>
    );
}

return (
    <div>
        <div className="flex flex-wrap">
            <span>You can get started by inviting</span>{' '}
            <CopyableCode className="mx-1">@FlareAppBot</CopyableCode> to your Telegram chat, and issuing this
            command:
        </div>
        <CopyableCode className="mt-2">/start@FlareAppBot {telegramInviteToken}</CopyableCode>
        <Alert className="mt-4">
            <i className="fas fa-spinner animate-spin mr-2" /> Waiting for connection
        </Alert>
    </div>
);

You don't need to understand the entire code. The important bit is that telegramChatId is used to decide which message is displayed in the UI. The telegramChatId is also put in a hidden form field that will get sent to the server when that form gets saved.

To poll for the chatId, we have this code a bit higher up in the same component.

useInterval(
    async () => {
        const response = await axios.post(telegramEndpoint);

        if (response.data.new_chat_id) {
            setTelegramChatId(response.data.new_chat_id);
        }
    },
    2000, // delay in milliseconds
    !telegramChatId,  // set to false to stop polling
);

Here you can see that we use a hand-rolled useInterval function that takes a function that uses axios to check if the chatId was received. setTelegramChatId is part of a React state hook that will set the telegramChatId.

Let's look at the code of that useInterval function.

import { useEffect, useRef } from 'react';

export default function useInterval(callback: Function, delay: number, enabled: boolean = true) {
    const savedCallback = useRef<Function>(() => {});

    useEffect(() => {
        savedCallback.current = callback;
    }, [callback]);

    useEffect(() => {
        function tick() {
            savedCallback.current?.();
        }

        let id: any;

        if (enabled && delay !== null) {
            id = setInterval(tick, delay);
        }

        return () => clearInterval(id);
    }, [delay, enabled]);
}

We're using a React effect here, which is used to handle side effects (in our case making API calls). Our callable that performs the axios request is wrapped in savedCallback.current and made callable via the tick() function. JavaScript's native setInterval is used to execute that tick every a few seconds (specified by delay).

The closure inside useEffect returns () => clearInterval(id). This is called the clean up function. It will be executed whenever one of the dependancies given to useEffect changes.

Whenever that axios call receives a response with a chatId, telegramChatId in the component that calls setInterval will be set to something, causing enabled to be set to false in setInterval. The dependencies of useEffect change, causing that clearInterval to be called. Pretty neat!

There's a bit more going in the useInterval, but let's ignore that for now. The extra cruft is needed to make useInterval work in hooks. If you want to know more details, read this extensive blog post on using setInterval in React hooks by Dan Abramov.

Start and stop polling in Livewire

The UI of Oh Dear is built using Livewire. Previously, I shared how we use Livewire to build complex forms in Oh Dear.

Here's how the Telegram notifications look like at Oh Dear. Just like on Flare, a user must copy/paste a specific token in a Telegram chat to get started. Notice that we are polling for an incoming chatId. When the component detects a chatId in the cache, a confirmation message is displayed, and the polling stops.

animation

Here's the Blade view of the TelegramChatIdComponent Livewire component that does the polling and renders the message.

<div class="mb-4">
    @if(! $chatId)
        <div
            wire:poll.keep-alive="pollForChatId()"
            class="p-4 bg-orange-200 border border-orange-300 rounded"
        >
            Perform these steps in the Telegram group where you want to receive notifications:

            <ul class="mt-2">
                <li>1. Add the Telegram bot "OhDearApp"</li>
                <li>
                    <div class="max-w-8">
                        2. Copy and paste this command

                        <x-jet-input type="text" readonly :value="' /start@OhDearAppBot ' . $token"
                                     class="mt-4 bg-orange-100 px-4 py-2 rounded font-mono text-sm border-orange-200 text-orange-900 w-full"
                                     autofocus autocomplete="off" autocorrect="off" autocapitalize="off"
                                     spellcheck="false"/>
                    </div>
                </li>
            </ul>
        </div>
    @else
        @if($isNewChatId)
            <div class="p-8 bg-green-200 border border-green-300 rounded text-green-900">
                You've connected Oh Dear to Telegram. You should now save this form.
            </div>
        @else
            <div class="p-8 bg-blue-200 border border-blue-300 rounded text-blue-900">
                Telegram has been connected.
            </div>
        @endif
        <input
            type="hidden"
            name="notification_destinations[{{ $index }}][destination][chat_id]"
            value="{{ $chatId }}">
    @endif
</div>

Livewire has built-in support for polling. Polling can be started by adding the wire:poll directive to an HTML element.

wire:poll.keep-alive="pollForChatId()

Our component will execute the pollForChatId() function every two seconds (that's Livewire's default polling frequency).

By default, Livewire will stop polling whenever the browser or tab where a component is displayed is not active. In our case, the user will probably have switched to Telegram, but we still want to have the confirmation message rendered as soon as possible. That's why we added the keep-alive option, which makes sure that we keep polling, even if the browser is not active.

Let's take a look at that pollForChatId() function on the TelegramChatIdComponent.

 public function pollForChatId()
{
    $cacheKey = $this->getInviteToken()->cacheKey();

    if ($chatId = cache()->get($cacheKey)) {
        $this->isNewChatId = true;

        $this->chatId = $chatId;

        cache()->forget($cacheKey);
    }
}

You can see that when we find a chatId in the $cacheKey of the cache, we set that value to $this->chatId. Because we change something to the public properties of the component, the Livewire component will re-render.

In the Blade view, you might have noticed that we only render that div containing the wire:poll directive whenever chatId isn't present.

@if(! $chatId)
    <div
        wire:poll.keep-alive="pollForChatId()"
        class="p-4 bg-orange-200 border border-orange-300 rounded"
    >
	{{-- other stuff omitted for brevity --}}
@endif

So with $chatId now set to a value, that div won't be rendered anymore. And because the div isn't rendered anymore, Livewire will stop polling. That's a pretty easy way to stop polling. To me, this is one of the many great details that Livewire creator Caleb Porzio added to Livewire. You don't have to think about it. Livewire does the right thing.

In closing

I hope you've enjoyed reading both approaches on how to start and stop polling. If you want to see this in action for yourself, create a free account on either Flare or Oh Dear. Both services offer a free 10-day trial.

Flare is the best exception tracker service for Laravel apps. Whenever an exception occurs in your production environment, we can send you a notification so that the exception doesn't go unnoticed.

Oh Dear is an all-in-one monitoring solution for your apps. It can notify you when your site is down, when your certificate is not ok, and whenever any of your app's pages contain broken links / mixed content. It can also monitor your scheduled jobs, and public status page, (like this one from Laravel), can be created.

Both services can notify you via Telegram and via mail, SMS, Slack, Webhooks, and a couple more services.

Did you notice that the React, PHP, JS and Blade code snippets in this post were perfectly highlighted? This is done using our free spatie/laravel-markdown package, which uses Shiki under the hood. Be sure to also take a look at our many other open source packages. Pretty sure there's something there for your next project.

(more…)

By , ago