Basic ghost plugin

This commit is contained in:
Zef Hemel
2022-04-09 14:28:41 +02:00
parent 6ebf8e7f15
commit 8fafd1cd4a
19 changed files with 594 additions and 53 deletions
+5
View File
@@ -60,3 +60,8 @@ functions:
path: ./materialized_queries.ts:updateMaterializedQueriesCommand
command:
name: "Materialized Queries: Update"
parseCommand:
path: ./page.ts:parsePage
command:
name: Parse Document
+4
View File
@@ -182,3 +182,7 @@ export async function clearPageIndex(page: string) {
console.log("Clearing page index for page", page);
await clearPageIndexForPage(page);
}
export async function parsePage() {
console.log(await parseMarkdown(await getText()));
}
+15
View File
@@ -0,0 +1,15 @@
functions:
downloadAllPostsCommand:
path: "./ghost.ts:downloadAllPostsCommand"
command:
name: "Ghost: Download Posts"
downloadAllPosts:
path: "./ghost.ts:downloadAllPosts"
env: server
publishPostCommand:
path: "./ghost.ts:publishPostCommand"
command:
name: "Ghost: Publish Post"
publishPost:
path: "./ghost.ts:publishPost"
env: server
+219
View File
@@ -0,0 +1,219 @@
import { readPage, writePage } from "plugos-silverbullet-syscall/space";
import { json } from "plugos-syscall/fetch";
import YAML from "yaml";
import { invokeFunction } from "plugos-silverbullet-syscall/system";
import { getCurrentPage, getText } from "plugos-silverbullet-syscall/editor";
type Post = {
id: string;
uuid: string;
title: string;
slug: string;
mobiledoc: string;
status: "draft" | "published";
visibility: string;
created_at: string;
upblished_at: string;
updated_at: string;
tags: Tag[];
primary_tag: Tag;
url: string;
excerpt: string;
};
type Tag = {
id: string;
name: string;
slug: string;
description: string | null;
};
type MobileDoc = {
version: string;
atoms: any[];
cards: Card[];
};
type Card = any[];
function mobileDocToMarkdown(doc: string): string | null {
let mobileDoc = JSON.parse(doc) as MobileDoc;
if (mobileDoc.cards.length > 0 && mobileDoc.cards[0][0] === "markdown") {
return mobileDoc.cards[0][1].markdown;
}
return null;
}
function markdownToMobileDoc(text: string): string {
return JSON.stringify({
version: "0.3.1",
atoms: [],
cards: [["markdown", { markdown: text }]],
markups: [],
sections: [
[10, 0],
[1, "p", []],
],
});
}
class GhostAdmin {
private token?: string;
constructor(private url: string, private key: string) {}
async init() {
const [id, secret] = this.key.split(":");
this.token = await self.syscall(
"jwt.jwt",
secret,
id,
"HS256",
"5m",
"/v3/admin/"
);
}
async listPosts(): Promise<Post[]> {
let result = await json(
`${this.url}/ghost/api/v3/admin/posts?order=published_at+DESC`,
{
headers: {
Authorization: `Ghost ${this.token}`,
},
}
);
return result.posts;
}
async listMarkdownPosts(): Promise<Post[]> {
let markdownPosts: Post[] = [];
for (let post of await this.listPosts()) {
let mobileDoc = JSON.parse(post.mobiledoc) as MobileDoc;
if (mobileDoc.cards.length > 0 && mobileDoc.cards[0][0] === "markdown") {
markdownPosts.push(post);
}
}
return markdownPosts;
}
async createPost(post: Partial<Post>): Promise<Post> {
let result = await json(`${this.url}/ghost/api/v3/admin/posts`, {
method: "POST",
headers: {
Authorization: `Ghost ${this.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
posts: [post],
}),
});
return result.posts[0];
}
async updatePost(post: Partial<Post>): Promise<any> {
let oldPost = await json(
`${this.url}/ghost/api/v3/admin/posts/${post.id}`,
{
headers: {
Authorization: `Ghost ${this.token}`,
"Content-Type": "application/json",
},
}
);
post.updated_at = oldPost.posts[0].updated_at;
let result = await json(`${this.url}/ghost/api/v3/admin/posts/${post.id}`, {
method: "PUT",
headers: {
Authorization: `Ghost ${this.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
posts: [post],
}),
});
return result.posts[0];
}
}
type GhostConfig = {
url: string;
adminKey: string;
pagePrefix: string;
};
function postToMarkdown(post: Post): string {
let text = mobileDocToMarkdown(post.mobiledoc);
return `<!-- #ghost-id: ${post.id} -->\n# ${post.title}\n${text}`;
}
const publishedPostRegex =
/<!-- #ghost-id:\s*(\w+)\s*-->\n#\s*([^\n]+)\n([^$]+)$/;
const newPostRegex = /#\s*([^\n]+)\n([^$]+)$/;
function markdownToPost(text: string): Partial<Post> {
let match = publishedPostRegex.exec(text);
if (match) {
let [, id, title, content] = match;
return {
id,
title,
mobiledoc: markdownToMobileDoc(content),
};
}
match = newPostRegex.exec(text);
if (match) {
let [, title, content] = match;
return {
title,
status: "draft",
mobiledoc: markdownToMobileDoc(content),
};
}
throw Error("Not a valid ghost post");
}
async function getConfig(): Promise<GhostConfig> {
let configPage = await readPage("ghost-config");
return YAML.parse(configPage.text) as GhostConfig;
}
export async function downloadAllPostsCommand() {
await invokeFunction("server", "downloadAllPosts");
}
export async function downloadAllPosts() {
let config = await getConfig();
let admin = new GhostAdmin(config.url, config.adminKey);
await admin.init();
let allPosts = await admin.listMarkdownPosts();
for (let post of allPosts) {
let text = mobileDocToMarkdown(post.mobiledoc);
text = `<!-- #ghost-id: ${post.id} -->\n# ${post.title}\n${text}`;
await writePage(`${config.pagePrefix}${post.slug}`, text);
}
}
export async function publishPostCommand() {
await invokeFunction(
"server",
"publishPost",
await getCurrentPage(),
await getText()
);
}
export async function publishPost(name: string, text: string) {
let config = await getConfig();
let admin = new GhostAdmin(config.url, config.adminKey);
await admin.init();
let post = markdownToPost(text);
post.slug = name.substring(config.pagePrefix.length);
if (post.id) {
await admin.updatePost(post);
} else {
let newPost = await admin.createPost(post);
text = `<!-- #ghost-id: ${newPost.id} -->\n${text}`;
await writePage(name, text);
}
}
+26 -3
View File
@@ -1,6 +1,14 @@
import { expect, test } from "@jest/globals";
import { parse } from "../../common/tree";
import { addParentPointers, collectNodesMatching, findParentMatching, nodeAtPos, renderMarkdown } from "./tree";
import {
addParentPointers,
collectNodesMatching,
findParentMatching,
nodeAtPos,
removeParentPointers,
renderMarkdown,
replaceNodesMatching
} from "./tree";
const mdTest1 = `
# Heading
@@ -31,6 +39,12 @@ Hello
Sup`;
const mdTest3 = `
\`\`\`yaml
name: something
\`\`\`
`;
test("Run a Node sandbox", async () => {
let mdTree = parse(mdTest1);
addParentPointers(mdTree);
@@ -47,6 +61,15 @@ test("Run a Node sandbox", async () => {
// Render back into markdown should be equivalent
expect(renderMarkdown(mdTree)).toBe(mdTest1);
let mdTree2 = parse(mdTest2);
console.log(JSON.stringify(mdTree2, null, 2));
removeParentPointers(mdTree);
replaceNodesMatching(mdTree, (n) => {
if (n.type === "Task") {
return {
type: "Tosk",
};
}
});
console.log(JSON.stringify(mdTree, null, 2));
let mdTree3 = parse(mdTest3);
console.log(JSON.stringify(mdTree3, null, 2));
});
+13 -15
View File
@@ -62,22 +62,20 @@ export function replaceNodesMatching(
mdTree: MarkdownTree,
substituteFn: (mdTree: MarkdownTree) => MarkdownTree | null | undefined
) {
let subst = substituteFn(mdTree);
if (subst !== undefined) {
if (!mdTree.parent) {
throw Error("Need parent pointers for this");
}
let parentChildren = mdTree.parent.children!;
let pos = parentChildren.indexOf(mdTree);
if (subst) {
parentChildren.splice(pos, 1, subst);
} else {
// null = delete
parentChildren.splice(pos, 1);
}
} else if (mdTree.children) {
if (mdTree.children) {
for (let child of mdTree.children) {
replaceNodesMatching(child, substituteFn);
let subst = substituteFn(child);
if (subst !== undefined) {
let pos = mdTree.children.indexOf(child);
if (subst) {
mdTree.children.splice(pos, 1, subst);
} else {
// null = delete
mdTree.children.splice(pos, 1);
}
} else {
replaceNodesMatching(child, substituteFn);
}
}
}
}
+3 -1
View File
@@ -3,7 +3,9 @@
"version": "1.0.0",
"dependencies": {
"@jest/globals": "^27.5.1",
"@types/yaml": "^1.9.7",
"plugos-silverbullet-syscall": "file:../plugos-silverbullet-syscall",
"plugos-syscall": "file:../plugos-syscall"
"plugos-syscall": "file:../plugos-syscall",
"yaml": "^2.0.0"
}
}