@stencil/angular-output-target 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,6 +58,52 @@ export const config: Config = {
58
58
  | `customElementsDir` | This is the directory where the custom elements are imported from when using the [Custom Elements Bundle](https://stenciljs.com/docs/custom-elements). Defaults to the `components` directory. Only applies for `outputType: "scam"` or `outputType: "standalone"`. |
59
59
  | `inlineProperties` | Experimental. When true, tries to inline the properties of components. This is required to enable Angular Language Service to type-check and show jsdocs when using the components in html-templates. |
60
60
  | `booleanAttributes` | When `true`, boolean properties are declared with an Angular input transform so they can be set by attribute presence, e.g. `<my-component disabled>` instead of `<my-component [disabled]="true">`. Type-checking the attribute form also requires `inlineProperties`. Defaults to `false`. Refer to [Boolean attributes](#boolean-attributes). |
61
+ | `ssrPropModule` | The component library's `ssr-prop` module (e.g. `'component-library/ssr-prop'`), from Stencil's `ssr` output target. Set it when apps server-render the wrappers with Angular SSR. Refer to [Server-side rendering](#server-side-rendering). |
62
+
63
+ ## Server-side rendering
64
+
65
+ With Stencil's `ssr` output target, an Angular SSR app passes each rendered page through the
66
+ library's `renderToString()`, which renders the components into it. That needs the wrappers'
67
+ inputs in the HTML, but Angular's server DOM never writes properties to the HTML, so
68
+ `<my-chart [data]="points">` would reach `renderToString()` with no `data`.
69
+
70
+ Set `ssrPropModule` to the library's `ssr-prop` module:
71
+
72
+ ```ts
73
+ angularOutputTarget({
74
+ componentCorePackage: 'component-library',
75
+ directivesProxyFile: '../component-library-angular/src/proxies.ts',
76
+ ssrPropModule: 'component-library/ssr-prop',
77
+ }),
78
+ ```
79
+
80
+ A wrapper rendering on the server then writes each input into the element's attributes, under
81
+ the prop's attribute name:
82
+
83
+ | Input value | Written as |
84
+ |---|---|
85
+ | a string or number | the attribute's value |
86
+ | `true` | an empty attribute |
87
+ | `null` or `undefined` | no attribute |
88
+ | `false`, an object, an array, a function, or any value for a prop with no attribute or not typed exactly `string`, `number` or `boolean` | an `ssrProp()` reference |
89
+
90
+ Strings, numbers and `true` stay in the HTML, so the components' first render in the browser
91
+ sees them too. A reference hands `renderToString()` the exact value and is removed from the HTML.
92
+ (`false` can't be an attribute: Stencil reads any attribute on a boolean prop as `true`. And
93
+ Stencil only converts an attribute for a prop of one of those three types: for a `string | boolean`
94
+ prop, `""` would stay a string.)
95
+
96
+ In the browser, inputs are set as properties, as usual. Some things to know:
97
+
98
+ - To keep an object or array after the components hydrate, the component has to carry it in its
99
+ attribute, for example with `@Prop({ serialize: 'json' })`. A function never reaches the
100
+ browser this way, but Angular sets it as a property when the app hydrates.
101
+ - The attributes written on the server stay on the element after it hydrates. Setting the input
102
+ later updates the property, not the attribute.
103
+ - Reading an input back on the server returns the value you set.
104
+
105
+ For a working setup, see the [Angular example](https://github.com/stenciljs/ssr-examples/tree/main/examples/angular)
106
+ in the Stencil SSR examples.
61
107
 
62
108
  ## Boolean attributes
63
109
 
@@ -1,8 +1,67 @@
1
1
  /* eslint-disable */
2
2
  /* tslint:disable */
3
+ import { inject, PLATFORM_ID } from '@angular/core';
3
4
  import { fromEvent } from 'rxjs';
4
5
 
5
- export const proxyInputs = (Cmp: any, inputs: string[]) => {
6
+ /**
7
+ * Server rendering options (the output target's `ssrPropModule`).
8
+ */
9
+ interface SsrOptions {
10
+ /** The component library's `ssrProp()`, which hands a value to Stencil's server render. */
11
+ ssrProp: (value: any) => any;
12
+ /** Inputs whose attribute isn't Stencil's dash-cased prop name. */
13
+ attributes?: Record<string, string>;
14
+ /** Inputs with no attribute: every value goes through `ssrProp()`. */
15
+ refs?: string[];
16
+ }
17
+
18
+ const serverInstances = new WeakSet<object>();
19
+
20
+ /**
21
+ * Records a wrapper created during server rendering. Call from the constructor.
22
+ */
23
+ export const markServer = (instance: object) => {
24
+ // what `isPlatformServer()` from `@angular/common` checks
25
+ if (inject(PLATFORM_ID) === 'server') {
26
+ serverInstances.add(instance);
27
+ }
28
+ };
29
+
30
+ /**
31
+ * Stencil's own prop name to attribute name conversion (`prop1` -> `prop-1`).
32
+ */
33
+ const toDashCase = (str: string): string =>
34
+ str
35
+ .replace(/([A-Z0-9])/g, (match) => ` ${match[0]}`)
36
+ .trim()
37
+ .split(' ')
38
+ .join('-')
39
+ .toLowerCase();
40
+
41
+ /**
42
+ * Angular's server DOM never writes properties to the HTML, so on the server an input becomes an
43
+ * attribute, which Stencil's `renderToString()` reads:
44
+ * - a string or number as itself, and `true` as an empty attribute, so the component's first
45
+ * render in the browser sees them too;
46
+ * - `false`, anything that isn't a primitive, and any value for a prop with no attribute through
47
+ * `ssrProp()`, which gives the server render the exact value and leaves no attribute behind.
48
+ */
49
+ const setServerAttribute = (el: any, item: string, val: any, ssr: SsrOptions) => {
50
+ const name = ssr.attributes?.[item] ?? toDashCase(item);
51
+ if (val == null) {
52
+ el.removeAttribute(name);
53
+ } else if (
54
+ ssr.refs?.includes(item) ||
55
+ val === false ||
56
+ (typeof val !== 'string' && typeof val !== 'number' && val !== true)
57
+ ) {
58
+ el.setAttribute(name, ssr.ssrProp(val));
59
+ } else {
60
+ el.setAttribute(name, val === true ? '' : String(val));
61
+ }
62
+ };
63
+
64
+ export const proxyInputs = (Cmp: any, inputs: string[], ssr?: SsrOptions) => {
6
65
  const Prototype = Cmp.prototype;
7
66
  inputs.forEach((item) => {
8
67
  Object.defineProperty(Prototype, item, {
@@ -10,6 +69,12 @@ export const proxyInputs = (Cmp: any, inputs: string[]) => {
10
69
  return this.el[item];
11
70
  },
12
71
  set(val: any) {
72
+ if (ssr && serverInstances.has(this)) {
73
+ // kept on the element too, so reading the input back on the server works
74
+ this.el[item] = val;
75
+ setServerAttribute(this.el, item, val, ssr);
76
+ return;
77
+ }
13
78
  this.z.runOutsideAngular(() => (this.el[item] = val));
14
79
  },
15
80
  /**
@@ -45,16 +110,16 @@ export const defineCustomElement = (tagName: string, customElement: any) => {
45
110
  };
46
111
 
47
112
  // tslint:disable-next-line: only-arrow-functions
48
- export function ProxyCmp(opts: { defineCustomElementFn?: () => void; inputs?: any; methods?: any }) {
113
+ export function ProxyCmp(opts: { defineCustomElementFn?: () => void; inputs?: any; methods?: any; ssr?: SsrOptions }) {
49
114
  const decorator = function (cls: any) {
50
- const { defineCustomElementFn, inputs, methods } = opts;
115
+ const { defineCustomElementFn, inputs, methods, ssr } = opts;
51
116
 
52
117
  if (defineCustomElementFn !== undefined) {
53
118
  defineCustomElementFn();
54
119
  }
55
120
 
56
121
  if (inputs) {
57
- proxyInputs(cls, inputs);
122
+ proxyInputs(cls, inputs, ssr);
58
123
  }
59
124
  if (methods) {
60
125
  proxyMethods(cls, methods);
@@ -5,6 +5,15 @@ import type { ComponentInputProperty, OutputType } from './types';
5
5
  * `angular-component-lib/boolean-attribute`.
6
6
  */
7
7
  export declare const INPUT_TRANSFORM_FUNCTION = "nullableBooleanAttribute";
8
+ /**
9
+ * What a wrapper needs to write its inputs as attributes during server rendering.
10
+ */
11
+ export interface SsrInputs {
12
+ /** Inputs whose attribute isn't the dash-cased prop name, mapped to that attribute. */
13
+ attributes: Record<string, string>;
14
+ /** Inputs whose every value goes through `ssrProp()`: no attribute, or not a primitive type. */
15
+ refs: string[];
16
+ }
8
17
  /**
9
18
  * Creates an Angular component declaration from formatted Stencil compiler metadata.
10
19
  *
@@ -15,9 +24,12 @@ export declare const INPUT_TRANSFORM_FUNCTION = "nullableBooleanAttribute";
15
24
  * @param standalone Whether to define the component as a standalone component.
16
25
  * @param inlineComponentProps List of properties that should be inlined into the component definition.
17
26
  * @param events The events of the Stencil component for generating outputs.
27
+ * @param ssr When set, the wrapper writes its inputs as attributes during server rendering.
28
+ * `attributes` maps the inputs whose attribute isn't the dash-cased prop name to that attribute,
29
+ * and `refs` lists the inputs whose every value goes through `ssrProp()`.
18
30
  * @returns The component declaration as a string.
19
31
  */
20
- export declare const createAngularComponentDefinition: (tagName: string, inputs: readonly ComponentInputProperty[], methods: readonly string[], includeImportCustomElements?: boolean, standalone?: boolean, inlineComponentProps?: readonly ComponentCompilerProperty[], events?: readonly ComponentCompilerEvent[]) => string;
32
+ export declare const createAngularComponentDefinition: (tagName: string, inputs: readonly ComponentInputProperty[], methods: readonly string[], includeImportCustomElements?: boolean, standalone?: boolean, inlineComponentProps?: readonly ComponentCompilerProperty[], events?: readonly ComponentCompilerEvent[], ssr?: SsrInputs) => string;
21
33
  /**
22
34
  * Creates the component interface type definition.
23
35
  * @param outputType The output type.
@@ -65,9 +65,12 @@ function formatInputs(inputs) {
65
65
  * @param standalone Whether to define the component as a standalone component.
66
66
  * @param inlineComponentProps List of properties that should be inlined into the component definition.
67
67
  * @param events The events of the Stencil component for generating outputs.
68
+ * @param ssr When set, the wrapper writes its inputs as attributes during server rendering.
69
+ * `attributes` maps the inputs whose attribute isn't the dash-cased prop name to that attribute,
70
+ * and `refs` lists the inputs whose every value goes through `ssrProp()`.
68
71
  * @returns The component declaration as a string.
69
72
  */
70
- export const createAngularComponentDefinition = (tagName, inputs, methods, includeImportCustomElements = false, standalone = false, inlineComponentProps = [], events = []) => {
73
+ export const createAngularComponentDefinition = (tagName, inputs, methods, includeImportCustomElements = false, standalone = false, inlineComponentProps = [], events = [], ssr) => {
71
74
  const tagNameAsPascal = dashToPascalCase(tagName);
72
75
  const outputs = events.filter((event) => !event.internal).map((event) => event.name);
73
76
  const hasInputs = inputs.length > 0;
@@ -93,6 +96,15 @@ export const createAngularComponentDefinition = (tagName, inputs, methods, inclu
93
96
  if (hasMethods) {
94
97
  proxyCmpOptions.push(`\n methods: [${formattedMethods}]`);
95
98
  }
99
+ if (ssr && hasInputs) {
100
+ const ssrOptions = ['ssrProp'];
101
+ const attributes = Object.entries(ssr.attributes).map(([prop, attr]) => `'${prop}': '${attr}'`);
102
+ if (attributes.length)
103
+ ssrOptions.push(`attributes: { ${attributes.join(', ')} }`);
104
+ if (ssr.refs.length)
105
+ ssrOptions.push(`refs: [${formatToQuotedList(ssr.refs)}]`);
106
+ proxyCmpOptions.push(`\n ssr: { ${ssrOptions.join(', ')} }`);
107
+ }
96
108
  let standaloneOption = '';
97
109
  if (!standalone) {
98
110
  standaloneOption = `\n standalone: false`;
@@ -129,7 +141,7 @@ export class ${tagNameAsPascal} {
129
141
  ${propertiesDeclarationText}
130
142
  constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) {
131
143
  c.detach();
132
- this.el = r.nativeElement;
144
+ this.el = r.nativeElement;${ssr && hasInputs ? '\n markServer(this);' : ''}
133
145
  }
134
146
  }`;
135
147
  return output;
package/dist/index.cjs CHANGED
@@ -219,9 +219,12 @@ function formatInputs(inputs) {
219
219
  * @param standalone Whether to define the component as a standalone component.
220
220
  * @param inlineComponentProps List of properties that should be inlined into the component definition.
221
221
  * @param events The events of the Stencil component for generating outputs.
222
+ * @param ssr When set, the wrapper writes its inputs as attributes during server rendering.
223
+ * `attributes` maps the inputs whose attribute isn't the dash-cased prop name to that attribute,
224
+ * and `refs` lists the inputs whose every value goes through `ssrProp()`.
222
225
  * @returns The component declaration as a string.
223
226
  */
224
- const createAngularComponentDefinition = (tagName, inputs, methods, includeImportCustomElements = false, standalone = false, inlineComponentProps = [], events = []) => {
227
+ const createAngularComponentDefinition = (tagName, inputs, methods, includeImportCustomElements = false, standalone = false, inlineComponentProps = [], events = [], ssr) => {
225
228
  const tagNameAsPascal = dashToPascalCase(tagName);
226
229
  const outputs = events.filter((event) => !event.internal).map((event) => event.name);
227
230
  const hasInputs = inputs.length > 0;
@@ -247,6 +250,15 @@ const createAngularComponentDefinition = (tagName, inputs, methods, includeImpor
247
250
  if (hasMethods) {
248
251
  proxyCmpOptions.push(`\n methods: [${formattedMethods}]`);
249
252
  }
253
+ if (ssr && hasInputs) {
254
+ const ssrOptions = ['ssrProp'];
255
+ const attributes = Object.entries(ssr.attributes).map(([prop, attr]) => `'${prop}': '${attr}'`);
256
+ if (attributes.length)
257
+ ssrOptions.push(`attributes: { ${attributes.join(', ')} }`);
258
+ if (ssr.refs.length)
259
+ ssrOptions.push(`refs: [${formatToQuotedList(ssr.refs)}]`);
260
+ proxyCmpOptions.push(`\n ssr: { ${ssrOptions.join(', ')} }`);
261
+ }
250
262
  let standaloneOption = '';
251
263
  if (!standalone) {
252
264
  standaloneOption = `\n standalone: false`;
@@ -283,7 +295,7 @@ export class ${tagNameAsPascal} {
283
295
  ${propertiesDeclarationText}
284
296
  constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) {
285
297
  c.detach();
286
- this.el = r.nativeElement;
298
+ this.el = r.nativeElement;${ssr && hasInputs ? '\n markServer(this);' : ''}
287
299
  }
288
300
  }`;
289
301
  return output;
@@ -801,6 +813,47 @@ stencilSetTagTransformer(\${transformerArg});
801
813
  const __filename$1 = url.fileURLToPath((typeof document === 'undefined' ? new (require('u' + 'rl').URL)('file:' + __filename).href : (document.currentScript && document.currentScript.tagName.toUpperCase() === 'SCRIPT' && document.currentScript.src || new URL('index.cjs', document.baseURI).href)));
802
814
  const __dirname$1 = path__default["default"].dirname(__filename$1);
803
815
  const filterInternalProps = (prop) => !prop.internal;
816
+ /**
817
+ * Stencil's own prop name to attribute name conversion (`prop1` -> `prop-1`).
818
+ */
819
+ const toDashCase = (str) => str
820
+ .replace(/([A-Z0-9])/g, (match) => ` ${match[0]}`)
821
+ .trim()
822
+ .split(' ')
823
+ .join('-')
824
+ .toLowerCase();
825
+ /**
826
+ * With `ssrPropModule` set, what the wrapper needs to write its inputs as attributes during
827
+ * server rendering. Otherwise `undefined`.
828
+ *
829
+ * Stencil only converts an attribute for a prop typed exactly `string`, `number` or `boolean`. Any
830
+ * other prop (`any`, which covers mixed unions like `string | boolean`, or `unknown`) gets the
831
+ * attribute's string as it is, so it's a ref: every value goes through `ssrProp()`. So is a prop
832
+ * with no attribute, which Stencil never reads.
833
+ */
834
+ const LITERAL_ATTRIBUTE_TYPES = ['string', 'number', 'boolean'];
835
+ const getSsrInputs = (outputTarget, props) => {
836
+ if (!outputTarget.ssrPropModule)
837
+ return undefined;
838
+ const ssr = { attributes: {}, refs: [] };
839
+ for (const prop of props) {
840
+ if (!prop.attribute || !LITERAL_ATTRIBUTE_TYPES.includes(prop.type))
841
+ ssr.refs.push(prop.name);
842
+ else if (prop.attribute !== toDashCase(prop.name))
843
+ ssr.attributes[prop.name] = prop.attribute;
844
+ }
845
+ return ssr;
846
+ };
847
+ /**
848
+ * Whether a component's wrapper has inputs: its properties and virtual properties.
849
+ */
850
+ const hasInputs = (cmp) => (cmp.properties ?? []).some(filterInternalProps) || (cmp.virtualProperties ?? []).length > 0;
851
+ /**
852
+ * The extra imports a proxy file needs for `ssrPropModule`.
853
+ */
854
+ const getSsrImports = (outputTarget, components) => outputTarget.ssrPropModule && components.some(hasInputs)
855
+ ? { componentLib: ['markServer'], source: `import { ssrProp } from '${outputTarget.ssrPropModule}';\n` }
856
+ : { componentLib: [], source: '' };
804
857
  /**
805
858
  * Whether a property declares an input transform.
806
859
  *
@@ -949,7 +1002,8 @@ function generateProxies(components, pkgData, outputTarget, rootDir, config) {
949
1002
  /**
950
1003
  * The collection of named imports from the angular-component-lib/utils.
951
1004
  */
952
- const componentLibImports = ['ProxyCmp'];
1005
+ const ssrImports = getSsrImports(outputTarget, components);
1006
+ const componentLibImports = ['ProxyCmp', ...ssrImports.componentLib];
953
1007
  /**
954
1008
  * The input transform lives in its own module so that it carries no runtime imports.
955
1009
  */
@@ -1017,7 +1071,7 @@ ${createImportStatement(componentLibImports, './angular-component-lib/utils')}${
1017
1071
  * 2. Optionally the @NgModule decorated class (if includeSingleComponentAngularModules is true)
1018
1072
  * 3. The component interface (using declaration merging for types).
1019
1073
  */
1020
- const componentDefinition = createAngularComponentDefinition(cmpMeta.tagName, orderedInputs, methods, isCustomElementsBuild, isStandaloneBuild, inlineComponentProps, cmpMeta.events || []);
1074
+ const componentDefinition = createAngularComponentDefinition(cmpMeta.tagName, orderedInputs, methods, isCustomElementsBuild, isStandaloneBuild, inlineComponentProps, cmpMeta.events || [], getSsrInputs(outputTarget, internalProps));
1021
1075
  const moduleDefinition = generateAngularModuleForComponent(cmpMeta.tagName);
1022
1076
  const componentTypeDefinition = createComponentTypeDefinition(outputType, tagNameAsPascal, cmpMeta.events, componentCorePackage, customElementsDir, getEventTypesModule(config, outputTarget));
1023
1077
  proxyFileOutput.push(componentDefinition, '\n');
@@ -1026,7 +1080,13 @@ ${createImportStatement(componentLibImports, './angular-component-lib/utils')}${
1026
1080
  }
1027
1081
  proxyFileOutput.push(componentTypeDefinition, '\n');
1028
1082
  }
1029
- const final = [imports, typeImports, sourceImports, ...proxyFileOutput];
1083
+ const final = [
1084
+ imports,
1085
+ typeImports,
1086
+ sourceImports,
1087
+ ...(ssrImports.source ? [ssrImports.source] : []),
1088
+ ...proxyFileOutput,
1089
+ ];
1030
1090
  return final.join('\n') + '\n';
1031
1091
  }
1032
1092
  /**
@@ -1048,7 +1108,8 @@ function generateComponentProxy(cmpMeta, pkgData, outputTarget, rootDir, config)
1048
1108
  angularCoreImports.push('NgModule');
1049
1109
  }
1050
1110
  const booleanAttributes = outputTarget.booleanAttributes === true;
1051
- const componentLibImports = ['ProxyCmp'];
1111
+ const ssrImports = getSsrImports(outputTarget, [cmpMeta]);
1112
+ const componentLibImports = ['ProxyCmp', ...ssrImports.componentLib];
1052
1113
  // The input transform lives in its own module so that it carries no runtime imports.
1053
1114
  let transformImport = '';
1054
1115
  if (usesInputTransform([cmpMeta], booleanAttributes)) {
@@ -1084,7 +1145,7 @@ ${createImportStatement(componentLibImports, './angular-component-lib/utils')}${
1084
1145
  methods.push(...cmpMeta.methods.filter(filterInternalProps).map(mapPropName));
1085
1146
  }
1086
1147
  const inlineComponentProps = outputTarget.inlineProperties ? internalProps : [];
1087
- const componentDefinition = createAngularComponentDefinition(cmpMeta.tagName, orderedInputs, methods, isCustomElementsBuild, isStandaloneBuild, inlineComponentProps, cmpMeta.events || []);
1148
+ const componentDefinition = createAngularComponentDefinition(cmpMeta.tagName, orderedInputs, methods, isCustomElementsBuild, isStandaloneBuild, inlineComponentProps, cmpMeta.events || [], getSsrInputs(outputTarget, internalProps));
1088
1149
  const moduleDefinition = generateAngularModuleForComponent(cmpMeta.tagName);
1089
1150
  const componentTypeDefinition = createComponentTypeDefinition(outputType, tagNameAsPascal, cmpMeta.events, componentCorePackage, customElementsDir, getEventTypesModule(config, outputTarget));
1090
1151
  const proxyFileOutput = [componentDefinition, '\n'];
@@ -1092,7 +1153,13 @@ ${createImportStatement(componentLibImports, './angular-component-lib/utils')}${
1092
1153
  proxyFileOutput.push(moduleDefinition, '\n');
1093
1154
  }
1094
1155
  proxyFileOutput.push(componentTypeDefinition, '\n');
1095
- const final = [imports, typeImports, sourceImport, ...proxyFileOutput];
1156
+ const final = [
1157
+ imports,
1158
+ typeImports,
1159
+ sourceImport,
1160
+ ...(ssrImports.source ? [ssrImports.source] : []),
1161
+ ...proxyFileOutput,
1162
+ ];
1096
1163
  return final.join('\n') + '\n';
1097
1164
  }
1098
1165
  /**
package/dist/index.js CHANGED
@@ -211,9 +211,12 @@ function formatInputs(inputs) {
211
211
  * @param standalone Whether to define the component as a standalone component.
212
212
  * @param inlineComponentProps List of properties that should be inlined into the component definition.
213
213
  * @param events The events of the Stencil component for generating outputs.
214
+ * @param ssr When set, the wrapper writes its inputs as attributes during server rendering.
215
+ * `attributes` maps the inputs whose attribute isn't the dash-cased prop name to that attribute,
216
+ * and `refs` lists the inputs whose every value goes through `ssrProp()`.
214
217
  * @returns The component declaration as a string.
215
218
  */
216
- const createAngularComponentDefinition = (tagName, inputs, methods, includeImportCustomElements = false, standalone = false, inlineComponentProps = [], events = []) => {
219
+ const createAngularComponentDefinition = (tagName, inputs, methods, includeImportCustomElements = false, standalone = false, inlineComponentProps = [], events = [], ssr) => {
217
220
  const tagNameAsPascal = dashToPascalCase(tagName);
218
221
  const outputs = events.filter((event) => !event.internal).map((event) => event.name);
219
222
  const hasInputs = inputs.length > 0;
@@ -239,6 +242,15 @@ const createAngularComponentDefinition = (tagName, inputs, methods, includeImpor
239
242
  if (hasMethods) {
240
243
  proxyCmpOptions.push(`\n methods: [${formattedMethods}]`);
241
244
  }
245
+ if (ssr && hasInputs) {
246
+ const ssrOptions = ['ssrProp'];
247
+ const attributes = Object.entries(ssr.attributes).map(([prop, attr]) => `'${prop}': '${attr}'`);
248
+ if (attributes.length)
249
+ ssrOptions.push(`attributes: { ${attributes.join(', ')} }`);
250
+ if (ssr.refs.length)
251
+ ssrOptions.push(`refs: [${formatToQuotedList(ssr.refs)}]`);
252
+ proxyCmpOptions.push(`\n ssr: { ${ssrOptions.join(', ')} }`);
253
+ }
242
254
  let standaloneOption = '';
243
255
  if (!standalone) {
244
256
  standaloneOption = `\n standalone: false`;
@@ -275,7 +287,7 @@ export class ${tagNameAsPascal} {
275
287
  ${propertiesDeclarationText}
276
288
  constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) {
277
289
  c.detach();
278
- this.el = r.nativeElement;
290
+ this.el = r.nativeElement;${ssr && hasInputs ? '\n markServer(this);' : ''}
279
291
  }
280
292
  }`;
281
293
  return output;
@@ -793,6 +805,47 @@ stencilSetTagTransformer(\${transformerArg});
793
805
  const __filename = fileURLToPath(import.meta.url);
794
806
  const __dirname = path.dirname(__filename);
795
807
  const filterInternalProps = (prop) => !prop.internal;
808
+ /**
809
+ * Stencil's own prop name to attribute name conversion (`prop1` -> `prop-1`).
810
+ */
811
+ const toDashCase = (str) => str
812
+ .replace(/([A-Z0-9])/g, (match) => ` ${match[0]}`)
813
+ .trim()
814
+ .split(' ')
815
+ .join('-')
816
+ .toLowerCase();
817
+ /**
818
+ * With `ssrPropModule` set, what the wrapper needs to write its inputs as attributes during
819
+ * server rendering. Otherwise `undefined`.
820
+ *
821
+ * Stencil only converts an attribute for a prop typed exactly `string`, `number` or `boolean`. Any
822
+ * other prop (`any`, which covers mixed unions like `string | boolean`, or `unknown`) gets the
823
+ * attribute's string as it is, so it's a ref: every value goes through `ssrProp()`. So is a prop
824
+ * with no attribute, which Stencil never reads.
825
+ */
826
+ const LITERAL_ATTRIBUTE_TYPES = ['string', 'number', 'boolean'];
827
+ const getSsrInputs = (outputTarget, props) => {
828
+ if (!outputTarget.ssrPropModule)
829
+ return undefined;
830
+ const ssr = { attributes: {}, refs: [] };
831
+ for (const prop of props) {
832
+ if (!prop.attribute || !LITERAL_ATTRIBUTE_TYPES.includes(prop.type))
833
+ ssr.refs.push(prop.name);
834
+ else if (prop.attribute !== toDashCase(prop.name))
835
+ ssr.attributes[prop.name] = prop.attribute;
836
+ }
837
+ return ssr;
838
+ };
839
+ /**
840
+ * Whether a component's wrapper has inputs: its properties and virtual properties.
841
+ */
842
+ const hasInputs = (cmp) => (cmp.properties ?? []).some(filterInternalProps) || (cmp.virtualProperties ?? []).length > 0;
843
+ /**
844
+ * The extra imports a proxy file needs for `ssrPropModule`.
845
+ */
846
+ const getSsrImports = (outputTarget, components) => outputTarget.ssrPropModule && components.some(hasInputs)
847
+ ? { componentLib: ['markServer'], source: `import { ssrProp } from '${outputTarget.ssrPropModule}';\n` }
848
+ : { componentLib: [], source: '' };
796
849
  /**
797
850
  * Whether a property declares an input transform.
798
851
  *
@@ -941,7 +994,8 @@ function generateProxies(components, pkgData, outputTarget, rootDir, config) {
941
994
  /**
942
995
  * The collection of named imports from the angular-component-lib/utils.
943
996
  */
944
- const componentLibImports = ['ProxyCmp'];
997
+ const ssrImports = getSsrImports(outputTarget, components);
998
+ const componentLibImports = ['ProxyCmp', ...ssrImports.componentLib];
945
999
  /**
946
1000
  * The input transform lives in its own module so that it carries no runtime imports.
947
1001
  */
@@ -1009,7 +1063,7 @@ ${createImportStatement(componentLibImports, './angular-component-lib/utils')}${
1009
1063
  * 2. Optionally the @NgModule decorated class (if includeSingleComponentAngularModules is true)
1010
1064
  * 3. The component interface (using declaration merging for types).
1011
1065
  */
1012
- const componentDefinition = createAngularComponentDefinition(cmpMeta.tagName, orderedInputs, methods, isCustomElementsBuild, isStandaloneBuild, inlineComponentProps, cmpMeta.events || []);
1066
+ const componentDefinition = createAngularComponentDefinition(cmpMeta.tagName, orderedInputs, methods, isCustomElementsBuild, isStandaloneBuild, inlineComponentProps, cmpMeta.events || [], getSsrInputs(outputTarget, internalProps));
1013
1067
  const moduleDefinition = generateAngularModuleForComponent(cmpMeta.tagName);
1014
1068
  const componentTypeDefinition = createComponentTypeDefinition(outputType, tagNameAsPascal, cmpMeta.events, componentCorePackage, customElementsDir, getEventTypesModule(config, outputTarget));
1015
1069
  proxyFileOutput.push(componentDefinition, '\n');
@@ -1018,7 +1072,13 @@ ${createImportStatement(componentLibImports, './angular-component-lib/utils')}${
1018
1072
  }
1019
1073
  proxyFileOutput.push(componentTypeDefinition, '\n');
1020
1074
  }
1021
- const final = [imports, typeImports, sourceImports, ...proxyFileOutput];
1075
+ const final = [
1076
+ imports,
1077
+ typeImports,
1078
+ sourceImports,
1079
+ ...(ssrImports.source ? [ssrImports.source] : []),
1080
+ ...proxyFileOutput,
1081
+ ];
1022
1082
  return final.join('\n') + '\n';
1023
1083
  }
1024
1084
  /**
@@ -1040,7 +1100,8 @@ function generateComponentProxy(cmpMeta, pkgData, outputTarget, rootDir, config)
1040
1100
  angularCoreImports.push('NgModule');
1041
1101
  }
1042
1102
  const booleanAttributes = outputTarget.booleanAttributes === true;
1043
- const componentLibImports = ['ProxyCmp'];
1103
+ const ssrImports = getSsrImports(outputTarget, [cmpMeta]);
1104
+ const componentLibImports = ['ProxyCmp', ...ssrImports.componentLib];
1044
1105
  // The input transform lives in its own module so that it carries no runtime imports.
1045
1106
  let transformImport = '';
1046
1107
  if (usesInputTransform([cmpMeta], booleanAttributes)) {
@@ -1076,7 +1137,7 @@ ${createImportStatement(componentLibImports, './angular-component-lib/utils')}${
1076
1137
  methods.push(...cmpMeta.methods.filter(filterInternalProps).map(mapPropName));
1077
1138
  }
1078
1139
  const inlineComponentProps = outputTarget.inlineProperties ? internalProps : [];
1079
- const componentDefinition = createAngularComponentDefinition(cmpMeta.tagName, orderedInputs, methods, isCustomElementsBuild, isStandaloneBuild, inlineComponentProps, cmpMeta.events || []);
1140
+ const componentDefinition = createAngularComponentDefinition(cmpMeta.tagName, orderedInputs, methods, isCustomElementsBuild, isStandaloneBuild, inlineComponentProps, cmpMeta.events || [], getSsrInputs(outputTarget, internalProps));
1080
1141
  const moduleDefinition = generateAngularModuleForComponent(cmpMeta.tagName);
1081
1142
  const componentTypeDefinition = createComponentTypeDefinition(outputType, tagNameAsPascal, cmpMeta.events, componentCorePackage, customElementsDir, getEventTypesModule(config, outputTarget));
1082
1143
  const proxyFileOutput = [componentDefinition, '\n'];
@@ -1084,7 +1145,13 @@ ${createImportStatement(componentLibImports, './angular-component-lib/utils')}${
1084
1145
  proxyFileOutput.push(moduleDefinition, '\n');
1085
1146
  }
1086
1147
  proxyFileOutput.push(componentTypeDefinition, '\n');
1087
- const final = [imports, typeImports, sourceImport, ...proxyFileOutput];
1148
+ const final = [
1149
+ imports,
1150
+ typeImports,
1151
+ sourceImport,
1152
+ ...(ssrImports.source ? [ssrImports.source] : []),
1153
+ ...proxyFileOutput,
1154
+ ];
1088
1155
  return final.join('\n') + '\n';
1089
1156
  }
1090
1157
  /**
@@ -9,6 +9,47 @@ import generateValueAccessors from './generate-value-accessors';
9
9
  import { generateAngularModuleForComponent } from './generate-angular-modules';
10
10
  import { generateTransformTagScript } from './generate-transformtag-script';
11
11
  const filterInternalProps = (prop) => !prop.internal;
12
+ /**
13
+ * Stencil's own prop name to attribute name conversion (`prop1` -> `prop-1`).
14
+ */
15
+ const toDashCase = (str) => str
16
+ .replace(/([A-Z0-9])/g, (match) => ` ${match[0]}`)
17
+ .trim()
18
+ .split(' ')
19
+ .join('-')
20
+ .toLowerCase();
21
+ /**
22
+ * With `ssrPropModule` set, what the wrapper needs to write its inputs as attributes during
23
+ * server rendering. Otherwise `undefined`.
24
+ *
25
+ * Stencil only converts an attribute for a prop typed exactly `string`, `number` or `boolean`. Any
26
+ * other prop (`any`, which covers mixed unions like `string | boolean`, or `unknown`) gets the
27
+ * attribute's string as it is, so it's a ref: every value goes through `ssrProp()`. So is a prop
28
+ * with no attribute, which Stencil never reads.
29
+ */
30
+ const LITERAL_ATTRIBUTE_TYPES = ['string', 'number', 'boolean'];
31
+ const getSsrInputs = (outputTarget, props) => {
32
+ if (!outputTarget.ssrPropModule)
33
+ return undefined;
34
+ const ssr = { attributes: {}, refs: [] };
35
+ for (const prop of props) {
36
+ if (!prop.attribute || !LITERAL_ATTRIBUTE_TYPES.includes(prop.type))
37
+ ssr.refs.push(prop.name);
38
+ else if (prop.attribute !== toDashCase(prop.name))
39
+ ssr.attributes[prop.name] = prop.attribute;
40
+ }
41
+ return ssr;
42
+ };
43
+ /**
44
+ * Whether a component's wrapper has inputs: its properties and virtual properties.
45
+ */
46
+ const hasInputs = (cmp) => (cmp.properties ?? []).some(filterInternalProps) || (cmp.virtualProperties ?? []).length > 0;
47
+ /**
48
+ * The extra imports a proxy file needs for `ssrPropModule`.
49
+ */
50
+ const getSsrImports = (outputTarget, components) => outputTarget.ssrPropModule && components.some(hasInputs)
51
+ ? { componentLib: ['markServer'], source: `import { ssrProp } from '${outputTarget.ssrPropModule}';\n` }
52
+ : { componentLib: [], source: '' };
12
53
  /**
13
54
  * Whether a property declares an input transform.
14
55
  *
@@ -157,7 +198,8 @@ export function generateProxies(components, pkgData, outputTarget, rootDir, conf
157
198
  /**
158
199
  * The collection of named imports from the angular-component-lib/utils.
159
200
  */
160
- const componentLibImports = ['ProxyCmp'];
201
+ const ssrImports = getSsrImports(outputTarget, components);
202
+ const componentLibImports = ['ProxyCmp', ...ssrImports.componentLib];
161
203
  /**
162
204
  * The input transform lives in its own module so that it carries no runtime imports.
163
205
  */
@@ -225,7 +267,7 @@ ${createImportStatement(componentLibImports, './angular-component-lib/utils')}${
225
267
  * 2. Optionally the @NgModule decorated class (if includeSingleComponentAngularModules is true)
226
268
  * 3. The component interface (using declaration merging for types).
227
269
  */
228
- const componentDefinition = createAngularComponentDefinition(cmpMeta.tagName, orderedInputs, methods, isCustomElementsBuild, isStandaloneBuild, inlineComponentProps, cmpMeta.events || []);
270
+ const componentDefinition = createAngularComponentDefinition(cmpMeta.tagName, orderedInputs, methods, isCustomElementsBuild, isStandaloneBuild, inlineComponentProps, cmpMeta.events || [], getSsrInputs(outputTarget, internalProps));
229
271
  const moduleDefinition = generateAngularModuleForComponent(cmpMeta.tagName);
230
272
  const componentTypeDefinition = createComponentTypeDefinition(outputType, tagNameAsPascal, cmpMeta.events, componentCorePackage, customElementsDir, getEventTypesModule(config, outputTarget));
231
273
  proxyFileOutput.push(componentDefinition, '\n');
@@ -234,7 +276,13 @@ ${createImportStatement(componentLibImports, './angular-component-lib/utils')}${
234
276
  }
235
277
  proxyFileOutput.push(componentTypeDefinition, '\n');
236
278
  }
237
- const final = [imports, typeImports, sourceImports, ...proxyFileOutput];
279
+ const final = [
280
+ imports,
281
+ typeImports,
282
+ sourceImports,
283
+ ...(ssrImports.source ? [ssrImports.source] : []),
284
+ ...proxyFileOutput,
285
+ ];
238
286
  return final.join('\n') + '\n';
239
287
  }
240
288
  /**
@@ -256,7 +304,8 @@ export function generateComponentProxy(cmpMeta, pkgData, outputTarget, rootDir,
256
304
  angularCoreImports.push('NgModule');
257
305
  }
258
306
  const booleanAttributes = outputTarget.booleanAttributes === true;
259
- const componentLibImports = ['ProxyCmp'];
307
+ const ssrImports = getSsrImports(outputTarget, [cmpMeta]);
308
+ const componentLibImports = ['ProxyCmp', ...ssrImports.componentLib];
260
309
  // The input transform lives in its own module so that it carries no runtime imports.
261
310
  let transformImport = '';
262
311
  if (usesInputTransform([cmpMeta], booleanAttributes)) {
@@ -292,7 +341,7 @@ ${createImportStatement(componentLibImports, './angular-component-lib/utils')}${
292
341
  methods.push(...cmpMeta.methods.filter(filterInternalProps).map(mapPropName));
293
342
  }
294
343
  const inlineComponentProps = outputTarget.inlineProperties ? internalProps : [];
295
- const componentDefinition = createAngularComponentDefinition(cmpMeta.tagName, orderedInputs, methods, isCustomElementsBuild, isStandaloneBuild, inlineComponentProps, cmpMeta.events || []);
344
+ const componentDefinition = createAngularComponentDefinition(cmpMeta.tagName, orderedInputs, methods, isCustomElementsBuild, isStandaloneBuild, inlineComponentProps, cmpMeta.events || [], getSsrInputs(outputTarget, internalProps));
296
345
  const moduleDefinition = generateAngularModuleForComponent(cmpMeta.tagName);
297
346
  const componentTypeDefinition = createComponentTypeDefinition(outputType, tagNameAsPascal, cmpMeta.events, componentCorePackage, customElementsDir, getEventTypesModule(config, outputTarget));
298
347
  const proxyFileOutput = [componentDefinition, '\n'];
@@ -300,7 +349,13 @@ ${createImportStatement(componentLibImports, './angular-component-lib/utils')}${
300
349
  proxyFileOutput.push(moduleDefinition, '\n');
301
350
  }
302
351
  proxyFileOutput.push(componentTypeDefinition, '\n');
303
- const final = [imports, typeImports, sourceImport, ...proxyFileOutput];
352
+ const final = [
353
+ imports,
354
+ typeImports,
355
+ sourceImport,
356
+ ...(ssrImports.source ? [ssrImports.source] : []),
357
+ ...proxyFileOutput,
358
+ ];
304
359
  return final.join('\n') + '\n';
305
360
  }
306
361
  /**
package/dist/types.d.ts CHANGED
@@ -51,6 +51,20 @@ export interface OutputTargetAngular {
51
51
  * to type-check and show jsdocs when using the components in html-templates.
52
52
  */
53
53
  inlineProperties?: boolean;
54
+ /**
55
+ * The component library's `ssr-prop` module, e.g. `'component-library/ssr-prop'`, from the
56
+ * `ssr` output target. Set it when apps render the wrappers with Angular SSR and pass the page
57
+ * through the library's `renderToString()`.
58
+ *
59
+ * Angular's server DOM never writes properties to the HTML, so `[value]="..."` bindings would
60
+ * be missing from the server render. With this set, a wrapper rendering on the server writes
61
+ * each input into the element's attributes instead. For a prop typed `string`, `number` or
62
+ * `boolean`, a string or number is written as is and `true` as an empty attribute. Everything
63
+ * else goes through `ssrProp()`: `false`, objects, arrays, functions, and any value for a prop
64
+ * of another type or with no attribute. In the browser, inputs are still set as properties.
65
+ * See the README's "Server-side rendering" section.
66
+ */
67
+ ssrPropModule?: string;
54
68
  /**
55
69
  * Use `transformTag` to generate a build-time script.
56
70
  * This script patches Angular `@Component` selectors in any installed Angular wrapper library
package/dist/wizard.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
2
- import { join, relative, dirname } from 'node:path';
2
+ import { join, relative, dirname, isAbsolute } from 'node:path';
3
3
  // ---------------------------------------------------------------------------
4
4
  // pnpm build approval
5
5
  // ---------------------------------------------------------------------------
@@ -77,7 +77,22 @@ function toPascalCase(s) {
77
77
  .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
78
78
  .join('');
79
79
  }
80
- async function scaffoldWrapperPackage(wrapperDir, packageName, corePackageName, corePkgVersion, namespace, outputType) {
80
+ /**
81
+ * The module the lazy loader's `defineCustomElements()` is imported from: the `./loader` entry
82
+ * when the project generates an `exports` map, otherwise its place in the `loader-bundle` output.
83
+ */
84
+ function getLoaderModule(config, corePackageName) {
85
+ if (config.generateExportMaps === true) {
86
+ return `${corePackageName}/loader`;
87
+ }
88
+ const target = config.outputTargets?.find((o) => o.type === 'loader-bundle');
89
+ const rawDir = target?.dir ?? 'dist/loader-bundle';
90
+ const dir = (isAbsolute(rawDir) ? relative(config.rootDir, rawDir) : rawDir).replace(/\\/g, '/');
91
+ return `${corePackageName}/${dir}/${target?.loaderPath || 'loader'}`;
92
+ }
93
+ async function scaffoldWrapperPackage(wrapperDir, packageName, corePackageName, corePkgVersion, namespace, outputType,
94
+ /** For the 'component' type: the module to import the loader's `defineCustomElements()` from. */
95
+ loaderModule) {
81
96
  const libDir = join(wrapperDir, 'src', 'lib');
82
97
  await mkdir(libDir, { recursive: true });
83
98
  const className = toPascalCase(namespace);
@@ -125,12 +140,23 @@ async function scaffoldWrapperPackage(wrapperDir, packageName, corePackageName,
125
140
  // NgModule file — only needed for 'component' outputType
126
141
  let ngModuleTs;
127
142
  if (outputType === 'component') {
143
+ // The 'component' type's wrappers register nothing themselves: this module does it, with the
144
+ // lazy loader, when the consuming app starts. It's written once and never regenerated.
128
145
  ngModuleTs =
129
- `import { NgModule } from '@angular/core';\n` +
146
+ `import { NgModule, provideAppInitializer } from '@angular/core';\n` +
147
+ `import { defineCustomElements } from '${loaderModule}';\n` +
130
148
  `import { DIRECTIVES } from './directives.array';\n\n` +
149
+ `/**\n` +
150
+ ` * Declares every component, and registers the custom elements when the app starts.\n` +
151
+ ` *\n` +
152
+ ` * This file is yours to edit - Stencil doesn't regenerate it. Do any setup your library\n` +
153
+ ` * needs before the components are defined in the initializer, e.g. \`setNonce()\` or\n` +
154
+ ` * \`setTagTransformer()\` (both exported by the loader).\n` +
155
+ ` */\n` +
131
156
  `@NgModule({\n` +
132
157
  ` declarations: [...DIRECTIVES],\n` +
133
158
  ` exports: [...DIRECTIVES],\n` +
159
+ ` providers: [provideAppInitializer(() => defineCustomElements())],\n` +
134
160
  `})\n` +
135
161
  `export class ${className}Module {}\n`;
136
162
  }
@@ -240,7 +266,7 @@ export const wizard = {
240
266
  const corePkgVersion = supportsWorkspaceProtocol
241
267
  ? 'workspace:*'
242
268
  : `file:${relative(wrapperDir, config.rootDir)}`;
243
- await scaffoldWrapperPackage(wrapperDir, wrapperPackageName, componentCorePackage, corePkgVersion, config.fsNamespace, selectedOutputType);
269
+ await scaffoldWrapperPackage(wrapperDir, wrapperPackageName, componentCorePackage, corePkgVersion, config.fsNamespace, selectedOutputType, selectedOutputType === 'component' ? getLoaderModule(config, componentCorePackage) : undefined);
244
270
  s.stop('Wrapper package scaffolded');
245
271
  }
246
272
  catch (e) {
@@ -263,7 +289,8 @@ export const wizard = {
263
289
  // Build target config
264
290
  const lines = [
265
291
  `componentCorePackage: '${componentCorePackage}'`,
266
- `customElementsDir: '${customElementsDir}'`,
292
+ // only the types built on the `standalone` output import from it
293
+ ...(selectedOutputType !== 'component' ? [`customElementsDir: '${customElementsDir}'`] : []),
267
294
  `directivesProxyFile: '${directivesProxyFile}'`,
268
295
  ...(directivesArrayFile ? [`directivesArrayFile: '${directivesArrayFile}'`] : []),
269
296
  ...(selectedOutputType !== 'standalone' ? [`outputType: '${selectedOutputType}'`] : []),
@@ -276,8 +303,18 @@ export const wizard = {
276
303
  editor.outputTargetsContains('type: "standalone"') ||
277
304
  editor.outputTargetsContains("type: 'dist-custom-elements'") ||
278
305
  editor.outputTargetsContains('type: "dist-custom-elements"');
279
- if (!hasStandalone)
306
+ const hasLoaderBundle = editor.outputTargetsContains("type: 'loader-bundle'") ||
307
+ editor.outputTargetsContains('type: "loader-bundle"') ||
308
+ editor.outputTargetsContains("type: 'dist'") ||
309
+ editor.outputTargetsContains('type: "dist"');
310
+ // 'component' wrappers rely on the lazy loader; the other types import from `standalone`
311
+ if (selectedOutputType === 'component') {
312
+ if (!hasLoaderBundle)
313
+ editor.addOutputTarget("{ type: 'loader-bundle' }");
314
+ }
315
+ else if (!hasStandalone) {
280
316
  editor.addOutputTarget("{ type: 'standalone' }");
317
+ }
281
318
  if (!editor.replaceOutputTarget('angularOutputTarget(', targetCode)) {
282
319
  editor.addOutputTarget(targetCode);
283
320
  }
@@ -306,6 +343,10 @@ export const wizard = {
306
343
  ], { cwd: wrapperDir, dev: true });
307
344
  log.success('Angular dependencies installed');
308
345
  }
346
+ if (selectedOutputType === 'component' && !shouldScaffold) {
347
+ log.info(`NgModule components: your Angular package's module needs to call defineCustomElements() ` +
348
+ `from '${getLoaderModule(config, componentCorePackage)}' when the app starts, e.g. with provideAppInitializer().`);
349
+ }
309
350
  outro('Angular output target configured');
310
351
  },
311
352
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stencil/angular-output-target",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "description": "Angular output target for @stencil/core components.",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",
@@ -64,6 +64,7 @@
64
64
  "npm-run-all2": "^6.2.6",
65
65
  "rimraf": "^5.0.10",
66
66
  "rollup": "^2.80.0",
67
+ "rxjs": "^7.8.2",
67
68
  "stencil-output-targets-shared": "workspace:*",
68
69
  "typescript": "~5.7.3",
69
70
  "vitest": "^2.1.9"