Skip to content

Commit 358a77a

Browse files
committed
Migrate kg-mobiledoc-html-renderer to TypeScript
- Move lib/ to src/, rename .js to .ts - Add tsconfig.json (strict, NodeNext, ESM) - Add "type": "module" to package.json - Convert source and tests from CJS to ESM - Add type annotations and type declaration files - Replace .eslintrc.js with eslint.config.js (flat config) - Output to build/ via tsc
1 parent 292345b commit 358a77a

File tree

13 files changed

+301
-339
lines changed

13 files changed

+301
-339
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
build/
2+
tsconfig.tsbuildinfo
Lines changed: 19 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,33 @@
1-
import {fixupPluginRules} from '@eslint/compat';
21
import eslint from '@eslint/js';
2+
import {defineConfig} from 'eslint/config';
33
import ghostPlugin from 'eslint-plugin-ghost';
4-
import globals from 'globals';
4+
import tseslint from 'typescript-eslint';
55

6-
const ghost = fixupPluginRules(ghostPlugin);
7-
8-
export default [
6+
export default defineConfig([
97
{ignores: ['build/**']},
10-
eslint.configs.recommended,
118
{
12-
files: ['**/*.js'],
13-
plugins: {ghost},
9+
files: ['**/*.ts'],
10+
extends: [
11+
eslint.configs.recommended,
12+
tseslint.configs.recommended
13+
],
1414
languageOptions: {
15-
globals: globals.node
15+
parserOptions: {ecmaVersion: 2022, sourceType: 'module'}
1616
},
17+
plugins: {ghost: ghostPlugin},
1718
rules: {
18-
...ghostPlugin.configs.node.rules,
19-
// match ESLint 8 behavior for catch clause variables
20-
'no-unused-vars': ['error', {caughtErrors: 'none'}],
21-
// disable rules incompatible with ESLint 9 flat config
22-
'ghost/filenames/match-exported-class': 'off',
23-
'ghost/filenames/match-exported': 'off',
24-
'ghost/filenames/match-regex': 'off'
19+
...ghostPlugin.configs.ts.rules,
20+
'@typescript-eslint/no-explicit-any': 'error'
2521
}
2622
},
2723
{
28-
files: ['test/**/*.js'],
29-
plugins: {ghost},
30-
languageOptions: {
31-
globals: {
32-
...globals.node,
33-
...globals.mocha,
34-
should: true,
35-
sinon: true
36-
}
37-
},
24+
files: ['test/**/*.ts'],
3825
rules: {
39-
...ghostPlugin.configs.test.rules
26+
...ghostPlugin.configs['ts-test'].rules,
27+
'ghost/mocha/no-global-tests': 'off',
28+
'ghost/mocha/handle-done-callback': 'off',
29+
'ghost/mocha/no-mocha-arrows': 'off',
30+
'ghost/mocha/max-top-level-suites': 'off'
4031
}
4132
}
42-
];
33+
]);

packages/kg-mobiledoc-html-renderer/index.js

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,33 @@
11
{
22
"name": "@tryghost/kg-mobiledoc-html-renderer",
33
"version": "7.1.15",
4-
"repository": "https://github.com/TryGhost/Koenig/tree/master/packages/kg-mobiledoc-html-renderer",
4+
"repository": "https://github.com/TryGhost/Koenig/tree/main/packages/kg-mobiledoc-html-renderer",
55
"author": "Ghost Foundation",
66
"license": "MIT",
7-
"main": "index.js",
7+
"main": "build/cjs/index.js",
8+
"module": "build/esm/index.js",
9+
"types": "build/esm/index.d.ts",
10+
"exports": {
11+
".": {
12+
"types": "./build/esm/index.d.ts",
13+
"import": "./build/esm/index.js",
14+
"require": "./build/cjs/index.js"
15+
}
16+
},
817
"scripts": {
918
"dev": "echo \"Implement me!\"",
10-
"test": "NODE_ENV=testing c8 --all --reporter text --reporter cobertura mocha './test/**/*.test.js'",
19+
"build": "tsc && tsc -p tsconfig.cjs.json && echo '{\"type\":\"module\"}' > build/esm/package.json",
20+
"prepare": "tsc && tsc -p tsconfig.cjs.json && echo '{\"type\":\"module\"}' > build/esm/package.json",
21+
"pretest": "tsc && tsc -p tsconfig.cjs.json && echo '{\"type\":\"module\"}' > build/esm/package.json && tsc -p tsconfig.test.json",
22+
"test": "NODE_ENV=testing c8 --all --src src --exclude 'src/index.ts' --exclude 'src/types.d.ts' --exclude 'test/**' --reporter text --reporter cobertura mocha --require tsx './test/**/*.test.ts'",
1123
"lint": "eslint . --cache",
1224
"posttest": "yarn lint"
1325
},
1426
"engines": {
1527
"node": "^22.13.1 || ^24.0.0"
1628
},
1729
"files": [
18-
"index.js",
19-
"lib"
30+
"build"
2031
],
2132
"publishConfig": {
2233
"access": "public"
@@ -27,6 +38,17 @@
2738
"simple-dom": "^1.4.0"
2839
},
2940
"devDependencies": {
30-
"c8": "11.0.0"
41+
"@eslint/js": "9.39.4",
42+
"@types/mocha": "10.0.10",
43+
"@types/node": "24.12.0",
44+
"@types/should": "13.0.0",
45+
"@types/sinon": "21.0.0",
46+
"c8": "11.0.0",
47+
"mocha": "11.7.5",
48+
"should": "13.2.3",
49+
"sinon": "21.0.3",
50+
"tsx": "4.21.0",
51+
"typescript": "5.8.3",
52+
"typescript-eslint": "8.57.0"
3153
}
3254
}

