aboutsummaryrefslogtreecommitdiff
path: root/lib/client/src
diff options
context:
space:
mode:
Diffstat (limited to 'lib/client/src')
-rw-r--r--lib/client/src/channels.ts104
-rw-r--r--lib/client/src/content.ts120
-rw-r--r--lib/client/src/index.ts19
-rw-r--r--lib/client/src/posts.ts169
-rw-r--r--lib/client/src/types.ts172
5 files changed, 584 insertions, 0 deletions
diff --git a/lib/client/src/channels.ts b/lib/client/src/channels.ts
new file mode 100644
index 0000000..b5fb817
--- /dev/null
+++ b/lib/client/src/channels.ts
@@ -0,0 +1,104 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import { find, isEqual } from 'lodash';
+import { CMNextApi, CMNextIndex, ChannelMeta } from './types'
+
+export interface ChannelApi extends CMNextApi<ChannelMeta> {
+ /**
+ * Gets the endpoint url for the channel
+ */
+ readonly channelFile: string;
+}
+
+/**
+ * Gets the channel api for the given channel json file url
+ * @param endpoint The url of the channel json file
+ */
+export const createChannelApi = (channelFile: string): ChannelApi => {
+
+ const getIndex = async () : Promise<CMNextIndex<ChannelMeta>> => {
+ const res = await fetch(channelFile)
+ return await res.json()
+ }
+
+ return { channelFile, getIndex }
+}
+
+export interface ScopedChannelApi extends ChannelApi {
+ /**
+ * Gets the post index path for the currently selected channel
+ */
+ getPostIndexPath(): Promise<string | undefined>;
+ /**
+ * Gets the content index path for the currently selected channel
+ */
+ getContentIndexPath(): Promise<string | undefined>;
+ /**
+ * Gets the base dir for the currently selected channel
+ */
+ getBaseDir(): Promise<string | undefined>;
+ /**
+ * Gets the content dir for the currently selected channel
+ */
+ getContentDir(): Promise<string | undefined>;
+}
+
+export const createScopedChannelApi = (channelFile: string, channelId: string): ScopedChannelApi => {
+
+ const channelApi = createChannelApi(channelFile);
+
+ const getSelectedChannel = async (): Promise<ChannelMeta | undefined> => {
+ const index = await channelApi.getIndex()
+ //Get the selected channel from the channels
+ return find(index.records, i => isEqual(i.id, channelId))
+ }
+
+ //begin getting the selected channel
+ const index = getSelectedChannel();
+
+ const getPostIndexPath = async (): Promise<string | undefined> => {
+ //Await the selected channel index
+ const channel = await index
+ return channel ? `${channel.path}/${channel.index}` : undefined;
+ }
+
+ const getContentDir = async (): Promise<string | undefined> => {
+ //Await the selected channel index
+ const channel = await index
+ return channel ? channel.content : undefined;
+ }
+
+ const getBaseDir = async (): Promise<string | undefined> => {
+ //Await the selected channel index
+ const channel = await index
+ return channel ? channel.path : undefined;
+ }
+
+ const getContentIndexPath = async (): Promise<string | undefined> => {
+ //Await the selected channel index
+ const channel = await index
+ //Get the post index from the channel
+ return channel ? `${channel.path}/${channel.content}` : undefined;
+ }
+
+ return{
+ ...channelApi,
+ getBaseDir,
+ getContentDir,
+ getPostIndexPath,
+ getContentIndexPath,
+ }
+} \ No newline at end of file
diff --git a/lib/client/src/content.ts b/lib/client/src/content.ts
new file mode 100644
index 0000000..f801432
--- /dev/null
+++ b/lib/client/src/content.ts
@@ -0,0 +1,120 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import { startsWith } from "lodash";
+import { CMNextApi, CMNextAutoConfig, CMNextEntity, CMNextIndex, ContentMeta } from "./types";
+import { createScopedChannelApi } from "./channels";
+
+export interface ContentApi extends CMNextApi<ContentMeta> {
+
+ /**
+ * Gets the public url for the content item to load
+ * in the page
+ * @param item
+ */
+ getContentUrl(item: ContentMeta | CMNextEntity): Promise<string>;
+
+ /**
+ * Fetches the raw string content for the given item and returns a string
+ * of the content
+ * @param item The content item to fetch the content for
+ */
+ getStringContent(item: ContentMeta | CMNextEntity): Promise<string>;
+}
+
+export interface ContentApiManualConfig {
+ /**
+ * The root directory path of the desired channel
+ */
+ readonly channelRootDir: string;
+ /**
+ * The relative path to the channel's content directory
+ */
+ readonly contentDir: string;
+ /**
+ * The relative path to the channel's content index file
+ */
+ readonly contentIndexPath: string;
+}
+
+/**
+ * Creates a manual content api for the given manual content config, that
+ * uses the known endpoint constants to avoid extra netowrk requests when
+ * getting content.
+ * @param param0 The CMNext configuration for the manual content api
+ * @returns The manual content api
+ */
+export const createManualContentApi = ({ channelRootDir, contentIndexPath, contentDir }: ContentApiManualConfig): ContentApi => {
+
+ //Make sure the content dir begins with a slash
+ contentDir = startsWith(contentDir, '/') ? contentDir : `/${contentDir}`
+
+ const getIndex = async () : Promise<CMNextIndex<ContentMeta>> => {
+ const res = await fetch(`${channelRootDir}/${contentIndexPath}`)
+ return await res.json()
+ }
+
+ const getContentUrl = async (item: ContentMeta): Promise<string> => {
+ //Content resides in the content dir within the channel dir
+ return `${channelRootDir}${contentDir}/${item.path}`
+ }
+
+ const getStringContent = async (item: ContentMeta): Promise<string> => {
+ const url = await getContentUrl(item);
+ const res = await fetch(url)
+ return await res.text()
+ }
+
+ return { getIndex, getContentUrl, getStringContent }
+}
+
+/**
+ * Creates an automatic content api for the given auto content config, that
+ * uses the known global CMS catalog and the id of a channel to get content for
+ * then discovers the required config from the channel. This api will cause multple
+ * network requests to the CMS to discover the CMS configuration automatically.
+ * @param param0 The CMNext configuration for the auto content api
+ * @returns The automatic discovery content api
+ */
+export const createAutoContentApi = ({ cmsChannelIndexPath, channelId }: CMNextAutoConfig): ContentApi => {
+
+ const channelApi = createScopedChannelApi(cmsChannelIndexPath, channelId);
+
+ const getIndex = async () : Promise<CMNextIndex<ContentMeta>> => {
+ //Get the content index path from the channel api
+ const contentIndexPath = await channelApi.getContentIndexPath()
+ if(!contentIndexPath){
+ return { version : '0.0.0', records: [], date: 0 }
+ }
+ //Fetch the content index
+ const res = await fetch(contentIndexPath)
+ return await res.json()
+ }
+
+ const getContentUrl = async (item: ContentMeta): Promise<string> => {
+ //Content resides in the content dir within the channel dir
+ const contentDir = await channelApi.getContentDir();
+ return contentDir ? `${contentDir}/${item.path}` : ''
+ }
+
+ const getStringContent = async (item: ContentMeta): Promise<string> => {
+ //Get the url of the item then fetch it
+ const url = await getContentUrl(item);
+ const res = await fetch(url)
+ return await res.text()
+ }
+
+ return { getIndex, getContentUrl, getStringContent }
+} \ No newline at end of file
diff --git a/lib/client/src/index.ts b/lib/client/src/index.ts
new file mode 100644
index 0000000..6a21ea7
--- /dev/null
+++ b/lib/client/src/index.ts
@@ -0,0 +1,19 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+export * from './types'
+export * from './channels'
+export * from './posts'
+export * from './content'
diff --git a/lib/client/src/posts.ts b/lib/client/src/posts.ts
new file mode 100644
index 0000000..f673b21
--- /dev/null
+++ b/lib/client/src/posts.ts
@@ -0,0 +1,169 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import { defaultTo, startsWith } from "lodash";
+import { ChannelApi, createScopedChannelApi } from "./channels";
+import { CMNextApi, CMNextAutoConfig, CMNextIndex, PostMeta } from "./types";
+
+export interface PostApi extends CMNextApi<PostMeta> {
+ /**
+ * Gets the content for a post by its Id
+ * @param postId The id of the post to fetch content for
+ * @returns A promise that resolves to the post content as a string
+ */
+ getPostContent: (post: string | PostMeta, extension?:string) => Promise<string>;
+ /**
+ * Gets the index file path for the channel
+ */
+ getIndexFilePath(): Promise<string>
+}
+
+/**
+ * A post api created from an automatic configuration
+ */
+export interface AutoPostApi extends PostApi, CMNextAutoConfig {
+ /**
+ * The channel api pass-thru
+ */
+ readonly channelApi: ChannelApi;
+}
+
+/**
+ * A post api created from a manual configuration
+ */
+export interface ManualPostApi extends PostApi, PostApiManualConfig {
+}
+
+export interface PostApiManualConfig {
+ /**
+ * The root directory path of the desired channel
+ */
+ readonly channelRootDir: string;
+ /**
+ * The relative path to the channel's post index file
+ */
+ readonly postIndexPath: string;
+ /**
+ * The relative path to the channel's content directory
+ */
+ readonly contentDir: string;
+}
+
+
+/**
+ * Creates a post api around the channel index file and the desired channel id
+ * to get posts from. This method requires an additional fetch to get the channel
+ * information before it can fetch posts, so it will be slower, but its
+ * discovery is automatic.
+ * @param channelUrl The url to the channel index file
+ * @param channelId The id of the channel to get posts from
+ * @returns A post api that can be used to get posts from the channel
+ */
+export const createAutoPostApi = ({ cmsChannelIndexPath, channelId }: CMNextAutoConfig): AutoPostApi => {
+ //Use scoped channel api
+ const channelApi = createScopedChannelApi(cmsChannelIndexPath, channelId);
+
+ const getIndex = async (): Promise<CMNextIndex<PostMeta>> => {
+ //Await the selected channel index
+ const indexUrl = await channelApi.getPostIndexPath();
+
+ if (!indexUrl){
+ //Return empty index
+ return { date: 0, records: [], version: "0.0.0" }
+ }
+
+ //Fetch the index file
+ const res = await fetch(indexUrl)
+ return await res.json()
+ }
+
+ const getPostContent = async (post: PostMeta | string, extension = '.html'): Promise<string> => {
+ //Get the selected channel
+ const contentDir = await channelApi.getContentDir();
+ const baseDir = await channelApi.getBaseDir();
+
+ if (!contentDir || !baseDir){
+ //Return empty content
+ return ""
+ }
+
+ const itemId = defaultTo(post.id, post)
+
+ //Fetch the content as text because it is html
+ const res = await fetch(`${baseDir}${contentDir}/${itemId}${extension}`)
+ return await res.text()
+ }
+
+ const getIndexFilePath = async () : Promise<string> => {
+ const indexUrl = await channelApi.getPostIndexPath();
+ return indexUrl || ""
+ }
+
+ return{
+ channelApi,
+ getPostContent,
+ channelId,
+ getIndexFilePath,
+ getIndex,
+ cmsChannelIndexPath,
+ }
+
+}
+
+/**
+ * Creates a post api around known channel information to avoid additional fetch
+ * requests to get the channel information. This method is faster, but requires
+ * the channel information to be known and remain constant.
+ * @param baseUrl The base url of the desired channel
+ * @param indexFilePath The path to the index file within the channel
+ * @param contentDir The path to the content directory within the channel
+ * @returns A post api that can be used to get posts from the channel
+ */
+export const createManualPostApi = ({ channelRootDir, contentDir, postIndexPath }: PostApiManualConfig): ManualPostApi => {
+
+ //Make sure inedx file has a leading slash
+ postIndexPath = startsWith(postIndexPath, '/') ? postIndexPath : `/${postIndexPath}`
+ contentDir = startsWith(contentDir, '/') ? contentDir : `/${contentDir}`
+
+ const getItemPath = (path: string) => `${channelRootDir}${path}`
+
+ const getIndex = async (): Promise<CMNextIndex<PostMeta>> => {
+ //Fetch the index file
+ const res = await fetch(getItemPath(postIndexPath))
+ return await res.json()
+ }
+
+ const getPostContent = async (post: PostMeta | string, extension = '.html'): Promise<string> => {
+ //Get the content url
+ const contentUrl = `${getItemPath(contentDir)}/${defaultTo(post.id, post)}${extension}`
+
+ //Fetch the content as text because it is html
+ const res = await fetch(contentUrl)
+ return await res.text()
+ }
+
+ const getIndexFilePath = async () : Promise<string> => {
+ return getItemPath(postIndexPath)
+ }
+
+ return{
+ getIndex,
+ channelRootDir,
+ contentDir,
+ postIndexPath,
+ getPostContent,
+ getIndexFilePath
+ }
+} \ No newline at end of file
diff --git a/lib/client/src/types.ts b/lib/client/src/types.ts
new file mode 100644
index 0000000..aa158e9
--- /dev/null
+++ b/lib/client/src/types.ts
@@ -0,0 +1,172 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+export interface CMNextAutoConfig{
+ /**
+ * The url to the global channel catalog index
+ */
+ readonly cmsChannelIndexPath: string;
+ /**
+ * The id of the channel to load
+ */
+ readonly channelId: string;
+}
+
+
+/**
+ * Represents a 1:1 mapping of a blog index to
+ * the CMS representation of the index
+ */
+export interface CMNextIndex<T>{
+ /**
+ * The collection of records in the index
+ */
+ readonly records: T[];
+ /**
+ * The date the index was last modified in unix seconds
+ */
+ readonly date: number;
+ /**
+ * The version of the index
+ */
+ readonly version: string;
+}
+
+/**
+ * A base blog entity that has a globally unique id and a date
+ */
+export interface CMNextEntity {
+ /**
+ * The globally unique id of the entity
+ */
+ readonly id: string;
+ /**
+ * The date the entity was last modified in unix seconds
+ */
+ readonly date: number;
+}
+
+/**
+ * A uniform api for the CMNext cms
+ */
+export interface CMNextApi<T> {
+ /**
+ * Gets the index file from the configured endpoint
+ */
+ getIndex(): Promise<CMNextIndex<T>>;
+}
+
+
+/**
+ * A channel configuration entity
+ */
+export interface ChannelMeta extends CMNextEntity {
+ /**
+ * The base path of the channel
+ */
+ readonly path: string;
+ /**
+ * The realtive path of the channel's index file
+ */
+ readonly index: string;
+ /**
+ * The realtive directory within the channel to the channel's content
+ */
+ readonly content: string;
+ /**
+ * Optiona channel feed configuration
+ */
+ readonly feed?: ChannelFeed;
+}
+
+/**
+ * A channel's feed configuration
+ */
+export interface ChannelFeed {
+ /**
+ * The public url the feed points to, aka the public url to this channel
+ */
+ readonly url: string;
+ /**
+ * The realtive path to the channel's rss feed xml file within the channel
+ */
+ readonly path: string;
+ /**
+ * The url to the image for this channel
+ */
+ readonly image?: string;
+ /**
+ * The description of the channel
+ */
+ readonly description?: string;
+ /**
+ * The author of the channel
+ */
+ readonly author?: string;
+ /**
+ * The webmaster contact email for the channel
+ */
+ readonly contact?: string;
+}
+
+/**
+ * A blog post entity and its metadata
+ */
+export interface PostMeta extends CMNextEntity {
+ /**
+ * The title of the post
+ */
+ readonly title?: string;
+ /**
+ * The summary of the post
+ */
+ readonly summary?: string;
+ /**
+ * The author of the post
+ */
+ readonly author?: string;
+ /**
+ * The date the post was created in unix seconds
+ */
+ readonly created?: number;
+ /**
+ * The post tags for categorization
+ */
+ readonly tags: string[];
+ /**
+ * The post's image, assumed to be an absolute url
+ */
+ readonly image?: string;
+}
+
+export interface ContentMeta extends CMNextEntity {
+ /**
+ * The relative path to the content file within the
+ * content directory of the channel
+ */
+ readonly path: string;
+ /**
+ * The name of the content file
+ */
+ readonly name: string;
+ /**
+ * The content type of the content file
+ */
+ readonly content_type: string;
+ /**
+ * The length of the content file in bytes
+ */
+ readonly length: number;
+}