将语雀文档导入Sapper站点

undefined August 30, 2020 View/edit this page on 语雀

Creative Commons License This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

本文的实际效果请参见: https://qutang.dev/blog/ny150b

阅读建议

  • 了解Javascript和Node.js,基本的前端开发概念,使用过至少一种前端框架
  • 知道静态网站是怎么回事
  • 了解过Svelte框架
  • 知道基本的HTTP请求原理

Sapper站点初始化

这里仅粗略介绍怎样快速初始化基于Sapper的站点。Sapper是类似Next.js的框架,由svelte驱动。svelte是和Vue.js和React类似的前端框架,不同在于svelte采用预编译并且绕过使用Virtual DOM,所以编译后的代码量更少,运行速度也更快。具体请戳Svelte官网Sapper官网

如果你装好了nodejs, 两分钟应该就能速度初始化一个站点。Sapper支持Code splitting,所以可以实现前后端使用同一套代码,并且支持静态站点导出。

npx degit "sveltejs/sapper-template#rollup" my-app
cd my-app
npm install
npm run dev
# you could open browser at http://localhost:3000 to see the site in dev mode

在Sapper中载入第三方数据

存储在 src/routes 目录下的 .svelte 文件会被自动编译为 .html 文件。具体操作见官方教程。

通过Preload机制载入外部数据

Preload机制可以同时在服务器端和浏览器端运行。下面的设置在 npm run dev 或者 npm run build & npm start 初始化时会先在服务端fetch相应的数据然后实时载入页面,但是完成载入后,当该页面被用户通过内部链接重新动态载入时(前往站点其他页面后再通过站内链接回来时)会在浏览器端fetch相应数据实时载入页面。在 npm run export 下Sapper会先fetch相应的数据然后据此生成静态页面。

假设我们想从语雀通过异步调用外部数据读取你的用户昵称到当前页面,标准操作如下。这种方式的问题在于在页面动态载入时代码会在浏览器端运行,这时会有CORS错误,因为调用的api域名跟网站运行的域名不一致。这个问题可以通过下一节的方法来解决。

另一个缺陷在于假如第三方API需要token授权,这种方式会导致token被暴露在服务器端,十分不安全。

// src/routes/index.svelte

<script context="module">
  export async function preload({ params, query }) {
    // fetch from yuque api
    console.log('Start fetching username');
    const res = await this.fetch(`https://www.yuque.com/api/v2/users/your-username`, {
		credentials: 'include',
		mode: 'no-cors',
		headers: {
'Content-Type': 'application/json',
'X-Auth-Token': 'your-yuque-private-token'
		},
	});
	if (res.status === 200) {
		const user = await res.json();
    console.log('Stop fetching username');
		return  { user };
	} else {
		console.log(res.err);
	}
  }
</script>

<script>
	export let user; //必须通过该语句来载入preload的user
</script>

<style>
	h1, figure, p {
		text-align: center;
		margin: 0 auto;
	}

	h1 {
		font-size: 2.8em;
		text-transform: uppercase;
		font-weight: 700;
		margin: 0 0 0.5em 0;
	}

	figure {
		margin: 0 0 1em 0;
	}

	img {
		width: 100%;
		max-width: 400px;
		margin: 0 0 1em 0;
	}

	p {
		margin: 1em auto;
	}

	@media (min-width: 480px) {
		h1 {
font-size: 4em;
		}
	}
</style>

<svelte:head>
	<title>Sapper project template</title>
</svelte:head>

<h1>Great success!</h1>

<figure>
	<img alt='Success Kid' src='successkid.jpg'>
	<figcaption>Have fun with Sapper!</figcaption>
</figure>

<div style='text-align: center;'>
	Yuque username: {user.data.name}
</div>

<p><strong>Try editing this file (src/routes/index.svelte) to test live reloading.</strong></p>

通过 json.js 来载入外部数据建立本地JSON API

这种方法的基本思路如下。

  1. 在服务器端通过http请求载入外部数据然后把结果保存到一个json文件里,这个json文件会放在跟站点其他文件同一根目录下(相同域名)。
  2. 通过上节的Preload机制在svelte文件里通过异步请求载入这个json文件的内容。