packages/kg-mobiledoc-html-renderer/src/MobiledocHtmlRenderer.ts

Lines changed: 89 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,38 @@
1-
const SimpleDom = require('simple-dom');
2-
const Renderer = require('mobiledoc-dom-renderer').default;
3-
const {slugify} = require('@tryghost/kg-utils');
1+
import {Document as SimpleDomDocument, HTMLSerializer, voidMap} from 'simple-dom';
2+
import MobiledocDomRenderer from 'mobiledoc-dom-renderer';
3+
import {slugify} from '@tryghost/kg-utils';
4+
5+
// mobiledoc-dom-renderer may use CJS default export pattern
6+
const Renderer = (MobiledocDomRenderer as {default?: typeof MobiledocDomRenderer}).default || MobiledocDomRenderer;
7+
8+
interface SimpleDomNode {
9+
nodeType: number;
10+
nodeName: string;
11+
nodeValue: string | null;
12+
tagName: string;
13+
firstChild: SimpleDomNode | null;
14+
lastChild: SimpleDomNode | null;
15+
nextSibling: SimpleDomNode | null;
16+
appendChild(child: SimpleDomNode): void;
17+
removeChild(child: SimpleDomNode): void;
18+
setAttribute(name: string, value: string): void;
19+
getAttribute(name: string): string | null;
20+
}
421

