@dosgato/templating 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.eslintrc.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "standard-with-typescript",
3
+ "parserOptions": {
4
+ "project": "./tsconfig.eslint.json"
5
+ },
6
+ "rules": {
7
+ "@typescript-eslint/explicit-function-return-type": "off", // useless boilerplate
8
+ "@typescript-eslint/array-type": "off", // forces you to use Array<CustomType> instead of CustomType[], silly
9
+ "@typescript-eslint/no-non-null-assertion": "off", // disables using ! to mark something as non-null,
10
+ // generally not avoidable without wasting cpu cycles on a check
11
+ "@typescript-eslint/no-unused-vars": "off", // typescript already reports this and VSCode darkens the variable
12
+ "@typescript-eslint/return-await": ["error", "always"], // allows you to accidentally break async stacktraces in node 14+
13
+ "@typescript-eslint/strict-boolean-expressions": "off" // we know how truthiness works, annoying to have to avoid
14
+ }
15
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 Texas State ETC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,8 @@
1
+ # dosgato-templating
2
+ A library to support building templates for dosgato CMS.
3
+
4
+ This library provides types and classes necessary to provide structure to the
5
+ process of building a new template. The rendering server, API, and Admin UI will depend
6
+ on this structure to accomplish their work.
7
+
8
+ For now, see the code for comments on how it all works.
@@ -0,0 +1,5 @@
1
+ import all from '../dist/index.js'
2
+
3
+ export const ResourceProvider = all.ResourceProvider
4
+ export const Component = all.Component
5
+ export const Page = all.Page
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@dosgato/templating",
3
+ "version": "0.0.1",
4
+ "description": "A library to support building templates for dosgato CMS.",
5
+ "exports": {
6
+ "require": "./dist/index.js",
7
+ "import": "./dist-esm/index.js"
8
+ },
9
+ "types": "dist/index.d.ts",
10
+ "scripts": {
11
+ "prepublishOnly": "npm run build",
12
+ "build": "rm -rf dist && tsc"
13
+ },
14
+ "dependencies": {},
15
+ "devDependencies": {
16
+ "eslint-config-standard-with-typescript": "^21.0.1",
17
+ "typescript": "^4.4.2"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/txstate-etc/dosgato-templating.git"
22
+ },
23
+ "keywords": [
24
+ "cms",
25
+ "component",
26
+ "template"
27
+ ],
28
+ "author": "Nick Wing",
29
+ "license": "MIT",
30
+ "bugs": {
31
+ "url": "https://github.com/txstate-etc/dosgato-templating/issues"
32
+ },
33
+ "homepage": "https://github.com/txstate-etc/dosgato-templating#readme"
34
+ }
@@ -0,0 +1,167 @@
1
+ import { Page } from './page'
2
+ import { ResourceProvider } from './provider'
3
+
4
+ /**
5
+ * This is the primary templating class to build your templates. Subclass it and provide
6
+ * at least a render function.
7
+ *
8
+ * During rendering, it will be "hydrated" - placed into a full page structure with its
9
+ * parent and child components linked.
10
+ */
11
+ export abstract class Component<DataType extends ComponentData = any, FetchedType = any, RenderContextType extends ContextBase = any> extends ResourceProvider {
12
+ // properties each template should provide
13
+ static templateKey: string
14
+ static templateName: string
15
+
16
+ // properties for use during hydration, you do not have to provide these when
17
+ // building a template, but you can use them in the functions you do provide
18
+ areas = new Map<string, Component[]>()
19
+ data: Omit<DataType, 'areas'>
20
+ fetched!: FetchedType
21
+ renderCtx!: RenderContextType
22
+ path: string
23
+ parent?: Component
24
+ page?: Page
25
+ hadError: boolean
26
+
27
+ /**
28
+ * The first phase of rendering a component is the fetch phase. Each component may
29
+ * provide a fetch method that looks up data it needs from external sources. This step
30
+ * is FLAT - it will be executed concurrently for all the components on the page for
31
+ * maximum speed.
32
+ *
33
+ * Note that this.page will be available, along with its ancestors property containing
34
+ * all the data from ancestor pages, in case there is a need for inheritance. It is
35
+ * recommended to copy any needed data into the return object, as future phases will not
36
+ * want to resolve the inheritance again.
37
+ */
38
+ async fetch (editMode: boolean) {
39
+ return undefined as unknown as FetchedType
40
+ }
41
+
42
+ /**
43
+ * The second phase of rendering a component is the context phase. This step is TOP-DOWN,
44
+ * each component will receive the parent component's context, modify it as desired,
45
+ * and then pass context to its children.
46
+ *
47
+ * This is useful for rendering logic that is sensitive to where the component exists in
48
+ * the hierarchy of the page. For instance, if a parent component has used an h2 header
49
+ * already, it will want to inform its children so that they can use h3 next, and they inform
50
+ * their children that h4 is next, and so on. (Header level tracking is actually required in
51
+ * dosgato CMS.)
52
+ *
53
+ * This function may return a promise in case you need to do something asynchronous based on
54
+ * the context received from the parent, but use it sparingly since it will stall the process.
55
+ * Try to do all asynchronous work in the fetch phase.
56
+ */
57
+ setContext (renderCtxFromParent: RenderContextType, editMode: boolean): RenderContextType|Promise<RenderContextType> {
58
+ return renderCtxFromParent
59
+ }
60
+
61
+ /**
62
+ * The final phase of rendering a component is the render phase. This step is BOTTOM-UP -
63
+ * components at the bottom of the hierarchy will be rendered first, and the result of the
64
+ * render will be passed to parent components so that the HTML can be included during the
65
+ * render of the parent component.
66
+ */
67
+ abstract render (renderedAreas: Map<string, string[]>, editMode: boolean): string
68
+
69
+ /**
70
+ * Sometimes pages are requested with an alternate extension like .rss or .ics. In these
71
+ * situations, each component should consider whether it should output anything. For
72
+ * instance, if the extension is .rss and a component represents an article, it should
73
+ * probably output an RSS item. If you don't recognize the extension, just return
74
+ * super.renderVariation(extension, renderedAreas) to give your child components a chance to
75
+ * respond, or return empty string if you want your child components to be silent in all
76
+ * cases.
77
+ *
78
+ * This function will be run after the fetch phase. The context and html rendering phases
79
+ * will be skipped.
80
+ */
81
+ renderVariation (extension: string, renderedAreas: Map<string, string>) {
82
+ return Array.from(renderedAreas.values()).join('')
83
+ }
84
+
85
+ // the constructor is part of the recursive hydration mechanism: constructing
86
+ // a Component will also construct/hydrate all its child components
87
+ constructor (data: DataType, path: string, parent: Component|undefined) {
88
+ super()
89
+ this.parent = parent
90
+ const { areas, ...ownData } = data
91
+ this.data = ownData
92
+ this.path = path
93
+ this.hadError = false
94
+ let tmpParent = this.parent ?? this
95
+ while (!(tmpParent instanceof Page) && tmpParent.parent) tmpParent = tmpParent.parent
96
+ if (!(tmpParent instanceof Page)) throw new Error('Hydration failed, could not map component back to its page.')
97
+ this.page = tmpParent
98
+ }
99
+
100
+ /**
101
+ * For logging errors during rendering without crashing the render. If your fetch, setContext,
102
+ * render, or renderVariation functions throw, the error will be logged but the page render will
103
+ * continue. You generally do not need to use this function, just throw when appropriate.
104
+ */
105
+ logError (e: Error) {
106
+ this.hadError = true
107
+ this.parent?.passError(e, this.path)
108
+ }
109
+
110
+ // helper function for recursively passing the error up until it reaches the page
111
+ protected passError (e: Error, path: string) {
112
+ this.parent?.passError(e, path)
113
+ }
114
+
115
+ /**
116
+ * During rendering, each component should determine the CSS blocks that it needs. This may
117
+ * change depending on the data. For instance, if you need some CSS to style up an image, but
118
+ * only when the editor uploaded an image, you can check whether the image is present during
119
+ * the execution of this function.
120
+ *
121
+ * This is evaluated after the fetch and context phases but before the rendering phase. If you
122
+ * need any async data to make this determination, be sure to fetch it during the fetch phase.
123
+ */
124
+ cssBlocks (): string[] {
125
+ return (this.constructor as any).cssBlocks().keys()
126
+ }
127
+
128
+ /**
129
+ * Same as cssBlocks() but for javascript.
130
+ */
131
+ jsBlocks (): string[] {
132
+ return (this.constructor as any).jsBlocks().keys()
133
+ }
134
+ }
135
+
136
+ export interface PageRecord<DataType extends PageData = PageData> {
137
+ id: string
138
+ linkId: string
139
+ path: string
140
+ data: DataType
141
+ }
142
+
143
+ export interface PageWithAncestors<DataType extends PageData = PageData> extends PageRecord<DataType> {
144
+ ancestors: PageRecord<PageData>[]
145
+ }
146
+
147
+ export interface ComponentData {
148
+ templateKey: string
149
+ areas: Record<string, ComponentData[]>
150
+ }
151
+
152
+ export interface PageData extends ComponentData {
153
+ savedAtVersion: Date
154
+ }
155
+
156
+ export interface ContextBase {
157
+ /**
158
+ * For accessibility, every component should consider whether it is creating headers
159
+ * using h1-h6 tags, and set the context for its children so that they will use the
160
+ * next higher number. For example, a page component might use h1 for the page title,
161
+ * in which case it should set headerLevel: 2 so that its child components will use
162
+ * h2 next. Those components in turn can increment headerLevel for their children.
163
+ *
164
+ * This way every page will have a perfect header tree and avoid complaints from WAVE.
165
+ */
166
+ headerLevel: number
167
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './component'
2
+ export * from './links'
3
+ export * from './page'
4
+ export * from './provider'
5
+ export * from './template'
package/src/links.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * This is what an AssetLink should look like when stored in component data. It includes
3
+ * lots of information so if the asset gets moved or recreated it may be possible to find
4
+ * the link target anyway.
5
+ */
6
+ export interface AssetLink {
7
+ type: 'asset'
8
+ source: string
9
+ id: string // the asset's dataId
10
+ siteId: string
11
+ path: string
12
+ checksum: string
13
+ }
14
+
15
+ /**
16
+ * Some components (e.g. document list) can point at a folder instead of individual
17
+ * assets, so we will want to track asset folders through moves, renames, and copies.
18
+ * This link format supports all that.
19
+ */
20
+ export interface AssetFolderLink {
21
+ type: 'assetfolder'
22
+ id: string // the asset folder's guid
23
+ siteId: string
24
+ path: string
25
+ }
26
+
27
+ /**
28
+ * A page link always points at the same pagetree as the page the link is on.
29
+ */
30
+ export interface PageLink {
31
+ type: 'page'
32
+ linkId: string
33
+ siteId: string
34
+ path: string
35
+ }
36
+
37
+ /**
38
+ * The link format for external webpages. This format seems a little extra since
39
+ * it's just a URL string. Why does it need to be an object with a type? However,
40
+ * components will often simply ask editors for a link, which could be a page, asset,
41
+ * or external URL. Having them all in the same object format makes interpreting
42
+ * the data a lot easier.
43
+ */
44
+ export interface WebLink {
45
+ type: 'url'
46
+ url: string
47
+ }
48
+
49
+ /**
50
+ * Many components will point at data records. That's the whole idea. Site id is
51
+ * required for all data links, it just might be null when the data being pointed at is
52
+ * global data.
53
+ */
54
+ export interface DataLink {
55
+ type: 'data'
56
+ id: string // the data item's dataId
57
+ siteId: string|null // null if global data
58
+ path: string
59
+ }
60
+
61
+ /**
62
+ * Just like with asset folders, we may have components that point at data folders. We
63
+ * would like to keep the links working through moves, renames, and copies.
64
+ */
65
+ export interface DataFolderLink {
66
+ type: 'datafolder'
67
+ id: string // the asset folder's guid
68
+ siteId?: string // null if global data
69
+ path: string
70
+ }
71
+
72
+ export type LinkDefinition = AssetLink | AssetFolderLink | PageLink | WebLink | DataLink | DataFolderLink
package/src/page.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { PageData, ContextBase, Component, PageRecord, PageWithAncestors } from './component'
2
+
3
+ export abstract class Page<DataType extends PageData = any, FetchedType = any, RenderContextType extends ContextBase = any> extends Component<DataType, FetchedType, RenderContextType> {
4
+ pagePath: string
5
+ ancestors: PageRecord[]
6
+
7
+ /**
8
+ * we will fill this before rendering, stuff that dosgato knows needs to be added to
9
+ * the <head> element
10
+ * the page's render function must include it
11
+ */
12
+ headContent!: string
13
+
14
+ protected passError (e: Error, path: string) {
15
+ console.warn(`Recoverable issue occured during render of ${this.pagePath}. Component at ${path} threw the following error:`, e)
16
+ }
17
+
18
+ constructor (page: PageWithAncestors<DataType>) {
19
+ super(page.data, '/', undefined)
20
+ this.pagePath = page.path
21
+ this.ancestors = page.ancestors
22
+ }
23
+ }
@@ -0,0 +1,72 @@
1
+ /* eslint-disable @typescript-eslint/no-extraneous-class */
2
+
3
+ /**
4
+ * This class is a parent class for Component, but it can also be used as a standalone
5
+ * if you are creating a set of templates with shared resources. This will be fairly
6
+ * typical for each entity running an instance and creating their own local templates. You'll
7
+ * probably want one place to set up very common resources like fontawesome or jquery, instead
8
+ * of having each and every component template provide them again and again.
9
+ *
10
+ * If you do this, don't forget to register the provider along with your templates!
11
+ */
12
+ export abstract class ResourceProvider {
13
+ /**
14
+ * Each template should provide a map of CSS blocks where the map key is the unique name for
15
+ * the CSS and the value is the CSS itself. For instance, if a template needs CSS from a
16
+ * standard library like jquery-ui, it could include the full CSS for jquery-ui with 'jquery-ui'
17
+ * as the key. Other templates that depend on jquery-ui would also provide the CSS, but
18
+ * a page with both components would only include the CSS once, because they both called it
19
+ * 'jquery-ui'.
20
+ *
21
+ * A version string (e.g. '1.2.5') may be provided for each block. The block with the highest
22
+ * version number of any given name will be used. Other versions of that name will be ignored.
23
+ *
24
+ * For convenience you can either provide the `css` property with the CSS as a string, or the
25
+ * `path` property with the full server path to a CSS file (node's __dirname function will
26
+ * help you determine it). You MUST provide one or the other.
27
+ */
28
+ static cssBlocks: Map<string, { css?: string, path?: string, version?: string }> = new Map()
29
+
30
+ /**
31
+ * Same as cssBlocks() but for javascript.
32
+ */
33
+ static jsBlocks: Map<string, { js?: string, path?: string, version?: string }> = new Map()
34
+
35
+ /**
36
+ * If your template needs to serve any files, like fonts or images, you can provide
37
+ * a filesystem path in this static property and the files will be served by the rendering
38
+ * server. Use the provided `webpaths` map to obtain the proper resource URLs. They will be
39
+ * available as soon as your template has been registered to the rendering server's templateRegistry.
40
+ *
41
+ * Typically you will set this to something like `${__dirname}/static` so that the path will be relative
42
+ * to where you are writing your template class.
43
+ *
44
+ * The map name you pick should be globally unique and only collide with other templates as
45
+ * intended. For instance, the fontawesome font only needs to be provided once, even though
46
+ * several templates might depend on it. Setting the name as 'fontawesome5' on all three
47
+ * templates would ensure that the file would only be served once. Including the major version
48
+ * number is a good idea only if the major versions can coexist.
49
+ *
50
+ * Include a version number if applicable for the file you've included with your source. If
51
+ * multiple templates have a common file, the one that provides the highest version number will
52
+ * have its file served, while the others will be ignored.
53
+ *
54
+ * DO NOT change the mime type without changing the name. Other templates could end up with
55
+ * the wrong file extension.
56
+ */
57
+ static files: Map<string, { path: string, version?: string, mime: string }> = new Map()
58
+
59
+ /**
60
+ * Template code will need to generate HTML and CSS that points at the static files
61
+ * provided above. In order to do so, we need information from the template registry (since
62
+ * we have to deduplicate with other registered templates at startup time).
63
+ *
64
+ * In order to avoid an ES6 dependency on the registry, we will have the registry write
65
+ * back to this map as templates are registered.
66
+ *
67
+ * Now when a template needs a web path to a resource to put into its HTML, it can do
68
+ * `<img src="${TemplateClass.webpath('keyname')}">`
69
+ */
70
+ static webpaths: Map<string, string> = new Map()
71
+ static webpath (name: string) { return this.webpaths.get(name) }
72
+ }
@@ -0,0 +1,88 @@
1
+ import { PageWithAncestors, ComponentData } from './component'
2
+ import { LinkDefinition } from './links'
3
+
4
+ export type TemplateType = 'page'|'component'|'data'
5
+
6
+ /**
7
+ * This interface lays out the structure the API needs for each template in the system.
8
+ */
9
+ export interface Template {
10
+ type: TemplateType
11
+
12
+ /**
13
+ * A unique string to globally identify this template across installations. Namespacing like
14
+ * edu.txstate.RichTextEditor could be useful but no special format is required.
15
+ */
16
+ templateKey: string
17
+
18
+ /**
19
+ * Each template must declare its areas and the template keys of components that will be
20
+ * permitted inside each area. The list of allowed component templates can be updated beyond
21
+ * the list provided here. See templateRegistry.addAvailableComponent's comment for info on why.
22
+ */
23
+ areas: Record<string, string[]>
24
+
25
+ /**
26
+ * Each template must provide a list of migrations for upgrading the data schema over time.
27
+ * Typically this will start as an empty array and migrations will be added as the template
28
+ * gets refactored.
29
+ */
30
+ migrations: Migration[]
31
+
32
+ /**
33
+ * Each template must provide a function that returns links from its data so that they
34
+ * can be indexed. Only fields that are links need to be returned. Links inside rich editor
35
+ * text will be extracted automatically from any text returned by getFulltext (see below)
36
+ */
37
+ getLinks: LinkGatheringFn
38
+
39
+ /**
40
+ * Each template must provide the text from any text or rich editor data it possesses, so that
41
+ * the text can be decomposed into words and indexed for fulltext searches. Any text returned
42
+ * by this function will also be scanned for links.
43
+ */
44
+ getFulltext: FulltextGatheringFn
45
+
46
+ /**
47
+ * Each template must provide a validation function so that the API can enforce its data is
48
+ * shaped properly. If there are no issues, it should return an empty object {}, otherwise it
49
+ * should return an object with keys that reference the path to the error and values that
50
+ * are an array of error messages pertaining to that path.
51
+ *
52
+ * For instance, if name is required and the user didn't provide one, you would return:
53
+ * { name: ['A name is required.'] }
54
+ *
55
+ * This method is async so that you can do things like look in the database for conflicting
56
+ * names.
57
+ */
58
+ validate: (data: any) => Promise<Record<string, string[]>>
59
+ }
60
+
61
+ /**
62
+ * In dosgato CMS, the data in the database is not altered except during user activity. This
63
+ * means that older records could have been saved when the schema expected by component
64
+ * rendering code was different than the date it's being rendered. To handle this, each
65
+ * page and component template is required to provide migrations responsible for
66
+ * transforming the data to the needed schema version.
67
+ *
68
+ * In order to support backwards compatibility, each API client will specify the date
69
+ * when the code was written, so that their assumptions about the schema will be
70
+ * frozen in time. This system means that migrations need to run backward as well as forward
71
+ * in time.
72
+ *
73
+ * The `up` method is for changing data from an older schema to a newer one. The
74
+ * `down` method is for changing data back from the newer schema to the older one.
75
+ * If a `down` method cannot be provided, the migration is considered to be a breaking
76
+ * change and anyone asking to rewind time to before the migration will receive an error.
77
+ *
78
+ * Your `up` and `down` methods will be applied to components in bottom-up fashion, so you
79
+ * can assume that any components inside one of your areas has already been processed.
80
+ */
81
+ export interface Migration {
82
+ createdAt: Date
83
+ up: (data: ComponentData, page: PageWithAncestors) => ComponentData|Promise<ComponentData>
84
+ down: (data: ComponentData, page: PageWithAncestors) => ComponentData|Promise<ComponentData>
85
+ }
86
+
87
+ export type LinkGatheringFn = (data: any) => LinkDefinition[]
88
+ export type FulltextGatheringFn = (data: any) => string[]
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "include": [
4
+ "src/**/*.ts",
5
+ "test/**/*.ts"
6
+ ]
7
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2019",
4
+ "module": "commonjs",
5
+ "declaration": true,
6
+ "outDir": "./dist",
7
+ "strict": true,
8
+ "esModuleInterop": true
9
+ },
10
+ "include": ["src"]
11
+ }