这种方法的好处是只需要在站点编译阶段调用第三方API获取数据(很多第三方API都有调用次数限制),之后站点通过本地的json文件来载入数据,并且因为只在服务器端运行,私人token不会暴露在浏览器端。这个本地的json文件类似于自己网站的API。这种方法的坏处就是不能实时更新第三方数据的变化,因为这要求重新编译网站。对于静态网站来说,可以通过连续部署(continuous integration)服务,比如免费的Github Actions,Circle CI等来实现周期性编译,达到更新第三方数据的效果。

对于Sapper来说,所有在 src/routes/ 目录下的 .json.js 文件都会自动编译为json文件并且生成一个可以通过http请求的endpoint,这些代码仅编译时在服务器端运行。下面我们在 about 页面里来实现。

首先,我们先安装一下 node-fetch 这个外部依赖,这是一个模拟浏览器端 fetch API的库,支持async/await语法。

npm install node-fetch

通过 node-fetch 在 about.json.js 里实现http请求。

// src/routes/about.json.js

const fetch = require("node-fetch");
const token = 'your-private-yuque-token';

async function fetchUserNickName() {
  let response = await fetch(`https://www.yuque.com/api/v2/users/your-username`, {
    method: "GET",
    headers: {
      "Content-Type": "application/json",
      "X-Auth-Token": token,
    },
  });
  const data = await response.json();
  const user = data.data;
  console.log(user);
  return user.name;
}

//这个get函数实际上就把这个about.json.js文件变成了一个可以请求的http endpoint。对应的地址就是about.json。
export async function get(req, res) {
  res.writeHead(200, {
    "Content-Type": "application/json",
  });
  const nickName = await fetchUserNickName();
  res.end(JSON.stringify(nickName));
}

运行 npm dev 后,在浏览器输入 https://localhost:3000/about.json 就能看到从语雀返回的昵称了。

接下来我们通过Preload在 about.svelte 里调用 about.json 来获得昵称。

// src/routes/about.svelte

<script context='module'>
	export async function preload({ params, query }) {
	// fetch from local json api
	  console.log('Start fetching nickname');
    const res = await this.fetch(`about.json`);
	if (res.status === 200) {
		const nickName = await res.json();
		console.log('Stop fetching nickname');
		return  { nickName };
	} else {
		console.log(res.err);
	}
  }
</script>

<script>
	export let nickName;
</script>

<svelte:head>
	<title>About</title>
</svelte:head>

<h1>About this site</h1>

<div>Yuque nick name: {nickName}</div>

<p>This is the 'about' page. There's not much here.</p>

运行 npm dev 后,在 https://localhost:3000/about 页面,可以看到以下效果。
Screenshot 2020-08-29 171852.png
当动态在 about 和 blog 页面切换时,能在Chrome的调试窗口里,看到下面的信息。
preload.gif
聪明的小朋友们可能已经想到了,通过这种方式,可以为任何外部数据建立本地的API。 这些数据可以是第三方网站提供,可以是任何类型的本地文件,比如Markdown, Jupyter notebook, Word等等,也可以是任何远程或者本地的数据库。你要做的就是用相应的代码读取或者拉取这些数据然后转换成json对象。
**

用语雀的Node SDK建立文档和知识库的JSON API

这个SDK其实就是一个容易使用的封装。首先通过 npm 安装语雀Node SDK.

npm i @yuque/sdk --save

这里我们把语雀的SDK调用封装成一个不依赖sapper的单独的js文件。在sapper里,这样的文件可以放到根目录下的任何位置。为了方便调用,这里把它放到 src/_plugins 目录下,创建一个 yuque.js 的文件。

初始化语雀SDK

// src/_plugins/yuque.js

class YuQue {
  constructor(token) {
    const SDK = require('@yuque/sdk');
    this._client = new SDK({
    	token: token || process.env.YUQUE_TOKEN, //如果不提供token,就检查环境变量里有没有YUQUE_TOKEN, 这里建议把token放到环境变量里,YUQUE_TOKEN是我自己定义的,你可以用任何你喜欢的变量名。
    });
  }
}