5-
const walkDom = function (node, func) {
22+
const walkDom = function (node: SimpleDomNode, func: (node: SimpleDomNode) => void): void {
623
func(node);
7-
node = node.firstChild;
24+
let child = node.firstChild;
825

9-
while (node) {
10-
walkDom(node, func);
11-
node = node.nextSibling;
26+
while (child) {
27+
walkDom(child, func);
28+
child = child.nextSibling;
1229
}
1330
};
1431

15-
const nodeTextContent = function (node) {
32+
const nodeTextContent = function (node: SimpleDomNode): string {
1633
let textContent = '';
1734

18-
walkDom(node, (currentNode) => {
35+
walkDom(node, (currentNode: SimpleDomNode) => {
1936
if (currentNode.nodeType === 3) {
2037
textContent += currentNode.nodeValue;
2138
}
@@ -24,21 +41,34 @@ const nodeTextContent = function (node) {
2441
return textContent;
2542
};
2643

44+
interface SimpleDom {
45+
createElement(tag: string): SimpleDomNode;
46+
}
47+
48+
interface DomModifierOptions {
49+
ghostVersion?: string;
50+
target?: string;
51+
dom: SimpleDom;
52+
[key: string]: unknown;
53+
}
54+
2755
// used to walk the rendered SimpleDOM output and modify elements before
2856
// serializing to HTML. Saves having a large HTML parsing dependency such as
2957
// jsdom that may break on malformed HTML in MD or HTML cards
3058
class DomModifier {
31-
constructor(options) {
32-
this.usedIds = [];
59+
usedIds: Record<string, number> = {};
60+
options: DomModifierOptions;
61+
62+
constructor(options: DomModifierOptions) {
3363
this.options = options;
3464
}
3565

36-
addHeadingId(node) {
66+
addHeadingId(node: SimpleDomNode): void {
3767
if (!node.firstChild || node.getAttribute('id')) {
3868
return;
3969
}
4070

41-
let text = nodeTextContent(node);
71+
const text = nodeTextContent(node);
4272
let id = slugify(text, this.options);
4373

4474
if (this.usedIds[id] !== undefined) {
@@ -51,7 +81,7 @@ class DomModifier {
5181
node.setAttribute('id', id);
5282
}
5383

54-
wrapBlockquoteContentInP(node) {
84+
wrapBlockquoteContentInP(node: SimpleDomNode): void {
5585
if (node.firstChild && node.firstChild.tagName === 'P') {
5686
return;
5787
}
@@ -64,11 +94,11 @@ class DomModifier {
6494
node.appendChild(p);
6595
}
6696

67-
modifyChildren(node) {
97+
modifyChildren(node: SimpleDomNode): void {
6898
walkDom(node, this.modify.bind(this));
6999
}
70100

71-
modify(node) {
101+
modify(node: SimpleDomNode): void {
72102
// add id attributes to H* tags
73103
if (node.nodeType === 1 && node.nodeName.match(/^h\d$/i)) {
74104
this.addHeadingId(node);
@@ -81,17 +111,50 @@ class DomModifier {
81111
}
82112
}
83113

84-
class MobiledocHtmlRenderer {
85-
constructor(options = {}) {
114+
interface CardDefinition {
115+
name: string;
116+
type: string;
117+
render(args: Record<string, unknown>): unknown;
118+
}
119+
120+
interface AtomDefinition {
121+
name: string;
122+
type: string;
123+
render(args: Record<string, unknown>): unknown;
124+
}
125+
126+
interface RendererOptions {
127+
cards?: CardDefinition[];
128+
atoms?: AtomDefinition[];
129+
unknownCardHandler?: (...args: unknown[]) => void;
130+
}
131+
132+
interface RendererInternalOptions {
133+
dom: SimpleDomDocument;
134+
cards: CardDefinition[];
135+
atoms: AtomDefinition[];
136+
unknownCardHandler: (...args: unknown[]) => void;
137+
}
138+
139+
interface Mobiledoc {
140+
ghostVersion?: string;
141+
version: string;
142+
[key: string]: unknown;
143+
}
144+
145+
export class MobiledocHtmlRenderer {
146+
options: RendererInternalOptions;
147+
148+
constructor(options: RendererOptions = {}) {
86149
this.options = {
87-
dom: new SimpleDom.Document(),
150+
dom: new SimpleDomDocument(),
88151
cards: options.cards || [],
89152
atoms: options.atoms || [],
90153
unknownCardHandler: options.unknownCardHandler || function () {}
91154
};
92155
}
93156

94-
render(mobiledoc, _cardOptions = {}) {
157+
render(mobiledoc: Mobiledoc, _cardOptions: Record<string, unknown> = {}): string {
95158
const ghostVersion = mobiledoc.ghostVersion || '4.0';
96159

97160
const defaultCardOptions = {
@@ -101,7 +164,7 @@ class MobiledocHtmlRenderer {
101164
const cardOptions = Object.assign({}, defaultCardOptions, _cardOptions);
102165

103166
const sectionElementRenderer = {
104-
ASIDE: function (tagName, dom) {
167+
ASIDE: function (_tagName: string, dom: SimpleDom) {
105168
// we use ASIDE sections in Koenig as a workaround for applying
106169
// a different blockquote style because mobiledoc doesn't support
107170
// storing arbitrary attributes with sections
@@ -114,11 +177,11 @@ class MobiledocHtmlRenderer {
114177
const rendererOptions = Object.assign({}, this.options, {cardOptions, sectionElementRenderer});
115178
const renderer = new Renderer(rendererOptions);
116179
const rendered = renderer.render(mobiledoc);
117-
const serializer = new SimpleDom.HTMLSerializer(SimpleDom.voidMap);
180+
const serializer = new HTMLSerializer(voidMap);
118181

119182
// Koenig keeps a blank paragraph at the end of a doc but we want to
120183
// make sure it doesn't get rendered
121-
const lastChild = rendered.result.lastChild;
184+
const lastChild = rendered.result.lastChild as SimpleDomNode | null;
122185
if (lastChild && lastChild.tagName === 'P') {
123186
if (!nodeTextContent(lastChild)) {
124187
rendered.result.removeChild(lastChild);
@@ -127,8 +190,8 @@ class MobiledocHtmlRenderer {
127190

128191
// Walk the DOM output and modify nodes as needed
129192
// eg. to add ID attributes to heading elements
130-
const modifier = new DomModifier(Object.assign({}, cardOptions, {dom: this.options.dom}));
131-
modifier.modifyChildren(rendered.result);
193+
const modifier = new DomModifier(Object.assign({}, cardOptions, {dom: this.options.dom}) as DomModifierOptions);
194+
modifier.modifyChildren(rendered.result as unknown as SimpleDomNode);
132195

133196
const output = serializer.serializeChildren(rendered.result);
134197

@@ -138,5 +201,3 @@ class MobiledocHtmlRenderer {
138201
return output;
139202
}
140203
}
141-
142-
module.exports = MobiledocHtmlRenderer;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export {MobiledocHtmlRenderer} from './MobiledocHtmlRenderer.js';
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
declare module 'mobiledoc-dom-renderer' {
2+
interface RenderResult {
3+
result: {
4+
lastChild: unknown;
5+
removeChild(child: unknown): void;
6+
};
7+
teardown(): void;
8+
}
9+
class Renderer {
10+
constructor(options: unknown);
11+
render(mobiledoc: unknown): RenderResult;
12+
}
13+
export default Renderer;
14+
}
15+
16+
declare module 'simple-dom' {
17+
class Document {
18+
createElement(tag: string): unknown;
19+
createComment(text: string): unknown;
20+
createDocumentFragment(): unknown;
21+
createTextNode(text: string): unknown;
22+
createRawHTMLSection(html: string): unknown;
23+
}
24+
class HTMLSerializer {
25+
constructor(voidMap: Record<string, boolean>);
26+
serialize(node: unknown): string;
27+
serializeChildren(node: unknown): string;
28+
}
29+
const voidMap: Record<string, boolean>;
30+
export {Document, HTMLSerializer, voidMap};
31+
}

0 commit comments

Comments
 (0)