diff --git a/packages/angular/package.json b/packages/angular/package.json index 70906d4b11..50b5b6c141 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -16,6 +16,7 @@ "build": "ng build", "watch": "ng build --watch --configuration development", "test": "ng test --watch=false", + "coverage": "ng test --watch=false --coverage", "lint": "eslint \"./src/**/*.ts\"" }, "prettier": { @@ -68,6 +69,7 @@ "@angular/platform-browser": "^21.1.0", "@angular/platform-server": "^21.1.0", "@angular/router": "^21.1.0", + "@vitest/coverage-v8": "^4.1.5", "jsdom": "^27.1.0", "ng-packagr": "^21.1.0", "rxjs": "~7.8.0", diff --git a/packages/angular/src/components/sc-form.component.spec.ts b/packages/angular/src/components/sc-form.component.spec.ts index 57f92a142e..52f0dfc646 100644 --- a/packages/angular/src/components/sc-form.component.spec.ts +++ b/packages/angular/src/components/sc-form.component.spec.ts @@ -48,9 +48,20 @@ const makePage = (isEditing: boolean): Page => isDesignLibrary: false, designLibrary: { isVariantGeneration: false }, }, - }) as unknown as Page; + } as Page); -/** Flush afterNextRender and loadForm promise (scripts / subscribe run in the same microtask). */ +function formRendering( + params: Record, + extra: Partial = {} +): ComponentRendering { + return { componentName: 'Form', params, ...extra } as ComponentRendering; +} + +/** + * Flush afterNextRender and loadForm promise (scripts / subscribe run in the same microtask). + * @param {ComponentFixture} fixture - Host fixture under test. + * @returns {Promise} Resolves when the form pipeline side effects have run. + */ async function flushFormLoadPipeline(fixture: ComponentFixture): Promise { fixture.detectChanges(); await fixture.whenStable(); @@ -97,7 +108,7 @@ describe('ScFormComponent', () => { warnSpy.mockRestore(); }); - it('should not call loadForm on the server (JSS: isPlatformBrowser guard)', async () => { + it('should not call loadForm on the server', async () => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ imports: [ScFormComponent], @@ -108,7 +119,7 @@ describe('ScFormComponent', () => { }); const fixture = createFixture(); - fixture.componentRef.setInput('params', { FormId: 'form-1' }); + fixture.componentRef.setInput('rendering', formRendering({ FormId: 'form-1' })); fixture.detectChanges(); await fixture.whenStable(); await new Promise((r) => setTimeout(r, 50)); @@ -116,9 +127,9 @@ describe('ScFormComponent', () => { expect(mocks.loadForm).not.toHaveBeenCalled(); }); - it('should not call loadForm when FormId is missing (JSS: FormId param)', async () => { + it('should not call loadForm when FormId is missing', async () => { const fixture = createFixture(); - fixture.componentRef.setInput('params', {}); + fixture.componentRef.setInput('rendering', formRendering({})); fixture.detectChanges(); await fixture.whenStable(); await new Promise((r) => setTimeout(r, 50)); @@ -126,9 +137,9 @@ describe('ScFormComponent', () => { expect(mocks.loadForm).not.toHaveBeenCalled(); }); - it('should call loadForm with edge context id, FormId, and edgeUrl from config (JSS: loadForm from Edge)', async () => { + it('should call loadForm with edge context id, FormId, and edgeUrl from config', async () => { const fixture = createFixture(); - fixture.componentRef.setInput('params', { FormId: 'my-form-id' }); + fixture.componentRef.setInput('rendering', formRendering({ FormId: 'my-form-id' })); await flushFormLoadPipeline(fixture); expect(mocks.loadForm).toHaveBeenCalledWith( @@ -138,6 +149,46 @@ describe('ScFormComponent', () => { ); }); + it('should use merged params when params input supplies FormId missing on rendering', async () => { + const fixture = createFixture(); + fixture.componentRef.setInput('rendering', formRendering({ RenderingIdentifier: 'r-1' })); + fixture.componentRef.setInput('params', { FormId: 'form-from-params-only' }); + await flushFormLoadPipeline(fixture); + + expect(mocks.loadForm).toHaveBeenCalledWith( + 'test-edge-context-id', + 'form-from-params-only', + 'https://edge.example.com' + ); + }); + + it('should prefer FormId from params input over rendering when both are provided', async () => { + const fixture = createFixture(); + fixture.componentRef.setInput( + 'rendering', + formRendering({ FormId: 'rendering-form-id' }) + ); + fixture.componentRef.setInput('params', { FormId: 'component-form-id' }); + await flushFormLoadPipeline(fixture); + + expect(mocks.loadForm).toHaveBeenCalledWith( + 'test-edge-context-id', + 'component-form-id', + 'https://edge.example.com' + ); + }); + + it('should apply styles from params when rendering has no styles param', async () => { + const fixture = createFixture(); + fixture.componentRef.setInput('rendering', formRendering({ FormId: 'f1' })); + fixture.componentRef.setInput('params', { styles: ' from-params-style ' }); + fixture.detectChanges(); + await fixture.whenStable(); + + const host = fixture.nativeElement.querySelector('div') as HTMLDivElement; + expect(host.className.trim()).toBe('from-params-style'); + }); + it('should not call loadForm when clientContextId is missing (no SITECORE_CONFIG_TOKEN)', async () => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ @@ -146,7 +197,7 @@ describe('ScFormComponent', () => { }); const fixture = createFixture(); - fixture.componentRef.setInput('params', { FormId: 'fid' }); + fixture.componentRef.setInput('rendering', formRendering({ FormId: 'fid' })); fixture.detectChanges(); await fixture.whenStable(); await new Promise((r) => setTimeout(r, 50)); @@ -156,12 +207,12 @@ describe('ScFormComponent', () => { expect(String(warnSpy.mock.calls[0][0])).toContain('clientContextId'); }); - it('should set loaded HTML into the container via innerHTML (JSS: innerHTML = content)', async () => { + it('should set loaded HTML into the container via innerHTML', async () => { // Markup is assigned on the container element ref (not [innerHTML], which sanitizes scripts). mocks.loadForm.mockResolvedValue('

Inner

'); const fixture = createFixture(); - fixture.componentRef.setInput('params', { FormId: 'f1' }); + fixture.componentRef.setInput('rendering', formRendering({ FormId: 'f1' })); await flushFormLoadPipeline(fixture); const host = fixture.nativeElement.querySelector('div') as HTMLDivElement; @@ -169,12 +220,15 @@ describe('ScFormComponent', () => { expect(host.textContent).toContain('Inner'); }); - it('should bind styles param as class with trailing whitespace trimmed (JSS: params.styles → className)', async () => { + it('should bind styles param as class with trailing whitespace trimmed', async () => { const fixture = createFixture(); - fixture.componentRef.setInput('params', { - FormId: 'f1', - styles: ' my-form-style ', - }); + fixture.componentRef.setInput( + 'rendering', + formRendering({ + FormId: 'f1', + styles: ' my-form-style ', + }) + ); fixture.detectChanges(); await fixture.whenStable(); @@ -182,12 +236,15 @@ describe('ScFormComponent', () => { expect(host.className.trim()).toBe('my-form-style'); }); - it('should bind RenderingIdentifier as element id (JSS: RenderingIdentifier → id)', async () => { + it('should bind RenderingIdentifier as element id', async () => { const fixture = createFixture(); - fixture.componentRef.setInput('params', { - FormId: 'f1', - RenderingIdentifier: 'form-rendering-42', - }); + fixture.componentRef.setInput( + 'rendering', + formRendering({ + FormId: 'f1', + RenderingIdentifier: 'form-rendering-42', + }) + ); fixture.detectChanges(); await fixture.whenStable(); @@ -195,12 +252,12 @@ describe('ScFormComponent', () => { expect(host.id).toBe('form-rendering-42'); }); - it('should call executeScriptElements on the container after load (JSS: executeScriptElements)', async () => { + it('should call executeScriptElements on the container after load', async () => { const fixture = createFixture(); const ctx = TestBed.inject(SitecoreContextService); ctx.setPage(makePage(false)); - fixture.componentRef.setInput('params', { FormId: 'f1' }); + fixture.componentRef.setInput('rendering', formRendering({ FormId: 'f1' })); await flushFormLoadPipeline(fixture); expect(mocks.executeScriptElements).toHaveBeenCalledTimes(1); @@ -208,13 +265,15 @@ describe('ScFormComponent', () => { expect(elArg.tagName).toBe('DIV'); }); - it('should call subscribeToFormSubmitEvent when not in editing mode (JSS: !isEditing)', async () => { + it('should call subscribeToFormSubmitEvent when not in editing mode', async () => { const fixture = createFixture(); const ctx = TestBed.inject(SitecoreContextService); ctx.setPage(makePage(false)); - fixture.componentRef.setInput('params', { FormId: 'f1' }); - fixture.componentRef.setInput('rendering', { uid: 'comp-uid-1' } as ComponentRendering); + fixture.componentRef.setInput( + 'rendering', + formRendering({ FormId: 'f1' }, { uid: 'comp-uid-1' }) + ); await flushFormLoadPipeline(fixture); expect(mocks.subscribeToFormSubmitEvent).toHaveBeenCalledTimes(1); @@ -224,24 +283,23 @@ describe('ScFormComponent', () => { ); }); - it('should not call subscribeToFormSubmitEvent in editing mode (JSS: isEditing)', async () => { + it('should not call subscribeToFormSubmitEvent in editing mode', async () => { const fixture = createFixture(); const ctx = TestBed.inject(SitecoreContextService); ctx.setPage(makePage(true)); - fixture.componentRef.setInput('params', { FormId: 'f1' }); - fixture.componentRef.setInput('rendering', { uid: 'x' } as ComponentRendering); + fixture.componentRef.setInput('rendering', formRendering({ FormId: 'f1' }, { uid: 'x' })); await flushFormLoadPipeline(fixture); expect(mocks.subscribeToFormSubmitEvent).not.toHaveBeenCalled(); expect(mocks.executeScriptElements).toHaveBeenCalled(); }); - it('should log when loadForm rejects (JSS: catch / hasError)', async () => { + it('should log when loadForm rejects', async () => { mocks.loadForm.mockRejectedValue(new Error('network')); const fixture = createFixture(); - fixture.componentRef.setInput('params', { FormId: 'bad-form' }); + fixture.componentRef.setInput('rendering', formRendering({ FormId: 'bad-form' })); fixture.detectChanges(); await fixture.whenStable(); await vi.waitFor(() => errorSpy.mock.calls.length > 0, { timeout: 3000, interval: 5 }); diff --git a/packages/angular/src/components/sc-form.component.ts b/packages/angular/src/components/sc-form.component.ts index a6c16a20b9..1eb7e8adbb 100644 --- a/packages/angular/src/components/sc-form.component.ts +++ b/packages/angular/src/components/sc-form.component.ts @@ -16,6 +16,7 @@ import { SitecoreContextService } from '../lib/sitecore-context.service'; const { executeScriptElements, loadForm, subscribeToFormSubmitEvent } = form; +/* eslint-disable @typescript-eslint/member-ordering -- ViewChild + signal inputs + constructor ordering conflicts with default groups */ /** * Angular wrapper for Sitecore Forms. * Loads form HTML from Edge, executes embedded scripts, and subscribes to form events. @@ -25,15 +26,11 @@ const { executeScriptElements, loadForm, subscribeToFormSubmitEvent } = form; */ @Component({ selector: 'sc-form', - standalone: true, - template: ` -
- `, + template: `
`, }) export class ScFormComponent { readonly rendering = input(); readonly params = input<{ [key: string]: string }>({}); - readonly fields = input<{ [key: string]: unknown }>({}); @ViewChild('formContainer', { static: true }) private formContainerRef!: ElementRef; @@ -43,18 +40,19 @@ export class ScFormComponent { private readonly platformId = inject(PLATFORM_ID); private readonly destroyRef = inject(DestroyRef); - readonly styles = () => { - const s = this.params()?.['styles']; - return s ? s.replace(/\s+$/, '') : ''; - }; - readonly renderingId = () => this.params()?.['RenderingIdentifier'] || undefined; + /** + * Merges `rendering.params` with the `params` input: the component `params()` values override layout for the same key. + */ + private mergedFormParams(): { [key: string]: string } { + return { ...(this.rendering()?.params ?? {}), ...this.params() }; + } constructor() { afterNextRender(() => { if (!isPlatformBrowser(this.platformId)) return; - const p = this.params(); - const formId = p?.['FormId']; + const p = this.mergedFormParams(); + const formId = p.FormId; if (!formId) return; const cfg = this.config; @@ -95,4 +93,15 @@ export class ScFormComponent { }); }); } + + readonly styles = () => { + const p = this.mergedFormParams(); + const s = p.styles; + return s ? s.replace(/\s+$/, '') : ''; + }; + readonly renderingId = () => { + const p = this.mergedFormParams(); + return p.RenderingIdentifier || undefined; + }; } +/* eslint-enable @typescript-eslint/member-ordering */ diff --git a/packages/angular/src/config/define-config.ts b/packages/angular/src/config/define-config.ts index a611ebd597..97fffcc7bf 100644 --- a/packages/angular/src/config/define-config.ts +++ b/packages/angular/src/config/define-config.ts @@ -1,6 +1,10 @@ import type { SitecoreConfig, SitecoreConfigInput } from '@sitecore-content-sdk/content/config'; import { defineConfig as baseDefineConfig } from '@sitecore-content-sdk/content/config'; +/** + * Reads `process.env` when running under Node; otherwise returns an empty object. + * @returns {Record} Environment map for merging into config. + */ function getProcessEnv(): Record { // Use globalThis so we do not need @types/node (lib tsconfig uses "types": []). const env = (globalThis as { process?: { env?: Record } }).process @@ -10,6 +14,9 @@ function getProcessEnv(): Record { /** * Merges `clientEnv` (browser-safe `environment*.ts`) with `process.env` for server-only variables. * On Node/SSR, load `.env` in the app entry before importing `sitecore.config` (see `load-env.ts` in the sample). + * @param {SitecoreConfigInput} [config] - Base Sitecore configuration input. + * @param {Record} [clientEnv] - Browser-safe env from `environment*.ts`. + * @returns {SitecoreConfig} Fully merged Sitecore configuration. * @public */ export function defineConfig( diff --git a/packages/angular/src/field-directives/link-field-utils.spec.ts b/packages/angular/src/field-directives/link-field-utils.spec.ts new file mode 100644 index 0000000000..08d4a5f440 --- /dev/null +++ b/packages/angular/src/field-directives/link-field-utils.spec.ts @@ -0,0 +1,381 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import type { Renderer2 } from '@angular/core'; +import { describe, it, expect } from 'vitest'; +import { + applyLinkFieldToAnchor, + buildHrefFromLinkField, + resolveLinkFromField, + type ApplyLinkFieldToAnchorOptions, +} from './link-field-utils'; +import type { LinkField, LinkFieldValue } from '@sitecore-content-sdk/content/layout'; + +function createDomRenderer(): Renderer2 { + return { + setAttribute(el: HTMLElement, name: string, value: string): void { + el.setAttribute(name, value); + }, + removeAttribute(el: HTMLElement, name: string): void { + el.removeAttribute(name); + }, + addClass(el: HTMLElement, name: string): void { + el.classList.add(name); + }, + setProperty(el: HTMLElement, name: string, value: unknown): void { + if (name === 'textContent') { + el.textContent = value === null || value === undefined ? '' : String(value); + } + }, + } as unknown as Renderer2; +} + +function baseOptions(overrides?: Partial): ApplyLinkFieldToAnchorOptions { + return { preferTextFromField: false, ...overrides }; +} + +describe('link-field-binding', () => { + describe('resolveLinkFromField', () => { + it('returns value from LinkField wrapper', () => { + const field: LinkField = { value: { href: '/x', text: 'X' } }; + expect(resolveLinkFromField(field)).toEqual(field.value); + }); + + it('returns bare LinkFieldValue when href is set at root', () => { + const field = { href: '/y', text: 'Y' }; + expect(resolveLinkFromField(field)).toBe(field); + }); + }); + + describe('buildHrefFromLinkField', () => { + it('concatenates query and hash fragment', () => { + expect( + buildHrefFromLinkField({ + href: '/p', + querystring: 'a=1', + anchor: 'sec', + linktype: 'internal', + }) + ).toBe('/p?a=1#sec'); + }); + }); + + describe('applyLinkFieldToAnchor', () => { + it('should remove href when the link field is empty', () => { + const renderer = createDomRenderer(); + const anchor = document.createElement('a'); + anchor.setAttribute('href', '/old'); + + applyLinkFieldToAnchor(renderer, anchor, undefined, baseOptions()); + + expect(anchor.hasAttribute('href')).toBe(false); + }); + + it('should restore originalClass, originalTitle, and originalTarget when the link field is empty', () => { + const renderer = createDomRenderer(); + const anchor = document.createElement('a'); + anchor.href = '/gone'; + anchor.className = 'stale'; + anchor.title = 'Stale title'; + anchor.target = '_top'; + + applyLinkFieldToAnchor( + renderer, + anchor, + undefined, + baseOptions({ + originalClass: 'btn btn-primary', + originalTitle: 'Home', + originalTarget: '_self', + }) + ); + + expect(anchor.hasAttribute('href')).toBe(false); + expect(Array.from(anchor.classList).sort()).toEqual(['btn', 'btn-primary']); + expect(anchor.getAttribute('title')).toBe('Home'); + expect(anchor.getAttribute('target')).toBe('_self'); + }); + + it('should set href from the link value when the link field is present', () => { + const renderer = createDomRenderer(); + const anchor = document.createElement('a'); + const link: LinkFieldValue = { href: '/page', linktype: 'internal' }; + + applyLinkFieldToAnchor(renderer, anchor, link, baseOptions()); + + expect(anchor.getAttribute('href')).toBe('/page'); + }); + + it('should add class tokens from the field and keep existing host classes when the field defines a class', () => { + const renderer = createDomRenderer(); + const anchor = document.createElement('a'); + anchor.className = 'host-a'; + const link: LinkFieldValue = { + href: '/x', + linktype: 'internal', + className: 'field-b field-c', + }; + + applyLinkFieldToAnchor(renderer, anchor, link, baseOptions()); + + const classes = Array.from(anchor.classList).sort(); + expect(classes).toEqual(['field-b', 'field-c', 'host-a']); + }); + + it('should not set title, target, rel, or text from the link when the field supplies a class (only href and classes apply)', () => { + const renderer = createDomRenderer(); + const anchor = document.createElement('a'); + anchor.textContent = 'Host label'; + const link: LinkFieldValue = { + href: '/x', + linktype: 'internal', + className: 'from-field', + title: 'Field title', + target: '_blank', + text: 'Field text', + }; + + applyLinkFieldToAnchor(renderer, anchor, link, baseOptions({ preferTextFromField: true })); + + expect(anchor.getAttribute('title')).toBeNull(); + expect(anchor.getAttribute('target')).toBeNull(); + expect(anchor.getAttribute('rel')).toBeNull(); + expect(anchor.textContent).toBe('Host label'); + }); + + it('should remove the class attribute then restore originalClass tokens when the field has no class but originalClass is provided', () => { + const renderer = createDomRenderer(); + const anchor = document.createElement('a'); + anchor.className = 'stale'; + const link: LinkFieldValue = { href: '/y', linktype: 'internal' }; + + applyLinkFieldToAnchor(renderer, anchor, link, baseOptions({ originalClass: 'restored-a restored-b' })); + + const classes = Array.from(anchor.classList).sort(); + expect(classes).toEqual(['restored-a', 'restored-b']); + }); + + it('should clear the class attribute when the field has no class and originalClass is omitted', () => { + const renderer = createDomRenderer(); + const anchor = document.createElement('a'); + anchor.className = 'only-stale'; + const link: LinkFieldValue = { href: '/z', linktype: 'internal' }; + + applyLinkFieldToAnchor(renderer, anchor, link, baseOptions()); + + expect(anchor.className).toBe(''); + }); + + it('should set title from the link when the field has no class and the link has a title', () => { + const renderer = createDomRenderer(); + const anchor = document.createElement('a'); + + applyLinkFieldToAnchor( + renderer, + anchor, + { href: '/t', linktype: 'internal', title: 'From field' }, + baseOptions() + ); + + expect(anchor.getAttribute('title')).toBe('From field'); + }); + + it('should remove title then restore originalTitle when the link has no title but originalTitle is provided', () => { + const renderer = createDomRenderer(); + const anchor = document.createElement('a'); + anchor.setAttribute('title', 'Stale'); + + applyLinkFieldToAnchor( + renderer, + anchor, + { href: '/t', linktype: 'internal' }, + baseOptions({ originalTitle: 'Host title' }) + ); + + expect(anchor.getAttribute('title')).toBe('Host title'); + }); + + it('should remove title and leave it unset when the link has no title and originalTitle is omitted', () => { + const renderer = createDomRenderer(); + const anchor = document.createElement('a'); + anchor.setAttribute('title', 'Stale'); + + applyLinkFieldToAnchor(renderer, anchor, { href: '/t', linktype: 'internal' }, baseOptions()); + + expect(anchor.hasAttribute('title')).toBe(false); + }); + + it('should set rel to noopener noreferrer when target is _blank and originalRel is absent', () => { + const renderer = createDomRenderer(); + const anchor = document.createElement('a'); + + applyLinkFieldToAnchor( + renderer, + anchor, + { href: '/ext', linktype: 'external', target: '_blank' }, + baseOptions() + ); + + expect(anchor.getAttribute('rel')).toBe('noopener noreferrer'); + }); + + it('should set rel to originalRel when target is _blank and the host had rel', () => { + const renderer = createDomRenderer(); + const anchor = document.createElement('a'); + + applyLinkFieldToAnchor( + renderer, + anchor, + { href: '/ext', linktype: 'external', target: '_blank' }, + baseOptions({ originalRel: 'nofollow' }) + ); + + expect(anchor.getAttribute('rel')).toBe('nofollow'); + }); + + it('should remove rel when target is not _blank and originalRel is absent', () => { + const renderer = createDomRenderer(); + const anchor = document.createElement('a'); + anchor.setAttribute('rel', 'nofollow'); + + applyLinkFieldToAnchor( + renderer, + anchor, + { href: '/same', linktype: 'internal', target: '_self' }, + baseOptions() + ); + + expect(anchor.hasAttribute('rel')).toBe(false); + }); + + it('should set rel to originalRel when target is not _blank and the host had rel', () => { + const renderer = createDomRenderer(); + const anchor = document.createElement('a'); + + applyLinkFieldToAnchor( + renderer, + anchor, + { href: '/same', linktype: 'internal', target: '_self' }, + baseOptions({ originalRel: 'noopener' }) + ); + + expect(anchor.getAttribute('rel')).toBe('noopener'); + }); + + it('should remove target then restore originalTarget when the link has no target but originalTarget is provided', () => { + const renderer = createDomRenderer(); + const anchor = document.createElement('a'); + anchor.setAttribute('target', '_top'); + + applyLinkFieldToAnchor( + renderer, + anchor, + { href: '/t', linktype: 'internal' }, + baseOptions({ originalTarget: '_parent' }) + ); + + expect(anchor.getAttribute('target')).toBe('_parent'); + }); + + it('should remove target when the link has no target and originalTarget is omitted', () => { + const renderer = createDomRenderer(); + const anchor = document.createElement('a'); + anchor.setAttribute('target', '_top'); + + applyLinkFieldToAnchor(renderer, anchor, { href: '/t', linktype: 'internal' }, baseOptions()); + + expect(anchor.hasAttribute('target')).toBe(false); + }); + + it('should set textContent from link text when the anchor has no meaningful text and the field has no class', () => { + const renderer = createDomRenderer(); + const anchor = document.createElement('a'); + + applyLinkFieldToAnchor( + renderer, + anchor, + { href: '/go', linktype: 'internal', text: 'Go here' }, + baseOptions() + ); + + expect(anchor.textContent).toBe('Go here'); + }); + + it('should set textContent from href when the anchor is empty and the link has no text', () => { + const renderer = createDomRenderer(); + const anchor = document.createElement('a'); + + applyLinkFieldToAnchor(renderer, anchor, { href: '/only-href', linktype: 'internal' }, baseOptions()); + + expect(anchor.textContent).toBe('/only-href'); + }); + + it('should set textContent to empty string when the link is empty and the anchor has no meaningful text', () => { + const renderer = createDomRenderer(); + const anchor = document.createElement('a'); + + applyLinkFieldToAnchor(renderer, anchor, undefined, baseOptions()); + + expect(anchor.textContent).toBe(''); + }); + + it('should leave existing text content when the anchor already has text and preferTextFromField is false', () => { + const renderer = createDomRenderer(); + const anchor = document.createElement('a'); + anchor.textContent = 'Keep me'; + + applyLinkFieldToAnchor( + renderer, + anchor, + { href: '/x', linktype: 'internal', text: 'Field text' }, + baseOptions({ preferTextFromField: false }) + ); + + expect(anchor.textContent).toBe('Keep me'); + }); + + it('should replace text content with field text when the anchor already has text and preferTextFromField is true', () => { + const renderer = createDomRenderer(); + const anchor = document.createElement('a'); + anchor.textContent = 'Old'; + + applyLinkFieldToAnchor( + renderer, + anchor, + { href: '/x', linktype: 'internal', text: 'New' }, + baseOptions({ preferTextFromField: true }) + ); + + expect(anchor.textContent).toBe('New'); + }); + + it('should treat whitespace-only anchor text as empty and set text from the link', () => { + const renderer = createDomRenderer(); + const anchor = document.createElement('a'); + anchor.textContent = ' \n '; + + applyLinkFieldToAnchor( + renderer, + anchor, + { href: '/x', linktype: 'internal', text: 'Visible' }, + baseOptions() + ); + + expect(anchor.textContent).toBe('Visible'); + }); + + it('should merge className and class from the field into tokens on the anchor when both are present', () => { + const renderer = createDomRenderer(); + const anchor = document.createElement('a'); + const link: LinkFieldValue = { + href: '/m', + linktype: 'internal', + className: 'cn-a', + class: 'c-b', + }; + + applyLinkFieldToAnchor(renderer, anchor, link, baseOptions()); + + const classes = Array.from(anchor.classList).sort(); + expect(classes).toEqual(['c-b', 'cn-a']); + }); + }); +}); diff --git a/packages/angular/src/field-directives/link-field-utils.ts b/packages/angular/src/field-directives/link-field-utils.ts new file mode 100644 index 0000000000..18ea6a9599 --- /dev/null +++ b/packages/angular/src/field-directives/link-field-utils.ts @@ -0,0 +1,113 @@ +import { Renderer2 } from '@angular/core'; +import { isFieldValueEmpty, LinkField, LinkFieldValue } from '@sitecore-content-sdk/content/layout'; +import { getClassFromField } from './utils'; + +/** + * Splits a CSS class string and applies each token via {@link Renderer2.addClass}. + * @param {Renderer2} renderer - Angular renderer. + * @param {HTMLElement} element - Target element. + * @param {string} classString - Space-separated class names. + * @returns {void} + */ +function addClassTokens(renderer: Renderer2, element: HTMLElement, classString: string): void { + for (const token of classString.trim().split(/\s+/).filter(Boolean)) { + renderer.addClass(element, token); + } +} + +/** + * Normalizes a Sitecore link field input to a {@link LinkFieldValue}, or `undefined` when empty. + * @param {LinkField | LinkFieldValue | undefined | null} field - Raw field or value from layout. + * @returns {LinkFieldValue | undefined} Resolved link value, or `undefined` when empty. + */ +export function resolveLinkFromField( + field: LinkField | LinkFieldValue | undefined | null +): LinkFieldValue | undefined { + if (!field || isFieldValueEmpty(field)) { + return undefined; + } + return (field as LinkFieldValue).href ? (field as LinkFieldValue) : (field as LinkField).value; +} + +/** + * Builds the `href` string (path + query + hash fragment) from a link value. + * @param {LinkFieldValue} link - Sitecore link field value. + * @returns {string} Full `href` string for an anchor. + */ +export function buildHrefFromLinkField(link: LinkFieldValue): string { + const anchor = link.linktype !== 'anchor' && link.anchor ? `#${link.anchor}` : ''; + const querystring = link.querystring ? `?${link.querystring}` : ''; + return `${link.href || ''}${querystring}${anchor}`; +} + +export interface ApplyLinkFieldToAnchorOptions { + preferTextFromField: boolean; + originalClass?: string; + originalTitle?: string; + originalTarget?: string; + /** Host `rel` captured before the directive applied field data (e.g. template `rel="nofollow"`). */ + originalRel?: string | null; +} + +/** + * Applies Sitecore link attributes and optional text to a host anchor (shared by ScLink / ScRouterLink), + * or preserves original attributes and text when the field link is empty. + * @param {Renderer2} renderer - Angular renderer. + * @param {HTMLAnchorElement} element - Host anchor element. + * @param {LinkFieldValue | undefined} link - Resolved link value, or `undefined` when empty. + * @param {ApplyLinkFieldToAnchorOptions} options - Text/class/title/target behavior flags. + * @returns {void} + */ +export function applyLinkFieldToAnchor( + renderer: Renderer2, + element: HTMLAnchorElement, + link: LinkFieldValue | undefined, + options: ApplyLinkFieldToAnchorOptions +): void { + if (!link) { + renderer.removeAttribute(element, 'href'); + } else { + renderer.setAttribute(element, 'href', buildHrefFromLinkField(link)); + } + const classValue = link ? getClassFromField(link) : undefined; + if (classValue) { + addClassTokens(renderer, element, classValue); + } else { + renderer.removeAttribute(element, 'class'); + if (options.originalClass) { + addClassTokens(renderer, element, options.originalClass); + } + + if (link?.title) { + renderer.setAttribute(element, 'title', link.title); + } else { + renderer.removeAttribute(element, 'title'); + if (options.originalTitle) { + renderer.setAttribute(element, 'title', options.originalTitle); + } + } + if (link?.target) { + renderer.setAttribute(element, 'target', link.target); + if (link?.target === '_blank' && !options.originalRel) { + renderer.setAttribute(element, 'rel', 'noopener noreferrer'); + } else { + options.originalRel + ? renderer.setAttribute(element, 'rel', options.originalRel) + : renderer.removeAttribute(element, 'rel'); + } + } else { + renderer.removeAttribute(element, 'target'); + if (options.originalTarget) { + renderer.setAttribute(element, 'target', options.originalTarget); + } + } + + const hasChildren = element.childNodes.length > 0 && element.textContent?.trim(); + if (!hasChildren) { + const text = link?.text || link?.href || ''; + renderer.setProperty(element, 'textContent', text); + } else if (options.preferTextFromField && link?.text) { + renderer.setProperty(element, 'textContent', link?.text || ''); + } + } +} diff --git a/packages/angular/src/field-directives/sc-image.directive.spec.ts b/packages/angular/src/field-directives/sc-image.directive.spec.ts index 4f18dfe60a..37c58d446d 100644 --- a/packages/angular/src/field-directives/sc-image.directive.spec.ts +++ b/packages/angular/src/field-directives/sc-image.directive.spec.ts @@ -6,7 +6,6 @@ import { ScImageDirective, type ImageField } from './sc-image.directive'; @Component({ selector: 'test-img', - standalone: true, imports: [ScImageDirective], template: ``, }) diff --git a/packages/angular/src/field-directives/sc-image.directive.ts b/packages/angular/src/field-directives/sc-image.directive.ts index 42ce21292c..bdef839330 100644 --- a/packages/angular/src/field-directives/sc-image.directive.ts +++ b/packages/angular/src/field-directives/sc-image.directive.ts @@ -30,7 +30,6 @@ export interface ImageField { * ```html * * ``` - * * @public */ @Directive({ diff --git a/packages/angular/src/field-directives/sc-link.directive.spec.ts b/packages/angular/src/field-directives/sc-link.directive.spec.ts index f474aff744..cb2f021fcf 100644 --- a/packages/angular/src/field-directives/sc-link.directive.spec.ts +++ b/packages/angular/src/field-directives/sc-link.directive.spec.ts @@ -15,7 +15,6 @@ function sortedClassTokens(el: HTMLElement): string[] { @Component({ selector: 'test-link', - standalone: true, imports: [ScLinkDirective], template: ``, }) @@ -25,7 +24,6 @@ class TestHostComponent { @Component({ selector: 'test-link-host-class', - standalone: true, imports: [ScLinkDirective], template: ``, }) @@ -35,7 +33,6 @@ class TestHostWithHostClassComponent { @Component({ selector: 'test-link-host-title', - standalone: true, imports: [ScLinkDirective], template: ``, }) @@ -45,7 +42,6 @@ class TestHostWithHostTitleComponent { @Component({ selector: 'test-link-host-target', - standalone: true, imports: [ScLinkDirective], template: ``, }) diff --git a/packages/angular/src/field-directives/sc-link.directive.ts b/packages/angular/src/field-directives/sc-link.directive.ts index 5e06f0c7aa..8a7c1be7c4 100644 --- a/packages/angular/src/field-directives/sc-link.directive.ts +++ b/packages/angular/src/field-directives/sc-link.directive.ts @@ -1,6 +1,6 @@ import { Directive, ElementRef, inject, input, effect, Renderer2 } from '@angular/core'; -import { isFieldValueEmpty, LinkFieldValue, LinkField } from '@sitecore-content-sdk/content/layout'; -import { getClassFromField } from './utils'; +import { LinkFieldValue, LinkField } from '@sitecore-content-sdk/content/layout'; +import { applyLinkFieldToAnchor, resolveLinkFromField } from './link-field-utils'; /** * Renders a Sitecore link field onto a host `` element. @@ -10,7 +10,6 @@ import { getClassFromField } from './utils'; * ```html * Optional child content * ``` - * * @public */ @Directive({ @@ -23,71 +22,31 @@ export class ScLinkDirective { /** Whether to show link text alongside existing child content. */ readonly preferTextFromField = input(false); - private readonly el = inject(ElementRef); + protected readonly el = inject(ElementRef); private readonly renderer = inject(Renderer2); private readonly originalClass: string | undefined; private readonly originalTitle: string | undefined; private readonly originalTarget: string | undefined; + private readonly originalRel: string | null; constructor() { this.originalClass = (this.el.nativeElement as HTMLAnchorElement).className; this.originalTitle = (this.el.nativeElement as HTMLAnchorElement).title; this.originalTarget = (this.el.nativeElement as HTMLAnchorElement).target; + this.originalRel = (this.el.nativeElement as HTMLAnchorElement).rel; effect(() => { const field = this.scLink(); const element = this.el.nativeElement; - if (!field || isFieldValueEmpty(field)) { - this.renderer.removeAttribute(element, 'href'); - return; - } - - const link = (field as LinkFieldValue).href - ? (field as LinkFieldValue) - : (field as LinkField).value; - - const anchor = link.linktype !== 'anchor' && link.anchor ? `#${link.anchor}` : ''; - const querystring = link.querystring ? `?${link.querystring}` : ''; - - this.renderer.setAttribute(element, 'href', `${link.href || ''}${querystring}${anchor}`); - - const classValue = getClassFromField(link); - if (classValue) { - this.renderer.addClass(element, classValue); - } else { - this.renderer.removeAttribute(element, 'class'); - if (this.originalClass) { - this.renderer.addClass(element, this.originalClass); - } - } - - if (link.title) { - this.renderer.setAttribute(element, 'title', link.title); - } else { - this.renderer.removeAttribute(element, 'title'); - if (this.originalTitle) { - this.renderer.setAttribute(element, 'title', this.originalTitle); - } - } - if (link.target) { - this.renderer.setAttribute(element, 'target', link.target); - if (link.target === '_blank' && !element.getAttribute('rel')) { - this.renderer.setAttribute(element, 'rel', 'noopener noreferrer'); - } - } else { - this.renderer.removeAttribute(element, 'target'); - if (this.originalTarget) { - this.renderer.setAttribute(element, 'target', this.originalTarget); - } - } + const link = resolveLinkFromField(field); - const hasChildren = element.childNodes.length > 0 && element.textContent?.trim(); - if (!hasChildren) { - const text = link.text || link.href || ''; - this.renderer.setProperty(element, 'textContent', text); - } else if (this.preferTextFromField() && link.text) { - this.renderer.setProperty(element, 'textContent', link.text || ''); - } + applyLinkFieldToAnchor(this.renderer, element, link, { + preferTextFromField: this.preferTextFromField(), + originalClass: this.originalClass, + originalTitle: this.originalTitle, + originalTarget: this.originalTarget, + originalRel: this.originalRel, + }); }); } } diff --git a/packages/angular/src/field-directives/sc-rich-text.directive.spec.ts b/packages/angular/src/field-directives/sc-rich-text.directive.spec.ts index efe5211b0d..ba81a397fa 100644 --- a/packages/angular/src/field-directives/sc-rich-text.directive.spec.ts +++ b/packages/angular/src/field-directives/sc-rich-text.directive.spec.ts @@ -7,7 +7,6 @@ import { ScRichTextDirective } from './sc-rich-text.directive'; @Component({ selector: 'test-richtext', - standalone: true, imports: [ScRichTextDirective], template: `
`, }) diff --git a/packages/angular/src/field-directives/sc-rich-text.directive.ts b/packages/angular/src/field-directives/sc-rich-text.directive.ts index 38ba70f349..e9daabaaff 100644 --- a/packages/angular/src/field-directives/sc-rich-text.directive.ts +++ b/packages/angular/src/field-directives/sc-rich-text.directive.ts @@ -18,7 +18,6 @@ import { isFieldValueEmpty, TextField } from '@sitecore-content-sdk/content/layo * ```html *
* ``` - * * @public */ @Directive({ diff --git a/packages/angular/src/field-directives/sc-router-link.directive.spec.ts b/packages/angular/src/field-directives/sc-router-link.directive.spec.ts new file mode 100644 index 0000000000..5492e213d8 --- /dev/null +++ b/packages/angular/src/field-directives/sc-router-link.directive.spec.ts @@ -0,0 +1,191 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { describe, it, expect, vi } from 'vitest'; +import { Component, input } from '@angular/core'; +import { provideRouter, Router } from '@angular/router'; +import { ScRouterLinkDirective } from './sc-router-link.directive'; +import type { LinkField } from '@sitecore-content-sdk/content/layout'; + +@Component({ standalone: true, template: '', selector: 'blank-cmp' }) +class BlankCmp {} + +@Component({ + selector: 'test-sc-router-link', + imports: [ScRouterLinkDirective], + template: `Label`, +}) +class TestScRouterLinkHost { + readonly field = input({ + value: { href: '/about', text: 'About' }, + }); +} + +describe('ScRouterLinkDirective', () => { + async function createFixture(): Promise<{ + fixture: ComponentFixture; + router: Router; + }> { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [TestScRouterLinkHost, BlankCmp], + providers: [provideRouter([{ path: '**', component: BlankCmp }])], + }); + const fixture = TestBed.createComponent(TestScRouterLinkHost); + const router = TestBed.inject(Router); + await router.navigateByUrl('/'); + fixture.detectChanges(); + return { fixture, router }; + } + + it('calls Router.navigateByUrl with href on click and preventDefault when no hash', async () => { + const { fixture, router } = await createFixture(); + const spy = vi.spyOn(router, 'navigateByUrl').mockResolvedValue(true); + + const a = fixture.nativeElement.querySelector('a') as HTMLAnchorElement; + expect(a.getAttribute('href')).toContain('/about'); + + const ev = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }); + const preventSpy = vi.spyOn(ev, 'preventDefault'); + a.dispatchEvent(ev); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('/about'); + expect(preventSpy).toHaveBeenCalled(); + + spy.mockRestore(); + }); + + it('calls navigateByUrl for href with hash and does not preventDefault', async () => { + const { fixture, router } = await createFixture(); + const spy = vi.spyOn(router, 'navigateByUrl').mockResolvedValue(true); + + fixture.componentRef.setInput('field', { + value: { href: '/page', text: 'Page', anchor: 'section', linktype: 'internal' }, + }); + fixture.detectChanges(); + + const a = fixture.nativeElement.querySelector('a') as HTMLAnchorElement; + expect(a.getAttribute('href')).toContain('#'); + + const ev = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }); + const preventSpy = vi.spyOn(ev, 'preventDefault'); + a.dispatchEvent(ev); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0]).toContain('#'); + expect(preventSpy).not.toHaveBeenCalled(); + + spy.mockRestore(); + }); + + it('should not call Router.navigateByUrl when target is _blank so the browser can open a new tab', async () => { + const { fixture, router } = await createFixture(); + const spy = vi.spyOn(router, 'navigateByUrl').mockResolvedValue(true); + + fixture.componentRef.setInput('field', { + value: { href: '/external', text: 'Ext', target: '_blank', linktype: 'internal' }, + }); + fixture.detectChanges(); + + const a = fixture.nativeElement.querySelector('a') as HTMLAnchorElement; + expect(a.getAttribute('target')).toBe('_blank'); + + const ev = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }); + const preventSpy = vi.spyOn(ev, 'preventDefault'); + a.dispatchEvent(ev); + + expect(spy).not.toHaveBeenCalled(); + expect(preventSpy).not.toHaveBeenCalled(); + + spy.mockRestore(); + }); + + it('should not call Router.navigateByUrl when href is missing so the anchor does not router-navigate', async () => { + const { fixture, router } = await createFixture(); + const spy = vi.spyOn(router, 'navigateByUrl').mockResolvedValue(true); + + fixture.componentRef.setInput('field', { value: { text: 'No href', linktype: 'internal' } }); + fixture.detectChanges(); + + const a = fixture.nativeElement.querySelector('a') as HTMLAnchorElement; + expect(a.hasAttribute('href')).toBe(false); + + const ev = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }); + a.dispatchEvent(ev); + + expect(spy).not.toHaveBeenCalled(); + + spy.mockRestore(); + }); + + it('should not call Router.navigateByUrl when href is empty so in-app routing is skipped', async () => { + const { fixture, router } = await createFixture(); + const spy = vi.spyOn(router, 'navigateByUrl').mockResolvedValue(true); + + fixture.componentRef.setInput('field', { + value: { href: '', text: 'Empty href', linktype: 'internal' }, + }); + fixture.detectChanges(); + + const a = fixture.nativeElement.querySelector('a') as HTMLAnchorElement; + expect(a.getAttribute('href')).toBe(null); + + const ev = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }); + a.dispatchEvent(ev); + + expect(spy).not.toHaveBeenCalled(); + + spy.mockRestore(); + }); + + it('should not call Router.navigateByUrl when href is only whitespace', async () => { + const { fixture, router } = await createFixture(); + const spy = vi.spyOn(router, 'navigateByUrl').mockResolvedValue(true); + + fixture.componentRef.setInput('field', { + value: { href: ' ', text: 'Whitespace href', linktype: 'internal' }, + }); + fixture.detectChanges(); + + const a = fixture.nativeElement.querySelector('a') as HTMLAnchorElement; + const ev = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }); + a.dispatchEvent(ev); + + expect(spy).not.toHaveBeenCalled(); + + spy.mockRestore(); + }); + + it.each([ + 'https://example.com/page', + 'http://example.com/', + 'mailto:user@example.com', + 'tel:+15551234567', + 'sms:+15551234567', + 'javascript:void(0)', + 'data:text/plain,hi', + 'ftp://files.example.com/', + '//cdn.example.com/asset.js', + ])( + 'should not call Router.navigateByUrl when href is a browser-handled URL (%s)', + async (href) => { + const { fixture, router } = await createFixture(); + const spy = vi.spyOn(router, 'navigateByUrl').mockResolvedValue(true); + + fixture.componentRef.setInput('field', { + value: { href, text: 'External', linktype: 'external' }, + }); + fixture.detectChanges(); + + const a = fixture.nativeElement.querySelector('a') as HTMLAnchorElement; + expect(a.getAttribute('href')).toBe(href); + + const ev = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }); + a.dispatchEvent(ev); + + expect(spy).not.toHaveBeenCalled(); + + spy.mockRestore(); + } + ); +}); diff --git a/packages/angular/src/field-directives/sc-router-link.directive.ts b/packages/angular/src/field-directives/sc-router-link.directive.ts new file mode 100644 index 0000000000..7db8fa6432 --- /dev/null +++ b/packages/angular/src/field-directives/sc-router-link.directive.ts @@ -0,0 +1,75 @@ +import { Directive, HostListener, inject, input } from '@angular/core'; +import { Router } from '@angular/router'; +import type { LinkField, LinkFieldValue } from '@sitecore-content-sdk/content/layout'; +import { ScLinkDirective } from './sc-link.directive'; + +/** + * Renders a Sitecore link field onto a host `` and calls `Router.navigateByUrl` on click + * for in-app paths only. Clicks are left to the browser when `href` is missing/empty, when + * `target="_blank"`, or when `href` uses http(s), mailto, tel, sms, javascript, data, ftp, + * or protocol-relative (`//`) URLs. + * + * Usage: + * ```html + * Optional child content + * ``` + * @public + */ +@Directive({ + selector: 'a[scRouterLink]', +}) +export class ScRouterLinkDirective extends ScLinkDirective { + /** + * Sitecore link field; host attribute `[scRouterLink]` maps to the base {@link ScLinkDirective.scLink} input. + */ + override readonly scLink = input.required({ alias: 'scRouterLink' }); + + private readonly router = inject(Router); + + @HostListener('click', ['$event']) + onClick(event: MouseEvent): void { + const el = this.el.nativeElement; + const hrefAttr = el.getAttribute('href')?.trim() ?? ''; + const targetAttr = el.getAttribute('target'); + if (this.shouldDeferNavigation(hrefAttr, targetAttr)) { + return; + } + + // Early return in editing mode + // if (this.sitecoreContext.isEditing()) { + // return; + // } + + void this.router.navigateByUrl(hrefAttr); + if (!hrefAttr.includes('#')) { + event.preventDefault(); + } + } + + /** + * Returns true when the browser should handle navigation (no in-app Router navigation). + * @param {string | null} hrefAttr - Raw `href` attribute from the anchor. + * @param {string | null} targetAttr - Raw `target` attribute from the anchor. + * @returns {boolean} Whether to skip `Router.navigateByUrl`. + */ + private shouldDeferNavigation(hrefAttr: string | null, targetAttr: string | null): boolean { + if (!hrefAttr || hrefAttr === '') { + return true; + } + if (targetAttr === '_blank') { + return true; + } + const lower = hrefAttr.toLowerCase(); + return ( + lower.startsWith('http://') || + lower.startsWith('https://') || + lower.startsWith('mailto:') || + lower.startsWith('tel:') || + lower.startsWith('sms:') || + lower.startsWith('javascript:') || + lower.startsWith('data:') || + lower.startsWith('ftp:') || + lower.startsWith('//') + ); + } +} diff --git a/packages/angular/src/field-directives/sc-text.directive.spec.ts b/packages/angular/src/field-directives/sc-text.directive.spec.ts index de32e0e6e6..aa8274085e 100644 --- a/packages/angular/src/field-directives/sc-text.directive.spec.ts +++ b/packages/angular/src/field-directives/sc-text.directive.spec.ts @@ -7,7 +7,6 @@ import { ScTextDirective } from './sc-text.directive'; @Component({ selector: 'test-host', - standalone: true, imports: [ScTextDirective], template: ``, }) @@ -17,7 +16,6 @@ class TestHostComponent { @Component({ selector: 'test-host-unencoded', - standalone: true, imports: [ScTextDirective], template: ``, }) diff --git a/packages/angular/src/field-directives/sc-text.directive.ts b/packages/angular/src/field-directives/sc-text.directive.ts index 403b7bb823..27ed2c8981 100644 --- a/packages/angular/src/field-directives/sc-text.directive.ts +++ b/packages/angular/src/field-directives/sc-text.directive.ts @@ -10,7 +10,6 @@ import { isFieldValueEmpty, TextField } from '@sitecore-content-sdk/content/layo *

* * ``` - * * @public */ @Directive({ diff --git a/packages/angular/src/lib/sitecore-context.service.spec.ts b/packages/angular/src/lib/sitecore-context.service.spec.ts index 2945eeeac8..dfc665cc7f 100644 --- a/packages/angular/src/lib/sitecore-context.service.spec.ts +++ b/packages/angular/src/lib/sitecore-context.service.spec.ts @@ -34,7 +34,7 @@ describe('SitecoreContextService', () => { isDesignLibrary: false, designLibrary: { isVariantGeneration: false }, }, - } as unknown as Page; + } as Page; service.setPage(page); expect(service.page()).toBe(page); @@ -53,7 +53,7 @@ describe('SitecoreContextService', () => { isDesignLibrary: false, designLibrary: { isVariantGeneration: false }, }, - } as unknown as Page; + } as Page; service.setPage(page); expect(service.isEditing()).toBe(true); @@ -71,7 +71,7 @@ describe('SitecoreContextService', () => { isDesignLibrary: false, designLibrary: { isVariantGeneration: false }, }, - } as unknown as Page; + } as Page; service.setPage(page); expect(service.page()).toBe(page); diff --git a/packages/angular/src/lib/sitecore-context.service.ts b/packages/angular/src/lib/sitecore-context.service.ts index e8e08349f9..7472870c09 100644 --- a/packages/angular/src/lib/sitecore-context.service.ts +++ b/packages/angular/src/lib/sitecore-context.service.ts @@ -1,4 +1,4 @@ -import { Injectable, signal, computed } from '@angular/core'; +import { Injectable, signal, computed, type Signal, type WritableSignal } from '@angular/core'; import type { Page } from '@sitecore-content-sdk/content/client'; /** @@ -8,22 +8,29 @@ import type { Page } from '@sitecore-content-sdk/content/client'; * Set once per navigation via `setPage(page)` — typically from the route component * after the page loader resolves. All consumers (placeholders, field directives, forms) * inject this service to read the current page and editing state. - * * @public */ @Injectable({ providedIn: 'root' }) export class SitecoreContextService { - private readonly _page = signal(null); - /** Current Sitecore page data (layout + mode). */ - readonly page = this._page.asReadonly(); + readonly page: Signal; /** Whether the current page is in editing mode. */ - readonly isEditing = computed(() => this._page()?.mode?.isEditing ?? false); + readonly isEditing: Signal; + + private readonly _page: WritableSignal; + + constructor() { + const pageSignal = signal(null); + this._page = pageSignal; + this.page = pageSignal.asReadonly(); + this.isEditing = computed(() => pageSignal()?.mode?.isEditing ?? false); + } /** * Update the current page context. Call this when route data resolves. - * @param page - The resolved Page from a loader, or null to clear. + * @param {Page | null} page - The resolved Page from a loader, or null to clear. + * @returns {void} */ setPage(page: Page | null): void { this._page.set(page); diff --git a/packages/angular/src/lib/sitecore-page-resolver.spec.ts b/packages/angular/src/lib/sitecore-page-resolver.spec.ts index 3bc84cd1f6..f16a52d7e7 100644 --- a/packages/angular/src/lib/sitecore-page-resolver.spec.ts +++ b/packages/angular/src/lib/sitecore-page-resolver.spec.ts @@ -50,7 +50,7 @@ describe('resolveSitecorePage', () => { }); it('should return the Page from getPage', async () => { - const page = { locale: 'en', layout: {} } as unknown as Page; + const page = { locale: 'en', layout: {} } as Page; getPage.mockResolvedValueOnce(page); const result = await resolveSitecorePage('/p', mockConfig, mockClient); diff --git a/packages/angular/src/lib/sitecore-page-resolver.ts b/packages/angular/src/lib/sitecore-page-resolver.ts index 18f142ca61..89728e8f28 100644 --- a/packages/angular/src/lib/sitecore-page-resolver.ts +++ b/packages/angular/src/lib/sitecore-page-resolver.ts @@ -7,12 +7,13 @@ import type { Page, PageOptions, SitecoreClient } from '@sitecore-content-sdk/co * this stays usable from route loaders without Angular injection context. * * Future: add helpers for personalization and multisite alongside this call. - * - * @param path - Route path (e.g. `'/'` or `'/about'`). - * @param sitecoreConfig - Resolved Sitecore configuration (e.g. default export from `sitecore.config.ts`). - * @param client - Sitecore client instance (e.g. from a module singleton). - * @param options - Optional `locale` / `site` overrides. - * @returns Page layout data, or `null` if not found. + * @param {string} path - Route path (e.g. `'/'` or `'/about'`). + * @param {SitecoreConfig} sitecoreConfig - Resolved Sitecore configuration (e.g. default export from `sitecore.config.ts`). + * @param {SitecoreClient} client - Sitecore client instance (e.g. from a module singleton). + * @param {{ locale?: string; site?: string }} [options] - Optional `locale` / `site` overrides. + * @param {string} [options.locale] - Optional locale override. + * @param {string} [options.site] - Optional site override. + * @returns {Promise} Page layout data, or `null` if not found. * @public */ export async function resolveSitecorePage( diff --git a/packages/angular/src/loaders/loader-resolver.spec.ts b/packages/angular/src/loaders/loader-resolver.spec.ts index a4218e8b80..49799374c0 100644 --- a/packages/angular/src/loaders/loader-resolver.spec.ts +++ b/packages/angular/src/loaders/loader-resolver.spec.ts @@ -23,7 +23,7 @@ function makeRouteSnapshot( params: overrides.params ?? {}, queryParams: overrides.queryParams ?? {}, pathFromRoot: overrides.pathFromRoot ?? [{ params: {} }], - } as unknown as ActivatedRouteSnapshot; + } as ActivatedRouteSnapshot; } function makeRouterStateSnapshot(url: string): RouterStateSnapshot { @@ -399,7 +399,7 @@ describe('loaderResolver', () => { describe('resolver metadata', () => { it('should tag resolver with LOADER_ID for prefetch discovery', () => { - const resolver = loaderResolver('page') as unknown as { [LOADER_ID]: string }; + const resolver = loaderResolver('page') as unknown as Record; expect(resolver[LOADER_ID]).toBe('page'); }); }); diff --git a/packages/angular/src/loaders/pre-loader-data.service.spec.ts b/packages/angular/src/loaders/pre-loader-data.service.spec.ts index 7a2ea4f2df..019258433a 100644 --- a/packages/angular/src/loaders/pre-loader-data.service.spec.ts +++ b/packages/angular/src/loaders/pre-loader-data.service.spec.ts @@ -29,7 +29,7 @@ function makeRouteSnapshot(overrides: { pathFromRoot: overrides.pathFromRoot, routeConfig: overrides.routeConfig, children: overrides.children ?? [], - } as unknown as MutableSnapshot; + } as MutableSnapshot; } function makeRouterStateSnapshot(url: string): RouterStateSnapshot { diff --git a/packages/angular/src/loaders/utils.spec.ts b/packages/angular/src/loaders/utils.spec.ts index 95428830fc..2db0e0fd6f 100644 --- a/packages/angular/src/loaders/utils.spec.ts +++ b/packages/angular/src/loaders/utils.spec.ts @@ -20,11 +20,10 @@ describe('applyRedirect', () => { it('returns void and calls window.location.assign for external URL', () => { const assignSpy = vi.fn(); const originalWindow = globalThis.window; - ( - globalThis as unknown as { window: { location: { assign: ReturnType } } } - ).window = { - location: { assign: assignSpy }, - }; + (globalThis as unknown as { window: { location: { assign: ReturnType } } }).window = + { + location: { assign: assignSpy }, + }; const result = applyRedirect(mockRouter as unknown as Router, 'https://example.com/path'); expect(result).toBeUndefined(); @@ -37,11 +36,10 @@ describe('applyRedirect', () => { it('treats http URL as external', () => { const assignSpy = vi.fn(); const originalWindow = globalThis.window; - ( - globalThis as unknown as { window: { location: { assign: ReturnType } } } - ).window = { - location: { assign: assignSpy }, - }; + (globalThis as unknown as { window: { location: { assign: ReturnType } } }).window = + { + location: { assign: assignSpy }, + }; const result = applyRedirect(mockRouter as unknown as Router, 'http://example.com'); expect(result).toBeUndefined(); diff --git a/packages/angular/src/placeholder/placeholder-utils.spec.ts b/packages/angular/src/placeholder/placeholder-utils.spec.ts index 24503584b5..778e3f362b 100644 --- a/packages/angular/src/placeholder/placeholder-utils.spec.ts +++ b/packages/angular/src/placeholder/placeholder-utils.spec.ts @@ -12,16 +12,16 @@ import { DEFAULT_EXPORT_NAME, } from './placeholder-utils'; -@Component({ selector: 'test-a', template: 'A', standalone: true }) +@Component({ selector: 'test-a', template: 'A' }) class TestComponentA {} -@Component({ selector: 'test-b', template: 'B', standalone: true }) +@Component({ selector: 'test-b', template: 'B' }) class TestComponentB {} -@Component({ selector: 'test-missing', template: 'Missing', standalone: true }) +@Component({ selector: 'test-missing', template: 'Missing' }) class CustomMissingComponent {} -@Component({ selector: 'test-hidden', template: 'Hidden', standalone: true }) +@Component({ selector: 'test-hidden', template: 'Hidden' }) class CustomHiddenComponent {} describe('getPlaceholderRenderings', () => { @@ -98,7 +98,7 @@ describe('getPlaceholderRenderings', () => { describe('getSXAParams', () => { it('should return empty styles when no params', () => { const rendering: ComponentRendering = { componentName: 'Test' }; - expect(getSXAParams(rendering)).toEqual({ styles: '' }); + expect(getSXAParams(rendering)).toEqual({ Styles: '' }); }); it('should return styles with GridParameters and Styles', () => { @@ -106,7 +106,7 @@ describe('getSXAParams', () => { componentName: 'Test', params: { GridParameters: 'col-9', Styles: 'custom-class' }, }; - expect(getSXAParams(rendering)).toEqual({ styles: 'col-9 custom-class' }); + expect(getSXAParams(rendering)).toEqual({ Styles: 'col-9 custom-class' }); }); it('should return styles with only GridParameters', () => { @@ -114,7 +114,7 @@ describe('getSXAParams', () => { componentName: 'Test', params: { GridParameters: 'col-12' }, }; - expect(getSXAParams(rendering)).toEqual({ styles: 'col-12 ' }); + expect(getSXAParams(rendering)).toEqual({ Styles: 'col-12 ' }); }); it('should return falsy when neither GridParameters nor Styles exist', () => { @@ -144,7 +144,7 @@ describe('getChildComponentProps', () => { }); expect(result.params.global).toBe('param'); expect(result.params.style).toBe('bold'); - expect(result.params.styles).toBe('col-6 test'); + expect(result.params.Styles).toBe('col-6 test'); expect(result.rendering).toBe(rendering); }); @@ -156,6 +156,7 @@ describe('getChildComponentProps', () => { const result = getChildComponentProps(undefined, undefined, rendering); expect(result.fields).toEqual({ a: { value: 1 } }); expect(result.rendering).toBe(rendering); + expect(result.params).toEqual({ Styles: '' }); }); }); diff --git a/packages/angular/src/placeholder/placeholder-utils.ts b/packages/angular/src/placeholder/placeholder-utils.ts index aaa7a9c4e0..295da96d43 100644 --- a/packages/angular/src/placeholder/placeholder-utils.ts +++ b/packages/angular/src/placeholder/placeholder-utils.ts @@ -18,12 +18,12 @@ export const DEFAULT_EXPORT_NAME = 'Default'; * @public */ export type AngularModule = { + /** Named variant exports (must be first for consistent member ordering). */ + [exportName: string]: Type | string | undefined; /** Default component for this rendering */ default?: Type; /** SXA convention: uppercase Default */ Default?: Type; - /** Named variant exports */ - [exportName: string]: Type | string | undefined; /** Component runtime type (reserved for future use) */ componentType?: 'client' | 'server' | 'universal'; }; @@ -44,6 +44,7 @@ export interface ComponentForRendering { /** * Merged props passed to each child component rendered by a placeholder. + * Matches React `getChildComponentProps`: merged `fields` / `params` props plus raw `rendering`. */ export interface ChildComponentProps { fields: { [key: string]: unknown }; @@ -54,10 +55,10 @@ export interface ChildComponentProps { /** * Get the renderings for the specified placeholder from the rendering layout data. * Includes dynamic placeholder handling aligned with React's implementation. - * @param rendering - rendering data - * @param name - placeholder name - * @param isEditing - whether editing mode is active - * @returns array of component renderings + * @param {ComponentRendering | RouteData} rendering - Rendering or route data containing placeholders. + * @param {string} name - Placeholder name. + * @param {boolean} isEditing - Whether editing mode is active. + * @returns {ComponentRendering[]} Child renderings for the placeholder. */ export const getPlaceholderRenderings = ( rendering: ComponentRendering | RouteData, @@ -111,7 +112,7 @@ export const getPlaceholderRenderings = ( }; /** - * Extra inputs to set on each dynamically rendered component (in addition to `fields`, `params`, `rendering`). + * Extra inputs to set on each dynamically rendered component (in addition to `fields`, `params`, and `rendering`). * Keys are Angular `input()` names on the host component. * @public */ @@ -119,18 +120,16 @@ export type PassThroughProps = Readonly>; /** * Get SXA specific params from Sitecore rendering params. - * @param rendering - rendering object - * @returns converted SXA params + * @param {ComponentRendering} rendering - Rendering object. + * @returns {{ Styles: string } | undefined} Converted SXA params, or `undefined` when none apply. */ -export const getSXAParams = ( - rendering: ComponentRendering -): { styles: string } | undefined => { - if (!rendering.params) return { styles: '' }; +export const getSXAParams = (rendering: ComponentRendering) => { + if (!rendering.params) return { Styles: '' }; const { GridParameters, Styles } = rendering.params; if (GridParameters || Styles) { - return { styles: `${GridParameters || ''} ${Styles || ''}` }; + return { Styles: `${GridParameters || ''} ${Styles || ''}` }; } return undefined; @@ -138,10 +137,10 @@ export const getSXAParams = ( /** * Merge placeholder-level fields/params with per-component fields/params. - * @param placeholderFields - placeholder-level fields - * @param placeholderParams - placeholder-level params - * @param componentRendering - the component rendering data - * @returns merged child component props + * @param {{ [key: string]: unknown } | undefined} placeholderFields - Placeholder-level fields. + * @param {{ [key: string]: string } | undefined} placeholderParams - Placeholder-level params. + * @param {ComponentRendering} componentRendering - The component rendering data. + * @returns {ChildComponentProps} Merged child component props. */ export function getChildComponentProps( placeholderFields: { [key: string]: unknown } | undefined, @@ -150,11 +149,12 @@ export function getChildComponentProps( ): ChildComponentProps { const fields = { ...(placeholderFields || {}), ...(componentRendering.fields || {}) }; const params = { ...(placeholderParams || {}), ...(componentRendering.params || {}) }; + const sxa = getSXAParams(componentRendering); return { fields, params: { ...params, - ...getSXAParams(componentRendering), + ...(sxa || {}), }, rendering: componentRendering, }; @@ -164,12 +164,12 @@ export function getChildComponentProps( * Resolve a component type for a rendering definition. * Handles hidden renderings, missing components, variant selection, and map lookup. * FEaaS/BYOC are intentionally not handled; they fall through to missingComponent. - * @param renderingDefinition - the rendering to resolve - * @param placeholderName - current placeholder name (for logging) - * @param componentMap - the app component map - * @param hiddenRenderingComponent - optional override for hidden renderings - * @param missingComponentComponent - optional override for missing/unknown components - * @returns resolved component info + * @param {ComponentRendering} renderingDefinition - The rendering to resolve. + * @param {string} placeholderName - Current placeholder name (for logging). + * @param {ComponentMap | undefined} componentMap - The app component map. + * @param {Type | undefined} hiddenRenderingComponent - Optional override for hidden renderings. + * @param {Type | undefined} missingComponentComponent - Optional override for missing/unknown components. + * @returns {ComponentForRendering} Resolved component info. */ export const resolveComponentForRendering = ( renderingDefinition: ComponentRendering, @@ -224,8 +224,7 @@ export const resolveComponentForRendering = ( : entry.default || entry.Default; if (!resolved || typeof resolved !== 'function') { - const variantLabel = - exportName && exportName !== DEFAULT_EXPORT_NAME ? ` (${exportName})` : ''; + const variantLabel = exportName && exportName !== DEFAULT_EXPORT_NAME ? ` (${exportName})` : ''; console.error( `Placeholder ${placeholderName} contains unknown component ${renderingDefinition.componentName}${variantLabel}. Ensure that an Angular component exists for it, and that it is registered in your component map.` ); diff --git a/packages/angular/src/placeholder/sc-hidden-rendering.component.ts b/packages/angular/src/placeholder/sc-hidden-rendering.component.ts index 5eaeab26f0..43b39ee94f 100644 --- a/packages/angular/src/placeholder/sc-hidden-rendering.component.ts +++ b/packages/angular/src/placeholder/sc-hidden-rendering.component.ts @@ -7,7 +7,6 @@ import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; */ @Component({ selector: 'sc-hidden-rendering', - standalone: true, template: `
The component is hidden
`, }) export class ScHiddenRenderingComponent { diff --git a/packages/angular/src/placeholder/sc-missing-component.component.ts b/packages/angular/src/placeholder/sc-missing-component.component.ts index 02f638323b..9c35eed5f8 100644 --- a/packages/angular/src/placeholder/sc-missing-component.component.ts +++ b/packages/angular/src/placeholder/sc-missing-component.component.ts @@ -7,7 +7,6 @@ import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; */ @Component({ selector: 'sc-missing-component', - standalone: true, template: `

{{ componentName() }}

diff --git a/packages/angular/src/placeholder/sc-placeholder.component.spec.ts b/packages/angular/src/placeholder/sc-placeholder.component.spec.ts index 9aaeccb669..20c948244e 100644 --- a/packages/angular/src/placeholder/sc-placeholder.component.spec.ts +++ b/packages/angular/src/placeholder/sc-placeholder.component.spec.ts @@ -12,7 +12,6 @@ import { SitecoreContextService } from '../lib/sitecore-context.service'; @Component({ selector: 'test-title', - standalone: true, template: `

{{ titleText() }}

`, }) class TitleComponent { @@ -22,14 +21,13 @@ class TitleComponent { titleText = () => { const f = this.fields(); - const title = f?.['Title'] as { value: string } | undefined; + const title = f?.Title as { value: string } | undefined; return title?.value ?? ''; }; } @Component({ selector: 'test-content', - standalone: true, template: `

Content

`, }) class ContentComponent { @@ -40,7 +38,6 @@ class ContentComponent { @Component({ selector: 'test-passthrough', - standalone: true, template: `{{ tag() }}`, }) class PassThroughChildComponent { @@ -62,7 +59,7 @@ const makePage = (isEditing = false): Page => isDesignLibrary: false, designLibrary: { isVariantGeneration: false }, }, - }) as unknown as Page; + } as Page); describe('ScPlaceholderComponent', () => { let warnSpy: ReturnType; @@ -80,9 +77,7 @@ describe('ScPlaceholderComponent', () => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ imports: [ScPlaceholderComponent], - providers: [ - { provide: SITECORE_COMPONENT_MAP, useValue: componentMap }, - ], + providers: [{ provide: SITECORE_COMPONENT_MAP, useValue: componentMap }], }); const ctx = TestBed.inject(SitecoreContextService); @@ -94,7 +89,10 @@ describe('ScPlaceholderComponent', () => { errorSpy.mockRestore(); }); - function createFixture(rendering: ComponentRendering | RouteData, name: string): ComponentFixture { + function createFixture( + rendering: ComponentRendering | RouteData, + name: string + ): ComponentFixture { const fixture = TestBed.createComponent(ScPlaceholderComponent); fixture.componentRef.setInput('rendering', rendering); fixture.componentRef.setInput('name', name); diff --git a/packages/angular/src/placeholder/sc-placeholder.component.ts b/packages/angular/src/placeholder/sc-placeholder.component.ts index d58e1c53bb..b3c48611ab 100644 --- a/packages/angular/src/placeholder/sc-placeholder.component.ts +++ b/packages/angular/src/placeholder/sc-placeholder.component.ts @@ -33,13 +33,11 @@ import { ScHiddenRenderingComponent } from './sc-hidden-rendering.component'; * * ``` * - * Optional `[passThroughProps]` sets extra `input()` values on each child (merged after `fields`, `params`, `rendering`). - * + * Optional `[passThroughProps]` sets extra `input()` values on each child (merged after `fields`, `params`, and `rendering`). * @public */ @Component({ selector: 'sc-placeholder', - standalone: true, imports: [CommonModule], template: ``, }) @@ -53,7 +51,7 @@ export class ScPlaceholderComponent { /** Optional placeholder-level fields merged into each child. */ readonly fields = input<{ [key: string]: unknown }>(); - /** Optional placeholder-level params merged into each child. */ + /** Optional placeholder-level params merged into each child's `params` input. */ readonly params = input<{ [key: string]: string }>(); /** diff --git a/packages/angular/src/public-api.ts b/packages/angular/src/public-api.ts index 124f2f6ca9..c8b8e9e4d0 100644 --- a/packages/angular/src/public-api.ts +++ b/packages/angular/src/public-api.ts @@ -105,6 +105,7 @@ export { export { ScTextDirective } from './field-directives/sc-text.directive'; export { ScImageDirective } from './field-directives/sc-image.directive'; export { ScLinkDirective } from './field-directives/sc-link.directive'; +export { ScRouterLinkDirective } from './field-directives/sc-router-link.directive'; export { ScRichTextDirective } from './field-directives/sc-rich-text.directive'; // ─── Form ────────────────────────────────────────────────────── diff --git a/packages/angular/src/server/loader-data-service-middleware.spec.ts b/packages/angular/src/server/loader-data-service-middleware.spec.ts index bc91f70bb4..761ad2e648 100644 --- a/packages/angular/src/server/loader-data-service-middleware.spec.ts +++ b/packages/angular/src/server/loader-data-service-middleware.spec.ts @@ -7,6 +7,10 @@ import { LOADER_DATA_ENDPOINT } from './constants'; import { EXTRACT_REQUEST_CONTEXT_TOKEN } from './models'; import type { LoaderRegistry } from './models'; +/** + * Minimal Express `res` stub for middleware tests. + * @returns {object} Mock with `status` and `json` spies. + */ function createMockRes() { return { status: vi.fn().mockReturnThis(), @@ -14,6 +18,10 @@ function createMockRes() { }; } +/** + * Express `next` stub. + * @returns {ReturnType} Spy function. + */ function createMockNext() { return vi.fn(); } @@ -30,6 +38,13 @@ describe('createLoaderDataServiceMiddleware', () => { }); }); + /** + * Wraps `createLoaderDataServiceMiddleware` with test defaults (registry + extract context). + * @param {{ loaders: LoaderRegistry; endpoint?: string }} opts - Options bag for the factory. + * @param {LoaderRegistry} opts.loaders - Registered loader functions. + * @param {string} [opts.endpoint] - Optional endpoint path override. + * @returns {ReturnType} Configured middleware. + */ function createMiddleware(opts: { loaders: LoaderRegistry; endpoint?: string; diff --git a/packages/angular/src/server/loader-data-service-middleware.ts b/packages/angular/src/server/loader-data-service-middleware.ts index 218e410682..2e4d7267d7 100644 --- a/packages/angular/src/server/loader-data-service-middleware.ts +++ b/packages/angular/src/server/loader-data-service-middleware.ts @@ -87,13 +87,20 @@ async function executeLoader( } /** - * Send the loader response to Express + * Send the loader response to Express. + * @param {ExpressResponse} res - Express response object. + * @param {LoaderApiResponse} result - Serialized loader API payload. + * @returns {void} */ function sendResponse(res: ExpressResponse, result: LoaderApiResponse): void { res.json(result); } -/** Parse POST body or GET query into LoaderApiRequest, or return a validation error. */ +/** + * Parse POST body or GET query into LoaderApiRequest, or return a validation error. + * @param {ExpressRequest} req - Incoming Express request. + * @returns {LoaderApiRequest | { status: number; message: string }} Parsed body or error shape. + */ function parseLoaderRequest( req: ExpressRequest ): LoaderApiRequest | { status: number; message: string } { @@ -126,9 +133,8 @@ function parseLoaderRequest( * The endpoint path must match the client: provide the same value to the Angular app via * {@link FETCH_DATA_ENDPOINT} (e.g. in app.config.ts). There is no Angular DI in Node/Express, * so you pass the endpoint here when calling this function (e.g. from server.ts). - * - * @param options - Handler options: loaders and optional endpoint (defaults to {@link LOADER_DATA_ENDPOINT}) - * @returns Express middleware that handles the data endpoint + * @param {ExpressDataHandlerOptions} options - Handler options: loaders and optional endpoint (defaults to {@link LOADER_DATA_ENDPOINT}). + * @returns {ExpressMiddleware} Express middleware that handles the data endpoint. * @example * ```typescript * import { createExpressDataMiddleware, LOADER_DATA_ENDPOINT } from '@sitecore-content-sdk/angular'; diff --git a/packages/create-content-sdk-app/src/templates/angular/.sitecore/component-map.ts b/packages/create-content-sdk-app/src/templates/angular/.sitecore/component-map.ts index c967ed6238..ad19e1a014 100644 --- a/packages/create-content-sdk-app/src/templates/angular/.sitecore/component-map.ts +++ b/packages/create-content-sdk-app/src/templates/angular/.sitecore/component-map.ts @@ -3,7 +3,7 @@ import type { AngularModule, ComponentMap } from '@sitecore-content-sdk/angular' import { ScFormComponent } from '@sitecore-content-sdk/angular'; import { TitleComponent } from 'components/title.component'; import { RichTextComponent } from 'components/rich-text.component'; -import { ImageComponent } from 'components/image.component'; +import { ImageDefaultComponent, ImageBannerComponent } from 'components/image.component'; import { ContentBlockComponent } from 'components/content-block.component'; import { PromoComponent } from 'components/promo.component'; import { ContainerComponent } from 'components/container.component'; @@ -12,20 +12,31 @@ import { RowSplitterComponent } from 'components/row-splitter.component'; import { NavigationComponent } from 'components/navigation.component'; import { PageContentComponent } from 'components/page-content.component'; import { LinkListComponent } from 'components/link-list.component'; +import { PartialDesignDynamicPlaceholderComponent } from 'components/partial-design-dynamic-placeholder.component'; + +const imageRendering: AngularModule = { + Default: ImageDefaultComponent, + Banner: ImageBannerComponent, +}; + +const promoRendering: AngularModule = { + Default: PromoComponent, + WithText: PromoComponent, +}; export const componentMap: ComponentMap = new Map | AngularModule>([ ['Title', TitleComponent], ['RichText', RichTextComponent], - ['Image', ImageComponent], + ['Image', imageRendering], ['ContentBlock', ContentBlockComponent], - ['Promo', PromoComponent], + ['Promo', promoRendering], ['Container', ContainerComponent], ['ColumnSplitter', ColumnSplitterComponent], ['RowSplitter', RowSplitterComponent], ['Navigation', NavigationComponent], ['PageContent', PageContentComponent], ['LinkList', LinkListComponent], - ['PartialDesignDynamicPlaceholder', ContainerComponent], + ['PartialDesignDynamicPlaceholder', PartialDesignDynamicPlaceholderComponent], ['Form', ScFormComponent], ]); diff --git a/packages/create-content-sdk-app/src/templates/angular/angular.json b/packages/create-content-sdk-app/src/templates/angular/angular.json index 2edfb960c7..62869af64a 100644 --- a/packages/create-content-sdk-app/src/templates/angular/angular.json +++ b/packages/create-content-sdk-app/src/templates/angular/angular.json @@ -87,6 +87,13 @@ }, "test": { "builder": "@angular/build:unit-test" + }, + "lint": { + "builder": "@angular-eslint/builder:lint", + "options": { + "eslintConfig": "eslint.config.mjs", + "lintFilePatterns": ["src/**/*.ts", "src/**/*.html", "sitecore.config.ts"] + } } } } diff --git a/packages/create-content-sdk-app/src/templates/angular/eslint.config.mjs b/packages/create-content-sdk-app/src/templates/angular/eslint.config.mjs new file mode 100644 index 0000000000..ff91826627 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/angular/eslint.config.mjs @@ -0,0 +1,72 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import angular from 'angular-eslint'; + +/** + * ESLint flat config (ESLint 9+). See: + * https://github.com/angular-eslint/angular-eslint/blob/main/docs/CONFIGURING_FLAT_CONFIG.md + */ +export default tseslint.config( + { + ignores: ['dist/**', 'node_modules/**', '.angular/**', 'coverage/**', 'projects/**'], + }, + { + files: ['**/*.ts'], + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommended, + ...angular.configs.tsRecommended, + ], + processor: angular.processInlineTemplates, + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + '@angular-eslint/prefer-standalone': 'off', + '@angular-eslint/prefer-inject': 'off', + '@angular-eslint/no-host-metadata-property': 'off', + '@angular-eslint/component-selector': [ + 'error', + { + type: 'element', + prefix: 'app', + style: 'kebab-case', + }, + ], + '@angular-eslint/directive-selector': [ + 'error', + { + type: 'attribute', + prefix: 'app', + style: 'camelCase', + }, + ], + '@typescript-eslint/dot-notation': 'off', + '@typescript-eslint/explicit-member-accessibility': [ + 'off', + { accessibility: 'explicit' }, + ], + 'brace-style': ['error', '1tbs'], + 'id-denylist': 'off', + 'id-match': 'off', + 'no-underscore-dangle': 'off', + // Sitecore field directives populate host element content at runtime + '@angular-eslint/template/elements-content': 'off', + // Navigation matches kit-nextjs-skate-park (click on title row / delegated nav close) + '@angular-eslint/template/click-events-have-key-events': 'off', + '@angular-eslint/template/interactive-supports-focus': 'off', + }, + }, + { + files: ['**/*.html'], + extends: [...angular.configs.templateRecommended], + rules: { + '@angular-eslint/template/elements-content': 'off', + '@angular-eslint/template/click-events-have-key-events': 'off', + '@angular-eslint/template/interactive-supports-focus': 'off', + }, + } +); diff --git a/packages/create-content-sdk-app/src/templates/angular/package.json b/packages/create-content-sdk-app/src/templates/angular/package.json index e22282f99f..734a21c5a3 100644 --- a/packages/create-content-sdk-app/src/templates/angular/package.json +++ b/packages/create-content-sdk-app/src/templates/angular/package.json @@ -11,7 +11,9 @@ "watch": "npm-run-all -s gen:env:dev \"ng build --watch --configuration development\"", "start": "npm-run-all -s build serve:ssr", "serve:ssr": "node dist/<%- appName %>/server/server.mjs", - "test": "ng test" + "test": "ng test", + "lint": "ng lint", + "lint:fix": "ng lint --fix" }, "prettier": { "printWidth": 100, @@ -48,18 +50,23 @@ "tslib": "^2.3.0" }, "devDependencies": { + "@angular-eslint/builder": "^21.3.1", "@angular/build": "^21.1.4", "@angular/cli": "^21.1.4", "@angular/compiler-cli": "^21.1.0", + "@eslint/js": "^10.0.1", "@tailwindcss/postcss": "^4.1.12", "@types/express": "^5.0.1", "@types/node": "^20.17.19", + "angular-eslint": "^21.3.1", + "eslint": "^10.3.0", "jsdom": "^27.1.0", "npm-run-all2": "^7.0.2", "postcss": "^8.5.3", "tailwindcss": "^4.1.12", "tsx": "^4.19.4", "typescript": "~5.9.2", + "typescript-eslint": "^8.59.1", "vitest": "^4.0.8" } } diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/app.css b/packages/create-content-sdk-app/src/templates/angular/src/app/app.css index e69de29bb2..1a12fa4179 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/app.css +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/app.css @@ -0,0 +1,37 @@ +a[target='_blank']:after { + content: '\1F5D7'; +} + +.logo { + background-image: url('../assets/images/sc_logo.svg'); + display: block; + height: 48px; + width: 221px; +} + +/* + Hides Sitecore Experience Editor markup, + if you run the app in connected mode while the Sitecore cookies + are set to edit mode. +*/ +.scChromeData, +.scpm { + display: none !important; +} + +/* + Style for default content block +*/ +.contentTitle { + font-size: 3.5rem; + font-weight: 300; + line-height: 1.2; +} + +a { + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/components/column-splitter.component.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/components/column-splitter.component.ts index c6c3c7f0ce..a4323bf723 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/components/column-splitter.component.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/components/column-splitter.component.ts @@ -1,37 +1,57 @@ -import { Component, input, computed } from '@angular/core'; -import { ComponentRendering, ScPlaceholderComponent } from '@sitecore-content-sdk/angular'; -import { scRenderingId } from '../sitecore/sitecore-component-classes'; +import { Component, computed } from '@angular/core'; +import { ScPlaceholderComponent } from '@sitecore-content-sdk/angular'; +import { SxaComponent } from './content-sdk/sxa.component'; +type ColumnNumber = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; @Component({ selector: 'app-column-splitter', - standalone: true, imports: [ScPlaceholderComponent], template: ` -
- @for (phName of placeholderNames(); track phName) { -
- +
+ @for (columnNum of enabledColumns(); track columnNum) { +
+
+
+
}
`, }) -export class ColumnSplitterComponent { - readonly fields = input<{ [key: string]: unknown }>({}); - readonly params = input<{ [key: string]: string }>({}); - readonly rendering = input(); - - readonly placeholderNames = computed(() => { - const r = this.rendering(); - if (!r?.placeholders) return []; - return Object.keys(r.placeholders); +export class ColumnSplitterComponent extends SxaComponent { + /** Enabled placeholder columns from params. */ + readonly enabledColumns = computed(() => { + const raw = this.params()?.EnabledPlaceholders; + return ( + raw + ?.split(',') + .map((segment: string) => segment.trim()) + .filter(Boolean) ?? [] + ); }); - readonly rowClass = computed(() => { - const s = this.params()?.['styles']?.trim(); - const base = 'row column-splitter'; - return s ? `${base} ${s}` : base; + /** Extra root-level classes from rendering params (SXA: GridParameters + Styles). */ + override readonly styles = computed(() => { + const renderingParams = this.params(); + const gridParams = renderingParams?.GridParameters?.trim() ?? ''; + const fromStyles = renderingParams?.Styles?.trim() ?? ''; + return `${gridParams} ${fromStyles}`.trim(); }); - readonly renderingId = computed(() => scRenderingId(this.params())); + placeholderKey(columnNum: string): string { + return `column-${columnNum}-{*}`; + } + + columnClassNames(columnNum: string): string { + const columnIndex = Number(columnNum) as ColumnNumber; + const columnWidth = this.params()?.[`ColumnWidth${columnIndex}`] ?? ''; + const columnStyle = this.params()?.[`Styles${columnIndex}`] ?? ''; + return `${columnWidth} ${columnStyle}`.trim(); + } } diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/components/container.component.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/components/container.component.ts index 6c5f5fcd49..57adf7cce5 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/components/container.component.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/components/container.component.ts @@ -1,34 +1,60 @@ -import { Component, input, computed } from '@angular/core'; -import { ComponentRendering, ScPlaceholderComponent } from '@sitecore-content-sdk/angular'; -import { scComponentRoot, scRenderingId } from '../sitecore/sitecore-component-classes'; +import { Component, computed } from '@angular/core'; +import { NgTemplateOutlet, NgStyle } from '@angular/common'; +import { ScPlaceholderComponent } from '@sitecore-content-sdk/angular'; +import { SxaComponent } from './content-sdk/sxa.component'; + +const mediaUrlPattern = /mediaurl="([^"]*)"/i; @Component({ selector: 'app-container', - standalone: true, - imports: [ScPlaceholderComponent], + imports: [NgTemplateOutlet, NgStyle, ScPlaceholderComponent], template: ` -
-
-
- @for (phName of placeholderNames(); track phName) { - - } + @if (needsWrapper()) { +
+ +
+ } @else { + + } + + +
+
+
+ @if (placeholderName()) { + + } +
-
+ `, }) -export class ContainerComponent { - readonly fields = input<{ [key: string]: unknown }>({}); - readonly params = input<{ [key: string]: string }>({}); - readonly rendering = input(); +export class ContainerComponent extends SxaComponent { + readonly placeholderName = computed(() => { + const id = this.params()?.DynamicPlaceholderId?.trim(); + return id ? `container-${id}` : ''; + }); - readonly placeholderNames = computed(() => { - const r = this.rendering(); - if (!r?.placeholders) return []; - return Object.keys(r.placeholders); + readonly needsWrapper = computed(() => { + const tokens = this.styles()?.split(/\s+/).filter(Boolean) ?? []; + return tokens.includes('container'); }); - readonly rootClass = computed(() => scComponentRoot('container', this.params())); - readonly renderingId = computed(() => scRenderingId(this.params())); + readonly backgroundStyle = computed((): { [key: string]: string } => { + const backgroundImage = this.params()?.BackgroundImage; + if (!backgroundImage || !mediaUrlPattern.test(backgroundImage)) { + return {}; + } + const mediaUrl = backgroundImage.match(mediaUrlPattern)?.at(1) ?? ''; + if (!mediaUrl) { + return {}; + } + return { + backgroundImage: `url('${mediaUrl}')`, + }; + }); } diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/components/content-block.component.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/components/content-block.component.ts index e2da81e758..a0876e5642 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/components/content-block.component.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/components/content-block.component.ts @@ -1,31 +1,35 @@ -import { Component, input, computed } from '@angular/core'; -import { ComponentRendering, Field } from '@sitecore-content-sdk/angular'; -import { ScTextDirective, ScRichTextDirective } from '@sitecore-content-sdk/angular'; -import { scComponentRoot, scRenderingId } from '../sitecore/sitecore-component-classes'; +import { Component, computed } from '@angular/core'; +import { Field } from '@sitecore-content-sdk/angular'; +import { ScTextDirective, ScRichTextDirective, TextField } from '@sitecore-content-sdk/angular'; +import { SxaComponent } from './content-sdk/sxa.component'; +/** + * Parity with kit-nextjs-skate-park ContentBlock (minimal section, no Sitecore chrome wrapper). + */ @Component({ selector: 'app-content-block', - standalone: true, imports: [ScTextDirective, ScRichTextDirective], template: ` -
-
-
-

-
-
+
+

+
+ @if (contentField(); as content) { +
+ }
`, }) -export class ContentBlockComponent { - readonly fields = input<{ [key: string]: unknown }>({}); - readonly params = input<{ [key: string]: string }>({}); - readonly rendering = input(); +export class ContentBlockComponent extends SxaComponent { + readonly headingField = computed((): Field | undefined => { + const all = this.fields(); + if (!all || Object.keys(all).length === 0) return undefined; + return (all.heading ?? all.Heading) as Field | undefined; + }); - readonly titleField = computed(() => this.fields()?.['Title'] as Field | undefined); - readonly contentField = computed(() => this.fields()?.['Content'] as Field | undefined); - - readonly rootClass = computed(() => scComponentRoot('content rich-text', this.params())); - readonly renderingId = computed(() => scRenderingId(this.params())); + readonly contentField = computed((): TextField | undefined => { + const all = this.fields(); + if (!all || Object.keys(all).length === 0) return undefined; + return (all.content ?? all.Content) as TextField | undefined; + }); } diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/components/content-sdk/json-ld.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/components/content-sdk/json-ld.ts new file mode 100644 index 0000000000..cb02143930 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/components/content-sdk/json-ld.ts @@ -0,0 +1,60 @@ +/** Minimal JSON-LD builders aligned with kit-nextjs-skate-park structured data. */ + +export type ArticleJsonLd = { + '@context': 'https://schema.org'; + '@type': 'Article'; + headline?: string; + articleBody?: string; + inLanguage?: string; +}; + +export type ProductJsonLd = { + '@context': 'https://schema.org'; + '@type': 'Product'; + name?: string; + description?: string; + image?: string | string[]; + url?: string; +}; + +const stripHtml = (html: string): string => + html + .replace(/[\s\S]*?<\/script[^>]*>/gi, ' ') + .replace(/[\s\S]*?<\/style[^>]*>/gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + +export function buildArticleJsonLd(input: { + headline?: string; + articleBodyHtml?: string; + inLanguage?: string; +}): ArticleJsonLd { + const articleBody = input.articleBodyHtml ? stripHtml(input.articleBodyHtml) : undefined; + + return { + '@context': 'https://schema.org', + '@type': 'Article', + headline: input.headline, + articleBody, + inLanguage: input.inLanguage, + }; +} + +export function buildProductJsonLd(input: { + name?: string; + descriptionHtml?: string; + image?: string | string[]; + url?: string; +}): ProductJsonLd { + const description = input.descriptionHtml ? stripHtml(input.descriptionHtml) : undefined; + + return { + '@context': 'https://schema.org', + '@type': 'Product', + name: input.name, + description, + image: input.image, + url: input.url, + }; +} diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/components/content-sdk/sxa.component.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/components/content-sdk/sxa.component.ts new file mode 100644 index 0000000000..fc57500e87 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/components/content-sdk/sxa.component.ts @@ -0,0 +1,24 @@ +import { Directive, computed, input } from '@angular/core'; +import { ComponentRendering } from '@sitecore-content-sdk/angular'; +import { computedRenderingId } from './utils'; + +/** SXA base: `fields` / `params` merge `rendering` with placeholder-bound inputs (same idea as React placeholder utils). */ +@Directive() +export abstract class SxaComponent { + readonly _fields = input>({}, { alias: 'fields' }); + readonly _params = input>({}, { alias: 'params' }); + readonly rendering = input(); + + readonly fields = computed(() => ({ + ...(this.rendering()?.fields ?? {}), + ...this._fields(), + })); + + readonly params = computed(() => ({ + ...(this.rendering()?.params ?? {}), + ...this._params(), + })); + + readonly renderingId = computedRenderingId(() => this.params()); + readonly styles = computed(() => this.params()?.Styles?.trim() ?? ''); +} diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/components/content-sdk/utils.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/components/content-sdk/utils.ts new file mode 100644 index 0000000000..2ac75567fb --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/components/content-sdk/utils.ts @@ -0,0 +1,23 @@ +import { computed, type Signal } from '@angular/core'; + +type LayoutParams = { RenderingIdentifier?: string; Styles?: string }; + +/** + * CSS class/id helpers aligned with kit-nextjs-skate-park component wrappers. + */ +export function computedRenderingId( + params: () => { [key: string]: string } | undefined, +): Signal { + return computed(() => { + const layoutParams = params() as LayoutParams | undefined; + const id = layoutParams?.RenderingIdentifier?.trim(); + return id || undefined; + }); +} + +export function scComponentRoot(kind: string, params?: { [key: string]: string }): string { + const layoutParams = params as LayoutParams | undefined; + const extra = layoutParams?.Styles?.trim(); + const base = `component ${kind}`.trim(); + return extra ? `${base} ${extra}` : base; +} diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/components/image.component.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/components/image.component.ts index c235e15f19..c58869e8cc 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/components/image.component.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/components/image.component.ts @@ -1,29 +1,117 @@ -import { Component, input, computed } from '@angular/core'; -import { ComponentRendering } from '@sitecore-content-sdk/angular'; -import { ScImageDirective, ImageField } from '@sitecore-content-sdk/angular'; -import { scComponentRoot, scRenderingId } from '../sitecore/sitecore-component-classes'; +import { Component, computed, inject } from '@angular/core'; +import { + Field, + ImageField, + LinkField, + ScImageDirective, + ScLinkDirective, + ScTextDirective, + SitecoreContextService, +} from '@sitecore-content-sdk/angular'; +import { SxaComponent } from './content-sdk/sxa.component'; + +const DEFAULT_SIZES = + '(max-width: 640px) 100vw, (max-width: 768px) 100vw, (max-width: 1024px) 90vw, 1200px'; + +const BANNER_SIZES = + '(max-width: 640px) 100vw, (max-width: 768px) 768px, (max-width: 1024px) 1024px, (max-width: 1440px) 1280px, 1920px'; + +interface ImageDefaultFields { + Image?: ImageField; + ImageCaption?: Field; + TargetUrl?: LinkField; +} + +interface ImageBannerFields { + Image?: ImageField; +} @Component({ - selector: 'app-image', - standalone: true, - imports: [ScImageDirective], + selector: 'app-image-default', + imports: [ScImageDirective, ScLinkDirective, ScTextDirective], template: ` -
+ @if (showEmpty()) { +
+
+ Image +
+
+ } @else { +
- - - + @if (wrapWithLink()) { + + + + } @else { + + } + +
+
+ } + `, +}) +export class ImageDefaultComponent extends SxaComponent { + readonly defaultSizes = DEFAULT_SIZES; + + private readonly context = inject(SitecoreContextService); + + readonly imageField = computed(() => (this.fields() as ImageDefaultFields)?.Image); + readonly captionField = computed(() => (this.fields() as ImageDefaultFields)?.ImageCaption); + readonly targetUrlField = computed(() => (this.fields() as ImageDefaultFields)?.TargetUrl); + + readonly showEmpty = computed(() => this.fields()?.Image == null); + + readonly wrapWithLink = computed(() => { + if (this.context.isEditing()) return false; + const href = this.targetUrlField()?.value?.href; + return !!href?.trim(); + }); + + readonly imageAlt = computed(() => { + const alt = this.imageField()?.value?.alt; + return typeof alt === 'string' ? alt : ''; + }); +} + +@Component({ + selector: 'app-image-banner', + imports: [ScImageDirective], + template: ` +
+
+
`, }) -export class ImageComponent { - readonly fields = input<{ [key: string]: unknown }>({}); - readonly params = input<{ [key: string]: string }>({}); - readonly rendering = input(); +export class ImageBannerComponent extends SxaComponent { + readonly bannerSizes = BANNER_SIZES; - readonly imageField = computed(() => this.fields()?.['Image'] as ImageField | undefined); + readonly bannerImageField = computed(() => { + const sourceImage = (this.fields() as ImageBannerFields)?.Image; + if (!sourceImage?.value) return sourceImage; + return { + ...sourceImage, + value: { + ...sourceImage.value, + style: { objectFit: 'cover', width: '100%', height: '100%' }, + }, + } as ImageField; + }); - readonly rootClass = computed(() => scComponentRoot('image', this.params())); - readonly renderingId = computed(() => scRenderingId(this.params())); + readonly bannerAlt = computed(() => { + const alt = (this.fields() as ImageBannerFields)?.Image?.value?.alt; + return typeof alt === 'string' ? alt : 'Hero banner'; + }); } diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/components/link-list.component.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/components/link-list.component.ts index c66eab7996..1fb9337d39 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/components/link-list.component.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/components/link-list.component.ts @@ -1,45 +1,72 @@ -import { Component, input, computed } from '@angular/core'; -import { ComponentRendering, Field } from '@sitecore-content-sdk/angular'; -import { ScTextDirective, ScLinkDirective, LinkField } from '@sitecore-content-sdk/angular'; -import { scComponentRoot, scRenderingId } from '../sitecore/sitecore-component-classes'; +import { Component, computed } from '@angular/core'; +import { isFieldValueEmpty } from '@sitecore-content-sdk/content/layout'; +import { Field, ScTextDirective, ScLinkDirective, LinkField } from '@sitecore-content-sdk/angular'; +import { SxaComponent } from './content-sdk/sxa.component'; -interface LinkListItem { - id: string; - fields: { - Title?: Field; - Link?: LinkField; +interface LinkListDatasource { + children?: { + results?: Array<{ + field?: { + link?: LinkField; + }; + }>; }; + field?: { + title?: Field; + }; +} + +interface LinkListFields { + data?: { datasource?: LinkListDatasource }; } @Component({ selector: 'app-link-list', - standalone: true, imports: [ScTextDirective, ScLinkDirective], template: ` -
+
+ @if (!datasource()) { +

Link List

+ } @else {

    - @for (item of linkItems(); track item.id) { -
  • - @if (item.fields?.Link) { - - } -
  • + @for (row of linkRows(); track $index) { +
  • + +
  • }
+ }
`, }) -export class LinkListComponent { - readonly fields = input<{ [key: string]: unknown }>({}); - readonly params = input<{ [key: string]: string }>({}); - readonly rendering = input(); +export class LinkListComponent extends SxaComponent { + readonly data = computed(() => (this.fields() as LinkListFields)?.data); + + readonly datasource = computed(() => this.data()?.datasource); + + readonly titleField = computed(() => this.datasource()?.field?.title); - readonly titleField = computed(() => this.fields()?.['Title'] as Field | undefined); - readonly linkItems = computed(() => (this.fields()?.['items'] as LinkListItem[]) ?? []); + readonly linkRows = computed(() => { + const results = this.datasource()?.children?.results; + if (!Array.isArray(results)) return [] as Array<{ link: LinkField }>; + return results + .filter((result) => { + const link = result?.field?.link; + return !!link && !isFieldValueEmpty(link); + }) + .map((result) => ({ link: result.field!.link! })); + }); - readonly rootClass = computed(() => scComponentRoot('link-list', this.params())); - readonly renderingId = computed(() => scRenderingId(this.params())); + /** SXA-style list row classes: `item{n}`, alternating odd/even (0-based), optional first/last. */ + itemClass(index: number, total: number): string { + const classes = [`item${index}`, index % 2 === 0 ? 'odd' : 'even']; + if (index === 0) classes.push('first'); + if (index === total - 1) classes.push('last'); + return classes.join(' '); + } } diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/components/navigation-item.component.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/components/navigation-item.component.ts new file mode 100644 index 0000000000..7b5e70964b --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/components/navigation-item.component.ts @@ -0,0 +1,95 @@ +import { Component, computed, input, output, signal } from '@angular/core'; +import { Field, ScRouterLinkDirective, ScTextDirective } from '@sitecore-content-sdk/angular'; + +/** Navigation datasource item (layout JSON field names). */ +export interface NavItemFields { + Id?: string; + DisplayName?: string; + Title?: Field; + NavigationTitle?: Field; + Href?: string; + Querystring?: string; + Children?: NavItemFields[]; + Styles?: string[]; +} + +@Component({ + selector: 'app-navigation-item', + imports: [ScRouterLinkDirective, ScTextDirective, NavigationItemComponent], + template: ` +
  • + + @if (hasChildren()) { +
      + @for (child of children(); track trackChild(child, index); let index = $index) { + + } +
    + } +
  • + `, +}) +export class NavigationItemComponent { + readonly navItemFields = input.required(); + readonly relativeLevel = input(1); + readonly linkClick = output(); + + readonly submenuOpen = signal(false); + + readonly children = computed(() => { + const childrenFromLayout = this.navItemFields().Children; + return Array.isArray(childrenFromLayout) ? childrenFromLayout : []; + }); + readonly hasChildren = computed(() => this.children().length > 0); + readonly linkField = computed(() => { + const navItem = this.navItemFields(); + const href = (navItem.Href ?? '').trim(); + const linkText = + navItem.NavigationTitle?.value != null && String(navItem.NavigationTitle.value) !== '' + ? String(navItem.NavigationTitle.value) + : navItem.Title?.value != null && String(navItem.Title.value) !== '' + ? String(navItem.Title.value) + : (navItem.DisplayName ?? ''); + return { + value: { + href, + text: linkText, + title: linkText || undefined, + querystring: navItem.Querystring ?? '', + }, + }; + }); + + readonly displayFallback = computed(() => this.navItemFields().DisplayName ?? ''); + + readonly itemClass = computed(() => { + const navItem = this.navItemFields(); + const active = this.hasChildren() && this.submenuOpen() ? 'active' : ''; + const styles = Array.isArray(navItem.Styles) ? navItem.Styles.filter(Boolean) : []; + return [...styles, `rel-level${this.relativeLevel()}`, active].filter(Boolean).join(' '); + }); + + toggleSubmenu(): void { + if (!this.hasChildren()) return; + this.submenuOpen.update((wasOpen) => !wasOpen); + } + + trackChild(child: NavItemFields, index: number): string { + const itemId = child.Id ?? ''; + return itemId ? `${index}-${itemId}` : String(index); + } +} diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/components/navigation.component.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/components/navigation.component.ts index 1a7a557ed6..7690970812 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/components/navigation.component.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/components/navigation.component.ts @@ -1,194 +1,16 @@ -import { isPlatformBrowser } from '@angular/common'; -import { Component, input, computed, signal, inject, PLATFORM_ID } from '@angular/core'; -import { RouterLink } from '@angular/router'; -import { ComponentRendering, Field, ScLinkDirective, LinkField } from '@sitecore-content-sdk/angular'; -import { scRenderingId } from '../sitecore/sitecore-component-classes'; +import { Component, computed, signal } from '@angular/core'; +import { SxaComponent } from './content-sdk/sxa.component'; +import { NavigationItemComponent, type NavItemFields } from './navigation-item.component'; -/** Navigation item from Sitecore (Pascal or camel case). */ -interface NavFields { - Id?: string; - id?: string; - DisplayName?: string; - displayName?: string; - Title?: Field; - NavigationTitle?: Field; - Href?: string; - href?: string; - Querystring?: string; - querystring?: string; - Children?: NavFields[]; - children?: NavFields[]; - Styles?: string[]; - styles?: string[]; -} - -function idOf(f: NavFields): string { - return f.Id ?? f.id ?? ''; -} - -function hrefOf(f: NavFields): string { - return (f.Href ?? f.href ?? '').trim(); -} - -function childrenOf(f: NavFields): NavFields[] { - const c = f.Children ?? f.children; - return Array.isArray(c) ? c : []; -} - -function stylesOf(f: NavFields): string[] { - const s = f.Styles ?? f.styles; - return Array.isArray(s) ? s : []; -} - -function querystringOf(f: NavFields): string { - return f.Querystring ?? f.querystring ?? ''; -} - -function labelOf(f: NavFields): string { - if (f.NavigationTitle?.value != null && f.NavigationTitle.value !== '') { - return String(f.NavigationTitle.value); - } - if (f.Title?.value != null && f.Title.value !== '') { - return String(f.Title.value); - } - return f.DisplayName ?? f.displayName ?? ''; -} - -function linkFieldFor(f: NavFields): LinkField { - return { - value: { - href: hrefOf(f), - title: labelOf(f), - querystring: querystringOf(f), - }, - }; -} - -function isNavFieldValue(v: unknown): v is NavFields { - return v != null && typeof v === 'object' && !Array.isArray(v); -} - -function parseQueryString(qs: string): Record { - const out: Record = {}; - if (!qs) return out; - for (const part of qs.split('&')) { - if (!part) continue; - const eq = part.indexOf('='); - const k = eq >= 0 ? decodeURIComponent(part.slice(0, eq)) : decodeURIComponent(part); - const v = eq >= 0 ? decodeURIComponent(part.slice(eq + 1)) : ''; - if (k) out[k] = v; - } - return out; -} - -/** Split path and ?query from a single href string. */ -function splitHrefQuery(href: string): { pathPart: string; queryFromHref: string } { - const i = href.indexOf('?'); - if (i === -1) return { pathPart: href, queryFromHref: '' }; - return { pathPart: href.slice(0, i), queryFromHref: href.slice(i + 1) }; -} - -function pathnameForRouter(pathPart: string): string { - let p = pathPart.trim(); - if (!p) return '/'; - if (/^https?:\/\//i.test(p)) { - try { - p = new URL(p).pathname || '/'; - } catch { - /* keep p */ - } - } - return p.startsWith('/') ? p : `/${p}`; -} - -function isSpaHref(href: string, platformId: object): boolean { - const h = href.trim(); - if (!h) return false; - const low = h.toLowerCase(); - if (low.startsWith('mailto:') || low.startsWith('tel:') || low.startsWith('javascript:')) { - return false; - } - if (h.startsWith('#')) return false; - if (h.startsWith('/') && !h.startsWith('//')) return true; - if (!isPlatformBrowser(platformId)) return false; - try { - return new URL(h).origin === window.location.origin; - } catch { - return false; - } -} - -@Component({ - selector: 'app-navigation-node', - standalone: true, - imports: [ScLinkDirective, RouterLink, NavigationNodeComponent], - template: ` -
  • - - @if (kids().length > 0) { -
      - @for (child of kids(); track trackKey(child, i); let i = $index) { - - } -
    - } -
  • - `, -}) -export class NavigationNodeComponent { - private readonly platformId = inject(PLATFORM_ID); - - readonly node = input.required(); - readonly relativeLevel = input(1); - - readonly label = computed(() => labelOf(this.node())); - readonly kids = computed(() => childrenOf(this.node())); - readonly linkField = computed(() => linkFieldFor(this.node())); - - readonly useRouter = computed(() => isSpaHref(hrefOf(this.node()), this.platformId)); - - readonly routerPath = computed(() => { - const href = hrefOf(this.node()); - const { pathPart } = splitHrefQuery(href); - return pathnameForRouter(pathPart); - }); - - readonly routerQueryParams = computed(() => { - const href = hrefOf(this.node()); - const { queryFromHref } = splitHrefQuery(href); - const fieldQs = querystringOf(this.node()); - const merged = queryFromHref || fieldQs; - return parseQueryString(merged); - }); - - readonly itemClass = computed(() => { - const level = this.relativeLevel(); - const styleClasses = stylesOf(this.node()).filter(Boolean); - const levelClass = level <= 1 ? 'level0' : 'level1'; - const submenu = this.kids().length > 0 ? 'submenu' : ''; - return [...styleClasses, levelClass, `rel-level${level}`, submenu].filter(Boolean).join(' '); - }); - - trackKey(child: NavFields, index: number): string { - const id = idOf(child); - return id ? `${index}-${id}` : String(index); - } +function isNavItem(candidate: unknown): candidate is NavItemFields { + return candidate != null && typeof candidate === 'object' && !Array.isArray(candidate); } @Component({ selector: 'app-navigation', - standalone: true, - imports: [NavigationNodeComponent], + imports: [NavigationItemComponent], template: ` -
    +
    @if (navItems().length === 0) {
    [Navigation]
    } @else { @@ -202,10 +24,14 @@ export class NavigationNodeComponent { />
    -
    `, }) -export class NavigationComponent { - readonly fields = input<{ [key: string]: unknown }>({}); - readonly params = input<{ [key: string]: string }>({}); - readonly rendering = input(); - +export class NavigationComponent extends SxaComponent { readonly menuOpen = signal(false); - readonly navRootClass = computed(() => { - const s = this.params()?.['styles']?.trim() ?? ''; - const parts = ['component', 'navigation', 'navigation-horizontal']; - if (s) parts.push(s); - return parts.join(' '); - }); - - readonly renderingId = computed(() => scRenderingId(this.params())); - readonly menuAriaLabel = computed(() => - this.menuOpen() ? 'Close navigation menu' : 'Open navigation menu' + this.menuOpen() ? 'Close navigation menu' : 'Open navigation menu', ); readonly navItems = computed(() => { - const f = this.fields(); - if (!f || Object.keys(f).length === 0) return [] as NavFields[]; - return Object.values(f).filter(isNavFieldValue); + const fieldsRecord = this.fields(); + if (!fieldsRecord || Object.keys(fieldsRecord).length === 0) return [] as NavItemFields[]; + return Object.values(fieldsRecord).filter(isNavItem); }); - onMenuChange(ev: Event): void { - const el = ev.target as HTMLInputElement | null; - if (el) this.menuOpen.set(el.checked); + onMenuChange(event: Event): void { + const checkbox = event.target as HTMLInputElement | null; + if (checkbox) this.menuOpen.set(checkbox.checked); } - /** Close mobile menu when a link is activated (bubbles from nested ul). */ - onNavDelegatedClick(ev: Event): void { - if ((ev.target as HTMLElement | null)?.closest('a')) { - this.menuOpen.set(false); - } + closeMenu(): void { + this.menuOpen.set(false); } - trackRoot(item: NavFields, index: number): string { - const id = idOf(item); - return id ? `${index}-${id}` : String(index); + trackRoot(item: NavItemFields, index: number): string { + const itemId = item.Id ?? ''; + return itemId ? `${index}-${itemId}` : String(index); } } diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/components/page-content.component.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/components/page-content.component.ts index 965ff72863..e40ed2dfeb 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/components/page-content.component.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/components/page-content.component.ts @@ -1,32 +1,74 @@ -import { Component, input, computed } from '@angular/core'; -import { ComponentRendering, ScPlaceholderComponent } from '@sitecore-content-sdk/angular'; -import { scComponentRoot, scRenderingId } from '../sitecore/sitecore-component-classes'; +import { Component, computed, inject } from '@angular/core'; +import { Field, ScRichTextDirective, SitecoreContextService, TextField } from '@sitecore-content-sdk/angular'; +import { StructuredDataComponent } from './structured-data.component'; +import { buildArticleJsonLd } from './content-sdk/json-ld'; +import { SxaComponent } from './content-sdk/sxa.component'; + +interface PageContentFields { + Content?: TextField; +} @Component({ selector: 'app-page-content', - standalone: true, - imports: [ScPlaceholderComponent], + imports: [ScRichTextDirective, StructuredDataComponent], template: ` -
    +
    - @for (phName of placeholderNames(); track phName) { - - } +
    + @if (contentField(); as content) { +
    + } @else { [Content] } +
    -
    + @if (jsonLdPayload()) { + + } + `, }) -export class PageContentComponent { - readonly fields = input<{ [key: string]: unknown }>({}); - readonly params = input<{ [key: string]: string }>({}); - readonly rendering = input(); +export class PageContentComponent extends SxaComponent { + private readonly context = inject(SitecoreContextService); + + readonly contentField = computed((): TextField | undefined => { + const fromFields = (this.fields() as unknown as PageContentFields)?.Content as + | TextField + | undefined; + if (fromFields?.value != null && String(fromFields.value).length > 0) { + return fromFields; + } + const route = this.context.page()?.layout?.sitecore?.route; + return route?.fields?.Content as TextField | undefined; + }); + + readonly headline = computed(() => { + const route = this.context.page()?.layout?.sitecore?.route; + const titleField = route?.fields?.Title as Field | undefined; + return titleField?.value != null ? String(titleField.value) : undefined; + }); - readonly placeholderNames = computed(() => { - const r = this.rendering(); - if (!r?.placeholders) return []; - return Object.keys(r.placeholders); + readonly jsonLdPayload = computed(() => { + const headline = this.headline(); + const articleContent = this.contentField(); + const articleBodyHtml = + articleContent?.value != null && String(articleContent.value).length > 0 + ? String(articleContent.value) + : undefined; + if (!headline && !articleBodyHtml) { + return null; + } + return buildArticleJsonLd({ + headline, + articleBodyHtml, + inLanguage: this.context.page()?.locale, + }); }); - readonly rootClass = computed(() => scComponentRoot('page-content', this.params())); - readonly renderingId = computed(() => scRenderingId(this.params())); + readonly jsonLdScriptId = computed( + () => `jsonld-article-${this.renderingId() ?? 'page-content'}` + ); } diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/components/partial-design-dynamic-placeholder.component.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/components/partial-design-dynamic-placeholder.component.ts new file mode 100644 index 0000000000..e267035ff3 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/components/partial-design-dynamic-placeholder.component.ts @@ -0,0 +1,34 @@ +import { Component, computed, input } from '@angular/core'; +import { ComponentRendering, ScPlaceholderComponent } from '@sitecore-content-sdk/angular'; + +/** + * Parity with kit-nextjs-skate-park PartialDesignDynamicPlaceholder: + * renders a single placeholder whose name comes from rendering.params.sig. + */ +@Component({ + selector: 'app-partial-design-dynamic-placeholder', + imports: [ScPlaceholderComponent], + template: ` + @if (placeholderSignature()) { + + } + `, +}) +export class PartialDesignDynamicPlaceholderComponent { + /** Required by `sc-placeholder` for every mapped component (merged layout values). */ + readonly fields = input<{ [key: string]: unknown }>({}); + readonly params = input<{ [key: string]: string }>({}); + readonly rendering = input(); + + /** Key from merged `rendering.params` and placeholder `params` input. */ + readonly placeholderSignature = computed(() => { + const merged = { + ...(this.rendering()?.params ?? {}), + ...(this.params() ?? {}), + } as { sig?: string }; + return merged.sig?.trim() ?? ''; + }); +} diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/components/promo.component.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/components/promo.component.ts index 32b73505ab..b39405b999 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/components/promo.component.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/components/promo.component.ts @@ -1,54 +1,124 @@ -import { Component, input, computed } from '@angular/core'; -import { ComponentRendering, Field } from '@sitecore-content-sdk/angular'; +import { Component, computed } from '@angular/core'; import { - ScTextDirective, - ScRichTextDirective, - ScImageDirective, - ScLinkDirective, ImageField, LinkField, + ScImageDirective, + ScLinkDirective, + ScRichTextDirective, + TextField, } from '@sitecore-content-sdk/angular'; -import { scComponentRoot, scRenderingId } from '../sitecore/sitecore-component-classes'; +import { StructuredDataComponent } from './structured-data.component'; +import { buildProductJsonLd } from './content-sdk/json-ld'; +import { SxaComponent } from './content-sdk/sxa.component'; + +interface PromoFields { + PromoIcon?: ImageField; + PromoText?: TextField; + PromoText2?: TextField; + PromoLink?: LinkField; +} + +function promoImageSrc(fields: Record): string | undefined { + const icon = fields.PromoIcon as ImageField | undefined; + const src = icon?.value?.src; + return typeof src === 'string' ? src : undefined; +} +/** Maps Promo / Promo-WithText layout fields to Product JSON-LD. */ +function promoProductJsonLd( + fields: Record, + link: LinkField | undefined, + primaryText: TextField | undefined, + secondaryText: TextField | undefined +) { + const descriptionHtml = + [primaryText?.value, secondaryText?.value] + .filter((segment) => segment != null && String(segment).length > 0) + .map((segment) => String(segment)) + .join(' ') || undefined; + + return buildProductJsonLd({ + name: + link?.value?.title || (primaryText?.value != null ? String(primaryText.value) : undefined), + descriptionHtml, + url: link?.value?.href, + image: promoImageSrc(fields), + }); +} + +/** + * Promo Default and Promo With Text share one implementation: optional `PromoText2` drives the + * second rich-text column and merged JSON-LD description. + */ @Component({ selector: 'app-promo', - standalone: true, - imports: [ScTextDirective, ScRichTextDirective, ScImageDirective, ScLinkDirective], + imports: [ScRichTextDirective, ScImageDirective, ScLinkDirective, StructuredDataComponent], template: ` -
    +
    - @if (imageField()) { -
    - + @if (showEmpty()) { + Promo + } @else { +
    + +
    +
    +
    + @if (promoText(); as primaryRichText) { +
    + }
    - } -
    + @if (promoText2(); as secondaryRichText) {
    -

    -
    +
    - @if (linkField()) { - } +
    + @if (jsonLd()) { + + } }
    -
    +
    `, }) -export class PromoComponent { - readonly fields = input<{ [key: string]: unknown }>({}); - readonly params = input<{ [key: string]: string }>({}); - readonly rendering = input(); - - readonly titleField = computed(() => this.fields()?.['Title'] as Field | undefined); - readonly descriptionField = computed( - () => this.fields()?.['Description'] as Field | undefined - ); - readonly imageField = computed(() => this.fields()?.['Image'] as ImageField | undefined); - readonly linkField = computed(() => this.fields()?.['Link'] as LinkField | undefined); - - readonly rootClass = computed(() => scComponentRoot('promo', this.params())); - readonly renderingId = computed(() => scRenderingId(this.params())); +export class PromoComponent extends SxaComponent { + readonly showEmpty = computed(() => { + const f = this.fields(); + return ( + f?.PromoIcon == null && f?.PromoText == null && f?.PromoText2 == null && f?.PromoLink == null + ); + }); + + readonly promoIcon = computed(() => (this.fields() as PromoFields)?.PromoIcon); + readonly promoText = computed(() => (this.fields() as PromoFields)?.PromoText); + readonly promoText2 = computed(() => (this.fields() as PromoFields)?.PromoText2); + readonly promoLink = computed(() => (this.fields() as PromoFields)?.PromoLink); + + readonly jsonLd = computed(() => { + const promoFields = this.fields() as PromoFields; + if ( + promoFields?.PromoIcon == null && + promoFields?.PromoText == null && + promoFields?.PromoText2 == null && + promoFields?.PromoLink == null + ) { + return null; + } + return promoProductJsonLd( + promoFields as Record, + this.promoLink(), + this.promoText(), + this.promoText2() + ); + }); + + readonly jsonLdScriptId = computed(() => `jsonld-product-${this.renderingId() ?? 'promo'}`); } diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/components/rich-text.component.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/components/rich-text.component.ts index abd2d3a0e7..401a01bf55 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/components/rich-text.component.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/components/rich-text.component.ts @@ -1,27 +1,26 @@ -import { Component, input, computed } from '@angular/core'; -import { ComponentRendering, Field } from '@sitecore-content-sdk/angular'; -import { ScRichTextDirective } from '@sitecore-content-sdk/angular'; -import { scComponentRoot, scRenderingId } from '../sitecore/sitecore-component-classes'; +import { Component, computed } from '@angular/core'; +import { ScRichTextDirective, TextField } from '@sitecore-content-sdk/angular'; +import { SxaComponent } from './content-sdk/sxa.component'; + +interface RichTextFields { + Text?: TextField; +} @Component({ selector: 'app-rich-text', - standalone: true, imports: [ScRichTextDirective], template: ` -
    +
    -
    + @if (contentField(); as content) { +
    + } @else { + Rich text + }
    `, }) -export class RichTextComponent { - readonly fields = input<{ [key: string]: unknown }>({}); - readonly params = input<{ [key: string]: string }>({}); - readonly rendering = input(); - - readonly contentField = computed(() => this.fields()?.['Text'] as Field | undefined); - - readonly rootClass = computed(() => scComponentRoot('rich-text', this.params())); - readonly renderingId = computed(() => scRenderingId(this.params())); +export class RichTextComponent extends SxaComponent { + readonly contentField = computed(() => (this.fields() as RichTextFields)?.Text); } diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/components/row-splitter.component.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/components/row-splitter.component.ts index 0062534338..10ec568f35 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/components/row-splitter.component.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/components/row-splitter.component.ts @@ -1,37 +1,48 @@ -import { Component, input, computed } from '@angular/core'; -import { ComponentRendering, ScPlaceholderComponent } from '@sitecore-content-sdk/angular'; -import { scRenderingId } from '../sitecore/sitecore-component-classes'; +import { Component, computed } from '@angular/core'; +import { ScPlaceholderComponent } from '@sitecore-content-sdk/angular'; +import { SxaComponent } from './content-sdk/sxa.component'; + +type RowNumber = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; @Component({ selector: 'app-row-splitter', - standalone: true, imports: [ScPlaceholderComponent], template: ` -
    - @for (phName of placeholderNames(); track phName) { -
    - +
    + @for (placeholderSegment of enabledPlaceholders(); track placeholderSegment) { +
    +
    +
    + +
    +
    }
    `, }) -export class RowSplitterComponent { - readonly fields = input<{ [key: string]: unknown }>({}); - readonly params = input<{ [key: string]: string }>({}); - readonly rendering = input(); - - readonly placeholderNames = computed(() => { - const r = this.rendering(); - if (!r?.placeholders) return []; - return Object.keys(r.placeholders); +export class RowSplitterComponent extends SxaComponent { + readonly enabledPlaceholders = computed(() => { + const raw = this.params()?.EnabledPlaceholders; + return ( + raw + ?.split(',') + .map((segment: string) => segment.trim()) + .filter(Boolean) ?? [] + ); }); - readonly rowClass = computed(() => { - const s = this.params()?.['styles']?.trim(); - const base = 'row row-splitter flex w-full flex-col flex-wrap'; - return s ? `${base} ${s}` : base; - }); + placeholderKey(placeholderSegment: string): string { + const rowIndex = Number(placeholderSegment) as RowNumber; + return `row-${rowIndex}-{*}`; + } - readonly renderingId = computed(() => scRenderingId(this.params())); + rowSectionClass(placeholderSegment: string): string { + const rowIndex = Number(placeholderSegment) as RowNumber; + const rowStyles = `${this.params()?.[`Styles${rowIndex}`] ?? ''}`.trimEnd(); + return `container-fluid ${rowStyles}`.trimEnd(); + } } diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/components/structured-data.component.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/components/structured-data.component.ts new file mode 100644 index 0000000000..c5121f4a34 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/components/structured-data.component.ts @@ -0,0 +1,48 @@ +import { + Component, + ElementRef, + Renderer2, + effect, + inject, + input, +} from '@angular/core'; + +/** + * Emits a JSON-LD script tag (kit-nextjs-skate-park StructuredData parity). + * Uses the DOM API so Angular does not strip script tags from templates. + */ +@Component({ + selector: 'app-structured-data', + template: '', +}) +export class StructuredDataComponent { + private readonly host = inject(ElementRef); + private readonly renderer = inject(Renderer2); + + /** Script element id attribute (stable when data updates). */ + readonly scriptId = input.required(); + + /** JSON-LD object or null to remove script. */ + readonly data = input(null); + + constructor() { + effect(() => { + const hostElement = this.host.nativeElement; + while (hostElement.firstChild) { + this.renderer.removeChild(hostElement, hostElement.firstChild); + } + + const jsonLdPayload = this.data(); + if (!jsonLdPayload) { + return; + } + + const script = this.renderer.createElement('script'); + this.renderer.setAttribute(script, 'type', 'application/ld+json'); + this.renderer.setAttribute(script, 'id', this.scriptId()); + const textNode = this.renderer.createText(JSON.stringify(jsonLdPayload)); + this.renderer.appendChild(script, textNode); + this.renderer.appendChild(hostElement, script); + }); + } +} diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/components/title.component.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/components/title.component.ts index f1a3a0404f..dbffee2ddd 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/components/title.component.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/components/title.component.ts @@ -1,29 +1,69 @@ -import { Component, input, computed } from '@angular/core'; -import { ComponentRendering, Field } from '@sitecore-content-sdk/angular'; -import { ScTextDirective } from '@sitecore-content-sdk/angular'; -import { scComponentRoot, scRenderingId } from '../sitecore/sitecore-component-classes'; +import { Component, computed, inject } from '@angular/core'; +import { + Field, + LinkField, + ScLinkDirective, + ScTextDirective, + SitecoreContextService, +} from '@sitecore-content-sdk/angular'; +import { SxaComponent } from './content-sdk/sxa.component'; + +interface GraphqlItem { + url?: { path?: string; siteName?: string }; + field?: { jsonValue?: unknown }; +} + +interface TitleFields { + data?: { datasource?: GraphqlItem; contextItem?: GraphqlItem }; +} @Component({ selector: 'app-title', - standalone: true, - imports: [ScTextDirective], + imports: [ScTextDirective, ScLinkDirective], template: ` -
    +
    -

    + @if (isEditing()) { + + } @else { + + }
    `, }) -export class TitleComponent { - readonly fields = input<{ [key: string]: unknown }>({}); - readonly params = input<{ [key: string]: string }>({}); - readonly rendering = input(); +export class TitleComponent extends SxaComponent { + private readonly context = inject(SitecoreContextService); + + readonly fieldData = computed(() => (this.fields() as TitleFields)?.data); + + readonly datasource = computed( + () => this.fieldData()?.datasource || this.fieldData()?.contextItem + ); + + readonly titleField = computed((): Field | undefined => { + const jsonVal = this.datasource()?.field?.jsonValue as Field | undefined; + if (jsonVal) { + return jsonVal; + } + const route = this.context.page()?.layout?.sitecore?.route; + return route?.fields?.Title as Field | undefined; + }); - readonly titleField = computed(() => this.fields()?.['Title'] as Field | undefined); + readonly titleLinkField = computed((): LinkField => { + const graphqlSource = this.datasource(); + const title = this.titleField(); + const href = graphqlSource?.url?.path; + const rawJson = graphqlSource?.field?.jsonValue as { value?: string } | undefined; + return { + value: { + href, + title: (title?.value != null ? String(title.value) : undefined) || rawJson?.value, + }, + }; + }); - readonly rootClass = computed(() => scComponentRoot('title', this.params())); - readonly renderingId = computed(() => scRenderingId(this.params())); + readonly isEditing = computed(() => this.context.isEditing()); } diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/pages/error.component.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/pages/error.component.ts index dd0620052c..efae4ee0ae 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/pages/error.component.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/pages/error.component.ts @@ -10,7 +10,6 @@ import { LayoutComponent } from '../shared/layout.component'; */ @Component({ selector: 'app-error', - standalone: true, imports: [LayoutComponent], template: ` @let pageValue = page(); diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/pages/not-found.component.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/pages/not-found.component.ts index 95fbe7d1e7..41d62b5c27 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/pages/not-found.component.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/pages/not-found.component.ts @@ -11,7 +11,6 @@ import { LayoutComponent } from '../shared/layout.component'; */ @Component({ selector: 'app-404', - standalone: true, imports: [RouterLink, LayoutComponent], template: ` @let pageValue = page(); diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/pages/page.component.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/pages/page.component.ts index db8f383d59..0dd08f201b 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/pages/page.component.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/pages/page.component.ts @@ -6,7 +6,6 @@ import { LayoutComponent } from '../shared/layout.component'; @Component({ selector: 'app-page', - standalone: true, imports: [LayoutComponent], template: ` @let pageValue = page(); diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/shared/layout.component.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/shared/layout.component.ts index db54e71ce7..34f0fc3f39 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/shared/layout.component.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/shared/layout.component.ts @@ -1,5 +1,4 @@ import { Component, input, computed, effect, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; import { Title } from '@angular/platform-browser'; import { Page, Field, RouteData, ScPlaceholderComponent } from '@sitecore-content-sdk/angular'; @@ -10,10 +9,9 @@ interface RouteFields { @Component({ selector: 'app-layout', - standalone: true, - imports: [CommonModule, ScPlaceholderComponent], + imports: [ScPlaceholderComponent], template: ` -
    +