Skip to content

Commit

Permalink
feat(types): generate typings for MediaInfo result object (closes #117)
Browse files Browse the repository at this point in the history
Generate TypeScript typings for the MediaInfo result object using a
new script `generate-types`. The script parses the MediaInfo XSD
schema, extracts relevant attributes, and creates TypeScript
interfaces `TrackType`, `MediaType`, and `MediaInfoType`.

BREAKING CHANGE: Consumers of the library may need to update their
type imports accordingly.
  • Loading branch information
buzz committed Jul 28, 2023
1 parent c4dab7d commit 57ca6f3
Show file tree
Hide file tree
Showing 9 changed files with 2,070 additions and 22 deletions.
1,074 changes: 1,069 additions & 5 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,14 @@
"dist/"
],
"scripts": {
"build:cli": "tsc --outDir ./dist/ --module commonjs --esModuleInterop src/cli.ts",
"build:cli": "tsc --outDir ./dist/ --module commonjs src/cli.ts",
"build:declaration": "tsc --emitDeclarationOnly --declarationDir ./dist/ --declaration true src/mediainfo.ts",
"build:deps": "bash scripts/build-deps.sh",
"build:js-wrapper": "rollup --bundleConfigAsCjs --config",
"build:wasm": "bash scripts/build.sh",
"build": "npm run build:deps && npm run build:wasm && npm run build:js-wrapper && cp src/types.d.ts dist/ && npm run build:declaration && npm run build:cli",
"build": "npm run build:deps && npm run build:wasm && npm run build:js-wrapper && cp src/types.d.ts src/types.generated.d.ts dist/ && npm run build:declaration && npm run build:cli",
"clean": "rimraf build dist vendor",
"generate-types": "ts-node scripts/generate-types/main.ts && npx prettier -w src/types.generated.d.ts",
"lint": "eslint .",
"test": "jest",
"type-check": "tsc --noEmit --project ."
Expand All @@ -58,9 +59,11 @@
"eslint-plugin-jest": "^27.2.1",
"eslint-plugin-prettier": "^4.2.1",
"jest": "^29.4.1",
"libxmljs2": "^0.31.0",
"prettier": "^2.8.3",
"rimraf": "^4.1.2",
"rollup": "^3.12.1",
"ts-node": "^10.9.1",
"typescript": "^4.9.5",
"xml2js": "^0.4.23"
}
Expand Down
31 changes: 31 additions & 0 deletions scripts/generate-types/factories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import ts from 'typescript'

export const exportModifier = ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)
export const readonlyModifier = ts.factory.createModifier(ts.SyntaxKind.ReadonlyKeyword)
const questionToken = ts.factory.createToken(ts.SyntaxKind.QuestionToken)

export function createProperty(
name: string,
typeName: string,
opts: { array?: boolean; required?: boolean; readonly?: boolean } = {}
) {
const defaultOpts = { array: false, required: false, readonly: true }
const mergedOpts = { ...defaultOpts, ...opts }
const refNode = ts.factory.createTypeReferenceNode(typeName)
return ts.factory.createPropertySignature(
mergedOpts.readonly ? [readonlyModifier] : undefined,
name,
mergedOpts.required ? undefined : questionToken,
mergedOpts.array ? ts.factory.createArrayTypeNode(refNode) : refNode
)
}

export function createInterface(name: string, members: readonly ts.TypeElement[]) {
return ts.factory.createInterfaceDeclaration(
[exportModifier],
name,
undefined,
undefined,
members
)
}
84 changes: 84 additions & 0 deletions scripts/generate-types/generate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import fs from 'fs/promises'
import ts from 'typescript'

import { createInterface, createProperty, exportModifier, readonlyModifier } from './factories'
import parseXsd from './parseXsd'

const topComment = '// DO NOT EDIT! File generated using `npm run generate-types`'

async function generate(outFilename: string) {
// CreationType
const ICreationType = createInterface('CreationType', [
createProperty('version', 'string', { required: true }),
createProperty('url', 'string'),
createProperty('build_date', 'string'),
createProperty('build_time', 'string'),
createProperty('compiler_ident', 'string'),
])

// ExtraType
const ExtraType = ts.factory.createTypeAliasDeclaration(
[exportModifier],
ts.factory.createIdentifier('ExtraType'),
undefined,
ts.factory.createTypeReferenceNode(ts.factory.createIdentifier('Record'), [
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword),
])
)

// TrackType
const ITrackType = createInterface('TrackType', [
ts.addSyntheticLeadingComment(
ts.factory.createPropertySignature(
[readonlyModifier],
"'@type'",
undefined,
ts.factory.createUnionTypeNode(
['General', 'Video', 'Audio', 'Text', 'Image', 'Chapters', 'Menu'].map((t) =>
ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral(t))
)
)
),

ts.SyntaxKind.MultiLineCommentTrivia,
'* Documents the type of encoded media with the track, ie: General, Video, Audio, Text, Image, etc. ',
true
),
ts.addSyntheticLeadingComment(
createProperty("'@typeorder'", 'string', { required: true }),
ts.SyntaxKind.MultiLineCommentTrivia,
'* If there is more than one track of the same type (i.e. four audio tracks) this attribute will number them according to storage order within the bitstream. ',
true
),
...(await parseXsd()), // Take long attribute list from MediaInfo XSD
])

