feat: Add support for templates in actions (#1891)

- Closes #1878
This commit is contained in:
Dermot Duffy
2025-02-14 07:23:19 -08:00
committed by GitHub
parent 8d3cf07b43
commit 7fb710d45d
18 changed files with 474 additions and 24 deletions
+1
View File
@@ -3,6 +3,7 @@
- [`actions`](README.md)
- [Custom Actions](./custom/README.md)
- [Stock Actions](./stock/README.md)
- [Templates](./templates.md)
- [`automations`](../automations.md)
- [`cameras`](../cameras/README.md)
- [`conditions`](../conditions.md)
@@ -3,6 +3,7 @@
- [`actions`](../README.md)
- [Custom Actions](README.md)
- [Stock Actions](../stock/README.md)
- [Templates](../templates.md)
- [`automations`](../../automations.md)
- [`cameras`](../../cameras/README.md)
- [`conditions`](../../conditions.md)
+3 -3
View File
@@ -73,13 +73,13 @@ elements:
action: toggle
- type: icon
icon: mdi:numeric-3-box
title: Call Service action
title: Perform Action / Call Service action
style:
left: 200px
top: 150px
tap_action:
action: call-service
service: homeassistant.toggle
action: perform-action
perform_action: homeassistant.toggle
data:
entity_id: light.office_main_lights
- type: icon
@@ -3,6 +3,7 @@
- [`actions`](../README.md)
- [Custom Actions](../custom/README.md)
- [Stock Actions](README.md)
- [Templates](../templates.md)
- [`automations`](../../automations.md)
- [`cameras`](../../cameras/README.md)
- [`conditions`](../../conditions.md)
+57
View File
@@ -0,0 +1,57 @@
# Templates
Before actions are executed, template values (if present) are replaced within
each action. This allows a variety of Home Assistant data to be automatically
populated in your actions, as well as some Advanced Camera Card data.
## Stock Templates
The Advanced Camera Card uses
[ha-nunjucks](https://github.com/Nerwyn/ha-nunjucks) to process templates.
Consult its documentation for the wide variety of different template values
supported.
See [an example](../../examples.md?id=accessing-home-assistant-state) that
accesses Home Assistant state.
## Custom Templates
Custom template values must be proceeded by `advanced_camera_card` (or `acc` for
short).
| Template | Replaced with |
| -------- | -------------------------------------------------- |
| `camera` | The currently selected camera. |
| `view` | The current [view](../view.md?id=supported-views). |
See [an example](../../examples.md?id=accessing-advanced-camera-card-state) that
accesses Advanced Camera Card state.
## Triggers
If the action is called by an [Advanced Camera Card
Automation](../automations.md), additional data is available representing the
current and prior state of whatever triggered the action.
Trigger template values must be proceeded by `advanced_camera_card.trigger` (or
`acc.trigger` for short).
| Template | Replaced with |
| -------------- | ------------------------------------------------------------------------------------------------- |
| `camera.to` | For [camera conditions](../conditions.md?id=camera), the currently selected camera. |
| `camera.from` | For [camera conditions](../conditions.md?id=camera), the previously selected camera. |
| `view.to` | For [view conditions](../conditions.md?id=view), the currently selected view. |
| `view.from` | For [view conditions](../conditions.md?id=view), the previously selected view. |
| `state.entity` | For [state conditions](../conditions.md?id=state), the entity state that triggered the condition. |
| `state.to` | For [state conditions](../conditions.md?id=state), the current state of the entity. |
| `state.from` | For [state conditions](../conditions.md?id=state), the previous state of the entity. |
!> If an action is triggered with multiple [state
conditions](../conditions.md?id=state), only data from the last listed state
condition is available.
Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if
you need data from additional conditions.
See [an example](../../examples.md?id=accessing-trigger-state) that accesses
trigger state.
+1 -1
View File
@@ -143,7 +143,7 @@ This card supports several different views.
| `clip` | Shows a viewer for the most recent clip for this camera. Can also be accessed by holding down the `clips` menu icon. |
| `clips` | Shows a gallery of clips for this camera. |
| `image` | Shows a static image specified by the `image` parameter, can be used as a discrete default view or a screensaver (via `view.interaction_seconds`). |
| `live` | Shows the live camera view with the configured [live provider](). |
| `live` | Shows the live camera view with the configured [live provider](./cameras/live-provider.md). |
| `recording` | Shows a viewer for the most recent recording for this camera. Can also be accessed by holding down the `recordings` menu icon. |
| `recordings` | Shows a gallery of recent (last day) recordings for this camera and its dependents. |
| `snapshot` | Shows a viewer for the most recent snapshot for this camera. Can also be accessed by holding down the `snapshots` menu icon. |
+55
View File
@@ -947,6 +947,61 @@ menu:
icon: mdi:high-definition
```
## Templates in actions
### Accessing Home Assistant state
Perhaps the most common usage of templates is to access Home Assistant state
values. In the below example a fictitious service `homeassistant.service` is
called with data that refers to the current state of the `light.sunroom_ceiling`
entity.
```yaml
tap_action:
action: perform-action
perform_action: homeassistant.service
data:
key: '{{ hass.states["light.sunroom_ceiling"].state }}'
```
See [Stock Templates](./configuration/actions/templates.md?id=stock-templates).
### Accessing Advanced Camera Card state
In this example, the currently selected camera and
[view](./configuration/view.md) are passed as data to a fictitious service
`homeassistant.service`.
```yaml
tap_action:
action: perform-action
perform_action: homeassistant.service
data:
camera: '{{ advanced_camera_card.camera }}'
view: '{{ advanced_camera_card.view }}'
```
See [Custom Templates](./configuration/actions/templates.md?id=custom-templates).
### Accessing Trigger state
In this example, an [automation](./configuration/automations.md) is triggered,
and values associated with the triggering are included in the action.
```yaml
automations:
- conditions:
- condition: camera
actions:
- action: perform-action
perform_action: homeassistant.service
data:
from_camera: '{{ acc.trigger.camera.from }}'
to_camera: '{{ acc.trigger.camera.to }}'
```
See [Trigger Templates](./configuration/actions/templates.md?id=triggers).
## Trigger actions
You can control the card itself with the `custom:advanced-camera-card-action` action.
+1
View File
@@ -33,6 +33,7 @@
"date-fns-tz": "^3.1.3",
"embla-carousel": "^8.3.0",
"embla-carousel-wheel-gestures": "^8.0.1",
"ha-nunjucks": "^1.3.0",
"home-assistant-js-websocket": "^9.4.0",
"js-yaml": "^4.1.0",
"keycharm": "^0.4.0",
+12
View File
@@ -89,6 +89,8 @@ const outputEntryTemplate = {
sourcemap: dev,
};
const CIRCULAR_DEPENDENCY_IGNORE_REGEXP = /(ha-nunjucks|ts-py-datetime)/;
/**
* @type {import('rollup').RollupOptions}
*/
@@ -116,6 +118,16 @@ const config = {
'./node_modules/@formatjs/intl-utils/lib/src/diff.js': 'window',
'./node_modules/@formatjs/intl-utils/lib/src/resolve-locale.js': 'window',
},
// Ignore circular dependencies from underlying libraries.
onwarn: (warning, defaultHandler) => {
if (
warning.code === 'CIRCULAR_DEPENDENCY' &&
warning.ids.some((id) => id.match(CIRCULAR_DEPENDENCY_IGNORE_REGEXP))
) {
return;
}
defaultHandler(warning);
},
};
export default config;
+26 -8
View File
@@ -1,10 +1,12 @@
import { ActionContext } from 'action';
import { z } from 'zod';
import { ConditionsEvaluationData } from '../../conditions/types.js';
import { Actions, ActionsConfig, ActionType } from '../../config/types.js';
import { getActionConfigGivenAction } from '../../utils/action.js';
import { ActionSet } from './actions/set.js';
import { TemplateRenderer } from '../templates/index.js';
import { CardActionsManagerAPI } from '../types.js';
import { ActionSet } from './actions/set.js';
import { ActionExecutionRequest, AuxillaryActionConfig } from './types.js';
import { ActionContext } from 'action';
const INTERACTIONS = ['tap', 'double_tap', 'hold', 'start_tap', 'end_tap'] as const;
export type InteractionName = (typeof INTERACTIONS)[number];
@@ -22,9 +24,11 @@ export class ActionsManager {
protected _api: CardActionsManagerAPI;
protected _actionsInFlight: ActionSet[] = [];
protected _actionContext: ActionContext = {};
protected _templateRenderer: TemplateRenderer | null;
constructor(api: CardActionsManagerAPI) {
constructor(api: CardActionsManagerAPI, templateRenderer?: TemplateRenderer) {
this._api = api;
this._templateRenderer = templateRenderer ?? null;
}
/**
@@ -72,7 +76,7 @@ export class ActionsManager {
// actions).
actionConfig
) {
this.executeActions(actionConfig, config);
this.executeActions(actionConfig, { config });
}
};
@@ -97,7 +101,9 @@ export class ActionsManager {
public handleActionExecutionRequestEvent = async (
ev: CustomEvent<ActionExecutionRequest>,
): Promise<void> => {
await this.executeActions(ev.detail.action, ev.detail.config);
await this.executeActions(ev.detail.action, {
config: ev.detail.config,
});
};
public uninitialize(): void {
@@ -107,10 +113,22 @@ export class ActionsManager {
public async executeActions(
action: ActionType | ActionType[],
config?: AuxillaryActionConfig,
options?: {
config?: AuxillaryActionConfig;
triggerData?: ConditionsEvaluationData;
},
): Promise<void> {
const actionSet = new ActionSet(this._actionContext, action, {
config: config,
const hass = this._api.getHASSManager().getHASS();
const renderedAction =
hass && this._templateRenderer
? this._templateRenderer.renderRecursively(hass, action, {
conditionState: this._api.getConditionStateManager().getState(),
triggerData: options?.triggerData,
})
: action;
const actionSet = new ActionSet(this._actionContext, renderedAction, {
config: options?.config,
cardID: this._api.getConfigManager().getConfig()?.card_id,
});
+5 -1
View File
@@ -64,6 +64,7 @@ export class AutomationsManager {
const runActions = async (actions: AutomationActions): Promise<void> => {
++this._nestedAutomationExecutions;
if (this._nestedAutomationExecutions > MAX_NESTED_AUTOMATION_EXECUTIONS) {
this._api.getMessageManager().setMessageIfHigherPriority({
type: 'error',
@@ -72,7 +73,10 @@ export class AutomationsManager {
return;
}
await this._api.getActionsManager().executeActions(actions);
await this._api
.getActionsManager()
.executeActions(actions, { triggerData: result.data });
--this._nestedAutomationExecutions;
};
runActions(actions);
+2 -1
View File
@@ -37,6 +37,7 @@ import { MicrophoneManager } from './microphone-manager';
import { QueryStringManager } from './query-string-manager';
import { StatusBarItemManager } from './status-bar-item-manager';
import { StyleManager } from './style-manager';
import { TemplateRenderer } from './templates';
import { TriggersManager } from './triggers-manager';
import {
CardActionsManagerAPI,
@@ -104,7 +105,7 @@ export class CardController
);
protected _resolvedMediaCache = new ResolvedMediaCache();
protected _actionsManager = new ActionsManager(this);
protected _actionsManager = new ActionsManager(this, new TemplateRenderer());
protected _automationsManager = new AutomationsManager(this);
protected _cameraManager = new CameraManager(this);
protected _cameraURLManager = new CameraURLManager(this);
+80
View File
@@ -0,0 +1,80 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { HASS, renderTemplate } from 'ha-nunjucks/dist';
import { ConditionsEvaluationData, ConditionState } from '../../conditions/types';
import { ActionType } from '../../config/types';
interface TemplateContextInternal {
camera?: string;
view?: string;
trigger?: ConditionsEvaluationData;
}
interface TemplateContext {
advanced_camera_card: TemplateContextInternal;
// Convenient alias.
acc: TemplateContextInternal;
}
export class TemplateRenderer {
public renderRecursively = (
hass: HomeAssistant,
data: unknown,
options?: {
conditionState?: ConditionState;
triggerData?: ConditionsEvaluationData;
},
): ActionType => {
return this._renderTemplateRecursively(
hass,
data,
this._conditionStateToTemplateContext(
options?.conditionState,
options?.triggerData,
),
);
};
protected _conditionStateToTemplateContext(
conditionState?: ConditionState,
triggerData?: ConditionsEvaluationData,
): TemplateContext | undefined {
if (!conditionState?.camera && !conditionState?.view && !triggerData) {
return;
}
const advancedCameraCardContext: TemplateContextInternal = {
...(conditionState?.camera && { camera: conditionState.camera }),
...(conditionState?.view && { view: conditionState.view }),
...(triggerData && { trigger: triggerData }),
};
return {
acc: advancedCameraCardContext,
advanced_camera_card: advancedCameraCardContext,
};
}
protected _renderTemplateRecursively(
hass: HomeAssistant,
data: unknown,
templateContext?: TemplateContext,
): ActionType {
if (typeof data === 'string') {
// ha-nunjucks has a more complete model of the Home Assistant object, but
// does not export it as a type.
return renderTemplate(hass as unknown as typeof HASS, data, templateContext);
} else if (Array.isArray(data)) {
return data.map((item) =>
this._renderTemplateRecursively(hass, item, templateContext),
);
} else if (typeof data === 'object' && data !== null) {
const result = {};
for (const key in data) {
result[key] = this._renderTemplateRecursively(hass, data[key], templateContext);
}
return result;
}
return data;
}
}
+2 -9
View File
@@ -48,17 +48,10 @@ export interface ConditionsEvaluationData {
view?: ConditionsEvaluationDataFromTo;
state?: ConditionsEvaluationDataState;
}
interface ConditionsEvaluationResultTrue {
result: true;
export interface ConditionsEvaluationResult {
result: boolean;
data?: ConditionsEvaluationData;
}
interface ConditionsEvaluationResultFalse {
result: false;
}
export type ConditionsEvaluationResult =
| ConditionsEvaluationResultTrue
| ConditionsEvaluationResultFalse;
export type ConditionsListener = (result: ConditionsEvaluationResult) => void;
@@ -1,9 +1,11 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import {
ActionsManager,
Interaction,
InteractionName,
} from '../../../src/card-controller/actions/actions-manager';
import { TemplateRenderer } from '../../../src/card-controller/templates';
import { AdvancedCameraCardView } from '../../../src/config/types';
import { createLogAction } from '../../../src/utils/action';
import {
@@ -286,4 +288,34 @@ describe('ActionsManager', () => {
expect(consoleSpy).not.toBeCalled();
});
});
it('should render templates', async () => {
const action = createLogAction('{{ acc.camera }}');
const templateRenderer = mock<TemplateRenderer>();
templateRenderer.renderRecursively.mockReturnValue(action);
const api = createCardAPI();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const conditionState = {
camera: 'camera',
};
vi.mocked(api.getConditionStateManager().getState).mockReturnValue(conditionState);
const manager = new ActionsManager(api, templateRenderer);
const config = { camera_image: 'camera-image' };
const triggerData = { view: { from: 'previous-view', to: 'view' } };
await manager.executeActions(action, {
config,
triggerData,
});
expect(templateRenderer.renderRecursively).toBeCalledWith(hass, action, {
conditionState,
triggerData,
});
});
});
+93
View File
@@ -0,0 +1,93 @@
import { renderTemplate } from 'ha-nunjucks';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TemplateRenderer } from '../../src/card-controller/templates';
import { ConditionsEvaluationData, ConditionState } from '../../src/conditions/types';
import { createHASS } from '../test-utils';
// ha-nunjucks attempts to make websocket calls initially so mock it out.
vi.mock('ha-nunjucks');
describe('TemplateRenderer', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render data', () => {
const data = {
camera: '{{ acc.camera }}',
array: ['{{ acc.camera }}', '{{ acc.view }}'],
nested: {
camera: '{{ acc.camera }}',
},
number: 42,
};
vi.mocked(renderTemplate)
.mockReturnValueOnce('one')
.mockReturnValueOnce('two')
.mockReturnValueOnce('three')
.mockReturnValueOnce('four');
const renderer = new TemplateRenderer();
const hass = createHASS();
expect(renderer.renderRecursively(hass, data)).toEqual({
camera: 'one',
array: ['two', 'three'],
nested: {
camera: 'four',
},
number: 42,
});
});
it('should include triggers', () => {
const conditionState: ConditionState = {
camera: 'camera',
view: 'view',
};
const triggerData: ConditionsEvaluationData = {
camera: {
to: 'camera',
from: 'previous-camera',
},
};
const renderer = new TemplateRenderer();
const hass = createHASS();
renderer.renderRecursively(
hass,
{
key: 'value',
},
{
conditionState,
triggerData,
},
);
expect(renderTemplate).toHaveBeenCalledWith(hass, 'value', {
acc: {
camera: 'camera',
view: 'view',
trigger: {
camera: {
to: 'camera',
from: 'previous-camera',
},
},
},
advanced_camera_card: {
camera: 'camera',
view: 'view',
trigger: {
camera: {
to: 'camera',
from: 'previous-camera',
},
},
},
});
});
});
+7
View File
@@ -72,6 +72,13 @@ const calculateFullCoverageThresholds = (): Record<string, Threshold> => {
// ts-prune-ignore-next
export default defineConfig({
test: {
server: {
deps: {
// These dependencies import without extensions.
// Related: https://github.com/vitest-dev/vitest/issues/2313
inline: ['ha-nunjucks', 'ts-py-datetime'],
},
},
include: ['tests/**/*.test.ts'],
coverage: {
exclude: ['docs/**', 'tests/**', '.eslintrc.cjs'],
+95 -1
View File
@@ -2394,6 +2394,13 @@ __metadata:
languageName: node
linkType: hard
"a-sync-waterfall@npm:^1.0.0":
version: 1.0.1
resolution: "a-sync-waterfall@npm:1.0.1"
checksum: 10c0/1c7b258da2c77eb1447dcc683afb10ca3dc8880de990562ccbb7b282538aba01e910345ce9e8500c1458272c7866b85fcfa5ca8159e33550b011ab5c586ec5a4
languageName: node
linkType: hard
"abab@npm:^2.0.6":
version: 2.0.6
resolution: "abab@npm:2.0.6"
@@ -2504,6 +2511,7 @@ __metadata:
eslint-config-airbnb-base: "npm:^15.0.0"
eslint-config-prettier: "npm:^9.1.0"
eslint-plugin-import: "npm:^2.29.1"
ha-nunjucks: "npm:^1.3.0"
home-assistant-js-websocket: "npm:^9.4.0"
js-yaml: "npm:^4.1.0"
jsdom: "npm:^21.1.2"
@@ -2835,6 +2843,13 @@ __metadata:
languageName: node
linkType: hard
"asap@npm:^2.0.3":
version: 2.0.6
resolution: "asap@npm:2.0.6"
checksum: 10c0/c6d5e39fe1f15e4b87677460bd66b66050cd14c772269cee6688824c1410a08ab20254bb6784f9afb75af9144a9f9a7692d49547f4d19d715aeb7c0318f3136d
languageName: node
linkType: hard
"assertion-error@npm:^1.1.0":
version: 1.1.0
resolution: "assertion-error@npm:1.1.0"
@@ -3485,6 +3500,13 @@ __metadata:
languageName: node
linkType: hard
"commander@npm:^5.1.0":
version: 5.1.0
resolution: "commander@npm:5.1.0"
checksum: 10c0/da9d71dbe4ce039faf1fe9eac3771dca8c11d66963341f62602f7b66e36d2a3f8883407af4f9a37b1db1a55c59c0c1325f186425764c2e963dc1d67aec2a4b6d
languageName: node
linkType: hard
"commander@npm:^6.2.1":
version: 6.2.1
resolution: "commander@npm:6.2.1"
@@ -3949,6 +3971,33 @@ __metadata:
languageName: node
linkType: hard
"d3-array@npm:2 - 3":
version: 3.2.4
resolution: "d3-array@npm:3.2.4"
dependencies:
internmap: "npm:1 - 2"
checksum: 10c0/08b95e91130f98c1375db0e0af718f4371ccacef7d5d257727fe74f79a24383e79aba280b9ffae655483ffbbad4fd1dec4ade0119d88c4749f388641c8bf8c50
languageName: node
linkType: hard
"d3-time-format@npm:^4.1.0":
version: 4.1.0
resolution: "d3-time-format@npm:4.1.0"
dependencies:
d3-time: "npm:1 - 3"
checksum: 10c0/735e00fb25a7fd5d418fac350018713ae394eefddb0d745fab12bbff0517f9cdb5f807c7bbe87bb6eeb06249662f8ea84fec075f7d0cd68609735b2ceb29d206
languageName: node
linkType: hard
"d3-time@npm:1 - 3":
version: 3.1.0
resolution: "d3-time@npm:3.1.0"
dependencies:
d3-array: "npm:2 - 3"
checksum: 10c0/a984f77e1aaeaa182679b46fbf57eceb6ebdb5f67d7578d6f68ef933f8eeb63737c0949991618a8d29472dbf43736c7d7f17c452b2770f8c1271191cba724ca1
languageName: node
linkType: hard
"data-uri-to-buffer@npm:^6.0.2":
version: 6.0.2
resolution: "data-uri-to-buffer@npm:6.0.2"
@@ -5714,6 +5763,17 @@ __metadata:
languageName: node
linkType: hard
"ha-nunjucks@npm:^1.3.0":
version: 1.3.0
resolution: "ha-nunjucks@npm:1.3.0"
dependencies:
home-assistant-js-websocket: "npm:latest"
nunjucks: "npm:latest"
ts-py-datetime: "npm:latest"
checksum: 10c0/9c346ee9866f748b41d98dbefefd46ddb0a43f7d563e4456e000047e273e5441e5a15321ba8684325a81d746a9e9c32c5c0f4f1a58637291babebcae45329442
languageName: node
linkType: hard
"handlebars@npm:^4.7.7":
version: 4.7.8
resolution: "handlebars@npm:4.7.8"
@@ -5817,7 +5877,7 @@ __metadata:
languageName: node
linkType: hard
"home-assistant-js-websocket@npm:^9.4.0":
"home-assistant-js-websocket@npm:^9.4.0, home-assistant-js-websocket@npm:latest":
version: 9.4.0
resolution: "home-assistant-js-websocket@npm:9.4.0"
checksum: 10c0/9c8a5ff903648ebe7856f62a58af53e3d89dd24a30c600a2145ed518cd5f5f2218345048cfc42bdc3ee5d6f2994da3948765c77b6012ed56cf4546173a1a1de1
@@ -6145,6 +6205,13 @@ __metadata:
languageName: node
linkType: hard
"internmap@npm:1 - 2":
version: 2.0.3
resolution: "internmap@npm:2.0.3"
checksum: 10c0/8cedd57f07bbc22501516fbfc70447f0c6812871d471096fad9ea603516eacc2137b633633daf432c029712df0baefd793686388ddf5737e3ea15074b877f7ed
languageName: node
linkType: hard
"intl-messageformat@npm:^9.11.1":
version: 9.13.0
resolution: "intl-messageformat@npm:9.13.0"
@@ -8239,6 +8306,24 @@ __metadata:
languageName: node
linkType: hard
"nunjucks@npm:latest":
version: 3.2.4
resolution: "nunjucks@npm:3.2.4"
dependencies:
a-sync-waterfall: "npm:^1.0.0"
asap: "npm:^2.0.3"
commander: "npm:^5.1.0"
peerDependencies:
chokidar: ^3.3.0
peerDependenciesMeta:
chokidar:
optional: true
bin:
nunjucks-precompile: bin/precompile
checksum: 10c0/7fe5197559b7c09972c79e2a86f9c093459b9075bc9b41134cd2bc599ae93567b53bd09d472a748edc736192d9ccd2998aa8c20cfcbe6a3fffd281f91897c888
languageName: node
linkType: hard
"nwsapi@npm:^2.2.4":
version: 2.2.7
resolution: "nwsapi@npm:2.2.7"
@@ -11198,6 +11283,15 @@ __metadata:
languageName: node
linkType: hard
"ts-py-datetime@npm:latest":
version: 0.10.0
resolution: "ts-py-datetime@npm:0.10.0"
dependencies:
d3-time-format: "npm:^4.1.0"
checksum: 10c0/2e82ba1b02edd566600052a6c90ea06d1eeeff7557e75cfba6c80bc3898b8ecbd825b7b51e9690c68ab97dc8c12d362e3e509896c617c6a556f4d6cfe9cf3579
languageName: node
linkType: hard
"tsconfig-paths@npm:^3.15.0":
version: 3.15.0
resolution: "tsconfig-paths@npm:3.15.0"