Views: 742
Number of votes: 1
Average rating:

Generate Typescript interfaces for pages and blocks

At Alm Brand we are using Vue.js together with the content delivery api for our Single Page Applications(SPA) that powers parts of our website and App, when we starting doing this we took a lot of inspiration from episerver musicfestival vue template and we ended up with what we call the "Vue-Epi Framework" (yea the name needs work).

One of our key differences with the template, and the focus for this blog post, is we are using TypeScript which leads to the question:
How do we get that sweet typing for our episerver models when using the content delivery api? read on and find out!

Typescript all the things

The first thing to do is find or create the folder where your typescript definitions lives and create a file named content.d.ts that maps all the base/common types/properties like the short sample below

/** interface for ContentLanguage coming from episerver content delivery api */
export interface ContentLanguage {
    /** the link to the this langauge version of the current content */
    link: string;
    /** the localized displayName for the language */
    displayName: string;
    /** the ISO name for the language */
    name: string;
}

/** interface for ContentReference coming from episerver content delivery api */
export interface ContentReference {
    /** the episerver content id */
    id: number;
    /** the episerver content work id */
    workId: number;
    /** the guid id of the content */
    guidValue: string;
    /** the content providerName */
    providerName?: string;
    /** url to the content (block points to the frontpage) */
    url: string;
}

(full file here

That is used by the ContentTypeCodeGenerator, a simple mapper that scans our assemblies for content, enums and "other things" to turn them into typescript interfaces and saving those to a file.

private void GenerateTypescriptInterfaces()
{
    IEnumerable<Assembly> assemblies = GetAssemblies();
    IEnumerable<Type> types = assemblies.SelectMany(a => GetTypesFromAssembly(a)).ToList();
    var contentTypes = types.Where(t => t.GetCustomAttribute<ContentTypeAttribute>() != null && !typeof(IContentMedia).IsAssignableFrom(t))
    StringBuilder builder = new StringBuilder();
    builder.AppendLine("import { IContent, ContentLanguage, ContentReference } from './content'");
    GenerateTypescriptEnums(types, builder)
    foreach (var contentType in contentTypes)
    {
        Logger.Information("Adding {ContentType} as typescript interface", contentType.Name);
        builder.AppendLine($"export interface {contentType.Name} extends IContent {{");
        AddProperties(contentType);
        builder.AppendLine("}")
    
    var fileText = builder.ToString();
    if (HasFileContentChanged(fileText))
    {
        File.WriteAllText(FilePath, fileText);
    }
}

(full class here)

most of the secret sauce is in the GetDataType method that maps an property to an typescript type

string GetDataType(Type contentType, PropertyInfo property)
{
    if (TypeMappings.TryGetValue(property.PropertyType, out var func))
    {
        return func(contentType, property);
    }
    else
    {
        if (typeof(IEnumerable).IsAssignableFrom(property.PropertyType))
        {
            return FindListType(contentType, property);
        }
        return property.PropertyType.Name;
    }
}

As you can see we either lookup the type in TypeMappings or return the name of the property type(mostly used with Enum and Block properties on models)
the TypeMappings is a simple Dictionary with mappings as can seen below 

private static IDictionary<Type, Func<Type, PropertyInfo, string>> CreateTypeMapping()
{
    var mappingDictionary = new Dictionary<Type, Func<Type, PropertyInfo, string>>();
    mappingDictionary.Add(typeof(string), (t, p) => "string");
    mappingDictionary.Add(typeof(int), (t, p) => "number");
    mappingDictionary.Add(typeof(int?), (t, p) => "number");
    mappingDictionary.Add(typeof(decimal), (t, p) => "number");
    mappingDictionary.Add(typeof(decimal?), (t, p) => "number");
    mappingDictionary.Add(typeof(float), (t, p) => "number");
    mappingDictionary.Add(typeof(float?), (t, p) => "number");
    mappingDictionary.Add(typeof(double), (t, p) => "number");
    mappingDictionary.Add(typeof(double?), (t, p) => "number");
    mappingDictionary.Add(typeof(bool), (t, p) => "boolean");
    mappingDictionary.Add(typeof(bool?), (t, p) => "boolean");
    mappingDictionary.Add(typeof(DateTime), (t, p) => "string");
    mappingDictionary.Add(typeof(DateTime?), (t, p) => "string");
    mappingDictionary.Add(typeof(ContentReference), (t, p) => GetContentReferenceType(t, p));
    mappingDictionary.Add(typeof(PageReference), (t, p) => "ContentReference");
    mappingDictionary.Add(typeof(ContentArea), (t, p) => "Array<IContent>");
    mappingDictionary.Add(typeof(LinkItemCollection), (t, p) => "Array<string>");
    mappingDictionary.Add(typeof(PropertyContentReferenceList), (t, p) => "Array<IContent>");
    mappingDictionary.Add(typeof(Url), (t, p) => "string");
    mappingDictionary.Add(typeof(XhtmlString), (t, p) => "string");
    return mappingDictionary;
    string GetContentReferenceType(Type contentType, PropertyInfo property)
    {
        //we convert ContentReferences that point to Images to an url in content delivery api
        var uiHint = property.GetCustomAttribute<UIHintAttribute>();
        if (uiHint?.UIHint == UIHint.Image)
        {
            return "string";
        }
        return "ContentReference";
    }
}

the resulting file is like this (only with much more data of course)

export enum TileVariantEnum {
  Navigation=1,
  Call=2,
}

export interface TileBlock extends IContent {
  tileVariant: TileVariantEnum;
  title: string;
  icon: string;
  link: string;
  phoneNumber: string;
  renderHtml: boolean;
  hide: boolean;
}

You can now import your models in your typescript code and get that sweet, sweet typing experience.

 

import { Vue, Component } from "vue-property-decorator";
import { PropType } from "vue";
import { mapState } from "vuex";
import template from './TileBlock.vue';
import { TileBlock, TileVariantEnum } from "@scripts/definitions/episerver/content-types";
import { AbLink } from "@scripts/app/components/sharedcomponents/baseComponents/components";

@Component({
    name: 'TileBlockComponent',
    mixins:[template],
    components: {
        AbLink
    },
    computed: mapState<any>({
        isEditable: state => state.epiContext.isEditable,
        parentModel: state => state.epiContent.model
    }),
    props: {
        model: Object as PropType<TileBlock>
    },
})

export default class TileBlockComponent extends Vue {
    model: TileBlock;
    path: string = "";

    mounted() {
        this.path = this.model.link;
        if (this.model.tileVariant === TileVariantEnum.Call) {
            this.path = this.model.phoneNumber;
        } 
    }
}

There is some small cavats with this, it assumes you are using SetFlattenPropertyModel(true) and SetExtendedContentTypeModel(true) with content delivery api and some of the mappings like ContentReference to string when it have the UIHint.Image attribute also requires you to expand the content delivery api with an custom IPropertyModelConverter to actually make the conversion but that another blog post ;) 

we call this code from both an InitializableModule when on localhost for devs to always have an up to date version of the file and for our deployments we have an CI/CD pipeline that does the same thing to make sure the frontend can compile with the code that are currently building.

Play around with ContentTypeCodeGenerator and content.d.ts and see if it is something you can use :) 

Jul 07, 2020

Michael Clausing
( By Michael Clausing, 7/7/2020 8:41:53 PM)

We are working on an addon for content management using react, and have had some success using Reinforced.Typings to generate Typescript interfaces/enums. It took a little trial an error to find the correct settings, but I think it works pretty well now.

Please login to comment.