// MediaType
const IMediaType = createInterface('MediaType', [
createProperty("'@ref'", 'string', { required: true }),
createProperty('track', 'TrackType', { array: true, required: true }),
])

// MediaInfoType
const IMediaInfo = createInterface('MediaInfoType', [
createProperty('creatingApplication', 'CreationType'),
createProperty('creatingLibrary', 'CreationType'),
createProperty('media', 'MediaType'),
createProperty('track', 'TrackType'),
])

// Generate source
const allNodes = [ICreationType, ExtraType, ITrackType, IMediaType, IMediaInfo]
const file = ts.createSourceFile('DUMMY.ts', '', ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS)
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
const tsSrc = [
topComment,
...allNodes.map((node) => printer.printNode(ts.EmitHint.Unspecified, node, file)),
].join('\n\n')

// Save generated source
await fs.writeFile(outFilename, tsSrc)
}

export default generate
11 changes: 11 additions & 0 deletions scripts/generate-types/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import path from 'path'

import generate from './generate'

const outFilename = path.resolve(__dirname, '..', '..', 'src', 'types.generated.d.ts')

generate(outFilename)
.then(() => {
console.log(`Types generated: ${outFilename}`)
})
.catch(console.error)
79 changes: 79 additions & 0 deletions scripts/generate-types/parseXsd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import https from 'https'

import { type Element, type Node, parseXml } from 'libxmljs2'
import ts from 'typescript'

import { createProperty } from './factories'

const URL = 'https://raw.githubusercontent.com/MediaArea/MediaAreaXml/master/mediainfo.xsd'
const namespace = 'http://www.w3.org/2001/XMLSchema'

function isElement(node: Node): node is Element {
return node.type() === 'element'
}

async function downloadSchema() {
return new Promise<string>((resolve, reject) => {
https
.get(URL, (resp) => {
let data = ''
resp.on('data', (chunk) => {
data += chunk
})
resp.on('end', () => {
resolve(data)
})
})
.on('error', reject)
})
}

async function parseXsd() {
const xmlDocData = await downloadSchema()
const xmlDoc = parseXml(xmlDocData)
const attrs = xmlDoc.find('//xmlns:complexType[@name="trackType"]/xmlns:all/*', namespace)

return attrs.filter(isElement).map((element) => {
let name = element.attr('name')?.value()
const minOccurs = element.attr('minOccurs')?.value()
const maxOccurs = element.attr('maxOccurs')?.value()
const xsdType = element.attr('type')?.value()
if (!name || !minOccurs || !maxOccurs || !xsdType) {
throw new Error('Element missing attribute')
}

name = name.includes('-') ? `'${name}'` : name

// all attributes should be required
if (minOccurs !== '0' || maxOccurs !== '1')
throw new Error(`minOccurs=${minOccurs} maxOccurs=${maxOccurs}`)

// extract type
let type: string
if (xsdType === 'extraType') type = 'ExtraType'
else if (xsdType === 'xsd:string') type = 'string'
else if (xsdType === 'xsd:integer') type = 'number'
else if (xsdType === 'xsd:float') type = 'number'
else throw new Error(`Unknown type: ${xsdType}`)

// extract docstring
let docString: string | undefined
const docEl = element.get('./xmlns:annotation/xmlns:documentation', namespace)
if (docEl && isElement(docEl)) {
docString = docEl.text().trim()
}

// create property
const prop = createProperty(name, type, { required: false })
return docString
? ts.addSyntheticLeadingComment(
prop,
ts.SyntaxKind.MultiLineCommentTrivia,
`* ${docString} `,
true
)
: prop
})
}

export default parseXsd
18 changes: 4 additions & 14 deletions src/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { MediaInfoType } from './types.generated'

export type FormatType = 'object' | 'JSON' | 'XML' | 'HTML' | 'text'

export interface MediaInfoWasmInterface {
Expand Down Expand Up @@ -106,20 +108,8 @@ export interface MediaInfo {
openBufferInit(size: number, offset: number): void
}

export type Track = {
'@type': 'General' | 'Video' | 'Audio' | 'Text' | 'Image' | 'Chapters' | 'Menu'
// Endless more properties:
// https://github.com/MediaArea/MediaInfoLib/tree/master/Source/Resource/Text/Stream
} & Record<string, unknown>

export interface ResultObject {
'@ref': string
media: {
track: Track[]
}
}

export type Result = ResultObject | string
/** MediaInfo result object. */
export type Result = MediaInfoType | string

export interface GetSizeFunc {
(): Promise<number> | number
Expand Down
Loading

0 comments on commit 57ca6f3

Please sign in to comment.