Miguel Piedrafita

Miguel Piedrafita

Building an experimental book-reading experience with AMP Stories

I've been doing this live-tweeting thing for almost a week now, and I'm really into it. Last Saturday, I live-tweeted my work as I upgraded Sitesauce's billing system to deal with VAT. On Sunday, it was an affiliate system, also for Sitesauce. Monday and Tuesday was spent on building a small chrome extension to display my Things' tasks in every tab I open.

On Wednesday, I decided to take on another small side project. It was a special occasion for my girlfriend and me, and I had bought her a book (in pdf format). The issue is that she doesn't have an ebook, and reading books on a computer is not a great experience. With only six hours before meeting her, I decided to get to work in crafting a unique book-reading experience for mobile devices.

Taking into account the time I had, I decided to go for a simple, story-like UI where each paragraph, quote or illustration is shown on a different slide.

Before starting to build this from scratch though, I decided to do a quick round of research to see if something like this already existed. I recalled seeing some story-building apps, but wasn't sure if they'd allow me to build what I had in mind.

After some research, I found there's a standard for web stories (part of AMP), and a few platforms that provide drag-and-drop experiences on top of the format. I ended up deciding to roll my own solution, since I wanted to automate parts of the process and not build everything by hand, but if you are looking to build something less complex, this article by Samuel Schmitt contains a great overview of all the alternatives, and the pros and cons of each.

The next step was to dig into the Web Story Spec. Before reading that, however, I found Google has an interactive guide on building your first Web Story, so I rebuilt their quickstart example to get used to the format.

With some practice and understanding of how the format worked, I decided to start by designing a cover for the book. It took me more time than I expected, since I was still quite new to their way of positioning elements using layers, but ended up getting to something I liked.

After that was done, I went on to design the table of contents for the book, which I wanted to look different from the rest of the book (which would be generated with templates). Since I already had some practice from the cover, it took me less time to get to something I liked.

With those two pages out of the way, I started designing the templates that would be reused throughout the book. I tried to base my design on the original book as much as possible, to the extent of extracting and vectorizing the background to port it to my version.

I tried to find a tool that would allow me to extract the contents of the pdf into a format I could work with, but couldn't find anything that would allow me to preserve bold or italics or that would handle images, so I ended up doing it myself.

Yes, I went through a 78-page document, copying each paragraph separately and formatting it. Would not recommend it.

Taking into account the amount of text I needed to migrate, I structured the content in a way that'd allow me to go faster, and figured I'd solve all the issues (mixing associative arrays with indexed arrays, for example) in post. I also grouped the contents not only by chapter but also by page, as a way to quickly jump between the PDF and the web version, but never ended implementing it. Here's how the format looks:

<?php

return [
    'This is the first chapter', // First Chapter title
    [ // First Chapter Contents
        [ // First Page Contents
            'Each paragraph on each page is shown on a different "slide", and is a separate string in the page array.',
            'When text has special styles, like bolding or italics, I use the standard HTML tags. For example <b>bold text</b>, <i>italized text</i> or <del>some crossed-out text here</del>. We dont even need to style them, since the browser already does it for us!',
            ['type' => 'quote', 'The book has this cool "quote" section, so I built a special style for them'],
        ], [ // Second Page Contents
            ['type' => 'title', "Here be title for this page"],
            ['type' => 'advice', "Please dont waste your time on trying to reproduce a book on array form like I did."],
            'The book also had this little "advice" snippets, which I made some special styles for. I also made images, which take a path to the file relative to the assets folder.',
            ['type' => 'image', 'image.png'],
        ]
    ],
    'This would be the second chapter', // Second Chapter title
    [ // Second Chapter Contents
        [ // Third Page Contents
            'But I got bored and stopped writing placeholder text'
        ]
    ]
];

Let's now convert that lisp-y mess into something we can work for. We'll use Laravel Collections, which we can use outside of Laravel by pulling in illuminate/support. We'll also auto-generate an id for each block, required by AMP to preserve position between page loads.

$bookData = collect(require_once('./demo.php'))->map(function ($chapter) {
    if (is_string($chapter)) {
        return [
            [
                'id' => Str::slug($chapter),
                'type' => 'section',
                'title' => $chapter,
            ]
        ];
    }

    return collect($chapter)->flatten(1)->map(fn ($section) => [
        'id' => Str::slug(Str::words(
            $contents = is_string($section) ? $section : $section[0],
            10, ''
        )),
        'type' => is_string($section) ? 'paragraph' : $section['type'],
        'contents' => $contents,
    ])->toArray();
})->flatten(1);
Here's how the result looks

Next step: using our data to generate our HTML. We'll again grab some boilerplate code from Matt Stauffer's Torch project (Illuminate components outside of Laravel) to kickstart Blade. Here's the code that makes it work:

<?php

require_once 'vendor/autoload.php';

use Illuminate\Support\Str;
use Illuminate\View\Factory;
use Illuminate\Events\Dispatcher;
use Illuminate\Container\Container;
use Illuminate\View\FileViewFinder;
use Illuminate\Filesystem\Filesystem;
use Illuminate\View\Engines\PhpEngine;
use Illuminate\View\Engines\CompilerEngine;
use Illuminate\View\Engines\EngineResolver;
use Illuminate\View\Compilers\BladeCompiler;

// Configuration
$pathsToTemplates = [__DIR__ . '/views'];
$pathToCompiledTemplates = __DIR__ . '/compiled';

// Dependencies
$filesystem = new Filesystem;
$eventDispatcher = new Dispatcher(new Container);

// Create View Factory capable of rendering PHP and Blade templates
$viewResolver = new EngineResolver;
$bladeCompiler = new BladeCompiler($filesystem, $pathToCompiledTemplates);

$viewResolver->register('blade', fn () => new CompilerEngine($bladeCompiler));
$viewResolver->register('php', fn () => new PhpEngine);

$viewFinder = new FileViewFinder($filesystem, $pathsToTemplates);
$viewFactory = new Factory($viewResolver, $viewFinder, $eventDispatcher);

// Render book.blade.php template
echo $viewFactory->make('book', [])->render();

Then we just need to loop through our $bookData array on Blade, and render the template for the type of content we're trying to display. To make it cleaner, I've extracted all templates to partials we can include.

<amp-story standalone
    publisher="Gal Shir"
    title="View Insignts"
    publisher-logo-src="logo.png"
    poster-portrait-src="poster-portrait.png"
>
    @foreach ($bookData as $section)
        @include("partials.{$section['type']}", $section)
    @endforeach
</amp-story>

Finally, since the script is just echoing a view, we can get a static version by simply outputting the contents of our index to an HTML file, which is really cool.

php index.php > index.html

If you want to the end result out, I've published a demo version on Vercel. I've also uploaded the source to my GitHub, so you can play around with it and adapt it to your use-case.

This article, like the others mentioned at the start of it, started as a Twitter thread. You can follow me if you want to see me live-tweet building more cool stuff. And, if you'd like to see something like this in video format, you can subscribe to my YouTube channel. There's not much there yet, but I'm planning to produce some original content this summer. Have a great day!