export { YuQue };

拉取知识库列表

// src/_plugins/yuque.js

class YuQue {
  constructor(token) {
    ...见上节
  }
  
  async getRepos() {
    let repos = await this._client.repos.list({ user: "your-user-id" }); //replace with your user id
    return repos;
  }
}

这个结果的json格式如下,这里需要重点关注的是 namespace 和 id ,我们需要通过它们之一来拉取文档列表和内容。

拉取文档列表

// src/_plugins/yuque.js

class YuQue {
  constructor(token) {
    ...见上节
  }
  
  async getRepos() {
    ...见上节
  }
  
  async getPosts(repo) {
    let posts = await this._client.docs.list({
      namespace: repo.namespace,
    });
    return posts;
  }
}

这个结果的json格式如下,这里需要关注的是 slug 值,我们需要通过它和上面的 namespace 来获取文档内容。

拉取文档内容

// src/_plugins/yuque.js

class YuQue {
  constructor(token) {
    ...见上节
  }
  
  async getRepos() {
    ...见上节
  }
  
  async getPosts(repo) {
    ...见上节
  }
  
  async getPost(namespace, slug) {
    let post = await this._client.docs.get({
      namespace: namespace,
      slug: slug,
      data: {
        raw: 1, //注意这里要设为1,这样可以拉取Markdown格式的博文内容
      },
    });
    return post;
  }
}

这个结果的json格式如下,这里重点关注 body 值,我们可以通过它得到Markdown源代码的字符串值,然后可以通过其他Markdown parser(比如Marked.js)来将它转换成html载入页面。这里 body_html 的值是yuque上的显示效果,不适用于自己的静态站点。

完整的yuque.js

// src/_plugins/yuque.js

class YuQue {
  constructor(token) {
    const SDK = require("@yuque/sdk");
    this._client = new SDK({
      token: token || process.env.YUQUE_TOKEN, //如果不提供token,就检查环境变量里有没有YUQUE_TOKEN, 这里建议把token放到环境变量里,YUQUE_TOKEN是我自己定义的,你可以用任何你喜欢的变量名。
    });
  }

  async getAllPosts(userId) {
    const repos = await this.getRepos(userId);
    let posts = await Promise.all(
      repos.map(async (repo) => {
        console.log(`fetch posts for ${repo.namespace}`);
        let repoPosts = await this.getPosts(repo);
        repoPosts = await Promise.all(
          repoPosts.map(async (post) => {
            return await this.getPost(repo.namespace, post.slug);
          })
        );
        return repoPosts;
      })
    );
    posts = await [].concat(...posts);
    return posts;
  }

  async getRepos(userId) {
    //replace with your user id
    let repos = await this._client.repos.list({ user: userId });
    return repos;
  }

  async getPosts(repo) {
    let posts = await this._client.docs.list({
      namespace: repo.namespace,
    });
    return posts;
  }

  async getPost(namespace, slug) {
    let post = await this._client.docs.get({
      namespace: namespace,
      slug: slug,
      data: {
        raw: 1, //注意这里要设为1,这样可以拉取Markdown格式的博文内容
      },
    });
    return post;
  }
}

export { YuQue };

src/routes/blog/index.json.js引用 yuque.js 来拉取文档列表

// src/routes/blog/index.json.js

import { YuQue } from "../../_plugins/yuque";

const token = process.env.YUQUE_TOKEN;

const yuque = new YuQue(token);

async function getPosts() {
  let repos = await yuque.getRepos("qutang");
  let posts = await Promise.all(
    repos.map(async (repo) => yuque.getPosts(repo))
  );
  posts = await [].concat(...posts);
  return posts;
}

export async function get(req, res) {
  res.writeHead(200, {
    "Content-Type": "application/json",
  });

  let posts = await getPosts();

  res.end(JSON.stringify(posts));
}

相应的在 src/routes/blog/index.svelte 调用这个对应的API显示文档列表。

// src/routes/blog/index.svelte

<script context="module">
	export function preload({ params, query }) {
		return this.fetch(`blog.json`).then(r => r.json()).then(posts => {
return { posts };
		});
	}
