- A true CMS
- What I need for backend
- What I need for front-end
- Actual codes for backend
- Dependencies for API
- To return unrendered Markdown when called
- Add Backend UI for API
- To save and delete file
- Actual codes for front-end
- To call API and Markdown
- Points to notice when rendering Markdown
- Add smooth scrolling effect
- Here is what it looks like
- Experience from this
A true CMS
This is a translation of my original article 给Nuxt使用的基于API的CMS——还有管理后台呢.
Github repo: https://github.com/c53hzn/april-cms
Last year when I started to learn Nuxt
, I read a lot about Headless CMS
, it can be called a CMS without front-end page design
according to my understanding. I studied some of these projects and methods to use them, and tried to use one myself, but did not make it to the end. I still wanted to build up my website without relying on these external services. Although for comment system and visitor counter I might still be using external services as long as I'm building static website.
Then I managed to write an API
to serve as a CMS
, you can refer to this article.
This API
allows me to update my Markdown
articles and use Nuxt
to generate static page files, then I can push them to Github and update my website.
Usually I use Sublime Text
to write codes, this editor can show working documents on the left, and it is easy to switch between files.
But humans tend to go on a hard way.
by Me
I want a CMS
with an interface, and I can create/ modify/ read/ save/ delete in that interface. Also I don't want a database, I just want to work on the Markdown
files.
I think I can do it.
What I need for backend
I wrote my API
with express
framework under Node
environment, and I still want to achieve more with the same structure, so there are a few methods that I need to master, like:
- Make the
API
to read static HTML page and return aCMS
UI on the browser. - When the
API
is started, the browser will automatically be opened and go to theCMS
control panel. - Make the
API
to receive data sent from browser(clients) and save on local computer. - Make the
API
to receive data sent from browser and delete related file.
What I need for front-end
I had a CodePen project Markdown Previewer that only has fewer than 5 visits in the last half year. It would be a shame to leave it there, and it might be better if I can use it here.
The CodePen project was rendered with marked
, while my API was rendered with markdown-it
. In order to consolidate my codes, I decided to change the renderer to markdown-it
.
The project was written with jQuery
, I decided to continue using jQuery
for the front-end page interaction after some comparison.
Here are some issues that we need to solve:
- To read article list from
API
and render it in article list ares. - To read article contents from
API
and fill in title/ tags/ description/ related blogs/ main contents to relevant input box or textarea, and then the preview area - Every time there is a change in the
Markdown
editor, the preview area should be updated simultaneously, it also should have code highlight and normal HTML styles. - When
Save post
is clicked, the page should confirm is all input boxed are filled, ifNo
then page willalert
that user should fill in all blanks. IfYes
then check if article already exists, ifYes
thenconfirm
if need to update post, ifNo
thenconfirm
if need to create new post. - When
Delete post
is clicked, the page should crosscheck ifslug
exists in post list, ifNo
thenalert
that there is nothing to delete, ifYes
thenconfirm
if really need to delete.。 - When
Ctrl+enter
is pressed on keyboard,save
action should be triggered. - When creating or updating, the browser will use
post
method injQuery
to submit contents to server and receive result and reflect it to browser. - When deleting, the browser will use use
post
method injQuery
to submit theslug
of the post to server, then receive result of deletion and reflect it to browser.
All actions should be completed in the same page.
Actual codes for backend
Dependencies for API
First require
all the dependencies and other required modules.
var express = require("express");
var fs = require("fs");
var fm = require('front-matter');
var hljs = require('highlight.js');
var yaml = require("yaml");
var markdownIt = require('markdown-it');
var markdownItToc = require('markdown-it-toc');
var open = require("open");
var config = require(__dirname+"\\config.js");
var app = express();
var md = markdownIt({
html: false, // 在源码中启用 HTML 标签
xhtmlOut: false, // 使用 '/' 来闭合单标签 (比如 <br />)。
breaks: false, // 转换段落里的 '\n' 到 <br>。
langPrefix: 'language-', // 给围栏代码块的 CSS 语言前缀。对于额外的高亮代码非常有用。
linkify: false, // 将类似 URL 的文本自动转换为链接。
typographer: false,
quotes: '“”‘’',
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(lang, str).value;
} catch (__) {}
}
return ''; // 使用额外的默认转义
}
});
md.use(markdownItToc);
The config
here is an extra configuration file that I added, the contents inside it is simple:
var config = {
isSlugUseDate: false,
blogPath: __dirname + '\\blog',
imgPath: __dirname + '\\img\\blog'
};
module.exports = config;
This config
is to control the Markdown
file path and image file path, and also whether to use date when it comes to slug
. If slug
uses date, then it will be a long slug
, like 2020-04-26-editor-for-my-cms-of-my-nuxt-blog-en
. If slug
does not use date, then it will be a short slug
, like editor-for-my-cms-of-my-nuxt-blog-en
, and the real blog URL
will also be changed with it.
To return unrendered Markdown when called
First do some settings in the API
entry point file app.js
, this is to control whether to return Markdown
or rendered HTML
contents, and API
will also return contents according to slug
in request based on configuration.
var slugToFileName = function(slug,isSlugUseDate) {
var result = "";
if (isSlugUseDate) {
result = slug+".md";
} else {
var files = fs.readdirSync(config.blogPath);
for (let i = 0; i < files.length; i++) {
let tempSlug = files[i].substring(11,files[i].length);
if (tempSlug == slug+".md") {
result = files[i];
break;
}
}
}
return result;
}
var fileNameToSlug = function(filename,isSlugUseDate) {
if (isSlugUseDate) {
return filename.substring(0,filename.length-3);
} else {
return filename.substring(11,filename.length-3);
}
}
var singleBlog = function(fullPath,slug,isContentRequired,isMD,isDev) {
var blog = {};
var content = fm(fs.readFileSync(fullPath, "utf8"));
blog = content.attributes;
blog.slug = slug;
var fullFilename = fullPath.replace(config.blogPath+"\\","");
blog.date = fullFilename.substring(0,10);
if (isContentRequired && !isMD) {
let html = md.render('@[toc]( )\n' + content.body);
//if isDev then use absolute path for images
if (isDev) {
html = html.replace(/src=\"(\/)?img/g,"src=\"http://127.0.0.1:4000/img");
}
// add class "hljs" for dark theme rendering
blog.content = html.replace(/\<pre/g,"<pre class='hljs'");
} else if (isContentRequired && isMD) {
blog.content = content.body;
}
return blog;
}
//return blog related contents
app.get("/blog", function(req, res) {
// allow cross orign access
res.header('Access-Control-Allow-Origin', '*');
var blogPath = config.blogPath;
var imgPath = config.imgPath;
var files = fs.readdirSync(blogPath);
var json = {};// result to be returned
var qSlug = req.query.slug || "";
var qTag = req.query.tag || "";
var qImg = req.query.img || "";
var qIsMD = req.query.ismd == "true";
var qIsDev = req.query.isdev == "true";
var isSlugUseDate = (req.query.iseditor == "true")?true:config.isSlugUseDate;
if (qSlug) {// with blog slug query
json = singleBlog(`${blogPath}\\${slugToFileName(qSlug,isSlugUseDate)}`,qSlug,true,qIsMD,qIsDev);
for (let k = 0; k < files.length; k++) {// add prev and next blog
if (qSlug == fileNameToSlug(files[k],isSlugUseDate)) {
if (k === 0) {
json.next = fileNameToSlug(files[k+1],isSlugUseDate);
} else if (k === files.length-1) {
json.prev = fileNameToSlug(files[k-1],isSlugUseDate);
} else {
json.prev = fileNameToSlug(files[k-1],isSlugUseDate);
json.next = fileNameToSlug(files[k+1],isSlugUseDate);
}
}
}
} else if (qTag) {// with tag query
if (qTag == "all_tags") {// returns list of all available tags
json.tags = {};
for (let i = 0; i < files.length; i++) {
let blogPost = singleBlog(blogPath+"\\"+files[i],fileNameToSlug(files[i],isSlugUseDate));
let tags = blogPost.tags || ['none'];
let entry = {
title: blogPost.title,
slug: blogPost.slug
};
for (let j = 0; j < tags.length; j++) {
if (json.tags[tags[j]]) {
json.tags[tags[j]]++;
} else {
json.tags[tags[j]] = 1;
}
}
}
} else {// returns blog list of specific tag
json.blogs = [];
for (let i = 0; i < files.length; i++) {
let blogPost = singleBlog(blogPath+"\\"+files[i],fileNameToSlug(files[i],isSlugUseDate));
let tags = blogPost.tags || ['none'];
let entry = {
title: blogPost.title,
slug: blogPost.slug
};
for (let j = 0; j < tags.length; j++) {
if (tags[j] == qTag) {
json.blogs.unshift(blogPost);
}
}
}
}
} else if (qImg) {// with img query
if (qImg == "all_imgs") {
json.imgs = {};
var imgFolders = fs.readdirSync(imgPath);
for (let i = 0; i < imgFolders.length; i++) {
let imgs = fs.readdirSync(imgPath+"\\"+imgFolders[i]);
json.imgs[imgFolders[i]] = imgs;
}
}
} else {//without requirement, returns list of all blogs
json.blogs = [];
for (let i = 0; i < files.length; i++) {
let blogPost = singleBlog(blogPath+"\\"+files[i],fileNameToSlug(files[i],isSlugUseDate));
json.blogs.unshift(blogPost);
}
}
res.send(json);
});
app.get("/config", function(req, res) {
// allow cross orign access
res.header('Access-Control-Allow-Origin', '*');
res.send(config);
});
Add Backend UI for API
The entry point for Backend UI is app.html
, and in order to access the static page app.html
, you need codes below:
//return blog content editor page
app.use(express.static(__dirname));
app.get("/", function(req, res) {
// allow cross orign access
res.header('Access-Control-Allow-Origin', '*');
res.sendFile(__dirname+"\\app.html");
});
/* nuxt is using port 3000, so choose another one */
app.listen(4000, () => {
console.log("express server running at http://127.0.0.1:4000")
});
Then the entry link for the CMS
should be http://127.0.0.1:4000/
.
In order to start API
and open browser to access this page, you will need an npm
called open
. Just write one line of command.
open("http:127.0.0.1:4000/",{app: "chrome.exe"});
This will open browser and go to CMS
control panel. There is only one page for the control panel, so it's easy to manage.
To save and delete file
Then there is the need for receiving data from browser and save it to local computer, or receiving data from browser and delete relevant file.
//api to submit post
// Parse URL-encoded bodies (as sent by HTML forms)
app.use(express.urlencoded({ extended: true }));
// Parse JSON bodies (as sent by API clients)
app.use(express.json());
// Access the parse results as request.body
app.post('/savepost', function(req, res){
var obj = req.body;
var fileName = config.blogPath+"\\"+obj.slug+".md";
fs.writeFileSync(fileName, obj.str);
//Client will get status of failure w/o this
//even if data is saved to server successfully
res.send({"status": "success"});
});
// delete post
app.post('/deletepost', function(req, res){
var obj = req.body;
var fileName = config.blogPath+"\\"+obj.slug+".md";
try {
fs.unlinkSync(fileName);
//file removed
} catch(err) {
console.error(err);
}
//Client will get status of failure w/o this
//even if data is saved to server successfully
res.send({"status": "success"});
});
Actual codes for front-end
To call API and Markdown
There is not much to say about the full codes. To get post list, you can call /blogs
with jQuery
's getJSON
method, and to save post you can call /savepost
, and to delete post you can call /deletepost
, both with post
method in jQuery
.
You can get Markdown
contents from the editor in UI and use markdown-it
to render to the preview area, also don't forget the highlight.js
and markdown-it-toc
plugins, then import github-markdown
and the css
for highlight.js
to render the converted HTML
, it's almost done.
Points to notice when rendering Markdown
Here you might want to take a look at the usage for those Node
when applying them to browser.
When it was just Node
script, first you require
them.
var markdownIt = require('markdown-it');
var hljs = require('highlight.js');
var markdownItToc = require('markdown-it-toc');
Then you configure them.
var md = markdownIt({
html: false,
xhtmlOut: false,
breaks: false,
langPrefix: 'language-',
linkify: false,
typographer: false,
quotes: '“”‘’',
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(lang, str).value;
} catch (__) {}
}
return '';
}
});
md.use(markdownItToc);
Now you can use it to render Markdown
.
But when I used the same settings for browser, there is always error saying this or that object is not found.
Then I discovered that the variables exposed by original authors have different casing comparing to my settings.
I opened those .js
files and check the variables one by one and found that markdown-it
used markdownit
as global variable, and markdown-it-toc
, the plugin that I used for generating anchor links, used markdownitTOC
as global variable. After changing my settings, the script is finally OK to run
This should not have been a problem, I'm really upset about it.
Add smooth scrolling effect
I added anchor links for preview area, and I want those #
links will not direct the page to those internal links, but rather have the page scroll to relevant position smoothly, here are the codes:
function updatePreview(){
var d = document;
var source = $("#editor").val();
var html = "\t" + md.render('[toc]\n' + source);
html = html.replace(/\<a/g,"<a target='_blank'");
$("#preview").html(html);
//add scrollIntoView to anchor link
var aTags = d.querySelectorAll("a[href]");
for (let i = 0; i < aTags.length; i++) {
if (aTags[i].href.indexOf("#") !== -1) {
aTags[i].onclick = function(e) {
var c = e || event;
c.preventDefault();
var href = aTags[i].href;
var hashPos = href.indexOf("#");
var id = href.substring(hashPos+1, href.length);
d.querySelector("a[id='"+String(id)+"']").scrollIntoView({behavior: "smooth"});
}
}
}
}
Here is what it looks like
When you need to access this API
, you can cd
to that folder and
node index.js
Then browser will open itself and be directed to http://127.0.0.1:4000/
, you will be able to see post list, and be able to create/delete/modify articles, see the capture below
Experience from this
I finished what I started a year ago, and it only took me one day, I'm really happy about it.
If I am to give a name to this CMS, I think I will call it April CMS
, because I finished it in April.
After finishing it, I did some study and think that, this CMS
fits both descriptions of API-based Headless CMS
and Flat File CMS
, which is a CMS that operates on text files rather than databases
. Seems good!
And the preview area and post list area will update themselves as you input and save or delete post, it will be really smooth when you write with it, maybe it can be used for writing documents or stories.
Don't know what more compliments can I say about it :)
On top of it, this CMS
is a useful thing, and I hope I can create more useful stuff in the future.