diff --git a/.github/workflows/.release-please-manifest.json b/.github/workflows/.release-please-manifest.json index 37fcefa..895bf0e 100644 --- a/.github/workflows/.release-please-manifest.json +++ b/.github/workflows/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.0.0" + ".": "2.0.0" } diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 46090b5..2adcc1c 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -32,31 +32,7 @@ jobs: if: needs.check-skip.outputs.should_skip != 'true' steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Retrieve version from package.json - id: retrieve_version - run: | - new_version=$(jq -r .version package.json) - if [ -z "$new_version" ]; then - echo "❌ version not found in package.json" >&2 - exit 1 - fi - if ! echo "$new_version" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' > /dev/null; then - echo "❌ version '$new_version' is not a valid semantic version (e.g., 1.2.3)" >&2 - exit 1 - fi - echo "new_version=$new_version" >> $GITHUB_OUTPUT - - - name: Set release version for changelog - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - git commit --allow-empty -m "chore: release ${{ steps.retrieve_version.outputs.new_version }}" -m "Release-As: ${{ steps.retrieve_version.outputs.new_version }}" - git push - - - name: Create release changelog + - name: Create release changelog & update version id: release uses: googleapis/release-please-action@v4 with: diff --git a/.github/workflows/release-please-config.json b/.github/workflows/release-please-config.json index e408baa..b6fe857 100644 --- a/.github/workflows/release-please-config.json +++ b/.github/workflows/release-please-config.json @@ -1,13 +1,14 @@ { - "plugins": ["node-workspace", "sentence-case"], + "plugins": ["sentence-case"], "always-update": true, "skip-github-release": true, + "pull-request-title-pattern": "chore: bump version to ${version} and update changelog", "pull-request-header": "🤖: I have generated a release changelog", "pull-request-footer": "This PR was automatically generated by 🤖.", "include-component-in-tag": false, "packages": { ".": { - "release-type": "simple", + "release-type": "node", "changelog-sections": [ { "type": "feat", @@ -50,6 +51,12 @@ "section": "Miscellaneous", "hidden": false, "type-plural": "Chores" + }, + { + "type": "ci", + "section": "CI", + "hidden": false, + "type-plural": "CI" } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index aa02949..9361581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## [2.0.0](https://github.com/pyx-industries/vc-render-template-utils/compare/1.0.0...v2.0.0) (2026-05-01) + + +### ⚠ BREAKING CHANGES + +* **render-method-2024:** align RenderTemplate2024 with downstream renderer ([#19](https://github.com/pyx-industries/vc-render-template-utils/issues/19)) + +### Features + +* Enhance removeLineBreaks to handle \r and consecutive line breaks ([#5](https://github.com/pyx-industries/vc-render-template-utils/issues/5)) ([4d14aaa](https://github.com/pyx-industries/vc-render-template-utils/commit/4d14aaa3db0c627c2e475bab69e9cea79edb065e)) +* Normalise template whitespace ([#4](https://github.com/pyx-industries/vc-render-template-utils/issues/4)) ([fb40692](https://github.com/pyx-industries/vc-render-template-utils/commit/fb40692c6a74f02aedae454a31498b79fc611608)) +* **render-method-2024:** Align RenderTemplate2024 with downstream renderer ([#19](https://github.com/pyx-industries/vc-render-template-utils/issues/19)) ([d042ef9](https://github.com/pyx-industries/vc-render-template-utils/commit/d042ef9afbbea4fca20bbeb5d51d4c1515a69faa)) + + +### Miscellaneous + +* Add repository information ([318eeed](https://github.com/pyx-industries/vc-render-template-utils/commit/318eeed09984066db8fa7335652b6db87c2e36b3)) +* Fix extractRenderTemplate example format ([#3](https://github.com/pyx-industries/vc-render-template-utils/issues/3)) ([1212afa](https://github.com/pyx-industries/vc-render-template-utils/commit/1212afa2b73ffa0c49e80df799a9a124d23c8a7a)) + + +### CI + +* Fix changelog manifest version bump ([#11](https://github.com/pyx-industries/vc-render-template-utils/issues/11)) ([4b5d4d9](https://github.com/pyx-industries/vc-render-template-utils/commit/4b5d4d984ef5efcb1aa36f03b57dd310077493b8)) +* Remove node workspace plugin ([#15](https://github.com/pyx-industries/vc-render-template-utils/issues/15)) ([b444c9a](https://github.com/pyx-industries/vc-render-template-utils/commit/b444c9ac435d2d8e8c16f428df2410967ccd53df)) +* Update changelog config ([#7](https://github.com/pyx-industries/vc-render-template-utils/issues/7)) ([f6bdf4b](https://github.com/pyx-industries/vc-render-template-utils/commit/f6bdf4b35c62e99cddf0821466ccffe9268b44dc)) +* Update changelog config ([#9](https://github.com/pyx-industries/vc-render-template-utils/issues/9)) ([f48a248](https://github.com/pyx-industries/vc-render-template-utils/commit/f48a24881342bdec3773e095eb569e808535d4dd)) + ## [1.0.0](https://github.com/pyx-industries/vc-render-template-utils/compare/v1.0.0...v1.0.0) (2025-04-29) diff --git a/README.md b/README.md index ffbd555..129e940 100644 --- a/README.md +++ b/README.md @@ -92,12 +92,12 @@ Constructs a render method object for the specified template and type. - `template`: The template string or empty if using a URL. - `renderMethodType`: Either `RenderTemplate2024` or `WebRenderingTemplate2022`. -- `extra`: Optional metadata (e.g., `url` or `mediaQuery`). +- `extra`: Optional metadata. For `RenderTemplate2024` the supported keys are `name`, `mediaQuery`, `url`, `mediaType`, and `digestMultibase`. Empty or non-string values are omitted from the constructed render method rather than emitted as empty strings. ### extractRenderTemplate ```typescript -`extractRenderTemplate(renderMethod: RenderMethod) +extractRenderTemplate(renderMethod: RenderMethod) ``` Extracts the template content, fetching from a URL if necessary. diff --git a/package.json b/package.json index 20c3d98..02335d2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,11 @@ { "name": "@pyx-industries/vc-render-template-utils", - "version": "1.0.0", + "version": "2.0.0", "description": "A lightweight utility library for constructing, extracting and rendering verifiable credential render templates.", + "repository": { + "type": "git", + "url": "https://github.com/pyx-industries/vc-render-template-utils.git" + }, "license": "Apache-2.0", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index c430e04..237c00e 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -33,6 +33,7 @@ describe('Main Index Functions', () => { MockedRenderMethodFactory.mockClear(); MockedTemplatingEngineFactory.mockClear(); mockedUtils.removeLineBreaks.mockClear(); + mockedUtils.normaliseWhitespace.mockClear(); mockRenderMethodProvider = { construct: jest.fn(), @@ -51,27 +52,39 @@ describe('Main Index Functions', () => { }); describe('constructRenderMethod', () => { - const template = 'template\nwith\nbreaks'; - const cleanedTemplate = 'templatewithbreaks'; + const template = 'template\nwith\nbreaks and spaces'; + const templateWithoutLineBreaks = 'templatewithbreaks and spaces'; + const cleanedTemplate = 'templatewithbreaks and spaces'; const type = RenderMethodType.RenderTemplate2024; const extra = { key: 'value' }; const expectedResult: RenderMethod = { - type: RenderMethodType.RenderTemplate2024, + type: [RenderMethodType.RenderTemplate2024], template: cleanedTemplate, - mediaQuery: '', - url: '', }; it('should call removeLineBreaks with the template', () => { - mockedUtils.removeLineBreaks.mockReturnValue(cleanedTemplate); + mockedUtils.removeLineBreaks.mockReturnValue(templateWithoutLineBreaks); + mockedUtils.normaliseWhitespace.mockReturnValue(cleanedTemplate); mockRenderMethodProvider.construct.mockReturnValue(expectedResult); constructRenderMethod(template, type, extra); expect(mockedUtils.removeLineBreaks).toHaveBeenCalledWith(template); }); + it('should call normaliseWhitespace with the result of removeLineBreaks', () => { + mockedUtils.removeLineBreaks.mockReturnValue(templateWithoutLineBreaks); + mockedUtils.normaliseWhitespace.mockReturnValue(cleanedTemplate); + mockRenderMethodProvider.construct.mockReturnValue(expectedResult); + + constructRenderMethod(template, type, extra); + expect(mockedUtils.normaliseWhitespace).toHaveBeenCalledWith( + templateWithoutLineBreaks, + ); + }); + it('should create RenderMethodFactory and call createRenderMethod', () => { - mockedUtils.removeLineBreaks.mockReturnValue(cleanedTemplate); + mockedUtils.removeLineBreaks.mockReturnValue(templateWithoutLineBreaks); + mockedUtils.normaliseWhitespace.mockReturnValue(cleanedTemplate); mockRenderMethodProvider.construct.mockReturnValue(expectedResult); constructRenderMethod(template, type, extra); @@ -82,7 +95,8 @@ describe('Main Index Functions', () => { }); it('should call construct on the created render method provider', () => { - mockedUtils.removeLineBreaks.mockReturnValue(cleanedTemplate); + mockedUtils.removeLineBreaks.mockReturnValue(templateWithoutLineBreaks); + mockedUtils.normaliseWhitespace.mockReturnValue(cleanedTemplate); mockRenderMethodProvider.construct.mockReturnValue(expectedResult); constructRenderMethod(template, type, extra); @@ -93,7 +107,8 @@ describe('Main Index Functions', () => { }); it('should return the result from the render method provider construct', () => { - mockedUtils.removeLineBreaks.mockReturnValue(cleanedTemplate); + mockedUtils.removeLineBreaks.mockReturnValue(templateWithoutLineBreaks); + mockedUtils.normaliseWhitespace.mockReturnValue(cleanedTemplate); mockRenderMethodProvider.construct.mockReturnValue(expectedResult); const result = constructRenderMethod(template, type, extra); @@ -103,12 +118,12 @@ describe('Main Index Functions', () => { describe('extractRenderTemplate', () => { const renderMethodObject: RenderMethod = { - type: RenderMethodType.RenderTemplate2024, + type: [RenderMethodType.RenderTemplate2024], template: 'some template', }; const expectedTemplate = 'extracted template content'; - it('should create RenderMethodFactory and call createRenderMethod', async () => { + it('should create RenderMethodFactory and call createRenderMethod with the resolved render method type', async () => { mockRenderMethodProvider.extractTemplate.mockResolvedValue( expectedTemplate, ); @@ -117,7 +132,55 @@ describe('Main Index Functions', () => { expect(MockedRenderMethodFactory).toHaveBeenCalledTimes(1); expect( MockedRenderMethodFactory.prototype.createRenderMethod, - ).toHaveBeenCalledWith(renderMethodObject.type); + ).toHaveBeenCalledWith(RenderMethodType.RenderTemplate2024); + }); + + it('should resolve the render method type from a `type` array containing additional values', async () => { + mockRenderMethodProvider.extractTemplate.mockResolvedValue( + expectedTemplate, + ); + + await extractRenderTemplate({ + type: ['SomeOtherType', RenderMethodType.RenderTemplate2024], + template: 'some template', + }); + expect( + MockedRenderMethodFactory.prototype.createRenderMethod, + ).toHaveBeenCalledWith(RenderMethodType.RenderTemplate2024); + }); + + it('should pass a non-array `type` through unchanged to the factory', async () => { + mockRenderMethodProvider.extractTemplate.mockResolvedValue( + expectedTemplate, + ); + + await extractRenderTemplate({ + type: RenderMethodType.WebRenderingTemplate2022, + template: 'some template', + }); + expect( + MockedRenderMethodFactory.prototype.createRenderMethod, + ).toHaveBeenCalledWith(RenderMethodType.WebRenderingTemplate2022); + }); + + it('should throw UnsupportedRenderMethodError including the unsupported entries when no `type` entry is supported', async () => { + await expect( + extractRenderTemplate({ + type: ['UnknownType', 'AnotherUnknown'], + template: 'some template', + } as unknown as RenderMethod), + ).rejects.toThrow( + 'Unsupported render method: UnknownType, AnotherUnknown', + ); + }); + + it('should throw UnsupportedRenderMethodError with an `` indicator when `type` is an empty array', async () => { + await expect( + extractRenderTemplate({ + type: [], + template: 'some template', + } as unknown as RenderMethod), + ).rejects.toThrow('Unsupported render method: '); }); it('should call extractTemplate on the created render method provider', async () => { diff --git a/src/__tests__/render_methods/render-method-2024.test.ts b/src/__tests__/render_methods/render-method-2024.test.ts index 2b2736c..bf08f72 100644 --- a/src/__tests__/render_methods/render-method-2024.test.ts +++ b/src/__tests__/render_methods/render-method-2024.test.ts @@ -25,23 +25,84 @@ describe('RenderMethod2024', () => { }); describe('construct', () => { - it('should construct a RenderTemplate2024 object correctly', () => { + it('should construct a RenderTemplate2024 object with `type` as an array containing the render method type', () => { + const result = renderMethod.construct(templateContent, extraData); + expect(result.type).toEqual([RenderMethodType.RenderTemplate2024]); + }); + + it('should include template and provided extra fields when present', () => { const result = renderMethod.construct(templateContent, extraData); expect(result).toEqual({ - type: RenderMethodType.RenderTemplate2024, + type: [RenderMethodType.RenderTemplate2024], template: templateContent, mediaQuery: extraData.mediaQuery, url: extraData.url, }); }); - it('should use default values if extra data is missing', () => { + it('should accept all spec-defined optional fields via extra', () => { + const fullExtra = { + name: 'Display Name', + mediaQuery: 'print', + url: 'http://example.com/t.html', + mediaType: 'text/html', + digestMultibase: 'zQmExampleHash', + }; + const result = renderMethod.construct(templateContent, fullExtra); + expect(result).toEqual({ + type: [RenderMethodType.RenderTemplate2024], + template: templateContent, + ...fullExtra, + }); + }); + + it('should omit optional fields that are not provided rather than emitting empty strings', () => { const result = renderMethod.construct(templateContent, {}); expect(result).toEqual({ - type: RenderMethodType.RenderTemplate2024, + type: [RenderMethodType.RenderTemplate2024], template: templateContent, + }); + expect(result).not.toHaveProperty('mediaQuery'); + expect(result).not.toHaveProperty('url'); + expect(result).not.toHaveProperty('name'); + expect(result).not.toHaveProperty('mediaType'); + expect(result).not.toHaveProperty('digestMultibase'); + }); + + it('should omit optional fields that are explicitly empty strings', () => { + const result = renderMethod.construct(templateContent, { + name: '', mediaQuery: '', url: '', + mediaType: '', + digestMultibase: '', + }); + expect(result).toEqual({ + type: [RenderMethodType.RenderTemplate2024], + template: templateContent, + }); + }); + + it('should omit `template` when it is an empty string (URL-only case)', () => { + const result = renderMethod.construct('', { + url: 'http://example.com/t.html', + }); + expect(result).toEqual({ + type: [RenderMethodType.RenderTemplate2024], + url: 'http://example.com/t.html', + }); + expect(result).not.toHaveProperty('template'); + }); + + it('should ignore non-string values supplied via extra', () => { + const result = renderMethod.construct(templateContent, { + name: 42, + mediaQuery: null, + url: undefined, + } as unknown as Record); + expect(result).toEqual({ + type: [RenderMethodType.RenderTemplate2024], + template: templateContent, }); }); }); diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index e47a36e..f9a8e07 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -1,5 +1,5 @@ import { TemplateFetchError } from '../errors'; -import { fetchTemplate, removeLineBreaks } from '../utils'; +import { fetchTemplate, normaliseWhitespace, removeLineBreaks } from '../utils'; describe('Utility Functions', () => { beforeEach(() => { @@ -85,5 +85,51 @@ describe('Utility Functions', () => { it('should handle mixed content', () => { expect(removeLineBreaks('start\nmiddle\nend')).toBe('startmiddleend'); }); + + it('should remove carriage returns', () => { + expect(removeLineBreaks('hello\rworld')).toBe('helloworld'); + }); + + it('should remove Windows-style line endings (CRLF)', () => { + expect(removeLineBreaks('hello\r\nworld')).toBe('helloworld'); + }); + + it('should remove multiple consecutive mixed line breaks', () => { + expect(removeLineBreaks('hello\n\n\rworld\r\n\r\nmiddle')).toBe( + 'helloworldmiddle', + ); + }); + + it('should treat consecutive line breaks as a single replacement', () => { + expect(removeLineBreaks('line\n\n\nbreak')).toBe('linebreak'); + expect(removeLineBreaks('line\r\r\rbreak')).toBe('linebreak'); + expect(removeLineBreaks('line\r\n\r\nbreak')).toBe('linebreak'); + }); + }); + + describe('normaliseWhitespace', () => { + it('should replace multiple spaces with a single space', () => { + expect(normaliseWhitespace('hello world')).toBe('hello world'); + }); + + it('should replace tabs with a single space', () => { + expect(normaliseWhitespace('hello\tworld')).toBe('hello world'); + }); + + it('should replace mixed whitespace with a single space', () => { + expect(normaliseWhitespace('hello \t \n world')).toBe('hello world'); + }); + + it('should handle leading and trailing whitespace', () => { + expect(normaliseWhitespace(' hello world ')).toBe(' hello world '); + }); + + it('should handle empty string', () => { + expect(normaliseWhitespace('')).toBe(''); + }); + + it('should handle string with only whitespace', () => { + expect(normaliseWhitespace(' \t\n ')).toBe(' '); + }); }); }); diff --git a/src/errors/render-methods.ts b/src/errors/render-methods.ts index 2df2fa7..bc7dc46 100644 --- a/src/errors/render-methods.ts +++ b/src/errors/render-methods.ts @@ -1,7 +1,7 @@ import { RenderMethodType } from '../types'; export class UnsupportedRenderMethodError extends Error { - constructor(method: RenderMethodType) { + constructor(method: RenderMethodType | string) { super( `Unsupported render method: ${method}. Supported methods are: ${Object.values( RenderMethodType, diff --git a/src/index.ts b/src/index.ts index 1902161..2980324 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,13 @@ +import { UnsupportedRenderMethodError } from './errors'; import { RenderMethodFactory } from './render_methods/factory'; import { TemplatingEngineFactory } from './templating_engines/factory'; -import { RenderMethod, RenderMethodType, TemplatingEngineType } from './types'; -import { removeLineBreaks } from './utils'; +import { + RenderMethod, + RenderMethodType, + TemplatingEngineType, + supportedRenderMethods, +} from './types'; +import { normaliseWhitespace, removeLineBreaks } from './utils'; export * from './errors'; export * from './types'; @@ -11,7 +17,7 @@ export function constructRenderMethod( renderMethodType: RenderMethodType, extra: Record = {}, ): RenderMethod { - const cleanedTemplate = removeLineBreaks(template); + const cleanedTemplate = normaliseWhitespace(removeLineBreaks(template)); const renderMethodFactory = new RenderMethodFactory(); const renderMethod = renderMethodFactory.createRenderMethod(renderMethodType); @@ -22,7 +28,7 @@ export function constructRenderMethod( export const extractRenderTemplate = async ( renderMethodObject: RenderMethod, ): Promise => { - const renderMethodType = renderMethodObject.type; + const renderMethodType = resolveRenderMethodType(renderMethodObject.type); const renderMethodFactory = new RenderMethodFactory(); const renderMethod = renderMethodFactory.createRenderMethod(renderMethodType); @@ -32,6 +38,26 @@ export const extractRenderTemplate = async ( return renderTemplate; }; +const resolveRenderMethodType = ( + type: RenderMethod['type'], +): RenderMethodType => { + if (!Array.isArray(type)) { + return type; + } + + const match = type.find((entry): entry is RenderMethodType => + (supportedRenderMethods as readonly string[]).includes(entry), + ); + + if (!match) { + throw new UnsupportedRenderMethodError( + type.length === 0 ? '' : type.join(', '), + ); + } + + return match; +}; + export const populateTemplate = ( templatingEngineType: TemplatingEngineType, renderTemplate: string, diff --git a/src/render_methods/render-method-2024.ts b/src/render_methods/render-method-2024.ts index 5231060..3bde028 100644 --- a/src/render_methods/render-method-2024.ts +++ b/src/render_methods/render-method-2024.ts @@ -9,12 +9,17 @@ import { import { fetchTemplate } from '../utils'; export const defaultRenderMethod2024: RenderTemplate2024 = { - type: RenderMethodType.RenderTemplate2024, - mediaQuery: '', - template: '', - url: '', + type: [RenderMethodType.RenderTemplate2024], }; +const OPTIONAL_FIELDS = [ + 'name', + 'mediaQuery', + 'url', + 'mediaType', + 'digestMultibase', +] as const; + export class RenderMethod2024 implements RenderMethodProvider { private readonly _formatter: Formatter; @@ -26,12 +31,22 @@ export class RenderMethod2024 implements RenderMethodProvider { template: string, extra: Record, ): RenderTemplate2024 { - return { - type: RenderMethodType.RenderTemplate2024, - mediaQuery: (extra.mediaQuery || '') as string, - template, - url: (extra.url || '') as string, + const result: RenderTemplate2024 = { + type: [RenderMethodType.RenderTemplate2024], }; + + if (template) { + result.template = template; + } + + for (const field of OPTIONAL_FIELDS) { + const value = extra[field]; + if (typeof value === 'string' && value !== '') { + result[field] = value; + } + } + + return result; } async extractTemplate(renderMethod: RenderMethod): Promise { diff --git a/src/types/render-methods.ts b/src/types/render-methods.ts index 0e3f63e..99147b3 100644 --- a/src/types/render-methods.ts +++ b/src/types/render-methods.ts @@ -9,10 +9,13 @@ export const supportedRenderMethods = [ ] as const; export interface RenderTemplate2024 { - type: RenderMethodType.RenderTemplate2024; + type: string[]; + name?: string; mediaQuery?: string; template?: string; url?: string; + mediaType?: string; + digestMultibase?: string; } export interface WebRenderingTemplate2022 { diff --git a/src/utils.ts b/src/utils.ts index be323ca..16a049c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -11,5 +11,9 @@ export const fetchTemplate = async (url: string) => { }; export const removeLineBreaks = (str: string) => { - return str.replace(/\n/g, ''); + return str.replace(/[\n\r]+/g, ''); +}; + +export const normaliseWhitespace = (str: string) => { + return str.replace(/\s+/g, ' '); };