</script>

<script>
	export let posts;
</script>

<style>
	ul {
		margin: 0 0 1em 0;
		line-height: 1.5;
	}
</style>

<svelte:head>
	<title>Blog</title>
</svelte:head>

<h1>Recent posts</h1>



<ul>
	{#each posts as post}
		<!-- we're using the non-standard `rel=prefetch` attribute to
	tell Sapper to load the data for the page as soon as
	the user hovers over the link or taps it, instead of
	waiting for the 'click' event -->
   <!-- 这里我们把知识库的id也放到地址里,这样对应的[...slug].svelte和[...slug].json.js才能得到相应的信息来拉取博文内容 -->
		<li><a rel='prefetch' href='blog/{post.book_id}/{post.slug}'>{post.title}</a></li>
	{/each}
</ul>

运行 npm dev 后, https://localhost:3000/blog 的显示效果如下,
Screenshot 2020-08-29 211025.png

src/routes/blog/[...slug].json.js 里引用 yuque.js 来拉取文档内容

import { YuQue } from "../../_plugins/yuque";

async function getPost(namespaceId, slug) {
  const post = await new YuQue().getPost(namespaceId, slug);
  return post;
}

export async function get(req, res, next) {
  // the `slug` parameter is available because
  // this file is called [slug].json.js
  const [namespaceId, slug] = req.params.slug;
  const post = await getPost(namespaceId, slug);

  res.writeHead(200, {
    "Content-Type": "application/json",
  });

  res.end(JSON.stringify(post));
}

相应的 src/routes/blog/[...slug].svelte 文件调用这个本地JSON API来载入文档内容。

<script context="module">
	export async function preload({ params, query }) {
		// the `slug` parameter is available because
		// this file is called [...slug].svelte
		console.log(params);
		const [namespaceId, slug] = params.slug;
		const res = await this.fetch(`blog/${namespaceId}/${slug}.json`);
		const data = await res.json();

		if (res.status === 200) {
return { post: data };
		} else {
this.error(res.status, data.message);
		}
	}
</script>

<script>
	export let post;
</script>

<style>
	/*
		By default, CSS is locally scoped to the component,
		and any unused styles are dead-code-eliminated.
		In this page, Svelte can't know which elements are
		going to appear inside the {{{post.html}}} block,
		so we have to use the :global(...) modifier to target
		all elements inside .content
	*/
	.content :global(h2) {
		font-size: 1.4em;
		font-weight: 500;
	}

	.content :global(pre) {
		background-color: #f9f9f9;
		box-shadow: inset 1px 1px 5px rgba(0,0,0,0.05);
		padding: 0.5em;
		border-radius: 2px;
		overflow-x: auto;
	}

	.content :global(pre) :global(code) {
		background-color: transparent;
		padding: 0;
	}

	.content :global(ul) {
		line-height: 1.5;
	}

	.content :global(li) {
		margin: 0 0 0.5em 0;
	}
</style>

<svelte:head>
	<title>{post.title}</title>
</svelte:head>

<h1>{post.title}</h1>

<div class='content'>
  <!-- 这里我们使用body_html来直接显示跟语雀上效果类似的内容,要想自定显示效果,请使用markdown库来解析body的内容把它转换为html然后再通过css来自定义显示效果。 -->
	{@html post.body_html}
</div>

运行 npm dev 后, http://localhost:3000/blog/1687847/ny150b  的显示效果如下,
Screenshot 2020-08-30 005106.png

扩展与思考

  1. 由于语雀的知识库和文档类型不只是 book ,怎样改良 yuque.js 让它只拉取一部分想要的文档?
  2. 怎样利用本地缓存来减少开发过程中由于热重载而频繁出现的调用语雀API的情况?
  3. 试一试静态导出后的效果,还能通过http访问对应的json地址吗?还需要每次进入页面都从json载入这些数据吗?
  4. 怎样用环境变量来存储语雀的token来提高安全性?千万不要把token直接嵌入代码,然后放到公共的代码托管库里!
  5. 怎么使用 marked.js 来解析拉取的 post.body 文档的markdown源代码,并把它转化为html然后显示在网页里?