zzxworld

使用 Typora 作为博客文章编辑器的体验

上周,我开始给自己这个小站点添加文章管理功能。文章管理一定离不开编辑功能,作为一次全新的尝试,我决定使用 Typora 作为本网站的文章编辑工具。这篇文章记录了我的过程经历和体验。

源起

使用 Typora 的目的很简单,就是我懒,不想在网站文章编辑的后台引入一个臃肿的编辑器。但我又希望有一个不错的写作体验。如果要达到这个目的,最理想的方案就是自己来撸一个。这从时间投入上来衡量,显然是一件得不偿失的事情。

所以我开始从众多成熟的桌面端 Markdown 编辑器中来寻找,期望有一个能满足自己当前的需求。这个编辑器除了要有良好的 Markdown 写作体验,还要支持脚本扩展的方式让我把想要发布的文章推送到自己网站上去。最终我发现 Typora 可以。

编辑和上传文章

Typora icon

Typora 是一个优秀的桌面端 Markdown 编辑器软件。它不仅提供了完善的 Markdown 撰写功能,还有丰富又灵活导出方式。而我正是要利用这个导出方式的自定义功能来实现文章从本地到网站的上传。实现这个目的的过程如下。

首先打开 Typora 的配置界面,找到导出设置项。点加号添加新的导出项,添加的模版选择自定义。

Typora 导出配置界面

这里面的重点就是 Command 配置项,它可以输入一个自定义的 Shell 命令。Typora 提供了一个占位符可以替换为要操作文章的路径。我在这个配置项里输入的自定义命令如下:

cd /home/zzxworld/Projects/homepage && \
   /usr/bin/php typora-publish.php ${currentPath}

关于这个命令的几点说明:

  • /home/zzxworld/Projects/homepage 是我博客项目的主目录
  • typora-publish.php 是我写的 Shell 脚本
  • ${currentPath} 是 Typora 提供的占位字符,表示当前操作的文章

因为我写的 Shell 脚本放在项目主目录中,所以上面的命令很好理解。就是跳转到项目主目录中,通过执行我的 Shell 脚本,把指定的文章上传到我的网站。

完成以上步骤后,回到文章编辑界面。以后只需要通过选择文件菜单下的导出项,然后选择自定义的那个导出条目就可以实现文章的上传了。

Typora 自定义导出菜单

Shell 脚本

除了 Typora 提供的导出功能,自定义的 Shell 功能也是整个流程中的关键部分。下面是我目前使用中的 Shell 脚本,实现了文章数据的解析,图片上传和文章提交的功能。

#!/usr/bin/env php
<?php

require __DIR__.'/vendor/autoload.php';

// 使用 Dotenv 实现和 Laravel 项目共用 .env 配置
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();

// 线上服务接口地址
define('API_URL', $_ENV['APP_URL']);
// 线上服务接口令牌
define('API_TOKEN', $_ENV['APP_TOKEN']);

/**
 * 解析文章标题和内容
 */
function parse_content($filename)
{
    echo '开始解析文章...';

    $title = 'unknow title';
    $content = '';
    $pictures = [];

    $fp = fopen($filename, 'rb');
    $pos = 0;
    while(!feof($fp)) {
        $line = fgets($fp);

        if (preg_match('/^# (.+)/', $line, $match)) {
            $title = $match[1];
        } else {
            if (preg_match('/\!\[.+\]\((.+)\)/', $line, $match)) {
                $pictures[] = trim($match[1]);
            }

            $content.= $line;
        }

        $pos++;
    }
    fclose($fp);

    if ($title) {
        echo '成功.'.chr(10);
    } else {
        die('失败(没有标题).'.chr(10));
    }

    return [
        'title' => trim($title),
        'content' => trim($content),
        'pictures' => $pictures,
    ];
}

/**
 * 上传图片
 */
function upload_picture($data)
{
    if ($data['pictures']) {
        echo '开始上传图片:'.chr(10);

        foreach ($data['pictures'] as $filename) {
            $cf = new CURLFile($filename);
            $ch = curl_init();

            curl_setopt_array($ch, [
                CURLOPT_URL => API_URL.'/api/picture',
                CURLOPT_POST => true,
                CURLOPT_POSTFIELDS => [
                    'api_token' => API_TOKEN,
                    'resource' => $cf,
                ],
                CURLOPT_RETURNTRANSFER => true,
            ]);

            echo '    - 上传 '.$filename.' ...';

            $result = curl_exec($ch);
            curl_close($ch);

            $response = json_decode($result, true);

            if (isset($response['url'])) {
                $data['content'] = str_replace($filename, $response['url'], $data['content']);
                echo '成功'.chr(10);
            } else {
                echo '失败'.chr(10);
            }
        }
    }

    return $data;
}

/**
 * 上传文章
 */
function upload_post($data)
{
    echo '开始提交文章...';

    $ch = curl_init();

    curl_setopt_array($ch, [
        CURLOPT_URL => API_URL.'/api/post',
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => [
            'Accept: application/json',
        ],
        CURLOPT_POSTFIELDS => [
            'api_token' => API_TOKEN,
            'title' => $data['title'],
            'content' => $data['content'],
        ],
        CURLOPT_RETURNTRANSFER => true,
    ]);

    $result = curl_exec($ch);
    $errorCode = curl_errno($ch);
    $errorMessage = curl_error($ch);
    curl_close($ch);

    $response = json_decode($result, true);

    if (isset($response['post_id'])) {
        echo '成功.'.chr(10);
        echo '文章 ID: '.$response['post_id'].'.'.chr(10);
    } else {
        echo '失败.'.chr(10);
    }

    return $data;
}

if (count($argv) != 2) {
    die('usage: '.$argv[0].' [filename]'.chr(10));
}

$filename = $argv[1];
if (!file_exists($filename)) {
    die('File('.$filename.') does not exists.'.chr(10));
}

$data = parse_content($filename);
$data = upload_picture($data);

upload_post($data);

关键代码都有注释,而且功能也不复杂,所以就不多做解释了。除了这份脚本,线上服务还提供了两个接口,一个用来接收文章中上传的文件。另外一个用来接收提交的文章。

使用体验

Typora 提供的良好写作感让我在敲文字时很尽兴。这一点比我尝试过的各种基于浏览器之上编辑器体验感要好很多。通过自定义脚本实现文章的无缝上传后,感觉更加舒服和流畅。

但针对已经上传的文章,如果需要修改,目前这套流程就歇菜了。所以后续我可能会尝试一下给文章添加唯一标记,把编辑流程也纳入进来。