diff --git a/packages/kg-card-factory/.gitignore b/packages/kg-card-factory/.gitignore new file mode 100644 index 0000000000..0264be4400 --- /dev/null +++ b/packages/kg-card-factory/.gitignore @@ -0,0 +1,2 @@ +build/ +tsconfig.tsbuildinfo diff --git a/packages/kg-card-factory/eslint.config.mjs b/packages/kg-card-factory/eslint.config.mjs index d43dd6ed67..49b87a9339 100644 --- a/packages/kg-card-factory/eslint.config.mjs +++ b/packages/kg-card-factory/eslint.config.mjs @@ -1,42 +1,33 @@ -import {fixupPluginRules} from '@eslint/compat'; +import {defineConfig} from 'eslint/config'; import eslint from '@eslint/js'; import ghostPlugin from 'eslint-plugin-ghost'; -import globals from 'globals'; +import tseslint from 'typescript-eslint'; -const ghost = fixupPluginRules(ghostPlugin); - -export default [ +export default defineConfig([ {ignores: ['build/**']}, - eslint.configs.recommended, { - files: ['**/*.js'], - plugins: {ghost}, + files: ['**/*.ts'], + extends: [ + eslint.configs.recommended, + tseslint.configs.recommended + ], languageOptions: { - globals: globals.node + parserOptions: {ecmaVersion: 2022, sourceType: 'module'} }, + plugins: {ghost: ghostPlugin}, rules: { - ...ghostPlugin.configs.node.rules, - // match ESLint 8 behavior for catch clause variables - 'no-unused-vars': ['error', {caughtErrors: 'none'}], - // disable rules incompatible with ESLint 9 flat config - 'ghost/filenames/match-exported-class': 'off', - 'ghost/filenames/match-exported': 'off', - 'ghost/filenames/match-regex': 'off' + ...ghostPlugin.configs.ts.rules, + '@typescript-eslint/no-explicit-any': 'error' } }, { - files: ['test/**/*.js'], - plugins: {ghost}, - languageOptions: { - globals: { - ...globals.node, - ...globals.mocha, - should: true, - sinon: true - } - }, + files: ['test/**/*.ts'], rules: { - ...ghostPlugin.configs.test.rules + ...ghostPlugin.configs['ts-test'].rules, + 'ghost/mocha/no-global-tests': 'off', + 'ghost/mocha/handle-done-callback': 'off', + 'ghost/mocha/no-mocha-arrows': 'off', + 'ghost/mocha/max-top-level-suites': 'off' } } -]; +]); diff --git a/packages/kg-card-factory/index.js b/packages/kg-card-factory/index.js deleted file mode 100644 index 3836faa527..0000000000 --- a/packages/kg-card-factory/index.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('./lib/CardFactory'); diff --git a/packages/kg-card-factory/lib/CardFactory.js b/packages/kg-card-factory/lib/CardFactory.js deleted file mode 100644 index 1763f3c56c..0000000000 --- a/packages/kg-card-factory/lib/CardFactory.js +++ /dev/null @@ -1,86 +0,0 @@ -module.exports = class CardFactory { - constructor(options) { - this.factoryOptions = options; - } - - createCard(card) { - const {factoryOptions} = this; - - const { - name, - type, - config = {} - } = card; - - return { - name, - type, - factoryOptions, - - render({env, payload, options}) { - const {dom} = env; - const cardOptions = Object.assign({}, factoryOptions, options); - - const cardOutput = card.render({env, payload, options: cardOptions}); - - if (cardOutput.nodeType === 3 && cardOutput.nodeValue === '') { - return cardOutput; - } - - if (config.commentWrapper) { - const cleanName = name.replace(/^card-/, ''); - const beginComment = dom.createComment(`kg-card-begin: ${cleanName}`); - const endComment = dom.createComment(`kg-card-end: ${cleanName}`); - const fragment = dom.createDocumentFragment(); - - fragment.appendChild(beginComment); - fragment.appendChild(cardOutput); - fragment.appendChild(endComment); - - return fragment; - } - - return cardOutput; - }, - - absoluteToRelative(payload, _options) { - if (card.absoluteToRelative) { - const defaultOptions = { - assetsOnly: false, - siteUrl: factoryOptions.siteUrl - }; - const options = Object.assign({}, defaultOptions, _options); - return card.absoluteToRelative(payload, options); - } - - return payload; - }, - - relativeToAbsolute(payload, _options) { - if (card.relativeToAbsolute) { - const defaultOptions = { - assetsOnly: false, - siteUrl: factoryOptions.siteUrl - }; - const options = Object.assign({}, defaultOptions, _options); - return card.relativeToAbsolute(payload, options); - } - - return payload; - }, - - toTransformReady(payload, _options) { - if (card.toTransformReady) { - const defaultOptions = { - assetsOnly: false, - siteUrl: factoryOptions.siteUrl - }; - const options = Object.assign({}, defaultOptions, _options); - return card.toTransformReady(payload, options); - } - - return payload; - } - }; - } -}; diff --git a/packages/kg-card-factory/package.json b/packages/kg-card-factory/package.json index 7931978a49..29210b7cac 100644 --- a/packages/kg-card-factory/package.json +++ b/packages/kg-card-factory/package.json @@ -1,13 +1,25 @@ { "name": "@tryghost/kg-card-factory", "version": "5.1.12", - "repository": "https://github.com/TryGhost/Koenig/tree/master/packages/kg-card-factory", + "repository": "https://github.com/TryGhost/Koenig/tree/main/packages/kg-card-factory", "author": "Ghost Foundation", "license": "MIT", - "main": "index.js", + "main": "build/cjs/index.js", + "module": "build/esm/index.js", + "types": "build/esm/index.d.ts", + "exports": { + ".": { + "types": "./build/esm/index.d.ts", + "import": "./build/esm/index.js", + "require": "./build/cjs/index.js" + } + }, "scripts": { "dev": "echo \"Implement me!\"", - "test": "NODE_ENV=testing c8 --all --reporter text --reporter cobertura mocha './test/**/*.test.js'", + "build": "tsc && tsc -p tsconfig.cjs.json && echo '{\"type\":\"module\"}' > build/esm/package.json", + "prepare": "tsc && tsc -p tsconfig.cjs.json && echo '{\"type\":\"module\"}' > build/esm/package.json", + "pretest": "tsc && tsc -p tsconfig.cjs.json && echo '{\"type\":\"module\"}' > build/esm/package.json && tsc -p tsconfig.test.json", + "test": "NODE_ENV=testing c8 --all --reporter text --reporter cobertura mocha --require tsx './test/**/*.test.ts'", "lint": "eslint . --cache", "posttest": "yarn lint" }, @@ -15,17 +27,24 @@ "node": "^22.13.1 || ^24.0.0" }, "files": [ - "index.js", - "lib" + "build" ], "publishConfig": { "access": "public" }, "devDependencies": { + "@eslint/js": "9.39.4", + "@types/mocha": "10.0.10", + "@types/node": "22.15.29", + "@types/should": "13.0.0", + "@types/sinon": "17.0.4", "c8": "11.0.0", "mocha": "11.7.5", "should": "13.2.3", "simple-dom": "1.4.0", - "sinon": "21.0.2" + "sinon": "21.0.2", + "tsx": "4.19.4", + "typescript": "5.9.3", + "typescript-eslint": "8.33.1" } } diff --git a/packages/kg-card-factory/src/CardFactory.ts b/packages/kg-card-factory/src/CardFactory.ts new file mode 100644 index 0000000000..b3d5b0a905 --- /dev/null +++ b/packages/kg-card-factory/src/CardFactory.ts @@ -0,0 +1,119 @@ +export interface FactoryOptions { + siteUrl?: string; + [key: string]: unknown; +} + +export interface CardPayload { + [key: string]: unknown; +} + +export interface CardTransformOptions { + assetsOnly?: boolean; + siteUrl?: string; + [key: string]: unknown; +} + +export interface DomNode { + nodeType?: number; + nodeValue?: string; + appendChild?(child: unknown): void; + [key: string]: unknown; +} + +export interface DomProvider { + createComment(text: string): DomNode; + createDocumentFragment(): DomNode; + createElement?(tag: string): DomNode; + createTextNode?(text: string): DomNode; + [key: string]: unknown; +} + +export interface CardRenderEnv { + dom: DomProvider; + [key: string]: unknown; +} + +export interface CardRenderArgs { + env: CardRenderEnv; + payload: CardPayload; + options?: Record; +} + +export interface CardDefinition { + name: string; + type: string; + config?: { commentWrapper?: boolean }; + render(args: CardRenderArgs): DomNode; + absoluteToRelative?(payload: CardPayload, options: CardTransformOptions): CardPayload; + relativeToAbsolute?(payload: CardPayload, options: CardTransformOptions): CardPayload; + toTransformReady?(payload: CardPayload, options: CardTransformOptions): CardPayload; +} + +export default class CardFactory { + factoryOptions: FactoryOptions; + + constructor(options: FactoryOptions = {}) { + this.factoryOptions = options; + } + + createCard(card: CardDefinition) { + const {factoryOptions} = this; + const {name, type, config = {}} = card; + + return { + name, + type, + factoryOptions, + + render({env, payload, options}: CardRenderArgs): DomNode { + const {dom} = env; + const cardOptions = Object.assign({}, factoryOptions, options); + const cardOutput = card.render({env, payload, options: cardOptions}); + + if (cardOutput.nodeType === 3 && cardOutput.nodeValue === '') { + return cardOutput; + } + + if (config.commentWrapper) { + const cleanName = name.replace(/^card-/, ''); + const beginComment = dom.createComment(`kg-card-begin: ${cleanName}`); + const endComment = dom.createComment(`kg-card-end: ${cleanName}`); + const fragment = dom.createDocumentFragment(); + fragment.appendChild!(beginComment); + fragment.appendChild!(cardOutput); + fragment.appendChild!(endComment); + return fragment; + } + + return cardOutput; + }, + + absoluteToRelative(payload: CardPayload, _options?: CardTransformOptions) { + if (card.absoluteToRelative) { + const defaultOptions = {assetsOnly: false, siteUrl: factoryOptions.siteUrl}; + const options = Object.assign({}, defaultOptions, _options); + return card.absoluteToRelative(payload, options); + } + return payload; + }, + + relativeToAbsolute(payload: CardPayload, _options?: CardTransformOptions) { + if (card.relativeToAbsolute) { + const defaultOptions = {assetsOnly: false, siteUrl: factoryOptions.siteUrl}; + const options = Object.assign({}, defaultOptions, _options); + return card.relativeToAbsolute(payload, options); + } + return payload; + }, + + toTransformReady(payload: CardPayload, _options?: CardTransformOptions) { + if (card.toTransformReady) { + const defaultOptions = {assetsOnly: false, siteUrl: factoryOptions.siteUrl}; + const options = Object.assign({}, defaultOptions, _options); + return card.toTransformReady(payload, options); + } + return payload; + } + }; + } +} diff --git a/packages/kg-card-factory/src/index.ts b/packages/kg-card-factory/src/index.ts new file mode 100644 index 0000000000..9b46dfe533 --- /dev/null +++ b/packages/kg-card-factory/src/index.ts @@ -0,0 +1,2 @@ +export {default} from './CardFactory.js'; +export type {FactoryOptions, CardPayload, CardTransformOptions, CardRenderEnv, CardRenderArgs, CardDefinition} from './CardFactory.js'; diff --git a/packages/kg-card-factory/test/CardFactory.test.js b/packages/kg-card-factory/test/CardFactory.test.js deleted file mode 100644 index 0de9a1b6f1..0000000000 --- a/packages/kg-card-factory/test/CardFactory.test.js +++ /dev/null @@ -1,88 +0,0 @@ -// Switch these lines once there are useful utils -// const testUtils = require('./utils'); -require('./utils'); - -const CardFactory = require('../'); -const SimpleDom = require('simple-dom'); -const serializer = new SimpleDom.HTMLSerializer(SimpleDom.voidMap); - -describe('CardFactory', function () { - describe('render', function () { - it('adds comment wrapper when configured', function () { - let cardDefinition = { - name: 'test', - type: 'dom', - config: { - commentWrapper: true - }, - render({env: {dom}}) { - let div = dom.createElement('div'); - div.appendChild(dom.createTextNode('Test!')); - return div; - } - }; - let factory = new CardFactory(); - let card = factory.createCard(cardDefinition); - - let opts = { - env: { - dom: new SimpleDom.Document() - } - }; - - serializer.serialize(card.render(opts)) - .should.eql('
Test!
'); - }); - - it('skips comment wrapper if card output is blank', function () { - let cardDefinition = { - name: 'test', - type: 'dom', - config: { - commentWrapper: true - }, - render({env: {dom}}) { - return dom.createTextNode(''); - } - }; - let factory = new CardFactory(); - let card = factory.createCard(cardDefinition); - - let opts = { - env: { - dom: new SimpleDom.Document() - } - }; - - serializer.serialize(card.render(opts)).should.eql(''); - }); - }); - - describe('absoluteToRelative', function () { - it('passes siteUrl in from factory options', function () { - let cardDefinition = { - absoluteToRelative(payload, options) { - return options.siteUrl; - } - }; - let factory = new CardFactory({siteUrl: 'http://127.0.0.1:2368/'}); - let card = factory.createCard(cardDefinition); - - card.absoluteToRelative().should.equal('http://127.0.0.1:2368/'); - }); - }); - - describe('relativeToAbsolute', function () { - it('passes siteUrl in from factory options', function () { - let cardDefinition = { - relativeToAbsolute(payload, options) { - return options.siteUrl; - } - }; - let factory = new CardFactory({siteUrl: 'http://127.0.0.1:2368/'}); - let card = factory.createCard(cardDefinition); - - card.relativeToAbsolute().should.equal('http://127.0.0.1:2368/'); - }); - }); -}); diff --git a/packages/kg-card-factory/test/CardFactory.test.ts b/packages/kg-card-factory/test/CardFactory.test.ts new file mode 100644 index 0000000000..a1a6a9f810 --- /dev/null +++ b/packages/kg-card-factory/test/CardFactory.test.ts @@ -0,0 +1,83 @@ +import './utils/index.js'; +import CardFactory from '../src/CardFactory.js'; +import type {DomProvider} from '../src/CardFactory.js'; +import {Document as SimpleDomDocument, HTMLSerializer, voidMap} from 'simple-dom'; + +const serializer = new HTMLSerializer(voidMap); + +describe('CardFactory', function () { + describe('render', function () { + it('adds comment wrapper when configured', function () { + const factory = new CardFactory(); + const card = factory.createCard({ + name: 'test', + type: 'dom', + config: {commentWrapper: true}, + render({env: {dom}}) { + const d = dom as DomProvider & InstanceType; + const div = d.createElement('div'); + div.appendChild!(d.createTextNode('Test!')); + return div; + } + }); + + const opts = {env: {dom: new SimpleDomDocument() as unknown as DomProvider}, payload: {}, options: {}}; + + serializer.serialize(card.render(opts) as unknown as Parameters[0]) + .should.eql('
Test!
'); + }); + + it('skips comment wrapper if card output is blank', function () { + const factory = new CardFactory(); + const card = factory.createCard({ + name: 'test', + type: 'dom', + config: {commentWrapper: true}, + render({env: {dom}}) { + const d = dom as DomProvider & InstanceType; + return d.createTextNode(''); + } + }); + + const opts = {env: {dom: new SimpleDomDocument() as unknown as DomProvider}, payload: {}, options: {}}; + + serializer.serialize(card.render(opts) as unknown as Parameters[0]).should.eql(''); + }); + }); + + describe('absoluteToRelative', function () { + it('passes siteUrl in from factory options', function () { + const factory = new CardFactory({siteUrl: 'http://127.0.0.1:2368/'}); + const card = factory.createCard({ + name: 'test', + type: 'dom', + render() { + return {}; + }, + absoluteToRelative(_payload, options) { + return {receivedSiteUrl: options.siteUrl}; + } + }); + + card.absoluteToRelative({}).should.have.property('receivedSiteUrl', 'http://127.0.0.1:2368/'); + }); + }); + + describe('relativeToAbsolute', function () { + it('passes siteUrl in from factory options', function () { + const factory = new CardFactory({siteUrl: 'http://127.0.0.1:2368/'}); + const card = factory.createCard({ + name: 'test', + type: 'dom', + render() { + return {}; + }, + relativeToAbsolute(_payload, options) { + return {receivedSiteUrl: options.siteUrl}; + } + }); + + card.relativeToAbsolute({}).should.have.property('receivedSiteUrl', 'http://127.0.0.1:2368/'); + }); + }); +}); diff --git a/packages/kg-card-factory/test/utils/assertions.js b/packages/kg-card-factory/test/utils/assertions.ts similarity index 100% rename from packages/kg-card-factory/test/utils/assertions.js rename to packages/kg-card-factory/test/utils/assertions.ts diff --git a/packages/kg-card-factory/test/utils/index.js b/packages/kg-card-factory/test/utils/index.ts similarity index 76% rename from packages/kg-card-factory/test/utils/index.js rename to packages/kg-card-factory/test/utils/index.ts index 0d67d86ff8..f385b7bf56 100644 --- a/packages/kg-card-factory/test/utils/index.js +++ b/packages/kg-card-factory/test/utils/index.ts @@ -5,7 +5,7 @@ */ // Require overrides - these add globals for tests -require('./overrides'); +import './overrides.js'; // Require assertions - adds custom should assertions -require('./assertions'); +import './assertions.js'; diff --git a/packages/kg-card-factory/test/utils/overrides.js b/packages/kg-card-factory/test/utils/overrides.js deleted file mode 100644 index 90203424ee..0000000000 --- a/packages/kg-card-factory/test/utils/overrides.js +++ /dev/null @@ -1,10 +0,0 @@ -// This file is required before any test is run - -// Taken from the should wiki, this is how to make should global -// Should is a global in our eslint test config -global.should = require('should').noConflict(); -should.extend(); - -// Sinon is a simple case -// Sinon is a global in our eslint test config -global.sinon = require('sinon'); diff --git a/packages/kg-card-factory/test/utils/overrides.ts b/packages/kg-card-factory/test/utils/overrides.ts new file mode 100644 index 0000000000..6138e929ff --- /dev/null +++ b/packages/kg-card-factory/test/utils/overrides.ts @@ -0,0 +1,15 @@ +// This file is required before any test is run + +// Taken from the should wiki, this is how to make should global +// Should is a global in our eslint test config +import should from 'should'; +import sinon from 'sinon'; + +// @types/should is incomplete — noConflict and extend exist at runtime +const shouldModule = should as unknown as {noConflict(): typeof should; extend(): void}; +Object.defineProperty(globalThis, 'should', {value: shouldModule.noConflict(), writable: true, configurable: true}); +shouldModule.extend(); + +// Sinon is a simple case +// Sinon is a global in our eslint test config +Object.defineProperty(globalThis, 'sinon', {value: sinon, writable: true, configurable: true}); diff --git a/packages/kg-card-factory/tsconfig.cjs.json b/packages/kg-card-factory/tsconfig.cjs.json new file mode 100644 index 0000000000..bd981491bc --- /dev/null +++ b/packages/kg-card-factory/tsconfig.cjs.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "build/cjs", + "verbatimModuleSyntax": false, + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "incremental": false + } +} diff --git a/packages/kg-card-factory/tsconfig.json b/packages/kg-card-factory/tsconfig.json new file mode 100644 index 0000000000..e2ebf36cde --- /dev/null +++ b/packages/kg-card-factory/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "ES2022", + "moduleResolution": "bundler", + "rootDir": "src", + "outDir": "build/esm", + "strict": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "incremental": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": [ + "src/**/*" + ] +} diff --git a/packages/kg-card-factory/tsconfig.test.json b/packages/kg-card-factory/tsconfig.test.json new file mode 100644 index 0000000000..b092804ad8 --- /dev/null +++ b/packages/kg-card-factory/tsconfig.test.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": null, + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "incremental": false + }, + "include": [ + "src/**/*", + "test/**/*" + ] +}