tighten / jigsaw

Simple static sites with Laravel’s Blade.
https://jigsaw.tighten.com
MIT License
2.13k stars 182 forks source link

Way to use internal links for a site not in the domain root? #206

Closed lxqueen closed 6 years ago

lxqueen commented 6 years ago

So I'm trying to build a site, with the final production URL being at https://example.com/wiki/. The only issue I'm having is trying to link internally to pages doesn't work in Markdown, as using [link text](/link/) would just lead you to the domain root, resulting in https://example.com/link/ instead of https://example.com/wiki/link/ in the final version.

As far as I'm aware, there are a few ways to fix this, but I don't think either is particularly ideal:

  1. Hardcode each URL in the Markdown to use [link text](/wiki/link/), which is annoying for trying to run locally or if I need to move my site in any form, local or production.
  2. Convert each Markdown file to use .blade.md and have links as [link text]({{ $page->baseUrl }}link/) or [link text]({{ $page->baseUrl . 'link/' }}), which while functionally works, isn't particularly pretty for readability when I'm throwing in heaven-knows-how-many links.
  3. Same as option 2, but using [link text]({{ $collection->get('link')->getUrl() }}).

I was considering fixing this with the new hooks available since 1.1, and perhaps doing a find-replace on links that use a custom scheme (replacing links with something like local:// or jigsaw:// in the Markdown with the real base URL automatically on build), but haven't really figured out how to do this.

I don't know if this is really in scope or too niche of a use-case for this generator, but it was worth asking. Perhaps exposing FrontYAML/Parsedown in a similar method to how the Jigsaw and Blade classes are now would do it?

Thank you so much for your time, I've been using Jigsaw a lot recently for static sites and it is truly amazing, especially with the recent Mix support :smile:

damiani commented 6 years ago

Yes, the simplest way to do this would be to use .blade.md files. You can clean it up a little bit by adding a global helper function to config.php so you don't have to handle the link concatenation in your templates. Something like:

return [
    'baseUrl' => 'https://example.com/wiki',
    'link' => function ($page, $link) {
        return $page->baseUrl . '/' . trim($link, '/');
    },
];

which would clean up your templates a tiny bit: [link text]({{ $page->link('link') }}).

I think that would be much easier than running through each of your files in an afterBuild event listener.

You could extend the markdown parser and add a custom rule (see this PR for an example on how to do that), but bear in mind that you wouldn't have access to any environment-specific config values (if you put a baseUrl in config.production.php, for instance). So I'd still recommend the blade.md solution.

lxqueen commented 6 years ago

Hi @damiani, thank you very much! This does seem like the easiest way to do it right now, time for me to go through my wiki and sort out all the links!

Shall I leave this issue up to you and the other devs' judgment on if they want to close this or keep it open, in case there's any other input people have? I don't mind either way :smiley:

cossssmin commented 6 years ago

Had the same issue.

In my case, I need the user to be able to write simple, relative links in Markdown, i.e. [click here](/slug/#anchor) instead of using Blade - mainly because I need to keep things simple for the casual user, who can use anchors and an 'offline' build script that always uses non-pretty URLs, which complicates things a lot.

I just extended the Markdown parser and overrode the inlineLink method, where I get the Jigsaw config through dependency injection from another class, check the environment, and massage the relative link accordingly (i.e. for pretty URLs it outputs ../slug/#anchor and for non-pretty, ../slug.html#anchor. Also doing other stuff like adding target="_blank" and rel="noopener" to external URLs, as well as custom classes.

If you're thinking of going that route, do keep in mind that the inlineLink method applies to URLs in both text links and image markdown, so you're going to have to check if it's an image path...

On this note, it would be useful if Jigsaw used Parsedown Extra. Mni\FrontYAML\Markdown\MarkdownParser's interface would allow for a bridge, but I guess this is a topic for a separate issue :)

damiani commented 6 years ago

@hellocosmin Would you be willing to share some of your code here so that @lxqueen (and others who encounter this need) have some guidance if they want to go the inlineLink route?

And I agree about using Parsedown Extra. I'm going to do some testing first to make sure it doesn't break anything, before switching it to be the default parser.

cossssmin commented 6 years ago

Sure thing @damiani, and big thanks for looking into Parsedown Extra! Have a few things on my plate right now, but will post the code today.

cossssmin commented 6 years ago

Right, so I initially started from this article, which explains how to add build-time syntax highlighting. You basically need to override the default Parsedown parser with a custom implementation.

Although the article explains autoload and such, I'll go through each step:

1. Namespacing

We'll be working under the App namespace, so we need to autoload it. Add a composer.json in the project root:

{
    "require": {
        "tightenco/jigsaw": "^1.0"
    },
    "autoload": {
        "psr-4": {
            "App\\": "app/"
        }
    }
}

2. Saving the Jigsaw config

We need a way to get the config outside of the build process. What I'm doing is to dump the config in a JSON file, which I can later read and retrieve settings from. Thankfully, Jigsaw now provides 'events', which makes this very easy.

I'm saving the config in the _assets folder, so that it doesn't automatically get copied over at build time.

3. Getting the Jigsaw config

We'll also need a way to get that config we just saved. Totally up to you - I just created another class with a get() method on it:

// app/GetJigsawConfig.php

<?php

namespace App;

class GetJigsawConfig {

    public function get()
    {

        if (file_exists('source/_assets/data/config.json')) {
            return json_decode(file_get_contents('source/_assets/data/config.json'));
        }

        return false;
    }

}

4. Custom Parsedown implementation

Now comes the fun part. We need to create a class that implements the default Parsedown parser used by Jigsaw, and provide it with our own custom implementation (where we can extend Parsedown and basically override its functions).

  1. Create app/ParsedownParser.php:

    // app/ParsedownParser.php
    
    <?php
    
    namespace App;
    
    use Mni\FrontYAML\Markdown\MarkdownParser;
    use App\CustomParsedown;
    
    class ParsedownParser implements MarkdownParser
    {
    
        /**
         * ParsedownParser constructor.
         */
        public function __construct()
        {
            $this->parser = new CustomParsedown();
        }
    
        /**
         * @param  string $markdown
         * @return  string
         */
        public function parse($markdown)
        {
            return $this->parser->text($markdown);
        }
    }
  2. App\CustomParsedown will be our custom Parsedown implementation, let's create it:

    // app/CustomParsedown.php
    
    <?php
    
    namespace App;
    
    use App\GetJigsawConfig;
    use Parsedown as BaseParsedown;
    
    class CustomParsedown extends BaseParsedown
    {
        protected $config;
    
        public function __construct()
        {   
            $jigsawConfig = new GetJigsawConfig();
            $this->config = $jigsawConfig->get();
        }
    }

    As you can see, our class is extending Parsedown - this allows us to manipulate Parsedown's output by overriding its methods.

    What we're doing here (for now) is getting the Jigsaw config we saved earlier through dependency injection, so that we can access config keys like $this->config->pretty or $this->config->baseUrl.

5. Overriding Parsedown methods

Parsedown has an example of doing this. For our links, we'll need to override the inlineLink() method.

Caveat: the URL/path you provide when writing something like [link](some-path/) is used by Parsedown for both links and image src's (think ![alt text](/path/to/image.jpg)). So we need to make sure we're not applying our transformations to images - and that's what the $ext variable is for.

The following is not very handsome, I agree - I just made it work, so don't hate :)

We will be doing the following:

OK, enough talk, change app/CustomParsedown.php to look like this:

// app/CustomParsedown.php

<?php

namespace App;

use App\GetJigsawConfig;
use Parsedown as BaseParsedown;

class CustomParsedown extends BaseParsedown
{
    protected $config;

    public function __construct()
    {   
        $jigsawConfig = new GetJigsawConfig();
        $this->config = $jigsawConfig->get();
    }

    /**
     * Extra link handling
     * @param  array $Excerpt
     * @return array
     */
    protected function inlineLink($Excerpt)
    {
        $Link = parent::inlineLink($Excerpt);
        if (! isset($Link)) {
            return null;
        }

        $isAnchorLink = false;
        $href = $Link['element']['attributes']['href'];
        $ext = strtolower(pathinfo($href, PATHINFO_EXTENSION));
        $isImage = in_array($ext, ['gif', 'jpg', 'jpeg', 'png', 'svg']);

        // 1. Add target and rel to external links
        if ($this->isExternalUrl($href) && ! $isImage) {
            $Link['element']['attributes']['target'] = '_blank';
            $Link['element']['attributes']['rel'] = 'noopener';
        }

        else {

            // 2. Add scroll-to class to anchor links
            if (preg_match('/#(.+)/', $href, $matches)) {
                $Link['element']['attributes']['class'] = 'scroll-to';
                $isAnchorLink = true;
            }

            // 3. Correct relative paths based on build environment
            if (! $isImage && $this->config) {
                if (! $this->config->pretty) {
                    $Link['element']['attributes']['href'] = str_replace('/', '.html', ltrim($href, '/'));
                } else {
                    // Set href depending on whether it's a link to some-other-page/#anchor or just an in-page #anchor
                    if (preg_match('/^(.+)?(?=#)/', $href, $hits)) {
                        $href = $hits[0] ? '../' . $href : $href;
                    }
                    $Link['element']['attributes']['href'] =  $href;
                }
            }

        }

        return $Link;
    }

    /**
     * Check if a URL is internal or external
     * @param string $url
     * @param null $internalHostName
     * @return bool
     */
    protected function isExternalUrl($url, $internalHostName = null) {
        $components = parse_url($url);
        $internalHostName = !$internalHostName && isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : $internalHostName;
        // we will treat url like '/relative.php' as relative
        if (empty($components['host'])) {
            return false;
        }
        // url host looks exactly like the local host
        if (strcasecmp($components['host'], $internalHostName) === 0) {
            return false;
        }

        $isNotSubdomain = strrpos(strtolower($components['host']), '.'.$internalHostName) !== strlen($components['host']) - strlen('.'.$internalHostName);

        return $isNotSubdomain;
    }
}

6 . Update bootstrap.php

Finally, we need to update bootstrap.php and bind our custom implementation to the container. It should look like this now:

<?php

use App\ParsedownParser;
use App\Listeners\JigsawConfig;
use Mni\FrontYAML\Markdown\MarkdownParser;

/** @var $container \Illuminate\Container\Container */
/** @var $events \TightenCo\Jigsaw\Events\EventBus */
/** @var $jigsaw \TightenCo\Jigsaw\Jigsaw */

$container->bind(MarkdownParser::class, ParsedownParser::class);

$events->beforeBuild(JigsawConfig::class);

That's all, hope I didn't forget anything.

Oh, right, one last thing: to make it easier to test the non-pretty URL handling, I added an extra script in package.json:

"offline": "cross-env NODE_ENV=offline node_modules/webpack/bin/webpack.js --progress --hide-modules --env=offline --config=node_modules/laravel-mix/setup/webpack.config.js",

And, of course, a config.offline.php file, where I set the pretty URLs to false:

<?php

return [
    'pretty' => false,
];
damiani commented 6 years ago

@hellocosmin Thanks so much for documenting this, it will be very helpful for others seeking to perform advanced markdown customizations!