mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-08 17:55:31 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 369d0538d1 |
@@ -1,98 +0,0 @@
|
||||
---
|
||||
name: ha-frontend-components
|
||||
description: Home Assistant frontend component patterns. Use when implementing or reviewing dialogs, ha-form, ha-alert, keyboard shortcuts, tooltips, panels, Lovelace cards, or ha-button usage.
|
||||
---
|
||||
|
||||
# HA Frontend Components
|
||||
|
||||
Use this skill when creating or reviewing Home Assistant UI components and common interaction patterns.
|
||||
|
||||
## Dialogs
|
||||
|
||||
Open dialogs through the fire-event pattern:
|
||||
|
||||
```ts
|
||||
fireEvent(this, "show-dialog", {
|
||||
dialogTag: "dialog-example",
|
||||
dialogImport: () => import("./dialog-example"),
|
||||
dialogParams: { title: "Example", data: someData },
|
||||
});
|
||||
```
|
||||
|
||||
Dialog implementation requirements:
|
||||
|
||||
- Use `ha-dialog`.
|
||||
- Implement `HassDialog<T>`.
|
||||
- Use `@state() private _open = false` to control visibility.
|
||||
- Set `_open = true` in `showDialog()` and `_open = false` in `closeDialog()`.
|
||||
- Return `nothing` while required params are absent.
|
||||
- Fire `dialog-closed` in the close handler.
|
||||
- Use `header-title` and `header-subtitle` for simple header text.
|
||||
- Use slots when standard header attributes are not enough.
|
||||
- Use `ha-dialog-footer` with `primaryAction` and `secondaryAction` slots.
|
||||
- Add `autofocus` to the first focusable element, such as `<ha-form autofocus>`, and forward it internally if needed.
|
||||
|
||||
Use standard dialog widths: `small`, `medium`, `large`, or `full`. Avoid custom dialog sizing unless there is a clear product need.
|
||||
|
||||
## Buttons
|
||||
|
||||
`ha-button` wraps the Web Awesome button in `src/components/ha-button.ts`.
|
||||
|
||||
Axes:
|
||||
|
||||
- `variant`: `brand`, `neutral`, `danger`, `warning`, `success`.
|
||||
- `appearance`: `accent`, `filled`, `outlined`, `plain`.
|
||||
- `size`: `xs`, `s`, `m`, `l`, `xl`.
|
||||
|
||||
Common usage:
|
||||
|
||||
- Use `appearance="filled"` for primary emphasis when needed.
|
||||
- Use `appearance="plain"` for cancel and dismiss actions.
|
||||
- Use `variant="danger"` for destructive actions.
|
||||
- Place primary actions in `slot="primaryAction"` and secondary actions in `slot="secondaryAction"`.
|
||||
|
||||
## Forms
|
||||
|
||||
`ha-form` is schema-driven with `HaFormSchema[]` and supports common selectors for entities, devices, areas, targets, numbers, booleans, time, actions, text, objects, selects, icons, media, and location.
|
||||
|
||||
Use `computeLabel`, `computeError`, and `computeHelper` for translated labels, validation, and helper text.
|
||||
|
||||
```ts
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._data}
|
||||
.schema=${this._schema}
|
||||
.error=${this._errors}
|
||||
.computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
```
|
||||
|
||||
## Alerts
|
||||
|
||||
Use `ha-alert` for user-visible status messaging.
|
||||
|
||||
- Alert types: `error`, `warning`, `info`, `success`.
|
||||
- Useful properties: `title`, `alert-type`, `dismissable`, `narrow`.
|
||||
- Slots: `icon` for custom leading icon, `action` for custom action content.
|
||||
- Content is announced by screen readers when dynamically displayed.
|
||||
|
||||
```html
|
||||
<ha-alert alert-type="error">Error message</ha-alert>
|
||||
<ha-alert alert-type="warning" title="Warning">Description</ha-alert>
|
||||
<ha-alert alert-type="success" dismissable>Success message</ha-alert>
|
||||
```
|
||||
|
||||
## Shortcuts And Tooltips
|
||||
|
||||
Use `ShortcutManager` from `src/common/keyboard/shortcuts.ts` for keyboard shortcuts. It blocks shortcuts in input fields, can prevent shortcuts during text selection, and supports character and KeyCode shortcuts for non-latin keyboards. See `src/state/quick-bar-mixin.ts` for global shortcut examples.
|
||||
|
||||
Use `ha-tooltip` from `src/components/ha-tooltip.ts` for contextual hover help. See `src/components/ha-label.ts` for an example.
|
||||
|
||||
## Panels And Lovelace Cards
|
||||
|
||||
Panels commonly extend `SubscribeMixin(LitElement)` and receive route and narrow-layout properties.
|
||||
|
||||
Lovelace cards should implement `LovelaceCard`, validate config in `setConfig()`, handle loading, error, unavailable, and missing-entity states, and add a configuration editor when needed.
|
||||
|
||||
Cards are user-story surfaces. Support different households, entity types, responsive layouts, and accessible interaction states.
|
||||
@@ -1,78 +0,0 @@
|
||||
---
|
||||
name: ha-frontend-contexts
|
||||
description: Home Assistant frontend Lit context and hass migration guidance. Use when adding or changing component state access, replacing hass reads, consuming entity or registry contexts, or reviewing rerender behavior.
|
||||
---
|
||||
|
||||
# HA Frontend Contexts
|
||||
|
||||
Use this skill when a component reads Home Assistant state, registries, localization, services, config, UI data, connection state, or API helpers.
|
||||
|
||||
## Goal
|
||||
|
||||
Move leaf components away from the broad `hass: HomeAssistant` object. Broad `hass` access rerenders components for unrelated changes, hides the data a component depends on, and makes tests harder to mock.
|
||||
|
||||
Container components may keep `hass` when they own it and feed providers. Leaf components should consume the narrowest context that covers their reads.
|
||||
|
||||
## Core Files
|
||||
|
||||
- Context definitions: `src/data/context/index.ts`
|
||||
- Entity-scoped consume helpers: `src/common/decorators/consume-context-entry.ts`
|
||||
- Transform decorator: `src/common/decorators/transform.ts`
|
||||
- Canonical migration example: `src/panels/lovelace/cards/hui-button-card.ts`
|
||||
- Providers are wired by `contextMixin` on `HassBaseEl`; consumers do not wire providers manually.
|
||||
|
||||
## Context Selection
|
||||
|
||||
| Context | Replaces |
|
||||
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `statesContext` | `hass.states` |
|
||||
| `entitiesContext`, `devicesContext`, `areasContext`, `floorsContext` | `hass.entities`, `hass.devices`, `hass.areas`, `hass.floors` |
|
||||
| `registriesContext` | all four registries together |
|
||||
| `servicesContext` | `hass.services` |
|
||||
| `internationalizationContext` | `hass.localize`, `hass.locale`, `hass.language` |
|
||||
| `formattersContext` | entity and attribute formatters |
|
||||
| `configContext` | `hass.config`, `hass.user`, `hass.auth`, `hass.userData` |
|
||||
| `connectionContext` | `hass.connection`, `hass.connected`, `hass.hassUrl` |
|
||||
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
|
||||
| `uiContext` | themes, selected theme, panels, sidebar, and UI state |
|
||||
| `narrowViewportContext` | narrow-layout boolean |
|
||||
|
||||
Lazy contexts subscribe on first consumer and tear down after the last consumer: `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, and `manifestsContext`.
|
||||
|
||||
The single-field contexts such as `localizeContext`, `themesContext`, and `userContext` are deprecated. Use grouped contexts instead.
|
||||
|
||||
## Consumption Patterns
|
||||
|
||||
Use entity-scoped helpers when the component watches an entity id held on the host:
|
||||
|
||||
```ts
|
||||
@state() @consumeEntityState({ entityIdPath: ["_config", "entity"] })
|
||||
private _stateObj?: HassEntity;
|
||||
|
||||
@state() @consumeEntityRegistryEntry({ entityIdPath: ["_config", "entity"] })
|
||||
private _entity?: EntityRegistryDisplayEntry;
|
||||
|
||||
@state() @consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
```
|
||||
|
||||
For a single field from a grouped context, pair `@consume` with `@transform`:
|
||||
|
||||
```ts
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
@transform<HomeAssistantUI, Themes>({ transformer: ({ themes }) => themes })
|
||||
private _themes!: Themes;
|
||||
```
|
||||
|
||||
Use `@transform` with `watch` when the transformer depends on a host property, such as a computed entity id. `consumeEntityState` only watches the first path segment.
|
||||
|
||||
To consume a whole group untransformed, omit `@transform` and type the field as `ContextType<typeof statesContext>` or the matching context type.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- The component consumes the narrowest context needed for the data it reads.
|
||||
- A broad `hass` property is kept only when the component is a container or external API requires it.
|
||||
- Entity-scoped reads use the consume helpers rather than ad hoc context transforms.
|
||||
- Context fields are marked `@state()` so updates trigger rendering.
|
||||
- Tests and mocks only provide the data the component actually consumes.
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
name: ha-frontend-review
|
||||
description: Home Assistant frontend PR and review guidance. Use when reviewing frontend changes, preparing a PR, checking recurring review issues, or applying the PR template.
|
||||
---
|
||||
|
||||
# HA Frontend Review
|
||||
|
||||
Use this skill when reviewing Home Assistant frontend changes or preparing a pull request.
|
||||
|
||||
## Pull Request Body
|
||||
|
||||
When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body.
|
||||
|
||||
- Do not omit, reorder, or rewrite template sections.
|
||||
- Check the appropriate "Type of change" box based on the actual change.
|
||||
- Do not check checklist items on behalf of the user.
|
||||
- If the PR includes UI changes, remind the user to add screenshots or a short video.
|
||||
- Explain what the change does for users, not only implementation details.
|
||||
- Use Markdown.
|
||||
|
||||
## Pre-Submission Checklist
|
||||
|
||||
- `yarn lint` passes when practical for the scope.
|
||||
- `yarn test` or focused relevant tests are green when practical for the scope.
|
||||
- Tests are added or updated for new data processing and utilities where applicable.
|
||||
- User-facing text is localized and follows `ha-frontend-user-facing-text` guidance.
|
||||
- Components handle loading, error, unavailable, and missing-entity states.
|
||||
- Entity existence is checked before property access.
|
||||
- Event listeners and subscriptions are cleaned up.
|
||||
- UI is accessible to screen readers and keyboard users.
|
||||
|
||||
## Recurring Review Issues
|
||||
|
||||
User experience and accessibility:
|
||||
|
||||
- Forms need proper labels, helper text, and validation feedback.
|
||||
- Form markup should not cause password managers to identify fields incorrectly.
|
||||
- Clickable areas should be large enough for touch interaction.
|
||||
- Hover, active, disabled, loading, and focus states should be clear.
|
||||
|
||||
Dialog and modal patterns:
|
||||
|
||||
- Multi-step operations should show progress.
|
||||
- Dialog state should survive background operations correctly.
|
||||
- Cancel and close buttons should behave consistently.
|
||||
- Defaults should be helpful without blocking user override.
|
||||
|
||||
Component design patterns:
|
||||
|
||||
- Terminology should be consistent. Use words like "Join" or "Apply" instead of "Group" when that better matches the user action.
|
||||
- Visual hierarchy should use appropriate font sizes, weights, and spacing ratios.
|
||||
- Components should align to the design grid.
|
||||
- Badges and indicators should be placed consistently.
|
||||
|
||||
Code quality:
|
||||
|
||||
- Null and undefined paths should be handled explicitly.
|
||||
- Potentially undefined array and object access should be guarded.
|
||||
- Event handlers, timers, observers, and subscriptions should be cleaned up.
|
||||
|
||||
Configuration and props:
|
||||
|
||||
- Make configuration fields optional when sensible.
|
||||
- Provide reasonable defaults.
|
||||
- Keep APIs extensible without adding speculative abstractions.
|
||||
- Validate configuration before applying changes.
|
||||
|
||||
## Review Flow
|
||||
|
||||
- Identify behavioral regressions, bugs, accessibility issues, and missing tests first.
|
||||
- Keep style-only comments secondary unless they affect maintainability or user experience.
|
||||
- Prefer small, direct fixes over large refactors during review follow-up.
|
||||
- Cross-load `ha-frontend-contexts`, `ha-frontend-components`, `ha-frontend-styling`, `ha-frontend-testing`, or `ha-frontend-user-facing-text` when a finding falls in that area.
|
||||
@@ -1,78 +0,0 @@
|
||||
---
|
||||
name: ha-frontend-styling
|
||||
description: Home Assistant frontend styling, theming, spacing, responsive layout, RTL, and View Transitions guidance. Use when editing CSS, layout, motion, or visual component structure.
|
||||
---
|
||||
|
||||
# HA Frontend Styling
|
||||
|
||||
Use this skill when editing CSS, layout, visual hierarchy, theme integration, responsive behavior, RTL support, or view transitions.
|
||||
|
||||
## Theme And Layout Basics
|
||||
|
||||
- Use Home Assistant CSS custom properties instead of hardcoded colors.
|
||||
- Use `--ha-space-*` spacing tokens instead of hardcoded spacing where possible.
|
||||
- Keep components mobile-first and enhance for desktop.
|
||||
- Keep layouts RTL-safe. Prefer logical properties when they fit.
|
||||
- Prefer `ha-*` components and current Web Awesome wrappers.
|
||||
- Avoid adding new legacy Material Web Components (`mwc-*`).
|
||||
- Scope styles to the component. Do not rely on global styles for component internals.
|
||||
|
||||
Spacing tokens are defined in `src/resources/theme/core.globals.ts`. The scale runs from `--ha-space-1` at 4px through `--ha-space-20` at 80px in 4px increments. Common values are `--ha-space-2` at 8px, `--ha-space-4` at 16px, and `--ha-space-8` at 32px.
|
||||
|
||||
```ts
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
padding: var(--ha-space-4);
|
||||
color: var(--primary-text-color);
|
||||
background-color: var(--card-background-color);
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
:host {
|
||||
padding: var(--ha-space-2);
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
## Interaction States
|
||||
|
||||
- Make touch targets large enough for mobile.
|
||||
- Provide clear hover, active, focus, disabled, loading, error, and unavailable states.
|
||||
- Preserve keyboard navigation and visible focus indicators.
|
||||
- Maintain WCAG AA contrast for text and essential UI affordances.
|
||||
|
||||
## View Transitions
|
||||
|
||||
Use the View Transitions API only for meaningful continuity between DOM states.
|
||||
|
||||
Core resources:
|
||||
|
||||
- Utility wrapper: `src/common/util/view-transition.ts`, `withViewTransition()`.
|
||||
- Launch-screen fade example: `src/util/launch-screen.ts`.
|
||||
- Animation keyframes: `src/resources/theme/animations.globals.ts`.
|
||||
- Animation duration tokens: `src/resources/theme/core.globals.ts`.
|
||||
|
||||
Implementation rules:
|
||||
|
||||
- Use `withViewTransition()` for fallback behavior.
|
||||
- Keep transitions simple. Subtle fades and crossfades usually work best.
|
||||
- Use `--ha-animation-duration-fast`, `--ha-animation-duration-normal`, or `--ha-animation-duration-slow` for timing.
|
||||
- Ensure each `view-transition-name` is unique at any given time.
|
||||
- Remember only one view transition can run at a time.
|
||||
- View transitions operate at document level and do not work inside Shadow DOM style isolation. For web components, set `view-transition-name` on `:host` or use document-level transitions.
|
||||
- The root gets `view-transition-name: root` by default. Target `::view-transition-group(root)` to customize the default page transition.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- Styling uses theme variables and spacing tokens where practical.
|
||||
- Layout is mobile-first and RTL-safe.
|
||||
- Component styles are scoped.
|
||||
- Interactive states are clear and accessible.
|
||||
- Motion is subtle, tokenized, and respects reduced-motion behavior through existing globals.
|
||||
@@ -1,70 +0,0 @@
|
||||
---
|
||||
name: ha-frontend-testing
|
||||
description: Home Assistant frontend validation workflow. Use when running lint, TypeScript checks, Vitest, Playwright e2e suites, dev servers, or chart-data benchmarks.
|
||||
---
|
||||
|
||||
# HA Frontend Testing
|
||||
|
||||
Use this skill when choosing or running validation for frontend changes.
|
||||
|
||||
## Core Commands
|
||||
|
||||
```bash
|
||||
yarn lint # ESLint + Prettier + TypeScript + Lit
|
||||
yarn format # Auto-fix ESLint + Prettier
|
||||
yarn lint:types # TypeScript compiler, run without file arguments
|
||||
yarn test # Vitest
|
||||
yarn dev # App dev server
|
||||
yarn dev:serve # Local serving dev server
|
||||
```
|
||||
|
||||
Never run `tsc` or `yarn lint:types` with file arguments. File arguments make `tsc` ignore `tsconfig.json` and can emit `.js` files into `src/`.
|
||||
|
||||
For focused type feedback on one file, use editor diagnostics instead of a file-scoped `tsc` command.
|
||||
|
||||
## Unit And Utility Tests
|
||||
|
||||
- Add or update Vitest tests for data processing, utility code, and behavior that can be tested without a browser.
|
||||
- Mock WebSocket connections and API calls at boundaries.
|
||||
- Cover loading, error, unavailable, and missing-entity states where relevant.
|
||||
- Test accessibility-sensitive behavior when it can be asserted without brittle DOM internals.
|
||||
|
||||
## Dev Servers
|
||||
|
||||
`yarn dev` builds and watches the app, served by a running Home Assistant core configured through `development_repo`.
|
||||
|
||||
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. Default local serving port is 8124.
|
||||
|
||||
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`.
|
||||
|
||||
## Playwright E2E
|
||||
|
||||
Each suite has its own dev server port. Playwright reuses an existing server locally when the port is already running; otherwise it performs a slow full build. The rspack watcher recompiles on save, so reruns should not need a restart.
|
||||
|
||||
Start the relevant suite server, then run that suite:
|
||||
|
||||
| Suite | Server | Test command |
|
||||
| ------- | ------------------------------- | ----------------------- |
|
||||
| App | `yarn test:e2e:app:dev` on 8095 | `yarn test:e2e:app` |
|
||||
| Demo | `yarn dev:demo` on 8090 | `yarn test:e2e:demo` |
|
||||
| Gallery | `yarn dev:gallery` on 8100 | `yarn test:e2e:gallery` |
|
||||
|
||||
Server reuse and `--stop` use the `/__ha_dev_status` health check, so starting or stopping twice is harmless.
|
||||
|
||||
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites. Run suites directly; piping through output truncation hides progress and failures.
|
||||
|
||||
The app suite uses a stripped-down harness for e2e. Demo and gallery use their normal dev servers.
|
||||
|
||||
## Benchmarks
|
||||
|
||||
For chart data transforms such as history, statistics, energy, and downsampling, follow `test/benchmarks/README.md`.
|
||||
|
||||
Use seeded fixtures, characterization snapshot tests, and `yarn test:bench` before and after optimization. Optimizations must keep output bit-identical.
|
||||
|
||||
## Verification Selection
|
||||
|
||||
- Documentation-only change: no code test required unless examples or commands changed.
|
||||
- Type-only or utility change: run focused Vitest if available, then `yarn lint:types` if practical.
|
||||
- Lit component change: run relevant tests plus lint or typecheck depending on scope.
|
||||
- E2E-sensitive flow: start the relevant e2e dev server and run the narrow Playwright suite.
|
||||
- Broad refactor: run `yarn lint` and relevant test suites when practical.
|
||||
@@ -1,98 +0,0 @@
|
||||
---
|
||||
name: ha-frontend-user-facing-text
|
||||
description: Home Assistant frontend copy, localization, terminology, and user-facing text guidance. Use when adding or reviewing labels, buttons, dialogs, errors, translations, or UI strings.
|
||||
---
|
||||
|
||||
# HA Frontend User-Facing Text
|
||||
|
||||
Use this skill for all user-facing text, translations, labels, buttons, dialog copy, errors, helper text, and review comments about wording.
|
||||
|
||||
## Localization
|
||||
|
||||
- All user-facing text must be translatable.
|
||||
- Add translation keys to `src/translations/en.json` when introducing new strings.
|
||||
- Use the localization system instead of inline user-visible strings.
|
||||
- Prefer complete localized strings with placeholders over concatenating translated fragments.
|
||||
- Give translators enough context through key naming and placeholders.
|
||||
|
||||
```ts
|
||||
this.hass.localize("ui.panel.config.updates.update_available", {
|
||||
count: 5,
|
||||
});
|
||||
```
|
||||
|
||||
## Voice And Style
|
||||
|
||||
- Use American English.
|
||||
- Use a friendly, informational tone.
|
||||
- Address users directly with "you" and "your" when appropriate.
|
||||
- Be inclusive, objective, and non-discriminatory.
|
||||
- Be concise and clear.
|
||||
- Use active voice.
|
||||
- Avoid jargon where a familiar home automation term works.
|
||||
- Always write "Home Assistant" in full. Do not use "HA" or "HASS" in user-facing copy.
|
||||
- Spell out terms when possible.
|
||||
- Use sentence case for titles, headings, buttons, labels, and UI elements.
|
||||
- Use the Oxford comma in lists.
|
||||
- Prefer "like" over "e.g." and "for example" over "i.e.".
|
||||
- Avoid all caps for emphasis. Use wording, bold, or italics instead.
|
||||
- Write for both technical and non-technical users.
|
||||
|
||||
Sentence case examples:
|
||||
|
||||
- Use: "Create new automation"
|
||||
- Avoid: "Create New Automation"
|
||||
- Use: "Device settings"
|
||||
- Avoid: "Device Settings"
|
||||
|
||||
## Terminology
|
||||
|
||||
Use "integration" instead of "component" for user-facing product language unless referring to a frontend component in developer context.
|
||||
|
||||
Technical product terms are lowercase in prose: automation, entity, device, service.
|
||||
|
||||
## Delete, Remove, Create, Add
|
||||
|
||||
Use "Remove" for actions that can be restored or reapplied:
|
||||
|
||||
- Removing a user's permission.
|
||||
- Removing a user from a group.
|
||||
- Removing links between items.
|
||||
- Removing a widget from a dashboard.
|
||||
- Removing an item from a cart.
|
||||
|
||||
Use "Delete" for permanent, non-recoverable actions:
|
||||
|
||||
- Deleting a field.
|
||||
- Deleting a value in a field.
|
||||
- Deleting a task.
|
||||
- Deleting a group.
|
||||
- Deleting a permission.
|
||||
- Deleting a calendar event.
|
||||
|
||||
Use "Add" for already-existing items:
|
||||
|
||||
- Adding a permission to a user.
|
||||
- Adding a user to a group.
|
||||
- Adding links between items.
|
||||
- Adding a widget to a dashboard.
|
||||
- Adding an item to a cart.
|
||||
|
||||
Use "Create" for something made from scratch:
|
||||
|
||||
- Creating a new field.
|
||||
- Creating a new task.
|
||||
- Creating a new group.
|
||||
- Creating a new permission.
|
||||
- Creating a new calendar event.
|
||||
|
||||
Create pairs with Delete. Add pairs with Remove.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- Text is localized.
|
||||
- Copy uses sentence case.
|
||||
- "Home Assistant" is written in full.
|
||||
- Delete/Remove and Create/Add match recoverability and object lifecycle.
|
||||
- Placeholders are used instead of string concatenation.
|
||||
- The wording is concise and understandable without implementation knowledge.
|
||||
@@ -1,36 +0,0 @@
|
||||
[modern]
|
||||
# Modern builds target recent browsers supporting the latest features to minimize transpilation, polyfills, etc.
|
||||
# It is served to browsers meeting the following requirements:
|
||||
# - released in the last year + current alpha/beta versions
|
||||
# - Firefox extended support release (ESR)
|
||||
# - with global utilization at or above 0.5%
|
||||
# - exclude dead browsers (no security maintenance for 2+ years)
|
||||
# - exclude KaiOS, QQ, and UC browsers due to lack of sufficient feature support data
|
||||
unreleased versions
|
||||
last 1 year
|
||||
Firefox ESR
|
||||
>= 0.5%
|
||||
not dead
|
||||
not KaiOS > 0
|
||||
not QQAndroid > 0
|
||||
not UCAndroid > 0
|
||||
|
||||
[legacy]
|
||||
# Legacy builds are served when modern requirements are not met and support browsers:
|
||||
# - released in the last 7 years + current alpha/beta versionss
|
||||
# - with global utilization at or above 0.05%
|
||||
# - exclude dead browsers (no security maintenance for 2+ years)
|
||||
# - exclude Opera Mini which does not support web sockets
|
||||
unreleased versions
|
||||
last 7 years
|
||||
>= 0.05%
|
||||
not dead
|
||||
not op_mini all
|
||||
|
||||
[legacy-sw]
|
||||
# Same as legacy plus supports service workers
|
||||
unreleased versions
|
||||
last 7 years
|
||||
>= 0.05% and supports serviceworkers
|
||||
not dead
|
||||
not op_mini all
|
||||
@@ -1 +0,0 @@
|
||||
../.agents/skills
|
||||
@@ -1,6 +1,13 @@
|
||||
FROM mcr.microsoft.com/devcontainers/python:3.14
|
||||
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.148.1/containers/python-3/.devcontainer/base.Dockerfile
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/python:0-3.9
|
||||
|
||||
ENV \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
DEVCONTAINER=true \
|
||||
PATH=$PATH:./node_modules/.bin
|
||||
|
||||
# Install nvm
|
||||
COPY .nvmrc /tmp/.nvmrc
|
||||
RUN \
|
||||
su vscode -c \
|
||||
"source /usr/local/share/nvm/nvm.sh && nvm install $(cat /tmp/.nvmrc) 2>&1"
|
||||
@@ -5,45 +5,30 @@
|
||||
"context": ".."
|
||||
},
|
||||
"appPort": "8124:8123",
|
||||
"postCreateCommand": "./.devcontainer/post_create.sh",
|
||||
"postStartCommand": "script/bootstrap",
|
||||
"containerEnv": {
|
||||
"DEV_CONTAINER": "1",
|
||||
"WORKSPACE_DIRECTORY": "${containerWorkspaceFolder}"
|
||||
},
|
||||
"remoteEnv": {
|
||||
"NODE_OPTIONS": "--max_old_space_size=8192"
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"runem.lit-plugin",
|
||||
"github.vscode-pull-request-github",
|
||||
"eamodio.gitlens",
|
||||
"yeion7.styled-global-variables-autocomplete"
|
||||
],
|
||||
"settings": {
|
||||
"files.eol": "\n",
|
||||
"editor.tabSize": 2,
|
||||
"editor.formatOnPaste": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnType": true,
|
||||
"editor.renderWhitespace": "boundary",
|
||||
"editor.rulers": [80],
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"terminal.integrated.shell.linux": "/usr/bin/zsh",
|
||||
"gitlens.showWelcomeOnInstall": false,
|
||||
"gitlens.showWhatsNewAfterUpgrades": false,
|
||||
"workbench.startupEditor": "none"
|
||||
}
|
||||
}
|
||||
"context": "..",
|
||||
"postCreateCommand": "script/bootstrap",
|
||||
"extensions": [
|
||||
"github.vscode-pull-request-github",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"ms-vscode.vscode-typescript-tslint-plugin",
|
||||
"esbenp.prettier-vscode",
|
||||
"bierner.lit-html",
|
||||
"runem.lit-plugin",
|
||||
"ms-python.vscode-pylance"
|
||||
],
|
||||
"settings": {
|
||||
"terminal.integrated.shell.linux": "/bin/bash",
|
||||
"files.eol": "\n",
|
||||
"editor.tabSize": 2,
|
||||
"editor.formatOnPaste": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnType": true,
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"files.trimTrailingWhitespace": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# This script will run after the container is created
|
||||
|
||||
# add github cli
|
||||
(type -p wget >/dev/null || (sudo apt update && sudo apt-get install wget -y)) \
|
||||
&& sudo mkdir -p -m 755 /etc/apt/keyrings \
|
||||
&& out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
||||
&& cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
|
||||
&& sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
|
||||
|
||||
# Update package lists
|
||||
sudo apt-get update
|
||||
|
||||
sudo apt upgrade -y
|
||||
|
||||
# Install necessary packages
|
||||
sudo apt-get install -y libpcap-dev gh
|
||||
|
||||
# Display a message
|
||||
echo "Post-create script has been executed successfully."
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"extends": [
|
||||
"airbnb-base",
|
||||
"airbnb-typescript/base",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:wc/recommended",
|
||||
"plugin:lit/all",
|
||||
"prettier"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2020,
|
||||
"ecmaFeatures": {
|
||||
"modules": true
|
||||
},
|
||||
"sourceType": "module",
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"settings": {
|
||||
"import/resolver": {
|
||||
"webpack": {
|
||||
"config": "./webpack.config.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"globals": {
|
||||
"__DEV__": false,
|
||||
"__DEMO__": false,
|
||||
"__BUILD__": false,
|
||||
"__VERSION__": false,
|
||||
"__STATIC_PATH__": false,
|
||||
"__SUPERVISOR__": false,
|
||||
"Polymer": true
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true
|
||||
},
|
||||
"rules": {
|
||||
"class-methods-use-this": "off",
|
||||
"new-cap": "off",
|
||||
"prefer-template": "off",
|
||||
"object-shorthand": "off",
|
||||
"func-names": "off",
|
||||
"no-underscore-dangle": "off",
|
||||
"strict": "off",
|
||||
"no-plusplus": "off",
|
||||
"no-bitwise": "error",
|
||||
"comma-dangle": "off",
|
||||
"vars-on-top": "off",
|
||||
"no-continue": "off",
|
||||
"no-param-reassign": "off",
|
||||
"no-multi-assign": "off",
|
||||
"no-console": "error",
|
||||
"radix": "off",
|
||||
"no-alert": "off",
|
||||
"no-nested-ternary": "off",
|
||||
"prefer-destructuring": "off",
|
||||
"no-restricted-globals": [2, "event"],
|
||||
"prefer-promise-reject-errors": "off",
|
||||
"import/prefer-default-export": "off",
|
||||
"import/no-default-export": "off",
|
||||
"import/no-unresolved": "off",
|
||||
"import/no-cycle": "off",
|
||||
"import/extensions": [
|
||||
"error",
|
||||
"ignorePackages",
|
||||
{ "ts": "never", "js": "never" }
|
||||
],
|
||||
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
|
||||
"object-curly-newline": "off",
|
||||
"default-case": "off",
|
||||
"wc/no-self-class": "off",
|
||||
"no-shadow": "off",
|
||||
"@typescript-eslint/camelcase": "off",
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"@typescript-eslint/no-use-before-define": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/no-shadow": ["error"],
|
||||
"@typescript-eslint/naming-convention": [
|
||||
"off",
|
||||
{
|
||||
"selector": "default",
|
||||
"format": ["camelCase", "snake_case"],
|
||||
"leadingUnderscore": "allow",
|
||||
"trailingUnderscore": "allow"
|
||||
},
|
||||
{
|
||||
"selector": ["variable"],
|
||||
"format": ["camelCase", "snake_case", "UPPER_CASE"],
|
||||
"leadingUnderscore": "allow",
|
||||
"trailingUnderscore": "allow"
|
||||
},
|
||||
{
|
||||
"selector": "typeLike",
|
||||
"format": ["PascalCase"]
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"unused-imports/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"vars": "all",
|
||||
"varsIgnorePattern": "^_",
|
||||
"args": "after-used",
|
||||
"argsIgnorePattern": "^_",
|
||||
"ignoreRestSiblings": true
|
||||
}
|
||||
],
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"lit/attribute-value-entities": "off",
|
||||
"lit/no-template-map": "off"
|
||||
},
|
||||
"plugins": ["disable", "unused-imports"],
|
||||
"processor": "disable/disable"
|
||||
}
|
||||
@@ -51,7 +51,7 @@ DO NOT DELETE ANY TEXT from this template! Otherwise, your issue may be closed w
|
||||
<!--
|
||||
Provide details about the versions you are using, which helps us reproducing
|
||||
and finding the issue quicker. Version information is found in the
|
||||
Home Assistant frontend: Settings -> About.
|
||||
Home Assistant frontend: Configuration -> Info.
|
||||
|
||||
Browser version and operating system is important! Please try to replicate
|
||||
your issue in a different browser and be sure to include your findings.
|
||||
@@ -67,7 +67,7 @@ DO NOT DELETE ANY TEXT from this template! Otherwise, your issue may be closed w
|
||||
<!--
|
||||
If your issue is about how an entity is shown in the UI, please add the state
|
||||
and attributes for all situations with a screenshot of the UI.
|
||||
You can find this information at `/config/tools/state`
|
||||
You can find this information at `/developer-tools/state`
|
||||
-->
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Report a bug with the UI / Dashboards
|
||||
name: Report a bug with the UI, Frontend or Lovelace
|
||||
description: Report an issue related to the Home Assistant frontend.
|
||||
labels: bug
|
||||
body:
|
||||
@@ -7,11 +7,11 @@ body:
|
||||
value: |
|
||||
Make sure you are running the [latest version of Home Assistant][releases] before reporting an issue.
|
||||
|
||||
If you have a feature or enhancement request for the frontend, please [start a discussion][fr] instead of creating an issue.
|
||||
If you have a feature or enhancement request for the frontend, please [start an discussion][fr] instead of creating an issue.
|
||||
|
||||
**Please do not report issues for custom cards.**
|
||||
**Please not not report issues for custom Lovelace cards.**
|
||||
|
||||
[fr]: https://github.com/orgs/home-assistant/discussions
|
||||
[fr]: https://github.com/home-assistant/frontend/discussions
|
||||
[releases]: https://github.com/home-assistant/home-assistant/releases
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
@@ -24,7 +24,6 @@ body:
|
||||
required: true
|
||||
- label: I have tried a different browser to see if it is related to my browser.
|
||||
required: true
|
||||
- label: I have tried reproducing the issue in [safe mode](https://www.home-assistant.io/blog/2023/11/01/release-202311/#restarting-into-safe-mode) to rule out problems with unsupported custom resources.
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
@@ -65,7 +64,7 @@ body:
|
||||
label: What version of Home Assistant Core has the issue?
|
||||
placeholder: core-
|
||||
description: >
|
||||
Can be found in: [Settings -> About](https://my.home-assistant.io/redirect/info/).
|
||||
Can be found in the Configuration panel -> Info.
|
||||
- type: input
|
||||
attributes:
|
||||
label: What was the last working version of Home Assistant Core?
|
||||
@@ -74,7 +73,7 @@ body:
|
||||
If known, otherwise leave blank.
|
||||
- type: input
|
||||
attributes:
|
||||
label: In which browser are you experiencing the issue?
|
||||
label: In which browser are you experiencing the issue with?
|
||||
placeholder: Google Chrome 88.0.4324.150
|
||||
description: >
|
||||
Provide the full name and don't forget to add the version!
|
||||
@@ -94,8 +93,8 @@ body:
|
||||
label: State of relevant entities
|
||||
description: >
|
||||
If your issue is about how an entity is shown in the UI, please add the
|
||||
state and attributes for all situations. You can find this
|
||||
information in the Details view of the More info dialog.
|
||||
state and attributes for all situations. You can find this information
|
||||
at Developer Tools -> States.
|
||||
render: txt
|
||||
- type: textarea
|
||||
attributes:
|
||||
@@ -108,9 +107,9 @@ body:
|
||||
render: yaml
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: JavaScript errors shown in your browser console/inspector
|
||||
label: Javascript errors shown in your browser console/inspector
|
||||
description: >
|
||||
If you come across any JavaScript or other error logs, e.g., in your
|
||||
If you come across any Javascript or other error logs, e.g., in your
|
||||
browser console/inspector please provide them.
|
||||
render: txt
|
||||
- type: textarea
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Request a feature for the UI / Dashboards
|
||||
url: https://github.com/orgs/home-assistant/discussions
|
||||
about: Request a new feature for the Home Assistant frontend.
|
||||
- name: Discuss UI or UX design
|
||||
url: https://github.com/OpenHomeFoundation/ux-design/discussions
|
||||
about: Share design feedback and discuss visual or UX changes with the design team.
|
||||
- name: Report a bug that is NOT related to the UI / Dashboards
|
||||
- name: Request a feature for the UI, Frontend or Lovelace
|
||||
url: https://github.com/home-assistant/frontend/discussions/category_choices
|
||||
about: Request an new feature for the Home Assistant frontend.
|
||||
- name: Report a bug that is NOT related to the UI, Frontend or Lovelace
|
||||
url: https://github.com/home-assistant/core/issues
|
||||
about: This is the issue tracker for our frontend. Please report other issues in the backend ("core") repository.
|
||||
about: This is the issue tracker for our frontend. Please report other issues with the backend repository.
|
||||
- name: Report incorrect or missing information on our website
|
||||
url: https://github.com/home-assistant/home-assistant.io/issues
|
||||
about: Our documentation has its own issue tracker. Please report issues with the website there.
|
||||
- name: I have a question or need support
|
||||
url: https://www.home-assistant.io/help
|
||||
about: We use GitHub for tracking bugs. Check our website for resources on getting help.
|
||||
about: We use GitHub for tracking bugs, check our website for resources on getting help.
|
||||
- name: I'm unsure where to go
|
||||
url: https://www.home-assistant.io/join-chat
|
||||
about: If you are unsure where to go, then joining our chat is recommended; Just ask!
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Task
|
||||
description: For staff only - Create a task
|
||||
type: Task
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## ⚠️ RESTRICTED ACCESS
|
||||
|
||||
**This form is restricted to Open Home Foundation staff and authorized contributors only.**
|
||||
|
||||
If you are a community member wanting to contribute, please:
|
||||
- For bug reports: Use the [bug report form](https://github.com/home-assistant/frontend/issues/new?template=bug_report.yml)
|
||||
- For feature requests: Submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)
|
||||
|
||||
---
|
||||
|
||||
### For authorized contributors
|
||||
|
||||
Use this form to create tasks for development work, improvements, or other actionable items that need to be tracked.
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
Provide a clear and detailed description of the task that needs to be accomplished.
|
||||
|
||||
Be specific about what needs to be done, why it's important, and any constraints or requirements.
|
||||
placeholder: |
|
||||
Describe the task, including:
|
||||
- What needs to be done
|
||||
- Why this task is needed
|
||||
- Expected outcome
|
||||
- Any constraints or requirements
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional_context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: |
|
||||
Any additional information, links, research, or context that would be helpful.
|
||||
|
||||
Include links to related issues, research, prototypes, roadmap opportunities etc.
|
||||
placeholder: |
|
||||
- Roadmap opportunity: [link]
|
||||
- Epic: [link]
|
||||
- Feature request: [link]
|
||||
- Technical design documents: [link]
|
||||
- Prototype/mockup: [link]
|
||||
- Dependencies: [links]
|
||||
validations:
|
||||
required: false
|
||||
@@ -2,7 +2,9 @@
|
||||
You are amazing! Thanks for contributing to our project!
|
||||
Please, DO NOT DELETE ANY TEXT from this template! (unless instructed).
|
||||
-->
|
||||
|
||||
## Breaking change
|
||||
|
||||
<!--
|
||||
If your PR contains a breaking change for existing users, it is important
|
||||
to tell them what breaks, how to make it work again and why we did this.
|
||||
@@ -11,8 +13,8 @@
|
||||
Note: Remove this section if this PR is NOT a breaking change.
|
||||
-->
|
||||
|
||||
|
||||
## Proposed change
|
||||
|
||||
<!--
|
||||
Describe the big picture of your changes here to communicate to the
|
||||
maintainers why we should accept this pull request. If it fixes a bug
|
||||
@@ -20,16 +22,8 @@
|
||||
in the additional information section.
|
||||
-->
|
||||
|
||||
|
||||
## Screenshots
|
||||
<!--
|
||||
If your PR includes visual changes, please add screenshots or a short video
|
||||
showing the before and after. This helps reviewers understand the impact of
|
||||
your changes.
|
||||
Note: Remove this section if this PR has no visual changes.
|
||||
-->
|
||||
|
||||
## Type of change
|
||||
|
||||
<!--
|
||||
What type of change does your PR introduce to the Home Assistant frontend?
|
||||
NOTE: Please, check only 1! box!
|
||||
@@ -43,7 +37,19 @@
|
||||
- [ ] Breaking change (fix/feature causing existing functionality to break)
|
||||
- [ ] Code quality improvements to existing code or addition of tests
|
||||
|
||||
## Example configuration
|
||||
|
||||
<!--
|
||||
Supplying a configuration snippet, makes it easier for a maintainer to test
|
||||
your PR.
|
||||
-->
|
||||
|
||||
```yaml
|
||||
|
||||
```
|
||||
|
||||
## Additional information
|
||||
|
||||
<!--
|
||||
Details are important, and help maintainers processing your PR.
|
||||
Please be sure to fill out additional details, if applicable.
|
||||
@@ -52,57 +58,26 @@
|
||||
- This PR fixes or closes issue: fixes #
|
||||
- This PR is related to issue or discussion:
|
||||
- Link to documentation pull request:
|
||||
- Link to developer documentation pull request:
|
||||
- Link to backend pull request:
|
||||
|
||||
## Checklist
|
||||
|
||||
<!--
|
||||
Put an `x` in the boxes that apply. You can also fill these out after
|
||||
creating the PR. If you're unsure about any of them, don't hesitate to ask.
|
||||
We're here to help! This is simply a reminder of what we are going to look
|
||||
for before merging your code.
|
||||
|
||||
AI tools are welcome, but contributors are responsible for *fully*
|
||||
understanding the code before submitting a PR.
|
||||
-->
|
||||
|
||||
- [ ] I understand the code I am submitting and can explain how it works.
|
||||
- [ ] The code change is tested and works locally.
|
||||
- [ ] There is no commented out code in this PR.
|
||||
- [ ] I have followed the [perfect PR recommendations][perfect-pr]
|
||||
- [ ] Any generated code has been carefully reviewed for correctness and compliance with project standards.
|
||||
- [ ] Tests have been added to verify that the new code works.
|
||||
|
||||
If user exposed functionality or configuration variables are added/changed:
|
||||
|
||||
- [ ] Documentation added/updated for [www.home-assistant.io][docs-repository]
|
||||
|
||||
<!--
|
||||
This project is very active and we have a high turnover of pull requests.
|
||||
|
||||
Unfortunately, the number of incoming pull requests is higher than what our
|
||||
reviewers can review and merge so there is a long backlog of pull requests
|
||||
waiting for review. You can help here!
|
||||
|
||||
By reviewing another pull request, you will help raise the code quality of
|
||||
that pull request and the final review will be faster. This way the general
|
||||
pace of pull request reviews will go up and your wait time will go down.
|
||||
|
||||
When picking a pull request to review, try to choose one that hasn't yet
|
||||
been reviewed.
|
||||
|
||||
Thanks for helping out!
|
||||
-->
|
||||
|
||||
To help with the load of incoming pull requests:
|
||||
|
||||
- [ ] I have reviewed two other [open pull requests][prs] in this repository.
|
||||
|
||||
[prs]: https://github.com/home-assistant/frontend/pulls?q=is%3Aopen+is%3Apr+-author%3A%40me+-draft%3Atrue+sort%3Acreated-desc+review%3Anone+-status%3Afailure
|
||||
|
||||
<!--
|
||||
Thank you for contributing <3
|
||||
|
||||
Below, some useful links you could explore:
|
||||
-->
|
||||
|
||||
[docs-repository]: https://github.com/home-assistant/home-assistant.io
|
||||
[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
name: Build frontend target
|
||||
description: Run a gulp build target
|
||||
|
||||
inputs:
|
||||
target:
|
||||
description: gulp target to run
|
||||
required: true
|
||||
github-token:
|
||||
description: GitHub token for fetching nightly translations; omit to build English-only
|
||||
default: ""
|
||||
is-test:
|
||||
description: Set IS_TEST for the build (skips source maps and compression)
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Build ${{ inputs.target }}
|
||||
shell: bash
|
||||
run: ./node_modules/.bin/gulp ${{ inputs.target }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.github-token }}
|
||||
IS_TEST: ${{ inputs.is-test }}
|
||||
@@ -1,40 +0,0 @@
|
||||
name: Deploy to Netlify
|
||||
description: Deploy a directory to Netlify (production when alias is empty, otherwise to the alias)
|
||||
|
||||
inputs:
|
||||
dir:
|
||||
description: Directory to deploy
|
||||
required: true
|
||||
alias:
|
||||
description: Deploy alias; leave empty to deploy to production
|
||||
default: ""
|
||||
auth-token:
|
||||
description: NETLIFY_AUTH_TOKEN
|
||||
required: true
|
||||
site-id:
|
||||
description: NETLIFY_SITE_ID
|
||||
required: true
|
||||
|
||||
outputs:
|
||||
netlify_url:
|
||||
description: The deployed URL
|
||||
value: ${{ steps.deploy.outputs.netlify_url }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
shell: bash
|
||||
env:
|
||||
DIR: ${{ inputs.dir }}
|
||||
ALIAS: ${{ inputs.alias }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ inputs.auth-token }}
|
||||
NETLIFY_SITE_ID: ${{ inputs.site-id }}
|
||||
run: |
|
||||
if [ -n "$ALIAS" ]; then
|
||||
npx -y netlify-cli deploy --dir="$DIR" --alias "$ALIAS" --json > deploy_output.json
|
||||
else
|
||||
npx -y netlify-cli deploy --dir="$DIR" --prod --json > deploy_output.json
|
||||
fi
|
||||
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
|
||||
@@ -1,23 +0,0 @@
|
||||
name: Setup Node and install
|
||||
description: Set up Node from .nvmrc and install yarn dependencies
|
||||
|
||||
inputs:
|
||||
immutable:
|
||||
description: Pass --immutable to yarn install
|
||||
default: "true"
|
||||
cache:
|
||||
description: Enable the yarn cache in setup-node
|
||||
default: "true"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: ${{ inputs.cache == 'true' && 'yarn' || '' }}
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: yarn install ${{ inputs.immutable == 'true' && '--immutable' || '' }}
|
||||
@@ -1 +0,0 @@
|
||||
../AGENTS.md
|
||||
@@ -1,20 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
# Dependabot only scans .github/workflows by default; composite actions
|
||||
# under .github/actions must be listed explicitly to stay updated.
|
||||
# https://github.com/dependabot/dependabot-core/issues/6704
|
||||
directories:
|
||||
- "/"
|
||||
- "/.github/actions/setup"
|
||||
- "/.github/actions/build"
|
||||
- "/.github/actions/netlify-deploy"
|
||||
schedule:
|
||||
interval: weekly
|
||||
time: "06:00"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- Dependencies
|
||||
- GitHub Actions
|
||||
@@ -1,77 +0,0 @@
|
||||
Build:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- build-scripts/**
|
||||
- .browserslistrc
|
||||
- gulpfile.js
|
||||
|
||||
Cast:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- cast/src/**
|
||||
- src/cast/**
|
||||
|
||||
Demo:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- demo/src/**
|
||||
- src/fake_data/**
|
||||
|
||||
Design:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- gallery/src/**
|
||||
- src/fake_data/**
|
||||
|
||||
Dependencies:
|
||||
- any:
|
||||
- changed-files:
|
||||
# Match when only these files are changed (i.e. don't match PRs that happen to add or remove packages)
|
||||
- any-glob-to-all-files:
|
||||
- package.json
|
||||
- renovate.json
|
||||
- yarn.lock
|
||||
- .yarn/**
|
||||
- .yarnrc.yml
|
||||
- .nvmrc
|
||||
# Dependabot and Renovate branches always match (i.e. compatibility tweaks by members considered minor)
|
||||
- head-branch:
|
||||
- "^renovate/"
|
||||
- "^dependabot/"
|
||||
|
||||
GitHub Actions:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- .github/actions/**
|
||||
- .github/workflows/**
|
||||
- .github/*.yml
|
||||
|
||||
"Tests: E2E":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- test/e2e/**
|
||||
|
||||
"Tests: App":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- test/e2e/app.spec.ts
|
||||
- test/e2e/app/**
|
||||
- test/e2e/playwright.app.config.ts
|
||||
|
||||
"Tests: Demo":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- test/e2e/demo.spec.ts
|
||||
- test/e2e/playwright.demo.config.ts
|
||||
|
||||
"Tests: Design":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- test/e2e/gallery.spec.ts
|
||||
- test/e2e/playwright.gallery.config.ts
|
||||
|
||||
"Tests: Unit":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- test/*.ts
|
||||
- "test/!(e2e)/**"
|
||||
@@ -1,8 +1,3 @@
|
||||
categories:
|
||||
- title: "Dependency updates"
|
||||
collapse-after: 3
|
||||
labels:
|
||||
- "Dependencies"
|
||||
template: |
|
||||
## What's Changed
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Fails the check when a pull request carries a label that blocks merging, and
|
||||
// writes the outcome to the job summary. Invoked from the `check` job in
|
||||
// .github/workflows/blocking-labels.yaml via actions/github-script:
|
||||
//
|
||||
// const { default: checkBlockingLabels } =
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`);
|
||||
// await checkBlockingLabels({ github, context, core });
|
||||
|
||||
export default async function checkBlockingLabels({ context, core }) {
|
||||
const blockingLabels = [
|
||||
"wait for backend",
|
||||
"Needs UX",
|
||||
"Do Not Review",
|
||||
"Blocked",
|
||||
"has-parent",
|
||||
];
|
||||
const prLabels = context.payload.pull_request.labels.map((l) => l.name);
|
||||
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
|
||||
if (found.length > 0) {
|
||||
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
|
||||
await core.summary
|
||||
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
|
||||
.addRaw(message)
|
||||
.write();
|
||||
core.setFailed(message);
|
||||
} else {
|
||||
await core.summary
|
||||
.addHeading(
|
||||
":white_check_mark: Pull Request is clear to merge after review",
|
||||
2
|
||||
)
|
||||
.addRaw(
|
||||
"This Pull Request is not blocked by any labels which prevent it from being merged."
|
||||
)
|
||||
.write();
|
||||
}
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Checks that a pull request follows the contribution standards: it must use the
|
||||
// PR template, tick exactly one "Type of change" option, and describe the change.
|
||||
// Labels and comments the PR when it does not, and fails the check so it blocks
|
||||
// merging. Invoked from the `check` job in .github/workflows/pull-request-standards.yaml
|
||||
// via actions/github-script:
|
||||
//
|
||||
// const { default: checkStandards } =
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`);
|
||||
// await checkStandards({ github, context, core });
|
||||
|
||||
export default async function checkPullRequestStandards({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
}) {
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
// Exempt bots (Copilot agent, dependabot), drafts, and maintainers.
|
||||
if (pr.user.type === "Bot") {
|
||||
core.info(`Skipping bot author: ${pr.user.login}`);
|
||||
return;
|
||||
}
|
||||
if (pr.draft) {
|
||||
core.info("Skipping draft pull request");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await github.rest.orgs.checkMembershipForUser({
|
||||
org: "home-assistant",
|
||||
username: pr.user.login,
|
||||
});
|
||||
core.info(`Skipping organization member: ${pr.user.login}`);
|
||||
return;
|
||||
} catch (_error) {
|
||||
core.info(
|
||||
`${pr.user.login} is not an organization member, checking standards`
|
||||
);
|
||||
}
|
||||
|
||||
const label = "Needs Template";
|
||||
const marker = "<!-- pr-standards-check -->";
|
||||
const { owner, repo } = context.repo;
|
||||
const issue_number = pr.number;
|
||||
|
||||
let body = pr.body || "";
|
||||
let previous;
|
||||
do {
|
||||
previous = body;
|
||||
body = body.replace(/<!--[\s\S]*?-->/g, "");
|
||||
} while (body !== previous);
|
||||
const normalized = body.toLowerCase();
|
||||
|
||||
// Ignore 404s from mutations that race manual edits or cancelled runs.
|
||||
const ignoreMissing = async (fn) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.info("Target already removed, nothing to do");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Hide/restore our comment via GraphQL (REST cannot minimize).
|
||||
const setMinimized = async (subjectId, minimized) => {
|
||||
const mutation = minimized
|
||||
? `mutation($id: ID!) {
|
||||
minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) {
|
||||
clientMutationId
|
||||
}
|
||||
}`
|
||||
: `mutation($id: ID!) {
|
||||
unminimizeComment(input: { subjectId: $id }) {
|
||||
clientMutationId
|
||||
}
|
||||
}`;
|
||||
try {
|
||||
await github.graphql(mutation, { id: subjectId });
|
||||
} catch (error) {
|
||||
core.info(
|
||||
`Could not ${minimized ? "minimize" : "restore"} comment: ${error.message}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Content of a "## <name>" section, or null when the heading is absent.
|
||||
const section = (name) => {
|
||||
const match = body.match(
|
||||
new RegExp(`##\\s${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i")
|
||||
);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
const problems = [];
|
||||
|
||||
const requiredHeadings = [
|
||||
"## proposed change",
|
||||
"## type of change",
|
||||
"## checklist",
|
||||
];
|
||||
if (requiredHeadings.some((h) => !normalized.includes(h))) {
|
||||
problems.push(
|
||||
"Use the pull request template without removing its sections."
|
||||
);
|
||||
}
|
||||
|
||||
const typeOfChange = section("type of change");
|
||||
if (typeOfChange !== null) {
|
||||
const ticked = (typeOfChange.match(/-\s*\[[xX]\]/g) || []).length;
|
||||
if (ticked !== 1) {
|
||||
problems.push('Select exactly one option under "Type of change".');
|
||||
}
|
||||
}
|
||||
|
||||
const proposedChange = section("proposed change");
|
||||
if (proposedChange !== null && proposedChange.trim().length === 0) {
|
||||
problems.push('Describe your changes under "Proposed change".');
|
||||
}
|
||||
|
||||
const isValid = problems.length === 0;
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
per_page: 100,
|
||||
});
|
||||
const existing = comments.find((c) => c.body.includes(marker));
|
||||
const hasLabel = pr.labels.some((l) => l.name === label);
|
||||
|
||||
if (isValid) {
|
||||
core.info("Pull request standards met");
|
||||
|
||||
if (hasLabel) {
|
||||
await ignoreMissing(() =>
|
||||
github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
name: label,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (existing) {
|
||||
await setMinimized(existing.node_id, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
|
||||
|
||||
if (!hasLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
labels: [label],
|
||||
});
|
||||
}
|
||||
|
||||
const message =
|
||||
`${marker}\n` +
|
||||
`Hey @${pr.user.login}!\n\n` +
|
||||
`Thank you for your contribution! To help reviewers, please update ` +
|
||||
`this pull request to follow our pull request standards:\n\n` +
|
||||
problems.map((p) => `- ${p}`).join("\n") +
|
||||
`\n\n` +
|
||||
`Please complete the ` +
|
||||
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
|
||||
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
|
||||
`for more on creating a great pull request (see point 6).`;
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body: message,
|
||||
});
|
||||
await setMinimized(existing.node_id, false);
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body: message,
|
||||
});
|
||||
}
|
||||
|
||||
// Fail this check so it can block the PR from being merged
|
||||
core.setFailed(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Restricts Task issues to organization members: closes and labels the issue with
|
||||
// an explanatory comment when the author is not an org member. Invoked from the
|
||||
// `check-authorization` job in .github/workflows/restrict-task-creation.yaml via
|
||||
// actions/github-script:
|
||||
//
|
||||
// const { default: checkTaskAuthorization } =
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`);
|
||||
// await checkTaskAuthorization({ github, context, core });
|
||||
|
||||
export default async function checkTaskAuthorization({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
}) {
|
||||
const issueAuthor = context.payload.issue.user.login;
|
||||
|
||||
// Check if user is an organization member
|
||||
try {
|
||||
await github.rest.orgs.checkMembershipForUser({
|
||||
org: "home-assistant",
|
||||
username: issueAuthor,
|
||||
});
|
||||
core.info(`✅ ${issueAuthor} is an organization member`);
|
||||
return; // Authorized
|
||||
} catch (_error) {
|
||||
core.info(`❌ ${issueAuthor} is not authorized to create Task issues`);
|
||||
}
|
||||
|
||||
// Close the issue with a comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body:
|
||||
`Hi @${issueAuthor}, thank you for your contribution!\n\n` +
|
||||
`Task issues are restricted to Open Home Foundation staff and authorized contributors.\n\n` +
|
||||
`If you would like to:\n` +
|
||||
`- Report a bug: Please use the [bug report form](https://github.com/home-assistant/frontend/issues/new?template=bug_report.yml)\n` +
|
||||
`- Request a feature: Please submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)\n\n` +
|
||||
`If you believe you should have access to create Task issues, please contact the maintainers.`,
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
state: "closed",
|
||||
});
|
||||
|
||||
// Add a label to indicate this was auto-closed
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ["auto-closed"],
|
||||
});
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
name: Lint workflow files
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
paths:
|
||||
- ".github/actions/**"
|
||||
- ".github/workflows/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
paths:
|
||||
- ".github/actions/**"
|
||||
- ".github/workflows/**"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
actionlint:
|
||||
name: Check workflow files
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# renovate: datasource=github-releases depName=rhysd/actionlint
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run actionlint
|
||||
run: |
|
||||
curl -sSfL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz actionlint
|
||||
./actionlint -color
|
||||
@@ -1,35 +0,0 @@
|
||||
name: Blocking labels
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- labeled
|
||||
- unlabeled
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check for labels which block the Pull Request from being merged
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
- name: Check for blocking labels
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { default: checkBlockingLabels } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`
|
||||
);
|
||||
await checkBlockingLabels({ github, context, core });
|
||||
@@ -1,79 +0,0 @@
|
||||
name: Cast deployment
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
jobs:
|
||||
deploy_dev:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy Development
|
||||
if: github.event_name != 'push'
|
||||
environment:
|
||||
name: Cast Development
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Cast
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-cast
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: cast/dist
|
||||
alias: dev
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_CAST_SITE_ID }}
|
||||
|
||||
deploy_master:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy Production
|
||||
if: github.event_name == 'push'
|
||||
environment:
|
||||
name: Cast Production
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Cast
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-cast
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: cast/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_CAST_SITE_ID }}
|
||||
+59
-75
@@ -11,103 +11,87 @@ on:
|
||||
- master
|
||||
|
||||
env:
|
||||
NODE_VERSION: 14
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint and check format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Node ${{ env.NODE_VERSION }}
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: yarn
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
env:
|
||||
CI: true
|
||||
- name: Build resources
|
||||
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-demos
|
||||
- name: Run eslint
|
||||
run: yarn run lint:eslint
|
||||
- name: Run tsc
|
||||
run: yarn run lint:types
|
||||
- name: Run prettier
|
||||
run: yarn run lint:prettier
|
||||
- name: Check for duplicate dependencies
|
||||
run: yarn dedupe --check
|
||||
- name: Build resources
|
||||
id: build_resources
|
||||
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup lint cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules/.cache/prettier
|
||||
node_modules/.cache/eslint
|
||||
node_modules/.cache/typescript
|
||||
key: lint-${{ github.sha }}
|
||||
restore-keys: lint-
|
||||
- name: Run eslint
|
||||
run: yarn run lint:eslint --quiet
|
||||
- name: Run tsc
|
||||
if: ${{ !cancelled() && steps.build_resources.outcome == 'success' }}
|
||||
run: yarn run lint:types
|
||||
- name: Run lit-analyzer
|
||||
if: ${{ !cancelled() && steps.build_resources.outcome == 'success' }}
|
||||
run: yarn run lint:lit --quiet
|
||||
- name: Run prettier
|
||||
if: ${{ !cancelled() && steps.build_resources.outcome == 'success' }}
|
||||
run: yarn run lint:prettier
|
||||
- name: Check dependency licenses
|
||||
if: ${{ !cancelled() && steps.build_resources.outcome == 'success' }}
|
||||
run: yarn run lint:licenses
|
||||
test:
|
||||
name: Run tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Node ${{ env.NODE_VERSION }}
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build resources
|
||||
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: yarn
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CI: true
|
||||
- name: Build resources
|
||||
run: ./node_modules/.bin/gulp build-translations build-locale-data
|
||||
- name: Run Tests
|
||||
run: yarn run test
|
||||
build:
|
||||
name: Build frontend
|
||||
needs:
|
||||
- lint
|
||||
- test
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test]
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Node ${{ env.NODE_VERSION }}
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: yarn
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
env:
|
||||
CI: true
|
||||
- name: Build Application
|
||||
uses: ./.github/actions/build
|
||||
run: ./node_modules/.bin/gulp build-app
|
||||
env:
|
||||
IS_TEST: "true"
|
||||
supervisor:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test]
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Node ${{ env.NODE_VERSION }}
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
target: build-app
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
- name: Upload bundle stats
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-bundle-stats
|
||||
path: build/stats/*.json
|
||||
if-no-files-found: error
|
||||
- name: Check entrypoint bundle size budget
|
||||
run: yarn run check-bundlesize
|
||||
- name: Upload frontend build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-build
|
||||
path: hass_frontend/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: yarn
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
env:
|
||||
CI: true
|
||||
- name: Build Application
|
||||
run: ./node_modules/.bin/gulp build-hassio
|
||||
env:
|
||||
IS_TEST: "true"
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches:
|
||||
- dev
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 360
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
security-events: write # To upload CodeQL results
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
@@ -0,0 +1,60 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, master]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [dev]
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Override automatic language detection by changing the below list
|
||||
# Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']
|
||||
language: ['javascript']
|
||||
# Learn more...
|
||||
# https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
fetch-depth: 2
|
||||
|
||||
# If this run was triggered by a pull request event, then checkout
|
||||
# the head of the pull request instead of the merge commit.
|
||||
- run: git checkout HEAD^2
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Demo
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
env:
|
||||
NODE_VERSION: 14
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Node ${{ env.NODE_VERSION }}
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: yarn
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
env:
|
||||
CI: true
|
||||
- name: Build Demo
|
||||
run: ./node_modules/.bin/gulp build-demo
|
||||
- name: Deploy to Netlify
|
||||
uses: netlify/actions/cli@master
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }}
|
||||
with:
|
||||
args: deploy --dir=demo/dist --prod
|
||||
@@ -1,79 +0,0 @@
|
||||
name: Demo deployment
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
jobs:
|
||||
deploy_dev:
|
||||
runs-on: ubuntu-latest
|
||||
name: Demo Development
|
||||
if: github.event_name != 'push' || github.ref_name != 'master'
|
||||
environment:
|
||||
name: Demo Development
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Demo
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-demo
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: demo/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }}
|
||||
|
||||
deploy_master:
|
||||
runs-on: ubuntu-latest
|
||||
name: Demo Production
|
||||
if: github.event_name == 'push' && github.ref_name == 'master'
|
||||
environment:
|
||||
name: Demo Production
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Demo
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-demo
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: demo/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_DEMO_SITE_ID }}
|
||||
@@ -1,41 +0,0 @@
|
||||
name: Design deployment
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: Design
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Gallery
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-gallery
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: gallery/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
|
||||
@@ -1,50 +0,0 @@
|
||||
name: Design preview
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- labeled
|
||||
branches:
|
||||
- dev
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
jobs:
|
||||
preview:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip running on forks since it won't have access to secrets
|
||||
# Skip running PRs without 'needs design preview' label
|
||||
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Gallery
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-gallery
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy preview to Netlify
|
||||
id: deploy
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: gallery/dist
|
||||
alias: deploy-preview-${{ github.event.number }}
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
|
||||
|
||||
- name: Generate summary
|
||||
run: echo "${{ steps.deploy.outputs.netlify_url }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,219 +0,0 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ── Build the demo once and share it across test jobs via artifact ──────────
|
||||
build-demo:
|
||||
name: Build demo
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build demo
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-demo
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload demo build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
# ── Build the e2e test app and share it via artifact ────────────────────────
|
||||
build-e2e-test-app:
|
||||
name: Build e2e test app
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build e2e test app
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-e2e-test-app
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload e2e test app build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
# ── Build the gallery and share it via artifact ─────────────────────────────
|
||||
build-gallery:
|
||||
name: Build gallery
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build gallery
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-gallery
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload gallery build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
# ── Run Playwright tests locally against Chromium ──────────────────────────
|
||||
e2e-local:
|
||||
name: E2E (local Chromium)
|
||||
needs: [build-demo, build-e2e-test-app, build-gallery]
|
||||
runs-on: ubuntu-latest
|
||||
# Fail fast if anything hangs. The whole suite should take < 15 minutes on
|
||||
# Chromium; anything longer is almost certainly an install or webServer
|
||||
# hang.
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
# Resolve the installed Playwright version so the browser cache tracks
|
||||
# Playwright itself, not every unrelated dependency bump.
|
||||
- name: Resolve Playwright version
|
||||
id: playwright-version
|
||||
run: echo "version=$(node -p 'require("@playwright/test/package.json").version')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Cache the downloaded browser build keyed on the installed Playwright
|
||||
# version, so re-runs skip the ~170 MB download unless Playwright changes.
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: yarn playwright install --with-deps chromium
|
||||
timeout-minutes: 10
|
||||
|
||||
- name: Download demo build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
|
||||
- name: Download e2e test app build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
|
||||
- name: Download gallery build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
|
||||
- name: Run Playwright tests (local)
|
||||
run: yarn test:e2e
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload blob report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-local
|
||||
path: test/e2e/reports/
|
||||
retention-days: 3
|
||||
|
||||
# ── Merge local blob reports and post PR comment ───────────────────────────
|
||||
report:
|
||||
name: Report
|
||||
needs: [e2e-local]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Download blob report (local)
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: blob-report-local
|
||||
path: test/e2e/reports/
|
||||
|
||||
- name: Stage blobs for merge
|
||||
run: node test/e2e/collect-blob-reports.mjs
|
||||
|
||||
- name: Merge blob reports
|
||||
run: npx playwright merge-reports -c test/e2e/playwright.merge.config.ts test/e2e/reports/blob
|
||||
|
||||
- name: Upload merged HTML report
|
||||
id: upload-report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report
|
||||
path: test/e2e/reports/combined/
|
||||
retention-days: 14
|
||||
|
||||
- name: Post report to PR
|
||||
if: github.event_name == 'pull_request' && needs.e2e-local.result == 'failure'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { default: postReportComment } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/test/e2e/post-report-comment.mjs`
|
||||
);
|
||||
await postReportComment({ github, context, core });
|
||||
@@ -1,15 +0,0 @@
|
||||
name: "Pull Request Labeler"
|
||||
|
||||
on: pull_request_target # zizmor: ignore[dangerous-triggers] -- safe: only runs actions/labeler, no PR code checkout
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Apply labels
|
||||
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
||||
with:
|
||||
sync-labels: true
|
||||
@@ -1,23 +0,0 @@
|
||||
name: Lock
|
||||
|
||||
# yamllint disable-line rule:truthy
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 * * * *"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
lock:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
process-only: "issues, prs"
|
||||
issue-inactive-days: "30"
|
||||
issue-lock-reason: ""
|
||||
pr-inactive-days: "1"
|
||||
pr-lock-reason: ""
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Lock
|
||||
|
||||
# yamllint disable-line rule:truthy
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 * * * *"
|
||||
|
||||
jobs:
|
||||
lock:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v2.0.1
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
issue-lock-inactive-days: "30"
|
||||
issue-exclude-created-before: "2020-10-01T00:00:00Z"
|
||||
issue-lock-reason: ""
|
||||
pr-lock-inactive-days: "1"
|
||||
pr-exclude-created-before: "2020-11-01T00:00:00Z"
|
||||
pr-lock-reason: ""
|
||||
@@ -0,0 +1,19 @@
|
||||
name: Netlify
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
|
||||
jobs:
|
||||
trigger_builds:
|
||||
name: Trigger netlify build preview
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- name: Trigger Cast build
|
||||
run: curl -X POST -d {} https://api.netlify.com/build_hooks/${{ secrets.NETLIFY_CAST_DEV_BUILD_HOOK }}
|
||||
|
||||
- name: Trigger Demo build
|
||||
run: curl -X POST -d {} https://api.netlify.com/build_hooks/${{ secrets.NETLIFY_DEMO_DEV_BUILD_HOOK }}
|
||||
|
||||
- name: Trigger Gallery build
|
||||
run: curl -X POST -d {} https://api.netlify.com/build_hooks/${{ secrets.NETLIFY_GALLERY_DEV_BUILD_HOOK }}
|
||||
@@ -1,69 +0,0 @@
|
||||
name: Nightly
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 1 * * *"
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: "3.14"
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
permissions:
|
||||
actions: none
|
||||
|
||||
jobs:
|
||||
nightly:
|
||||
name: Nightly
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
immutable: false
|
||||
|
||||
- name: Download translations
|
||||
run: ./script/translations_download
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
|
||||
- name: Bump version
|
||||
run: script/version_bump.js nightly
|
||||
|
||||
- name: Build nightly Python wheels
|
||||
run: |
|
||||
pip install build
|
||||
yarn install
|
||||
export SKIP_FETCH_NIGHTLY_TRANSLATIONS=1
|
||||
script/build_frontend
|
||||
rm -rf dist home_assistant_frontend.egg-info
|
||||
python3 -m build
|
||||
|
||||
- name: Archive translations
|
||||
run: tar -czvf translations.tar.gz translations
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: wheels
|
||||
path: dist/home_assistant_frontend*.whl
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload translations
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: translations
|
||||
path: translations.tar.gz
|
||||
if-no-files-found: error
|
||||
@@ -1,38 +0,0 @@
|
||||
name: Pull request standards
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] -- safe: reads PR metadata from event payload only, checks out base repo scripts only, never PR head code
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
- ready_for_review
|
||||
branches:
|
||||
- dev
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check pull request follows contribution standards
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write # To label and comment on pull requests
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
- name: Check pull request standards
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { default: checkStandards } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`
|
||||
);
|
||||
await checkStandards({ github, context, core });
|
||||
@@ -1,39 +0,0 @@
|
||||
name: RelativeCI
|
||||
|
||||
on:
|
||||
# zizmor: ignore[dangerous-triggers] -- safe: only downloads artifacts, no PR code checkout
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
upload-frontend-modern:
|
||||
name: Upload stats (frontend/modern)
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send bundle stats and build information to RelativeCI
|
||||
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY_frontend_modern }}
|
||||
token: ${{ github.token }}
|
||||
artifactName: frontend-bundle-stats
|
||||
webpackStatsFile: frontend-modern.json
|
||||
|
||||
upload-frontend-legacy:
|
||||
name: Upload stats (frontend/legacy)
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send bundle stats and build information to RelativeCI
|
||||
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY_frontend_legacy }}
|
||||
token: ${{ github.token }}
|
||||
artifactName: frontend-bundle-stats
|
||||
webpackStatsFile: frontend-legacy.json
|
||||
@@ -5,19 +5,10 @@ on:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update_release_draft:
|
||||
permissions:
|
||||
# write permission for contents is required to create a github release
|
||||
contents: write
|
||||
# write permission for pull-requests is required for autolabeler
|
||||
# otherwise, read permission is required at least
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
|
||||
- uses: release-drafter/release-drafter@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -6,131 +6,86 @@ on:
|
||||
- published
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: "3.14"
|
||||
PYTHON_VERSION: 3.8
|
||||
NODE_VERSION: 14
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
# Set default workflow permissions
|
||||
# All scopes not mentioned here are set to no access
|
||||
# https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token
|
||||
permissions:
|
||||
actions: none
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
permissions:
|
||||
contents: write # Required to upload release assets
|
||||
id-token: write # For "Trusted Publisher" to PyPi
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@master
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@f4ca6f671bd429efb108c0f2fa0ae8af0215986c # master
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
- name: Set up Node ${{ env.NODE_VERSION }}
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
immutable: false
|
||||
cache: false
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
|
||||
- name: Download Translations
|
||||
run: ./script/translations_download
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
|
||||
- name: Build and release package
|
||||
run: |
|
||||
python3 -m pip install build
|
||||
export SKIP_FETCH_NIGHTLY_TRANSLATIONS=1
|
||||
python3 -m pip install twine
|
||||
export TWINE_USERNAME="__token__"
|
||||
export TWINE_PASSWORD="${{ secrets.TWINE_TOKEN }}"
|
||||
|
||||
script/release
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
- name: Upload release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
run: gh release upload "$TAG_NAME" dist/*.whl dist/*.tar.gz --clobber
|
||||
|
||||
wheels-init:
|
||||
name: Init wheels build
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate requirements.txt
|
||||
env:
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
run: |
|
||||
version=$(echo "$GITHUB_REF" | awk -F"/" '{print $NF}' )
|
||||
# Wait for the package to become available on PyPI
|
||||
echo "Waiting for home-assistant-frontend==$version to appear on PyPI..."
|
||||
for i in $(seq 1 30); do
|
||||
status=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/home-assistant-frontend/$version/json")
|
||||
if [ "$status" = "200" ]; then
|
||||
echo "Package is available on PyPI!"
|
||||
break
|
||||
fi
|
||||
if [ "$i" = "30" ]; then
|
||||
echo "Timed out waiting for package to appear on PyPI"
|
||||
exit 1
|
||||
fi
|
||||
echo "Not available yet (HTTP $status), retrying in 30 seconds... ($i/30)"
|
||||
sleep 30
|
||||
done
|
||||
# Sleep to give pypi time to populate the new version across mirrors
|
||||
sleep 240
|
||||
version=$(echo "${{ github.ref }}" | awk -F"/" '{print $NF}' )
|
||||
echo "home-assistant-frontend==$version" > ./requirements.txt
|
||||
|
||||
# home-assistant/wheels doesn't support SHA pinning
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716 # 2026.07.0
|
||||
- name: Upload requirements.txt
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
abi: cp314
|
||||
tag: musllinux_1_2
|
||||
arch: amd64
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
requirements: "requirements.txt"
|
||||
name: requirements
|
||||
path: ./requirements.txt
|
||||
|
||||
release-landing-page:
|
||||
name: Release landing-page frontend
|
||||
if: github.event.release.prerelease == false
|
||||
build-wheels:
|
||||
name: Build wheels for ${{ matrix.arch }}
|
||||
needs: wheels-init
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # Required to upload release assets
|
||||
strategy:
|
||||
matrix:
|
||||
arch: ["aarch64", "armhf", "armv7", "amd64", "i386"]
|
||||
tag:
|
||||
- "3.9-alpine3.14"
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Download requirements.txt
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
name: requirements
|
||||
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@master
|
||||
with:
|
||||
immutable: false
|
||||
cache: false
|
||||
- name: Download Translations
|
||||
run: ./script/translations_download
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
- name: Build landing-page
|
||||
run: landing-page/script/build_landing_page
|
||||
- name: Tar folder
|
||||
env:
|
||||
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
run: tar -czf "landing-page/home_assistant_frontend_landingpage-${TAG_NAME}.tar.gz" -C landing-page/dist .
|
||||
- name: Upload release asset
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
run: gh release upload "$TAG_NAME" "landing-page/home_assistant_frontend_landingpage-${TAG_NAME}.tar.gz" --clobber
|
||||
tag: ${{ matrix.tag }}
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-host: ${{ secrets.WHEELS_HOST }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
wheels-user: wheels
|
||||
requirements: "requirements.txt"
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
name: Restrict task creation
|
||||
|
||||
# yamllint disable-line rule:truthy
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number }}
|
||||
|
||||
jobs:
|
||||
add-no-stale:
|
||||
name: Add no-stale label
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write # To add labels to issues
|
||||
if: >-
|
||||
github.event.issue.type.name == 'Task'
|
||||
|| github.event.issue.type.name == 'Epic'
|
||||
|| github.event.issue.type.name == 'Opportunity'
|
||||
steps:
|
||||
- name: Add no-stale label
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['no-stale']
|
||||
});
|
||||
|
||||
check-authorization:
|
||||
name: Check authorization
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To check out workflow scripts
|
||||
issues: write # To comment on, label, and close issues
|
||||
# Only run if this is a Task issue type (from the issue form)
|
||||
if: github.event.issue.type.name == 'Task'
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
- name: Check if user is authorized
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { default: checkTaskAuthorization } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`
|
||||
);
|
||||
await checkTaskAuthorization({ github, context, core });
|
||||
@@ -1,47 +0,0 @@
|
||||
name: Stale
|
||||
|
||||
# yamllint disable-line rule:truthy
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 * * * *"
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 90 days stale policy
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 90
|
||||
days-before-close: 7
|
||||
operations-per-run: 25
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-label: "stale"
|
||||
exempt-issue-labels: "no-stale,Help%20wanted,help-wanted,feature-request,feature%20request"
|
||||
stale-issue-message: >
|
||||
There hasn't been any activity on this issue recently. Due to the
|
||||
high number of incoming GitHub notifications, we have to clean some
|
||||
of the old issues, as many of them have already been resolved with
|
||||
the latest updates.
|
||||
|
||||
Please make sure to update to the latest Home Assistant version and
|
||||
check if that solves the issue. Let us know if that works for you by
|
||||
adding a comment 👍
|
||||
|
||||
This issue has now been marked as stale and will be closed if no
|
||||
further activity occurs. Thank you for your contributions.
|
||||
|
||||
stale-pr-label: "stale"
|
||||
exempt-pr-labels: "no-stale"
|
||||
stale-pr-message: >
|
||||
There hasn't been any activity on this pull request recently. This
|
||||
pull request has been automatically marked as stale because of that
|
||||
and will be closed if no further activity occurs within 7 days.
|
||||
|
||||
Thank you for your contributions.
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Stale
|
||||
|
||||
# yamllint disable-line rule:truthy
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 * * * *"
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 90 days stale policy
|
||||
uses: actions/stale@v3.0.13
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 90
|
||||
days-before-close: 7
|
||||
operations-per-run: 25
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-label: "stale"
|
||||
exempt-issue-labels: "no-stale,Help%20wanted,help-wanted,feature-request,feature%20request"
|
||||
stale-issue-message: >
|
||||
There hasn't been any activity on this issue recently. Due to the
|
||||
high number of incoming GitHub notifications, we have to clean some
|
||||
of the old issues, as many of them have already been resolved with
|
||||
the latest updates.
|
||||
|
||||
Please make sure to update to the latest Home Assistant version and
|
||||
check if that solves the issue. Let us know if that works for you by
|
||||
adding a comment 👍
|
||||
|
||||
This issue has now been marked as stale and will be closed if no
|
||||
further activity occurs. Thank you for your contributions.
|
||||
|
||||
stale-pr-label: "stale"
|
||||
exempt-pr-labels: "no-stale"
|
||||
stale-pr-message: >
|
||||
There hasn't been any activity on this pull request recently. This
|
||||
pull request has been automatically marked as stale because of that
|
||||
and will be closed if no further activity occurs within 7 days.
|
||||
|
||||
Thank you for your contributions.
|
||||
@@ -1,47 +0,0 @@
|
||||
name: Sync numeric device classes
|
||||
|
||||
# Mirrors Home Assistant Core's numeric `SensorDeviceClass` list into the
|
||||
# build-time default in src/data/sensor_numeric_device_classes.ts and opens a PR
|
||||
# when it drifts. Reads homeassistant/generated/sensor.json from core.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 4 * * *" # Daily, 04:00 UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Sync
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Regenerate numeric device classes
|
||||
run: ./script/gen_numeric_device_classes
|
||||
|
||||
- name: Format
|
||||
run: yarn prettier --write src/data/sensor_numeric_device_classes.ts
|
||||
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
branch: chore/sync-numeric-device-classes
|
||||
commit-message: Update numeric sensor device classes
|
||||
title: Update numeric sensor device classes
|
||||
body: |
|
||||
Regenerated `SENSOR_NUMERIC_DEVICE_CLASSES` from Home Assistant Core's
|
||||
`SensorDeviceClass`.
|
||||
|
||||
Automated by `.github/workflows/sync-numeric-device-classes.yaml`.
|
||||
@@ -1,16 +1,14 @@
|
||||
name: Translations
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
paths:
|
||||
- .github/workflows/translations.yaml
|
||||
- src/translations/en.json
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
NODE_VERSION: 14
|
||||
|
||||
jobs:
|
||||
upload:
|
||||
@@ -18,11 +16,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Upload Translations
|
||||
run: ./script/translations_upload_base
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
run: |
|
||||
export LOKALISE_TOKEN="${{ secrets.LOKALISE_TOKEN }}"
|
||||
|
||||
./script/translations_upload_base
|
||||
|
||||
+6
-30
@@ -2,12 +2,9 @@
|
||||
.reify-cache
|
||||
|
||||
# build
|
||||
build/
|
||||
dist/
|
||||
/hass_frontend/
|
||||
/translations/
|
||||
# Composite action source, not build output
|
||||
!/.github/actions/build/
|
||||
build
|
||||
hass_frontend/*
|
||||
dist
|
||||
|
||||
# yarn
|
||||
.yarn/*
|
||||
@@ -17,7 +14,7 @@ dist/
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
.pnp.*
|
||||
node_modules/
|
||||
node_modules/*
|
||||
yarn-error.log
|
||||
npm-debug.log
|
||||
|
||||
@@ -29,7 +26,7 @@ npm-debug.log
|
||||
# venv stuff
|
||||
pyvenv.cfg
|
||||
pip-selfcheck.json
|
||||
/venv/
|
||||
venv/*
|
||||
.venv
|
||||
|
||||
# vscode
|
||||
@@ -48,25 +45,4 @@ src/cast/dev_const.ts
|
||||
.tool-versions
|
||||
|
||||
# Home Assistant config
|
||||
/config/
|
||||
|
||||
# Jetbrains
|
||||
/.idea/
|
||||
|
||||
# test coverage
|
||||
test/coverage/
|
||||
|
||||
# Playwright e2e output
|
||||
test/e2e/reports/
|
||||
test/e2e/test-results/
|
||||
# E2E test app build output
|
||||
test/e2e/app/dist/
|
||||
|
||||
# AI tooling
|
||||
.claude/*
|
||||
!.claude/skills
|
||||
.cursor
|
||||
.opencode
|
||||
.serena
|
||||
|
||||
test/benchmarks/results/
|
||||
/config
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
yarn run lint-staged --relative
|
||||
+9
-4
@@ -1,4 +1,9 @@
|
||||
CLA.md
|
||||
CODE_OF_CONDUCT.md
|
||||
LICENSE.md
|
||||
PULL_REQUEST_TEMPLATE.md
|
||||
build
|
||||
translations/*
|
||||
node_modules/*
|
||||
hass_frontend/*
|
||||
pip-selfcheck.json
|
||||
|
||||
# vscode
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
Vendored
+2
-5
@@ -2,10 +2,7 @@
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"runem.lit-plugin",
|
||||
"github.vscode-pull-request-github",
|
||||
"eamodio.gitlens",
|
||||
"vitest.explorer",
|
||||
"yeion7.styled-global-variables-autocomplete"
|
||||
"bierner.lit-html",
|
||||
"runem.lit-plugin"
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+4
-2
@@ -9,7 +9,9 @@
|
||||
"webRoot": "${workspaceFolder}/hass_frontend",
|
||||
"disableNetworkCache": true,
|
||||
"preLaunchTask": "Develop Frontend",
|
||||
"outFiles": ["${workspaceFolder}/hass_frontend/frontend_latest/*.js"]
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/hass_frontend/frontend_latest/*.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Debug Gallery",
|
||||
@@ -37,6 +39,6 @@
|
||||
"webRoot": "${workspaceFolder}/cast/dist",
|
||||
"disableNetworkCache": true,
|
||||
"preLaunchTask": "Develop Cast"
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+23
-48
@@ -1,42 +1,6 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Develop and serve Frontend",
|
||||
"type": "shell",
|
||||
"command": "script/develop_and_serve -c ${input:coreUrl}",
|
||||
// Sync changes here to other tasks until issue resolved
|
||||
// https://github.com/Microsoft/vscode/issues/61497
|
||||
"problemMatcher": {
|
||||
"owner": "ha-build",
|
||||
"source": "ha-build",
|
||||
"fileLocation": "absolute",
|
||||
"severity": "error",
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "(SyntaxError): (.+): (.+) \\((\\d+):(\\d+)\\)",
|
||||
"severity": 1,
|
||||
"file": 2,
|
||||
"message": 3,
|
||||
"line": 4,
|
||||
"column": 5
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": "Changes detected. Starting compilation",
|
||||
"endsPattern": "Build done @"
|
||||
}
|
||||
},
|
||||
"isBackground": true,
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"runOptions": {
|
||||
"instanceLimit": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Develop Frontend",
|
||||
"type": "gulp",
|
||||
@@ -74,9 +38,9 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Develop Gallery",
|
||||
"label": "Develop Supervisor panel",
|
||||
"type": "gulp",
|
||||
"task": "develop-gallery",
|
||||
"task": "develop-hassio",
|
||||
"problemMatcher": {
|
||||
"owner": "ha-build",
|
||||
"source": "ha-build",
|
||||
@@ -98,7 +62,6 @@
|
||||
"endsPattern": "Build done @"
|
||||
}
|
||||
},
|
||||
|
||||
"isBackground": true,
|
||||
"group": "build",
|
||||
"runOptions": {
|
||||
@@ -106,9 +69,9 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Develop Landing Page",
|
||||
"label": "Develop Gallery",
|
||||
"type": "gulp",
|
||||
"task": "develop-landing-page",
|
||||
"task": "develop-gallery",
|
||||
"problemMatcher": {
|
||||
"owner": "ha-build",
|
||||
"source": "ha-build",
|
||||
@@ -216,18 +179,30 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Setup and fetch nightly translations",
|
||||
"type": "gulp",
|
||||
"task": "setup-and-fetch-nightly-translations",
|
||||
"problemMatcher": []
|
||||
"label": "Run HA Core for Supervisor in devcontainer",
|
||||
"type": "shell",
|
||||
"command": "HASSIO=${input:supervisorHost} HASSIO_TOKEN=${input:supervisorToken} script/core",
|
||||
"isBackground": true,
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"problemMatcher": [],
|
||||
"runOptions": {
|
||||
"instanceLimit": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"id": "coreUrl",
|
||||
"id": "supervisorHost",
|
||||
"type": "promptString",
|
||||
"description": "The URL of the Home Assistant Core instance",
|
||||
"default": "http://127.0.0.1:8123"
|
||||
"description": "The IP of the Supervisor host running the Remote API proxy add-on"
|
||||
},
|
||||
{
|
||||
"id": "supervisorToken",
|
||||
"type": "promptString",
|
||||
"description": "The token for the Remote API proxy add-on"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
|
||||
diff --git a/lib/uni-virtualizer/lib/polyfillLoaders/EventTarget.js b/lib/uni-virtualizer/lib/polyfillLoaders/EventTarget.js
|
||||
index d92179f7fd5315203f870a6963e871dc8ddf6c0c..362e284121b97e0fba0925225777aebc32e26b8d 100644
|
||||
--- a/lib/uni-virtualizer/lib/polyfillLoaders/EventTarget.js
|
||||
+++ b/lib/uni-virtualizer/lib/polyfillLoaders/EventTarget.js
|
||||
@@ -1,14 +1,15 @@
|
||||
-let _ET, ET;
|
||||
+let _ET;
|
||||
+let ET;
|
||||
export default async function EventTarget() {
|
||||
- return ET || init();
|
||||
+ return ET || init();
|
||||
}
|
||||
async function init() {
|
||||
- _ET = window.EventTarget;
|
||||
- try {
|
||||
- new _ET();
|
||||
- }
|
||||
- catch (_a) {
|
||||
- _ET = (await import('event-target-shim')).EventTarget;
|
||||
- }
|
||||
- return (ET = _ET);
|
||||
+ _ET = window.EventTarget;
|
||||
+ try {
|
||||
+ new _ET();
|
||||
+ } catch (_a) {
|
||||
+ _ET = (await import("event-target-shim")).default.EventTarget;
|
||||
+ }
|
||||
+ return (ET = _ET);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
diff --git a/mwc-formfield-base.js b/mwc-formfield-base.js
|
||||
index 7b763326d7d51835ad52646bfbc80fe21989abd3..f2baa8224e6d03df1fdb0b9fd03f5c6d77fc8747 100644
|
||||
--- a/mwc-formfield-base.js
|
||||
+++ b/mwc-formfield-base.js
|
||||
@@ -9,7 +9,7 @@ import { BaseElement } from '@material/mwc-base/base-element.js';
|
||||
import { FormElement } from '@material/mwc-base/form-element.js';
|
||||
import { observer } from '@material/mwc-base/observer.js';
|
||||
import { html } from 'lit';
|
||||
-import { property, query, queryAssignedNodes } from 'lit/decorators.js';
|
||||
+import { property, query, queryAssignedElements } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
export class FormfieldBase extends BaseElement {
|
||||
constructor() {
|
||||
@@ -96,7 +96,7 @@ __decorate([
|
||||
query('.mdc-form-field')
|
||||
], FormfieldBase.prototype, "mdcRoot", void 0);
|
||||
__decorate([
|
||||
- queryAssignedNodes('', true, '*')
|
||||
+ queryAssignedElements({ slot: "", flatten: true, selector: "*" })
|
||||
], FormfieldBase.prototype, "slottedInputs", void 0);
|
||||
__decorate([
|
||||
query('label')
|
||||
@@ -1,26 +0,0 @@
|
||||
diff --git a/mwc-list-base.js b/mwc-list-base.js
|
||||
index 1ba95b6a01dcecea4d85b5cbbbcc3dfb04c40d5f..dced13fdb7929c490d6661b1bbe7e9f96dcd2285 100644
|
||||
--- a/mwc-list-base.js
|
||||
+++ b/mwc-list-base.js
|
||||
@@ -11,7 +11,7 @@ import { BaseElement } from '@material/mwc-base/base-element.js';
|
||||
import { observer } from '@material/mwc-base/observer.js';
|
||||
import { deepActiveElementPath, doesElementContainFocus, isNodeElement } from '@material/mwc-base/utils.js';
|
||||
import { html } from 'lit';
|
||||
-import { property, query, queryAssignedNodes } from 'lit/decorators.js';
|
||||
+import { property, query, queryAssignedElements } from 'lit/decorators.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import MDCListFoundation, { isIndexSet } from './mwc-list-foundation.js';
|
||||
export { createSetFromIndex, isEventMulti, isIndexSet } from './mwc-list-foundation.js';
|
||||
@@ -425,10 +425,10 @@ __decorate([
|
||||
query('.mdc-deprecated-list')
|
||||
], ListBase.prototype, "mdcRoot", void 0);
|
||||
__decorate([
|
||||
- queryAssignedNodes('', true, '*')
|
||||
+ queryAssignedElements({ flatten: true, selector: "*" })
|
||||
], ListBase.prototype, "assignedElements", void 0);
|
||||
__decorate([
|
||||
- queryAssignedNodes('', true, '[tabindex="0"]')
|
||||
+ queryAssignedElements({ flatten: true, selector: '[tabindex="0"]' })
|
||||
], ListBase.prototype, "tabbableElements", void 0);
|
||||
__decorate([
|
||||
property({ type: Boolean }),
|
||||
@@ -0,0 +1,12 @@
|
||||
diff --git a/mwc-icon-button-base.js b/mwc-icon-button-base.js
|
||||
index 45cdaab93ccc0a6daaaaabc01266dcdc32e46bfd..b3ea5b541597308d85f86ce6c23fd00785fda835 100644
|
||||
--- a/mwc-icon-button-base.js
|
||||
+++ b/mwc-icon-button-base.js
|
||||
@@ -63,7 +63,6 @@ export class IconButtonBase extends LitElement {
|
||||
@touchend="${this.handleRippleDeactivate}"
|
||||
@touchcancel="${this.handleRippleDeactivate}"
|
||||
>${this.renderRipple()}
|
||||
- <i class="material-icons">${this.icon}</i>
|
||||
<span
|
||||
><slot></slot
|
||||
></span>
|
||||
@@ -0,0 +1,34 @@
|
||||
diff --git a/lib/legacy/class.js b/lib/legacy/class.js
|
||||
index aee2511be1cd9bf900ee552bc98190c1631c57c0..f2f499d68bf52034cac9c28307c99e8ce6b8417d 100644
|
||||
--- a/lib/legacy/class.js
|
||||
+++ b/lib/legacy/class.js
|
||||
@@ -304,17 +304,23 @@ function GenerateClassFromInfo(info, Base, behaviors) {
|
||||
// only proceed if the generated class' prototype has not been registered.
|
||||
const generatedProto = PolymerGenerated.prototype;
|
||||
if (!generatedProto.hasOwnProperty(JSCompiler_renameProperty('__hasRegisterFinished', generatedProto))) {
|
||||
- generatedProto.__hasRegisterFinished = true;
|
||||
+ // make sure legacy lifecycle is called on the *element*'s prototype
|
||||
+ // and not the generated class prototype; if the element has been
|
||||
+ // extended, these are *not* the same.
|
||||
+ const proto = Object.getPrototypeOf(this);
|
||||
+ // Only set flag when generated prototype itself is registered,
|
||||
+ // as this element may be extended from, and needs to run `registered`
|
||||
+ // on all behaviors on the subclass as well.
|
||||
+ if (proto === generatedProto) {
|
||||
+ generatedProto.__hasRegisterFinished = true;
|
||||
+ }
|
||||
// ensure superclass is registered first.
|
||||
super._registered();
|
||||
// copy properties onto the generated class lazily if we're optimizing,
|
||||
- if (legacyOptimizations) {
|
||||
+ if (legacyOptimizations && !Object.hasOwnProperty(generatedProto, '__hasCopiedProperties')) {
|
||||
+ generatedProto.__hasCopiedProperties = true;
|
||||
copyPropertiesToProto(generatedProto);
|
||||
}
|
||||
- // make sure legacy lifecycle is called on the *element*'s prototype
|
||||
- // and not the generated class prototype; if the element has been
|
||||
- // extended, these are *not* the same.
|
||||
- const proto = Object.getPrototypeOf(this);
|
||||
let list = lifecycle.beforeRegister;
|
||||
if (list) {
|
||||
for (let i=0; i < list.length; i++) {
|
||||
File diff suppressed because one or more lines are too long
@@ -1,60 +0,0 @@
|
||||
diff --git a/modular/sortable.core.esm.js b/modular/sortable.core.esm.js
|
||||
index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa526090a00 100644
|
||||
--- a/modular/sortable.core.esm.js
|
||||
+++ b/modular/sortable.core.esm.js
|
||||
@@ -1781,11 +1781,16 @@ Sortable.prototype = /** @lends Sortable.prototype */{
|
||||
}
|
||||
if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, !!target) !== false) {
|
||||
capture();
|
||||
- if (elLastChild && elLastChild.nextSibling) {
|
||||
- // the last draggable element is not the last node
|
||||
- el.insertBefore(dragEl, elLastChild.nextSibling);
|
||||
- } else {
|
||||
- el.appendChild(dragEl);
|
||||
+ try {
|
||||
+ if (elLastChild && elLastChild.nextSibling) {
|
||||
+ // the last draggable element is not the last node
|
||||
+ el.insertBefore(dragEl, elLastChild.nextSibling);
|
||||
+ } else {
|
||||
+ el.appendChild(dragEl);
|
||||
+ }
|
||||
+ }
|
||||
+ catch(err) {
|
||||
+ return completed(false);
|
||||
}
|
||||
parentEl = el; // actualization
|
||||
|
||||
@@ -1802,7 +1807,12 @@ Sortable.prototype = /** @lends Sortable.prototype */{
|
||||
targetRect = getRect(target);
|
||||
if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, false) !== false) {
|
||||
capture();
|
||||
- el.insertBefore(dragEl, firstChild);
|
||||
+ try {
|
||||
+ el.insertBefore(dragEl, firstChild);
|
||||
+ }
|
||||
+ catch(err) {
|
||||
+ return completed(false);
|
||||
+ }
|
||||
parentEl = el; // actualization
|
||||
|
||||
changed();
|
||||
@@ -1849,10 +1859,15 @@ Sortable.prototype = /** @lends Sortable.prototype */{
|
||||
_silent = true;
|
||||
setTimeout(_unsilent, 30);
|
||||
capture();
|
||||
- if (after && !nextSibling) {
|
||||
- el.appendChild(dragEl);
|
||||
- } else {
|
||||
- target.parentNode.insertBefore(dragEl, after ? nextSibling : target);
|
||||
+ try {
|
||||
+ if (after && !nextSibling) {
|
||||
+ el.appendChild(dragEl);
|
||||
+ } else {
|
||||
+ target.parentNode.insertBefore(dragEl, after ? nextSibling : target);
|
||||
+ }
|
||||
+ }
|
||||
+ catch(err) {
|
||||
+ return completed(false);
|
||||
}
|
||||
|
||||
// Undo chrome's scroll adjustment (has no effect on other browsers)
|
||||
@@ -1,86 +0,0 @@
|
||||
diff --git a/dist/tinykeys.cjs b/dist/tinykeys.cjs
|
||||
index 08c98b6eff3b8fb4b727fe8e6b096951d6ef6347..9c44f14862f582766ea1733b6dc0e97f962800d8 100644
|
||||
--- a/dist/tinykeys.cjs
|
||||
+++ b/dist/tinykeys.cjs
|
||||
@@ -61,6 +61,18 @@ function defaultKeybindingsHandlerIgnore(event) {
|
||||
function getModifierState(event, mod) {
|
||||
return typeof event.getModifierState === "function" ? event.getModifierState(mod) || ALT_GRAPH_ALIASES.includes(mod) && event.getModifierState("AltGraph") : false;
|
||||
}
|
||||
+function splitKeybindingPress(press) {
|
||||
+ let parts = [];
|
||||
+ let start = 0;
|
||||
+ for (let index = 0; index < press.length; index++) {
|
||||
+ if (press[index] === "+" && /[\w\]]/.test(press[index - 1] || "")) {
|
||||
+ parts.push(press.slice(start, index));
|
||||
+ start = index + 1;
|
||||
+ }
|
||||
+ }
|
||||
+ parts.push(press.slice(start));
|
||||
+ return parts;
|
||||
+}
|
||||
/**
|
||||
* Parses a keybinding string into its parts.
|
||||
*
|
||||
@@ -76,10 +88,10 @@ function getModifierState(event, mod) {
|
||||
*/
|
||||
function parseKeybinding(str) {
|
||||
return str.trim().split(" ").map((press) => {
|
||||
- let parts = press.split(/(?<=\w|\])\+/);
|
||||
+ let parts = splitKeybindingPress(press);
|
||||
let last = parts.pop();
|
||||
let regex = last.match(/^\((.+)\)$/);
|
||||
- let key = regex ? new RegExp(`^(?:${regex[1]})$`, "iv") : last;
|
||||
+ let key = regex ? new RegExp(`^(?:${regex[1]})$`, "i") : last;
|
||||
let requiredModifiers = [];
|
||||
let optionalModifiers = [];
|
||||
for (const part of parts) {
|
||||
@@ -201,5 +213,3 @@ exports.defaultKeybindingsHandlerIgnore = defaultKeybindingsHandlerIgnore;
|
||||
exports.matchKeybindingPress = matchKeybindingPress;
|
||||
exports.parseKeybinding = parseKeybinding;
|
||||
exports.tinykeys = tinykeys;
|
||||
-
|
||||
-//# sourceMappingURL=tinykeys.cjs.map
|
||||
\ No newline at end of file
|
||||
diff --git a/dist/tinykeys.mjs b/dist/tinykeys.mjs
|
||||
index c289972d2728e03d9b272268c38fd3392e8845bf..e22897b00aae6cdb0dbbb971445227c07be52918 100644
|
||||
--- a/dist/tinykeys.mjs
|
||||
+++ b/dist/tinykeys.mjs
|
||||
@@ -60,6 +60,18 @@ function defaultKeybindingsHandlerIgnore(event) {
|
||||
function getModifierState(event, mod) {
|
||||
return typeof event.getModifierState === "function" ? event.getModifierState(mod) || ALT_GRAPH_ALIASES.includes(mod) && event.getModifierState("AltGraph") : false;
|
||||
}
|
||||
+function splitKeybindingPress(press) {
|
||||
+ let parts = [];
|
||||
+ let start = 0;
|
||||
+ for (let index = 0; index < press.length; index++) {
|
||||
+ if (press[index] === "+" && /[\w\]]/.test(press[index - 1] || "")) {
|
||||
+ parts.push(press.slice(start, index));
|
||||
+ start = index + 1;
|
||||
+ }
|
||||
+ }
|
||||
+ parts.push(press.slice(start));
|
||||
+ return parts;
|
||||
+}
|
||||
/**
|
||||
* Parses a keybinding string into its parts.
|
||||
*
|
||||
@@ -75,10 +87,10 @@ function getModifierState(event, mod) {
|
||||
*/
|
||||
function parseKeybinding(str) {
|
||||
return str.trim().split(" ").map((press) => {
|
||||
- let parts = press.split(/(?<=\w|\])\+/);
|
||||
+ let parts = splitKeybindingPress(press);
|
||||
let last = parts.pop();
|
||||
let regex = last.match(/^\((.+)\)$/);
|
||||
- let key = regex ? new RegExp(`^(?:${regex[1]})$`, "iv") : last;
|
||||
+ let key = regex ? new RegExp(`^(?:${regex[1]})$`, "i") : last;
|
||||
let requiredModifiers = [];
|
||||
let optionalModifiers = [];
|
||||
for (const part of parts) {
|
||||
@@ -196,5 +208,3 @@ function tinykeys(target, keybindingMap, options = {}) {
|
||||
}
|
||||
//#endregion
|
||||
export { createKeybindingsHandler, defaultKeybindingsHandlerIgnore, matchKeybindingPress, parseKeybinding, tinykeys };
|
||||
-
|
||||
-//# sourceMappingURL=tinykeys.mjs.map
|
||||
\ No newline at end of file
|
||||
@@ -1,55 +0,0 @@
|
||||
diff --git a/build/inject-manifest.js b/build/inject-manifest.js
|
||||
index 60e3d2bb51c11a19fbbedbad65e101082ec41c36..fed6026630f43f86e25446383982cf6fb694313b 100644
|
||||
--- a/build/inject-manifest.js
|
||||
+++ b/build/inject-manifest.js
|
||||
@@ -104,7 +104,7 @@ async function injectManifest(config) {
|
||||
replaceString: manifestString,
|
||||
searchString: options.injectionPoint,
|
||||
});
|
||||
- filesToWrite[options.swDest] = source;
|
||||
+ filesToWrite[options.swDest] = source.replace(url, encodeURI(upath_1.default.basename(destPath)));
|
||||
filesToWrite[destPath] = map;
|
||||
}
|
||||
else {
|
||||
diff --git a/build/lib/translate-url-to-sourcemap-paths.js b/build/lib/translate-url-to-sourcemap-paths.js
|
||||
index 3220c5474eeac6e8a56ca9b2ac2bd9be48529e43..5f003879a904d4840529a42dd056d288fd213771 100644
|
||||
--- a/build/lib/translate-url-to-sourcemap-paths.js
|
||||
+++ b/build/lib/translate-url-to-sourcemap-paths.js
|
||||
@@ -22,7 +22,7 @@ function translateURLToSourcemapPaths(url, swSrc, swDest) {
|
||||
const possibleSrcPath = upath_1.default.resolve(upath_1.default.dirname(swSrc), url);
|
||||
if (fs_extra_1.default.existsSync(possibleSrcPath)) {
|
||||
srcPath = possibleSrcPath;
|
||||
- destPath = upath_1.default.resolve(upath_1.default.dirname(swDest), url);
|
||||
+ destPath = `${swDest}.map`;
|
||||
}
|
||||
else {
|
||||
warning = `${errors_1.errors['cant-find-sourcemap']} ${possibleSrcPath}`;
|
||||
diff --git a/src/inject-manifest.ts b/src/inject-manifest.ts
|
||||
index 8795ddcaa77aea7b0356417e4bc4b19e2b3f860c..fcdc68342d9ac53936c9ed40a9ccfc2f5070cad3 100644
|
||||
--- a/src/inject-manifest.ts
|
||||
+++ b/src/inject-manifest.ts
|
||||
@@ -129,7 +129,10 @@ export async function injectManifest(
|
||||
searchString: options.injectionPoint!,
|
||||
});
|
||||
|
||||
- filesToWrite[options.swDest] = source;
|
||||
+ filesToWrite[options.swDest] = source.replace(
|
||||
+ url!,
|
||||
+ encodeURI(upath.basename(destPath)),
|
||||
+ );
|
||||
filesToWrite[destPath] = map;
|
||||
} else {
|
||||
// If there's no sourcemap associated with swSrc, a simple string
|
||||
diff --git a/src/lib/translate-url-to-sourcemap-paths.ts b/src/lib/translate-url-to-sourcemap-paths.ts
|
||||
index 072eac40d4ef5d095a01cb7f7e392a9e034853bd..f0bbe69e88ef3a415de18a7e9cb264daea273d71 100644
|
||||
--- a/src/lib/translate-url-to-sourcemap-paths.ts
|
||||
+++ b/src/lib/translate-url-to-sourcemap-paths.ts
|
||||
@@ -28,7 +28,7 @@ export function translateURLToSourcemapPaths(
|
||||
const possibleSrcPath = upath.resolve(upath.dirname(swSrc), url);
|
||||
if (fse.existsSync(possibleSrcPath)) {
|
||||
srcPath = possibleSrcPath;
|
||||
- destPath = upath.resolve(upath.dirname(swDest), url);
|
||||
+ destPath = `${swDest}.map`;
|
||||
} else {
|
||||
warning = `${errors['cant-find-sourcemap']} ${possibleSrcPath}`;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+631
File diff suppressed because one or more lines are too long
Vendored
-944
File diff suppressed because one or more lines are too long
+6
-13
@@ -1,16 +1,9 @@
|
||||
approvedGitRepositories:
|
||||
- "**"
|
||||
|
||||
compressionLevel: mixed
|
||||
|
||||
defaultSemverRangePrefix: ""
|
||||
|
||||
enableGlobalCache: false
|
||||
|
||||
enableScripts: true
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 3d
|
||||
plugins:
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs
|
||||
spec: "@yarnpkg/plugin-typescript"
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
|
||||
spec: "@yarnpkg/plugin-interactive-tools"
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.17.0.cjs
|
||||
yarnPath: .yarn/releases/yarn-3.0.2.cjs
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# Home Assistant Frontend Agent Guide
|
||||
|
||||
You are helping develop the Home Assistant frontend. This repository is a TypeScript application built from Lit-based Web Components for the Home Assistant web UI.
|
||||
|
||||
For gallery-specific documentation, demos, page structure, and examples, read `gallery/AGENTS.md` when working under `gallery/`.
|
||||
|
||||
## Essential Commands
|
||||
|
||||
```bash
|
||||
yarn lint # ESLint + Prettier + TypeScript + Lit
|
||||
yarn format # Auto-fix ESLint + Prettier
|
||||
yarn lint:types # TypeScript compiler, run without file arguments
|
||||
yarn test # Vitest
|
||||
yarn dev # App dev server, supports --background/--status/--stop/--logs
|
||||
yarn dev:serve # Local serving dev server, supports -c core URL, -p port, and dev flags
|
||||
```
|
||||
|
||||
Never run `tsc` or `yarn lint:types` with file arguments. When `tsc` receives file arguments, it ignores `tsconfig.json` and can emit `.js` files into `src/`. Always run `yarn lint:types` without arguments. For individual file type checking, rely on editor diagnostics.
|
||||
|
||||
## Architecture
|
||||
|
||||
- The frontend uses custom elements built with Lit and TypeScript strict mode.
|
||||
- Components communicate with the backend through the Home Assistant WebSocket API.
|
||||
- Use `ha-` for Home Assistant components, `hui-` for Lovelace UI components, and `dialog-` for dialogs.
|
||||
- Prefer `ha-*` components and current Web Awesome wrappers. Avoid adding new legacy `mwc-*` usage.
|
||||
- Leaf components should consume narrow Lit contexts instead of taking the broad `hass` object unless they are containers that own and provide `hass`.
|
||||
|
||||
## Development Standards
|
||||
|
||||
- Use strict TypeScript, proper interfaces, and `import type` for type-only imports.
|
||||
- Avoid `any`; model data with existing Home Assistant types or narrow new types.
|
||||
- Keep imports organized and remove unused imports.
|
||||
- Do not use `console`; use existing logging or user-visible error patterns.
|
||||
- Use `@state()` for internal Lit state and `@property()` for public API.
|
||||
- Do not query or manipulate DOM manually when Lit decorators, component refs, or render state are appropriate.
|
||||
- Scope styles to components, use theme custom properties, and keep layouts mobile-first and RTL-safe.
|
||||
- All user-facing text must be localized through the translation system.
|
||||
|
||||
## Project Skills
|
||||
|
||||
Detailed guidance lives in project skills under `.agents/skills/`. Load the matching skill before detailed implementation or review:
|
||||
|
||||
- `ha-frontend-contexts`: Lit contexts, `hass` migration, and rerender-sensitive state access.
|
||||
- `ha-frontend-components`: dialogs, forms, alerts, shortcuts, tooltips, panels, and Lovelace cards.
|
||||
- `ha-frontend-styling`: theme variables, spacing tokens, responsive layout, RTL, and view transitions.
|
||||
- `ha-frontend-testing`: lint, typecheck, Vitest, Playwright e2e dev servers, and benchmarks.
|
||||
- `ha-frontend-user-facing-text`: localization, terminology, sentence case, and Home Assistant text style.
|
||||
- `ha-frontend-review`: PR template use, review checklist, and recurring review issues.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the PR body. Preserve template sections, check only the appropriate type-of-change boxes, and do not check checklist items on behalf of the user. If the PR includes UI changes, remind the user to add screenshots or a short video.
|
||||
@@ -1,8 +0,0 @@
|
||||
# People marked here will be automatically requested for a review
|
||||
# when the code that they own is touched.
|
||||
# https://github.com/blog/2392-introducing-code-owners
|
||||
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||
|
||||
# Part of the frontend that mobile developper should review
|
||||
src/external_app/ @bgoncal @TimoPtr
|
||||
test/external_app/ @bgoncal @TimoPtr
|
||||
@@ -1,4 +1,5 @@
|
||||
include README.md
|
||||
include LICENSE.md
|
||||
graft hass_frontend
|
||||
graft hass_frontend_es5
|
||||
recursive-exclude * *.py[co]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This is the repository for the official [Home Assistant](https://home-assistant.io) frontend.
|
||||
|
||||
[](https://demo.home-assistant.io/)
|
||||
[](https://demo.home-assistant.io/)
|
||||
|
||||
- [View demo of Home Assistant](https://demo.home-assistant.io/)
|
||||
- [More information about Home Assistant](https://home-assistant.io)
|
||||
@@ -14,6 +14,7 @@ This is the repository for the official [Home Assistant](https://home-assistant.
|
||||
- Development: [Instructions](https://developers.home-assistant.io/docs/frontend/development/)
|
||||
- Production build: `script/build_frontend`
|
||||
- Gallery: `cd gallery && script/develop_gallery`
|
||||
- Supervisor: [Instructions](https://developers.home-assistant.io/docs/supervisor/developing)
|
||||
|
||||
## Frontend development
|
||||
|
||||
@@ -26,5 +27,3 @@ A complete guide can be found at the following [link](https://www.home-assistant
|
||||
Home Assistant is open-source and Apache 2 licensed. Feel free to browse the repository, learn and reuse parts in your own projects.
|
||||
|
||||
We use [BrowserStack](https://www.browserstack.com) to test Home Assistant on a large variety of devices.
|
||||
|
||||
[](https://www.openhomefoundation.org/)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"rules": {
|
||||
"import/no-extraneous-dependencies": 0,
|
||||
"no-restricted-syntax": 0,
|
||||
"no-console": 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../.eslintrc.json",
|
||||
"rules": {
|
||||
"import/no-extraneous-dependencies": 0,
|
||||
"global-require": 0
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ The Home Assistant build pipeline contains various steps to prepare a build.
|
||||
|
||||
Currently in Home Assistant we use a bundler to convert TypeScript, CSS and JSON files to JavaScript files that the browser understands.
|
||||
|
||||
We currently rely on Webpack. Both of these programs bundle the converted files in both production and development.
|
||||
We currently rely on Webpack but also have experimental Rollup support. Both of these programs bundle the converted files in both production and development.
|
||||
|
||||
For development, bundling is optional. We just want to get the right files in the browser.
|
||||
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import defineProvider from "@babel/helper-define-polyfill-provider";
|
||||
import { join } from "node:path";
|
||||
import paths from "../paths.cjs";
|
||||
|
||||
const POLYFILL_DIR = join(paths.root_dir, "src/resources/polyfills");
|
||||
|
||||
// List of polyfill keys with supported browser targets for the functionality
|
||||
const polyfillSupport = {
|
||||
// Note states and shadowRoot properties should be supported.
|
||||
"element-internals": {
|
||||
android: 90,
|
||||
chrome: 90,
|
||||
edge: 90,
|
||||
firefox: 126,
|
||||
ios: 17.4,
|
||||
opera: 76,
|
||||
opera_mobile: 64,
|
||||
safari: 17.4,
|
||||
samsung: 15.0,
|
||||
},
|
||||
"element-getattributenames": {
|
||||
android: 61,
|
||||
chrome: 61,
|
||||
edge: 18,
|
||||
firefox: 45,
|
||||
ios: 10.3,
|
||||
opera: 48,
|
||||
opera_mobile: 45,
|
||||
safari: 10.1,
|
||||
samsung: 8.0,
|
||||
},
|
||||
"element-toggleattribute": {
|
||||
android: 69,
|
||||
chrome: 69,
|
||||
edge: 18,
|
||||
firefox: 63,
|
||||
ios: 12.0,
|
||||
opera: 56,
|
||||
opera_mobile: 48,
|
||||
safari: 12.0,
|
||||
samsung: 10.0,
|
||||
},
|
||||
// FormatJS polyfill detects fix for https://bugs.chromium.org/p/v8/issues/detail?id=10682,
|
||||
// so adjusted to several months after that was marked fixed
|
||||
"intl-getcanonicallocales": {
|
||||
android: 90,
|
||||
chrome: 90,
|
||||
edge: 90,
|
||||
firefox: 48,
|
||||
ios: 10.3,
|
||||
opera: 76,
|
||||
opera_mobile: 64,
|
||||
safari: 10.1,
|
||||
samsung: 15.0,
|
||||
},
|
||||
"intl-locale": {
|
||||
android: 74,
|
||||
chrome: 74,
|
||||
edge: 79,
|
||||
firefox: 75,
|
||||
ios: 14.0,
|
||||
opera: 62,
|
||||
opera_mobile: 53,
|
||||
safari: 14.0,
|
||||
samsung: 11.0,
|
||||
},
|
||||
"intl-other": {
|
||||
// Not specified (i.e. always try polyfill) since compatibility depends on supported locales
|
||||
},
|
||||
"resize-observer": {
|
||||
android: 64,
|
||||
chrome: 64,
|
||||
edge: 79,
|
||||
firefox: 69,
|
||||
ios: 13.4,
|
||||
opera: 51,
|
||||
opera_mobile: 47,
|
||||
safari: 13.1,
|
||||
samsung: 9.0,
|
||||
},
|
||||
};
|
||||
|
||||
// Map of global variables and/or instance and static properties to the
|
||||
// corresponding polyfill key and actual module to import
|
||||
const polyfillMap = {
|
||||
global: {
|
||||
ResizeObserver: {
|
||||
key: "resize-observer",
|
||||
module: join(POLYFILL_DIR, "resize-observer.ts"),
|
||||
},
|
||||
},
|
||||
instance: {
|
||||
attachInternals: {
|
||||
key: "element-internals",
|
||||
module: "element-internals-polyfill",
|
||||
},
|
||||
...Object.fromEntries(
|
||||
["getAttributeNames", "toggleAttribute"].map((prop) => {
|
||||
const key = `element-${prop.toLowerCase()}`;
|
||||
return [prop, { key, module: join(POLYFILL_DIR, `${key}.ts`) }];
|
||||
})
|
||||
),
|
||||
},
|
||||
static: {
|
||||
Intl: {
|
||||
getCanonicalLocales: {
|
||||
key: "intl-getcanonicallocales",
|
||||
module: join(POLYFILL_DIR, "intl-polyfill.ts"),
|
||||
},
|
||||
Locale: {
|
||||
key: "intl-locale",
|
||||
module: join(POLYFILL_DIR, "intl-polyfill.ts"),
|
||||
},
|
||||
...Object.fromEntries(
|
||||
[
|
||||
"DateTimeFormat",
|
||||
"DurationFormat",
|
||||
"DisplayNames",
|
||||
"ListFormat",
|
||||
"NumberFormat",
|
||||
"PluralRules",
|
||||
"RelativeTimeFormat",
|
||||
].map((obj) => [
|
||||
obj,
|
||||
{ key: "intl-other", module: join(POLYFILL_DIR, "intl-polyfill.ts") },
|
||||
])
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Create plugin using the same factory as for CoreJS
|
||||
export default defineProvider(
|
||||
({ createMetaResolver, debug, shouldInjectPolyfill }) => {
|
||||
const resolvePolyfill = createMetaResolver(polyfillMap);
|
||||
return {
|
||||
name: "custom-polyfill",
|
||||
polyfills: polyfillSupport,
|
||||
usageGlobal(meta, utils) {
|
||||
const polyfill = resolvePolyfill(meta);
|
||||
if (polyfill && shouldInjectPolyfill(polyfill.desc.key)) {
|
||||
debug(polyfill.desc.key);
|
||||
utils.injectGlobalImport(polyfill.desc.module);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -1,168 +0,0 @@
|
||||
const path = require("path");
|
||||
|
||||
// Currently only supports CommonJS modules, as require is synchronous. `import` would need babel running asynchronous.
|
||||
module.exports = function inlineConstants(babel, options, cwd) {
|
||||
const t = babel.types;
|
||||
|
||||
if (!Array.isArray(options.modules)) {
|
||||
throw new TypeError(
|
||||
"babel-plugin-inline-constants: expected a `modules` array to be passed"
|
||||
);
|
||||
}
|
||||
|
||||
if (options.resolveExtensions && !Array.isArray(options.resolveExtensions)) {
|
||||
throw new TypeError(
|
||||
"babel-plugin-inline-constants: expected `resolveExtensions` to be an array"
|
||||
);
|
||||
}
|
||||
|
||||
const ignoreModuleNotFound = options.ignoreModuleNotFound;
|
||||
const resolveExtensions = options.resolveExtensions;
|
||||
|
||||
const hasRelativeModules = options.modules.some(
|
||||
(module) => module.startsWith(".") || module.startsWith("/")
|
||||
);
|
||||
|
||||
const modules = Object.fromEntries(
|
||||
options.modules.map((module) => {
|
||||
const absolute = module.startsWith(".")
|
||||
? require.resolve(module, { paths: [cwd] })
|
||||
: module;
|
||||
return [absolute, require(absolute)];
|
||||
})
|
||||
);
|
||||
|
||||
const toLiteral = (value) => {
|
||||
if (typeof value === "string") {
|
||||
return t.stringLiteral(value);
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return t.numericLiteral(value);
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
return t.booleanLiteral(value);
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
return t.nullLiteral();
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"babel-plugin-inline-constants: cannot handle non-literal `" + value + "`"
|
||||
);
|
||||
};
|
||||
|
||||
const resolveAbsolute = (value, state, resolveExtensionIndex) => {
|
||||
if (!state.filename) {
|
||||
throw new TypeError(
|
||||
"babel-plugin-inline-constants: expected a `filename` to be set for files"
|
||||
);
|
||||
}
|
||||
|
||||
if (resolveExtensions && resolveExtensionIndex !== undefined) {
|
||||
value += resolveExtensions[resolveExtensionIndex];
|
||||
}
|
||||
|
||||
try {
|
||||
return require.resolve(value, { paths: [path.dirname(state.filename)] });
|
||||
} catch (error) {
|
||||
if (
|
||||
error.code === "MODULE_NOT_FOUND" &&
|
||||
resolveExtensions &&
|
||||
(resolveExtensionIndex === undefined ||
|
||||
resolveExtensionIndex < resolveExtensions.length - 1)
|
||||
) {
|
||||
const resolveExtensionIdx = (resolveExtensionIndex || -1) + 1;
|
||||
return resolveAbsolute(value, state, resolveExtensionIdx);
|
||||
}
|
||||
|
||||
if (error.code === "MODULE_NOT_FOUND" && ignoreModuleNotFound) {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const importDeclaration = (p, state) => {
|
||||
if (p.node.type !== "ImportDeclaration") {
|
||||
return;
|
||||
}
|
||||
const absolute =
|
||||
hasRelativeModules && p.node.source.value.startsWith(".")
|
||||
? resolveAbsolute(p.node.source.value, state)
|
||||
: p.node.source.value;
|
||||
|
||||
if (!absolute || !(absolute in modules)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const module = modules[absolute];
|
||||
|
||||
for (const specifier of p.node.specifiers) {
|
||||
if (
|
||||
specifier.type === "ImportDefaultSpecifier" &&
|
||||
specifier.local &&
|
||||
specifier.local.type === "Identifier"
|
||||
) {
|
||||
if (!("default" in module)) {
|
||||
throw new Error(
|
||||
"babel-plugin-inline-constants: cannot access default export from `" +
|
||||
p.node.source.value +
|
||||
"`"
|
||||
);
|
||||
}
|
||||
|
||||
const variableValue = toLiteral(module.default);
|
||||
const variable = t.variableDeclarator(
|
||||
t.identifier(specifier.local.name),
|
||||
variableValue
|
||||
);
|
||||
|
||||
p.insertBefore({
|
||||
type: "VariableDeclaration",
|
||||
kind: "const",
|
||||
declarations: [variable],
|
||||
});
|
||||
} else if (
|
||||
specifier.type === "ImportSpecifier" &&
|
||||
specifier.imported &&
|
||||
specifier.imported.type === "Identifier" &&
|
||||
specifier.local &&
|
||||
specifier.local.type === "Identifier"
|
||||
) {
|
||||
if (!(specifier.imported.name in module)) {
|
||||
throw new Error(
|
||||
"babel-plugin-inline-constants: cannot access `" +
|
||||
specifier.imported.name +
|
||||
"` from `" +
|
||||
p.node.source.value +
|
||||
"`"
|
||||
);
|
||||
}
|
||||
|
||||
const variableValue = toLiteral(module[specifier.imported.name]);
|
||||
const variable = t.variableDeclarator(
|
||||
t.identifier(specifier.local.name),
|
||||
variableValue
|
||||
);
|
||||
|
||||
p.insertBefore({
|
||||
type: "VariableDeclaration",
|
||||
kind: "const",
|
||||
declarations: [variable],
|
||||
});
|
||||
} else {
|
||||
throw new Error("Cannot handle specifier `" + specifier.type + "`");
|
||||
}
|
||||
}
|
||||
p.remove();
|
||||
};
|
||||
|
||||
return {
|
||||
visitor: {
|
||||
ImportDeclaration: importDeclaration,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const path = require("path");
|
||||
|
||||
// Currently only supports CommonJS modules, as require is synchronous. `import` would need babel running asynchronous.
|
||||
module.exports = function inlineConstants(babel, options, cwd) {
|
||||
const t = babel.types;
|
||||
|
||||
if (!Array.isArray(options.modules)) {
|
||||
throw new TypeError(
|
||||
"babel-plugin-inline-constants: expected a `modules` array to be passed"
|
||||
);
|
||||
}
|
||||
|
||||
if (options.resolveExtensions && !Array.isArray(options.resolveExtensions)) {
|
||||
throw new TypeError(
|
||||
"babel-plugin-inline-constants: expected `resolveExtensions` to be an array"
|
||||
);
|
||||
}
|
||||
|
||||
const ignoreModuleNotFound = options.ignoreModuleNotFound;
|
||||
const resolveExtensions = options.resolveExtensions;
|
||||
|
||||
const hasRelativeModules = options.modules.some(
|
||||
(module) => module.startsWith(".") || module.startsWith("/")
|
||||
);
|
||||
|
||||
const modules = Object.fromEntries(
|
||||
options.modules.map((module) => {
|
||||
const absolute = module.startsWith(".")
|
||||
? require.resolve(module, { paths: [cwd] })
|
||||
: module;
|
||||
// eslint-disable-next-line import/no-dynamic-require
|
||||
return [absolute, require(absolute)];
|
||||
})
|
||||
);
|
||||
|
||||
const toLiteral = (value) => {
|
||||
if (typeof value === "string") {
|
||||
return t.stringLiteral(value);
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return t.numericLiteral(value);
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
return t.booleanLiteral(value);
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
return t.nullLiteral();
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"babel-plugin-inline-constants: cannot handle non-literal `" + value + "`"
|
||||
);
|
||||
};
|
||||
|
||||
const resolveAbsolute = (value, state, resolveExtensionIndex) => {
|
||||
if (!state.filename) {
|
||||
throw new TypeError(
|
||||
"babel-plugin-inline-constants: expected a `filename` to be set for files"
|
||||
);
|
||||
}
|
||||
|
||||
if (resolveExtensions && resolveExtensionIndex !== undefined) {
|
||||
value += resolveExtensions[resolveExtensionIndex];
|
||||
}
|
||||
|
||||
try {
|
||||
return require.resolve(value, { paths: [path.dirname(state.filename)] });
|
||||
} catch (error) {
|
||||
if (
|
||||
error.code === "MODULE_NOT_FOUND" &&
|
||||
resolveExtensions &&
|
||||
(resolveExtensionIndex === undefined ||
|
||||
resolveExtensionIndex < resolveExtensions.length - 1)
|
||||
) {
|
||||
const resolveExtensionIdx = (resolveExtensionIndex || -1) + 1;
|
||||
return resolveAbsolute(value, state, resolveExtensionIdx);
|
||||
}
|
||||
|
||||
if (error.code === "MODULE_NOT_FOUND" && ignoreModuleNotFound) {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const importDeclaration = (p, state) => {
|
||||
if (p.node.type !== "ImportDeclaration") {
|
||||
return;
|
||||
}
|
||||
const absolute =
|
||||
hasRelativeModules && p.node.source.value.startsWith(".")
|
||||
? resolveAbsolute(p.node.source.value, state)
|
||||
: p.node.source.value;
|
||||
|
||||
if (!absolute || !(absolute in modules)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const module = modules[absolute];
|
||||
|
||||
for (const specifier of p.node.specifiers) {
|
||||
if (
|
||||
specifier.type === "ImportDefaultSpecifier" &&
|
||||
specifier.local &&
|
||||
specifier.local.type === "Identifier"
|
||||
) {
|
||||
if (!("default" in module)) {
|
||||
throw new Error(
|
||||
"babel-plugin-inline-constants: cannot access default export from `" +
|
||||
p.node.source.value +
|
||||
"`"
|
||||
);
|
||||
}
|
||||
|
||||
const variableValue = toLiteral(module.default);
|
||||
const variable = t.variableDeclarator(
|
||||
t.identifier(specifier.local.name),
|
||||
variableValue
|
||||
);
|
||||
|
||||
p.insertBefore({
|
||||
type: "VariableDeclaration",
|
||||
kind: "const",
|
||||
declarations: [variable],
|
||||
});
|
||||
} else if (
|
||||
specifier.type === "ImportSpecifier" &&
|
||||
specifier.imported &&
|
||||
specifier.imported.type === "Identifier" &&
|
||||
specifier.local &&
|
||||
specifier.local.type === "Identifier"
|
||||
) {
|
||||
if (!(specifier.imported.name in module)) {
|
||||
throw new Error(
|
||||
"babel-plugin-inline-constants: cannot access `" +
|
||||
specifier.imported.name +
|
||||
"` from `" +
|
||||
p.node.source.value +
|
||||
"`"
|
||||
);
|
||||
}
|
||||
|
||||
const variableValue = toLiteral(module[specifier.imported.name]);
|
||||
const variable = t.variableDeclarator(
|
||||
t.identifier(specifier.local.name),
|
||||
variableValue
|
||||
);
|
||||
|
||||
p.insertBefore({
|
||||
type: "VariableDeclaration",
|
||||
kind: "const",
|
||||
declarations: [variable],
|
||||
});
|
||||
} else {
|
||||
throw new Error("Cannot handle specifier `" + specifier.type + "`");
|
||||
}
|
||||
}
|
||||
p.remove();
|
||||
};
|
||||
|
||||
return {
|
||||
visitor: {
|
||||
ImportDeclaration: importDeclaration,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
|
||||
"frontend-modern": {
|
||||
"app": 561513,
|
||||
"core": 54473,
|
||||
"authorize": 544272,
|
||||
"onboarding": 647136
|
||||
},
|
||||
"frontend-legacy": {
|
||||
"app": 790323,
|
||||
"core": 237208,
|
||||
"authorize": 765464,
|
||||
"onboarding": 918679
|
||||
}
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
const path = require("path");
|
||||
const env = require("./env.cjs");
|
||||
const paths = require("./paths.cjs");
|
||||
const { dependencies } = require("../package.json");
|
||||
|
||||
const BABEL_PLUGINS = path.join(__dirname, "babel-plugins");
|
||||
|
||||
// GitHub base URL to use for production source maps
|
||||
// Nightly builds use the commit SHA, otherwise assumes there is a tag that matches the version
|
||||
module.exports.sourceMapURL = () => {
|
||||
const ref = env.version().endsWith("dev")
|
||||
? process.env.GITHUB_SHA || "dev"
|
||||
: env.version();
|
||||
return `https://raw.githubusercontent.com/home-assistant/frontend/${ref}/`;
|
||||
};
|
||||
|
||||
// Files from NPM Packages that should not be imported
|
||||
module.exports.ignorePackages = () => [];
|
||||
|
||||
// Files from NPM packages that we should replace with empty file
|
||||
module.exports.emptyPackages = ({ isLandingPageBuild }) =>
|
||||
[
|
||||
// Icons in landingpage conflict with icons in HA so we don't load.
|
||||
isLandingPageBuild &&
|
||||
require.resolve(
|
||||
path.resolve(paths.root_dir, "src/components/ha-icon.ts")
|
||||
),
|
||||
isLandingPageBuild &&
|
||||
require.resolve(
|
||||
path.resolve(paths.root_dir, "src/components/ha-icon-picker.ts")
|
||||
),
|
||||
].filter(Boolean);
|
||||
|
||||
module.exports.definedVars = ({ isProdBuild, latestBuild, defineOverlay }) => ({
|
||||
__DEV__: !isProdBuild,
|
||||
__BUILD__: JSON.stringify(latestBuild ? "modern" : "legacy"),
|
||||
__VERSION__: JSON.stringify(env.version()),
|
||||
__DEMO__: false,
|
||||
__BACKWARDS_COMPAT__: false,
|
||||
__STATIC_PATH__: "/static/",
|
||||
__HASS_URL__: `\`${
|
||||
"HASS_URL" in process.env
|
||||
? process.env.HASS_URL
|
||||
: // eslint-disable-next-line no-template-curly-in-string
|
||||
"${location.protocol}//${location.host}"
|
||||
}\``,
|
||||
"process.env.NODE_ENV": JSON.stringify(
|
||||
isProdBuild ? "production" : "development"
|
||||
),
|
||||
...defineOverlay,
|
||||
});
|
||||
|
||||
module.exports.htmlMinifierOptions = {
|
||||
caseSensitive: true,
|
||||
collapseWhitespace: true,
|
||||
conservativeCollapse: true,
|
||||
decodeEntities: true,
|
||||
removeComments: true,
|
||||
removeRedundantAttributes: true,
|
||||
minifyCSS: {
|
||||
compatibility: "*,-properties.zeroUnits",
|
||||
},
|
||||
};
|
||||
|
||||
module.exports.terserOptions = ({ latestBuild, isTestBuild }) => ({
|
||||
safari10: !latestBuild,
|
||||
ecma: latestBuild ? 2015 : 5,
|
||||
module: latestBuild,
|
||||
format: { comments: false },
|
||||
sourceMap: !isTestBuild,
|
||||
});
|
||||
|
||||
/** @type {import('@rspack/core').SwcLoaderOptions} */
|
||||
module.exports.swcOptions = () => ({
|
||||
jsc: {
|
||||
loose: true,
|
||||
externalHelpers: true,
|
||||
target: "ES2021",
|
||||
parser: {
|
||||
syntax: "typescript",
|
||||
decorators: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
|
||||
babelrc: false,
|
||||
compact: false,
|
||||
assumptions: {
|
||||
privateFieldsAsProperties: true,
|
||||
setPublicClassFields: true,
|
||||
setSpreadProperties: true,
|
||||
},
|
||||
browserslistEnv: latestBuild ? "modern" : `legacy${sw ? "-sw" : ""}`,
|
||||
presets: [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
shippedProposals: true,
|
||||
},
|
||||
],
|
||||
],
|
||||
plugins: [
|
||||
// Inject Core-JS polyfills on demand. Babel 8 removed preset-env's
|
||||
// `useBuiltIns`/`corejs` options, so the equivalent polyfill provider is
|
||||
// configured directly here (`usage-global` matches the old `useBuiltIns: "usage"`).
|
||||
[
|
||||
"babel-plugin-polyfill-corejs3",
|
||||
{
|
||||
method: "usage-global",
|
||||
version: dependencies["core-js"],
|
||||
shippedProposals: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
path.join(BABEL_PLUGINS, "inline-constants-plugin.cjs"),
|
||||
{
|
||||
modules: ["@mdi/js"],
|
||||
ignoreModuleNotFound: true,
|
||||
},
|
||||
],
|
||||
// Import helpers and regenerator from runtime package.
|
||||
// `moduleName` is pinned so helpers resolve from `@babel/runtime`: the
|
||||
// corejs3 polyfill provider above otherwise redirects them to the
|
||||
// (uninstalled) `@babel/runtime-corejs3`, which preset-env used to suppress
|
||||
// internally when it owned the polyfill injection via `useBuiltIns`.
|
||||
[
|
||||
"@babel/plugin-transform-runtime",
|
||||
{ version: dependencies["@babel/runtime"], moduleName: "@babel/runtime" },
|
||||
],
|
||||
"@babel/plugin-transform-class-properties",
|
||||
"@babel/plugin-transform-private-methods",
|
||||
].filter(Boolean),
|
||||
exclude: [
|
||||
// \\ for Windows, / for Mac OS and Linux
|
||||
/node_modules[\\/]core-js/,
|
||||
],
|
||||
sourceMaps: !isTestBuild,
|
||||
overrides: [
|
||||
{
|
||||
// Add plugin to inject various polyfills, excluding the polyfills
|
||||
// themselves to prevent self-injection.
|
||||
plugins: [
|
||||
[
|
||||
path.join(BABEL_PLUGINS, "custom-polyfill-plugin.js"),
|
||||
{ method: "usage-global" },
|
||||
],
|
||||
],
|
||||
exclude: [
|
||||
path.join(paths.root_dir, "src/resources/polyfills"),
|
||||
...[
|
||||
"@formatjs/(?:ecma402-abstract|intl-\\w+)",
|
||||
"@lit-labs/virtualizer/polyfills",
|
||||
"@webcomponents/scoped-custom-element-registry",
|
||||
"element-internals-polyfill",
|
||||
"proxy-polyfill",
|
||||
"unfetch",
|
||||
].map((p) => new RegExp(`/node_modules/${p}/`)),
|
||||
],
|
||||
},
|
||||
{
|
||||
// Use unambiguous for dependencies so that require() is correctly injected into CommonJS files
|
||||
// Exclusions are needed in some cases where ES modules have no static imports or exports, such as polyfills
|
||||
// (otherwise babel-plugin-polyfill-corejs3 injects bare require("core-js/modules/...") calls
|
||||
// that rspack does not transform, causing ReferenceError in browsers like Safari 14).
|
||||
sourceType: "unambiguous",
|
||||
include: /\/node_modules\//,
|
||||
exclude: [
|
||||
"element-internals-polyfill",
|
||||
"@?lit(?:-labs|-element|-html)?",
|
||||
"@formatjs/(?:ecma402-abstract|intl-\\w+)",
|
||||
].map((p) => new RegExp(`/node_modules/${p}/`)),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const nameSuffix = (latestBuild) => (latestBuild ? "-modern" : "-legacy");
|
||||
|
||||
const outputPath = (outputRoot, latestBuild) =>
|
||||
path.resolve(outputRoot, latestBuild ? "frontend_latest" : "frontend_es5");
|
||||
|
||||
const publicPath = (latestBuild, root = "") =>
|
||||
latestBuild ? `${root}/frontend_latest/` : `${root}/frontend_es5/`;
|
||||
|
||||
/*
|
||||
BundleConfig {
|
||||
// Object with entrypoints that need to be bundled
|
||||
entry: { [name: string]: pathToFile },
|
||||
// Folder where bundled files need to be written
|
||||
outputPath: string,
|
||||
// absolute url-path where bundled files can be found
|
||||
publicPath: string,
|
||||
// extra definitions that we need to replace in source
|
||||
defineOverlay: {[name: string]: value },
|
||||
// if this is a production build
|
||||
isProdBuild: boolean,
|
||||
// If we're targeting latest browsers
|
||||
latestBuild: boolean,
|
||||
// If we're doing a stats build (create nice chunk names)
|
||||
isStatsBuild: boolean,
|
||||
// If it's just a test build in CI, skip time on source map generation
|
||||
isTestBuild: boolean,
|
||||
// Names of entrypoints that should not be hashed
|
||||
dontHash: Set<string>
|
||||
}
|
||||
*/
|
||||
|
||||
module.exports.config = {
|
||||
app({ isProdBuild, latestBuild, isStatsBuild, isTestBuild, isWDS }) {
|
||||
return {
|
||||
name: "frontend" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
"service-worker": !latestBuild
|
||||
? {
|
||||
import: "./src/entrypoints/service-worker.ts",
|
||||
layer: "sw",
|
||||
}
|
||||
: "./src/entrypoints/service-worker.ts",
|
||||
app: "./src/entrypoints/app.ts",
|
||||
authorize: "./src/entrypoints/authorize.ts",
|
||||
onboarding: "./src/entrypoints/onboarding.ts",
|
||||
core: "./src/entrypoints/core.ts",
|
||||
"custom-panel": "./src/entrypoints/custom-panel.ts",
|
||||
},
|
||||
outputPath: outputPath(paths.app_output_root, latestBuild),
|
||||
publicPath: publicPath(latestBuild),
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
isWDS,
|
||||
};
|
||||
},
|
||||
|
||||
demo({ isProdBuild, latestBuild, isStatsBuild }) {
|
||||
return {
|
||||
name: "demo" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
main: path.resolve(paths.demo_dir, "src/entrypoint.ts"),
|
||||
},
|
||||
outputPath: outputPath(paths.demo_output_root, latestBuild),
|
||||
publicPath: publicPath(latestBuild),
|
||||
defineOverlay: {
|
||||
__VERSION__: JSON.stringify(`DEMO-${env.version()}`),
|
||||
__DEMO__: true,
|
||||
},
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
};
|
||||
},
|
||||
|
||||
cast({ isProdBuild, latestBuild }) {
|
||||
const entry = {
|
||||
launcher: path.resolve(paths.cast_dir, "src/launcher/entrypoint.ts"),
|
||||
media: path.resolve(paths.cast_dir, "src/media/entrypoint.ts"),
|
||||
};
|
||||
|
||||
if (latestBuild) {
|
||||
entry.receiver = path.resolve(
|
||||
paths.cast_dir,
|
||||
"src/receiver/entrypoint.ts"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name: "cast" + nameSuffix(latestBuild),
|
||||
entry,
|
||||
outputPath: outputPath(paths.cast_output_root, latestBuild),
|
||||
publicPath: publicPath(latestBuild),
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
defineOverlay: {
|
||||
__BACKWARDS_COMPAT__: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
gallery({ isProdBuild, latestBuild }) {
|
||||
return {
|
||||
name: "gallery" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
entrypoint: path.resolve(paths.gallery_dir, "src/entrypoint.js"),
|
||||
},
|
||||
outputPath: outputPath(paths.gallery_output_root, latestBuild),
|
||||
publicPath: publicPath(latestBuild),
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
defineOverlay: {
|
||||
__DEMO__: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
landingPage({ isProdBuild, latestBuild }) {
|
||||
return {
|
||||
name: "landing-page" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
entrypoint: path.resolve(paths.landingPage_dir, "src/entrypoint.js"),
|
||||
},
|
||||
outputPath: outputPath(paths.landingPage_output_root, latestBuild),
|
||||
publicPath: publicPath(latestBuild),
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isLandingPageBuild: true,
|
||||
};
|
||||
},
|
||||
|
||||
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
|
||||
return {
|
||||
name: "e2e-test-app" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
|
||||
},
|
||||
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
|
||||
publicPath: publicPath(latestBuild),
|
||||
defineOverlay: {
|
||||
__VERSION__: JSON.stringify(`E2E-TEST-${env.version()}`),
|
||||
__DEMO__: true,
|
||||
},
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const path = require("path");
|
||||
const env = require("./env.js");
|
||||
const paths = require("./paths.js");
|
||||
|
||||
// Files from NPM Packages that should not be imported
|
||||
module.exports.ignorePackages = ({ latestBuild }) => [
|
||||
// Part of yaml.js and only used for !!js functions that we don't use
|
||||
require.resolve("esprima"),
|
||||
];
|
||||
|
||||
// Files from NPM packages that we should replace with empty file
|
||||
module.exports.emptyPackages = ({ latestBuild }) =>
|
||||
[
|
||||
// Contains all color definitions for all material color sets.
|
||||
// We don't use it
|
||||
require.resolve("@polymer/paper-styles/color.js"),
|
||||
require.resolve("@polymer/paper-styles/default-theme.js"),
|
||||
// Loads stuff from a CDN
|
||||
require.resolve("@polymer/font-roboto/roboto.js"),
|
||||
require.resolve("@vaadin/vaadin-material-styles/typography.js"),
|
||||
require.resolve("@vaadin/vaadin-material-styles/font-icons.js"),
|
||||
// Compatibility not needed for latest builds
|
||||
latestBuild &&
|
||||
// wrapped in require.resolve so it blows up if file no longer exists
|
||||
require.resolve(
|
||||
path.resolve(paths.polymer_dir, "src/resources/compatibility.ts")
|
||||
),
|
||||
// This polyfill is loaded in workers to support ES5, filter it out.
|
||||
latestBuild && require.resolve("proxy-polyfill/src/index.js"),
|
||||
].filter(Boolean);
|
||||
|
||||
module.exports.definedVars = ({ isProdBuild, latestBuild, defineOverlay }) => ({
|
||||
__DEV__: !isProdBuild,
|
||||
__BUILD__: JSON.stringify(latestBuild ? "latest" : "es5"),
|
||||
__VERSION__: JSON.stringify(env.version()),
|
||||
__DEMO__: false,
|
||||
__SUPERVISOR__: false,
|
||||
__BACKWARDS_COMPAT__: false,
|
||||
__STATIC_PATH__: "/static/",
|
||||
"process.env.NODE_ENV": JSON.stringify(
|
||||
isProdBuild ? "production" : "development"
|
||||
),
|
||||
...defineOverlay,
|
||||
});
|
||||
|
||||
module.exports.terserOptions = (latestBuild) => ({
|
||||
safari10: !latestBuild,
|
||||
ecma: latestBuild ? undefined : 5,
|
||||
output: { comments: false },
|
||||
});
|
||||
|
||||
module.exports.babelOptions = ({ latestBuild }) => ({
|
||||
babelrc: false,
|
||||
compact: false,
|
||||
presets: [
|
||||
!latestBuild && [
|
||||
"@babel/preset-env",
|
||||
{
|
||||
useBuiltIns: "entry",
|
||||
corejs: "3.15",
|
||||
bugfixes: true,
|
||||
},
|
||||
],
|
||||
"@babel/preset-typescript",
|
||||
].filter(Boolean),
|
||||
plugins: [
|
||||
[
|
||||
path.resolve(
|
||||
paths.polymer_dir,
|
||||
"build-scripts/babel-plugins/inline-constants-plugin.js"
|
||||
),
|
||||
{
|
||||
modules: ["@mdi/js"],
|
||||
ignoreModuleNotFound: true,
|
||||
},
|
||||
],
|
||||
// Part of ES2018. Converts {...a, b: 2} to Object.assign({}, a, {b: 2})
|
||||
!latestBuild && [
|
||||
"@babel/plugin-proposal-object-rest-spread",
|
||||
{ loose: true, useBuiltIns: true },
|
||||
],
|
||||
// Only support the syntax, Webpack will handle it.
|
||||
"@babel/plugin-syntax-import-meta",
|
||||
"@babel/plugin-syntax-dynamic-import",
|
||||
"@babel/plugin-syntax-top-level-await",
|
||||
"@babel/plugin-proposal-optional-chaining",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator",
|
||||
["@babel/plugin-proposal-decorators", { decoratorsBeforeExport: true }],
|
||||
["@babel/plugin-proposal-private-methods", { loose: true }],
|
||||
["@babel/plugin-proposal-private-property-in-object", { loose: true }],
|
||||
["@babel/plugin-proposal-class-properties", { loose: true }],
|
||||
].filter(Boolean),
|
||||
exclude: [
|
||||
// \\ for Windows, / for Mac OS and Linux
|
||||
/node_modules[\\/]core-js/,
|
||||
/node_modules[\\/]webpack[\\/]buildin/,
|
||||
],
|
||||
});
|
||||
|
||||
const outputPath = (outputRoot, latestBuild) =>
|
||||
path.resolve(outputRoot, latestBuild ? "frontend_latest" : "frontend_es5");
|
||||
|
||||
const publicPath = (latestBuild, root = "") =>
|
||||
latestBuild ? `${root}/frontend_latest/` : `${root}/frontend_es5/`;
|
||||
|
||||
/*
|
||||
BundleConfig {
|
||||
// Object with entrypoints that need to be bundled
|
||||
entry: { [name: string]: pathToFile },
|
||||
// Folder where bundled files need to be written
|
||||
outputPath: string,
|
||||
// absolute url-path where bundled files can be found
|
||||
publicPath: string,
|
||||
// extra definitions that we need to replace in source
|
||||
defineOverlay: {[name: string]: value },
|
||||
// if this is a production build
|
||||
isProdBuild: boolean,
|
||||
// If we're targeting latest browsers
|
||||
latestBuild: boolean,
|
||||
// If we're doing a stats build (create nice chunk names)
|
||||
isStatsBuild: boolean,
|
||||
// Names of entrypoints that should not be hashed
|
||||
dontHash: Set<string>
|
||||
}
|
||||
*/
|
||||
|
||||
module.exports.config = {
|
||||
app({ isProdBuild, latestBuild, isStatsBuild, isWDS }) {
|
||||
return {
|
||||
entry: {
|
||||
service_worker: "./src/entrypoints/service_worker.ts",
|
||||
app: "./src/entrypoints/app.ts",
|
||||
authorize: "./src/entrypoints/authorize.ts",
|
||||
onboarding: "./src/entrypoints/onboarding.ts",
|
||||
core: "./src/entrypoints/core.ts",
|
||||
"custom-panel": "./src/entrypoints/custom-panel.ts",
|
||||
},
|
||||
outputPath: outputPath(paths.app_output_root, latestBuild),
|
||||
publicPath: publicPath(latestBuild),
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isWDS,
|
||||
};
|
||||
},
|
||||
|
||||
demo({ isProdBuild, latestBuild, isStatsBuild }) {
|
||||
return {
|
||||
entry: {
|
||||
main: path.resolve(paths.demo_dir, "src/entrypoint.ts"),
|
||||
},
|
||||
outputPath: outputPath(paths.demo_output_root, latestBuild),
|
||||
publicPath: publicPath(latestBuild),
|
||||
defineOverlay: {
|
||||
__VERSION__: JSON.stringify(`DEMO-${env.version()}`),
|
||||
__DEMO__: true,
|
||||
},
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
};
|
||||
},
|
||||
|
||||
cast({ isProdBuild, latestBuild }) {
|
||||
const entry = {
|
||||
launcher: path.resolve(paths.cast_dir, "src/launcher/entrypoint.ts"),
|
||||
};
|
||||
|
||||
if (latestBuild) {
|
||||
entry.receiver = path.resolve(
|
||||
paths.cast_dir,
|
||||
"src/receiver/entrypoint.ts"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
entry,
|
||||
outputPath: outputPath(paths.cast_output_root, latestBuild),
|
||||
publicPath: publicPath(latestBuild),
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
defineOverlay: {
|
||||
__BACKWARDS_COMPAT__: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
hassio({ isProdBuild, latestBuild }) {
|
||||
return {
|
||||
entry: {
|
||||
entrypoint: path.resolve(paths.hassio_dir, "src/entrypoint.ts"),
|
||||
},
|
||||
outputPath: outputPath(paths.hassio_output_root, latestBuild),
|
||||
publicPath: publicPath(latestBuild, paths.hassio_publicPath),
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
defineOverlay: {
|
||||
__SUPERVISOR__: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
gallery({ isProdBuild, latestBuild }) {
|
||||
return {
|
||||
entry: {
|
||||
entrypoint: path.resolve(paths.gallery_dir, "src/entrypoint.js"),
|
||||
},
|
||||
outputPath: outputPath(paths.gallery_output_root, latestBuild),
|
||||
publicPath: publicPath(latestBuild),
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
defineOverlay: {
|
||||
__DEMO__: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,155 +0,0 @@
|
||||
/* global require, process, __dirname */
|
||||
// Enforce a strict size budget on the initial JS of the most critical
|
||||
// entrypoints (`app` and `core`). These two are downloaded on every cold load
|
||||
// before anything interactive can happen, so unintended growth here hurts
|
||||
// first-load performance directly.
|
||||
//
|
||||
// In production rspack does not split initial chunks (splitChunks only operates
|
||||
// on `!chunk.canBeInitial()`), so each entrypoint resolves to a single initial
|
||||
// JS asset. We read the per-build stats written by StatsWriterPlugin and compare
|
||||
// the entrypoint's initial JS size against a committed budget.
|
||||
//
|
||||
// Usage:
|
||||
// node build-scripts/check-bundle-size.cjs # enforce, exit 1 on regression
|
||||
// node build-scripts/check-bundle-size.cjs --update # rewrite budgets from current sizes
|
||||
// node build-scripts/check-bundle-size.cjs --update --headroom=3 # current + 3% headroom
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const paths = require("./paths.cjs");
|
||||
|
||||
// Entrypoints whose initial JS we hold to a strict budget. These are all
|
||||
// downloaded on a user-facing cold load before anything interactive can happen:
|
||||
// `app`/`core` for the main app, plus the standalone `authorize` and
|
||||
// `onboarding` pages. `custom-panel` is intentionally excluded (only loaded
|
||||
// when a custom panel is opened).
|
||||
const TRACKED_ENTRYPOINTS = ["app", "core", "authorize", "onboarding"];
|
||||
|
||||
// App build stats files, as written by StatsWriterPlugin (`${name}.json`).
|
||||
const BUILDS = ["frontend-modern", "frontend-legacy"];
|
||||
|
||||
const BUDGET_FILE = path.join(__dirname, "bundle-budget.json");
|
||||
const STATS_DIR = path.join(paths.build_dir, "stats");
|
||||
|
||||
const readStats = (build) => {
|
||||
const file = path.join(STATS_DIR, `${build}.json`);
|
||||
if (!fs.existsSync(file)) {
|
||||
throw new Error(
|
||||
`Missing stats file: ${path.relative(process.cwd(), file)}.\n` +
|
||||
`Run a production build first (e.g. \`gulp build-app\`), then re-run this check.`
|
||||
);
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
};
|
||||
|
||||
// Initial JS bytes for an entrypoint = sum of the .js asset sizes of its initial
|
||||
// entry chunk(s). Sizes are raw (uncompressed) bytes, matching the stats output.
|
||||
const entrypointInitialJS = (stats, entrypoint) => {
|
||||
const assetSize = new Map(stats.assets.map((a) => [a.name, a.size]));
|
||||
let total = 0;
|
||||
let found = false;
|
||||
for (const chunk of stats.chunks) {
|
||||
if (!chunk.entry || !chunk.initial) {
|
||||
continue;
|
||||
}
|
||||
if (!(chunk.names || []).includes(entrypoint)) {
|
||||
continue;
|
||||
}
|
||||
found = true;
|
||||
for (const file of chunk.files || []) {
|
||||
if (file.endsWith(".js") && assetSize.has(file)) {
|
||||
total += assetSize.get(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
throw new Error(`Entrypoint "${entrypoint}" not found in bundle stats.`);
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
const kib = (bytes) => `${(bytes / 1024).toFixed(1)} KiB`;
|
||||
|
||||
const main = () => {
|
||||
const update = process.argv.includes("--update");
|
||||
const headroomArg = process.argv.find((a) => a.startsWith("--headroom="));
|
||||
const headroom = headroomArg ? Number(headroomArg.split("=")[1]) : 0;
|
||||
|
||||
const current = {};
|
||||
for (const build of BUILDS) {
|
||||
const stats = readStats(build);
|
||||
current[build] = {};
|
||||
for (const entrypoint of TRACKED_ENTRYPOINTS) {
|
||||
current[build][entrypoint] = entrypointInitialJS(stats, entrypoint);
|
||||
}
|
||||
}
|
||||
|
||||
if (update) {
|
||||
const budget = { _comment: BUDGET_COMMENT };
|
||||
for (const build of BUILDS) {
|
||||
budget[build] = {};
|
||||
for (const entrypoint of TRACKED_ENTRYPOINTS) {
|
||||
budget[build][entrypoint] = Math.ceil(
|
||||
current[build][entrypoint] * (1 + headroom / 100)
|
||||
);
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(BUDGET_FILE, `${JSON.stringify(budget, null, 2)}\n`);
|
||||
console.log(
|
||||
`Updated ${path.relative(process.cwd(), BUDGET_FILE)} from current sizes` +
|
||||
(headroom ? ` (+${headroom}% headroom).` : ".")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(BUDGET_FILE)) {
|
||||
throw new Error(
|
||||
`Missing budget file ${path.relative(process.cwd(), BUDGET_FILE)}.\n` +
|
||||
`Seed it from a production build with: node build-scripts/check-bundle-size.cjs --update --headroom=3`
|
||||
);
|
||||
}
|
||||
const budget = JSON.parse(fs.readFileSync(BUDGET_FILE, "utf8"));
|
||||
|
||||
let failed = false;
|
||||
console.log("Initial JS budget (entry chunks, raw bytes):\n");
|
||||
for (const build of BUILDS) {
|
||||
for (const entrypoint of TRACKED_ENTRYPOINTS) {
|
||||
const actual = current[build][entrypoint];
|
||||
const limit = budget[build] && budget[build][entrypoint];
|
||||
if (typeof limit !== "number") {
|
||||
failed = true;
|
||||
console.log(
|
||||
` ✗ ${build} / ${entrypoint}: no budget set (current ${kib(actual)})`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const ok = actual <= limit;
|
||||
const delta = (((actual - limit) / limit) * 100).toFixed(1);
|
||||
console.log(
|
||||
` ${ok ? "✓" : "✗"} ${build} / ${entrypoint}: ` +
|
||||
`${kib(actual)} / ${kib(limit)}${ok ? "" : ` (+${delta}% over budget)`}`
|
||||
);
|
||||
if (!ok) {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error(
|
||||
"\nInitial JS budget exceeded for a critical entrypoint.\n" +
|
||||
"Investigate what was pulled into the entry chunk (a static import that should be lazy?).\n" +
|
||||
"If the growth is intentional, re-seed the budget:\n" +
|
||||
" node build-scripts/check-bundle-size.cjs --update --headroom=3"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nAll tracked entrypoints within budget.");
|
||||
};
|
||||
|
||||
const BUDGET_COMMENT =
|
||||
"Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. " +
|
||||
"Enforced by build-scripts/check-bundle-size.cjs in CI. " +
|
||||
"Re-seed after an intentional change with `--update --headroom=<percent>`.";
|
||||
|
||||
main();
|
||||
@@ -1,678 +0,0 @@
|
||||
// Manage a Home Assistant frontend dev server with an agent-friendly interface.
|
||||
//
|
||||
// node build-scripts/dev-server.mjs --suite <suite> [mode] [extra args]
|
||||
//
|
||||
// (no mode) Run in the foreground.
|
||||
// --background Start detached, wait until it is ready, print the URL
|
||||
// (when it has one) and pid, then exit and leave it running.
|
||||
// --status Report whether the suite's dev server is running.
|
||||
// --stop Stop a running background dev server.
|
||||
// --logs [--follow] Print (or follow) the background dev server log.
|
||||
//
|
||||
// Extra args (for example -p or -c on app-serve) are forwarded to the underlying
|
||||
// script. Suites use one of two liveness models:
|
||||
//
|
||||
// health demo, gallery, e2e-app: a fixed port plus the /__ha_dev_status
|
||||
// endpoint each dev server exposes (see runDevServer in
|
||||
// build-scripts/gulp/rspack.js). The port is the source of truth and
|
||||
// the pid is found from it; no state file.
|
||||
// process app (yarn dev) and app-serve (yarn dev:serve): the app watcher has
|
||||
// no health endpoint, and plain yarn dev has no port at all, so these
|
||||
// track a pidfile and treat the first "Build done" log line as ready.
|
||||
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
".."
|
||||
);
|
||||
const gulpBin = path.join(repoRoot, "node_modules", ".bin", "gulp");
|
||||
const developAndServeScript = path.join(
|
||||
repoRoot,
|
||||
"script",
|
||||
"develop_and_serve"
|
||||
);
|
||||
const logDir = path.join(repoRoot, "node_modules", ".cache", "ha-dev-server");
|
||||
|
||||
// Each suite names its yarn alias (for hints), a liveness model, and how to
|
||||
// spawn it. health suites carry a fixed port; process suites carry the log line
|
||||
// that means "ready" and, for app-serve, forward extra args to the script.
|
||||
const SUITES = {
|
||||
"e2e-app": {
|
||||
alias: "test:e2e:app:dev",
|
||||
liveness: "health",
|
||||
port: 8095,
|
||||
spawn: { cmd: gulpBin, args: ["develop-e2e-test-app"] },
|
||||
},
|
||||
demo: {
|
||||
alias: "dev:demo",
|
||||
liveness: "health",
|
||||
port: 8090,
|
||||
spawn: { cmd: gulpBin, args: ["develop-demo"] },
|
||||
},
|
||||
gallery: {
|
||||
alias: "dev:gallery",
|
||||
liveness: "health",
|
||||
port: 8100,
|
||||
spawn: { cmd: gulpBin, args: ["develop-gallery"] },
|
||||
},
|
||||
app: {
|
||||
alias: "dev",
|
||||
liveness: "process",
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: gulpBin, args: ["develop-app"] },
|
||||
},
|
||||
"app-serve": {
|
||||
alias: "dev:serve",
|
||||
liveness: "process",
|
||||
acceptsArgs: true,
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: developAndServeScript, args: [] },
|
||||
},
|
||||
};
|
||||
|
||||
// Cover a cold build on a slow machine before the server starts listening.
|
||||
// Override with HA_DEV_SERVER_TIMEOUT (seconds).
|
||||
const READY_TIMEOUT_MS =
|
||||
Number(process.env.HA_DEV_SERVER_TIMEOUT || "180") * 1000;
|
||||
|
||||
// Detect a coding agent from a small set of environment markers set by common
|
||||
// agent CLIs (env-only; no process-ancestry detection).
|
||||
const detectAgent = () => {
|
||||
const env = process.env;
|
||||
const has = (name) => Boolean(env[name]);
|
||||
const eq = (name, value) => env[name] === value;
|
||||
const signals = {
|
||||
opencode: () =>
|
||||
[
|
||||
"OPENCODE",
|
||||
"OPENCODE_BIN_PATH",
|
||||
"OPENCODE_SERVER",
|
||||
"OPENCODE_APP_INFO",
|
||||
].some(has),
|
||||
"claude-code": () => has("CLAUDECODE"),
|
||||
cursor: () => has("CURSOR_TRACE_ID"),
|
||||
"github-copilot": () =>
|
||||
eq("TERM_PROGRAM", "vscode") && eq("GIT_PAGER", "cat"),
|
||||
// Convention shared by several agents (Crush, Amp, ...).
|
||||
generic: () => has("AGENT") || has("AI_AGENT"),
|
||||
};
|
||||
return Object.keys(signals).find((id) => signals[id]());
|
||||
};
|
||||
|
||||
const usage = () => {
|
||||
const suites = Object.keys(SUITES).join("|");
|
||||
process.stderr.write(
|
||||
`Usage: node build-scripts/dev-server.mjs --suite <${suites}> ` +
|
||||
`[--background | --status | --stop | --logs [--follow]]\n`
|
||||
);
|
||||
};
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const args = {
|
||||
mode: "foreground",
|
||||
follow: false,
|
||||
suite: undefined,
|
||||
passthrough: [],
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
switch (arg) {
|
||||
case "--suite":
|
||||
args.suite = argv[++i];
|
||||
break;
|
||||
case "--background":
|
||||
args.mode = "background";
|
||||
break;
|
||||
case "--status":
|
||||
args.mode = "status";
|
||||
break;
|
||||
case "--stop":
|
||||
args.mode = "stop";
|
||||
break;
|
||||
case "--logs":
|
||||
args.mode = "logs";
|
||||
break;
|
||||
case "--follow":
|
||||
args.follow = true;
|
||||
break;
|
||||
default:
|
||||
// Anything unrecognised is forwarded to the underlying script.
|
||||
args.passthrough.push(arg);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
};
|
||||
|
||||
const sleep = (ms) =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
const logFileFor = (suite) => path.join(logDir, `${suite}.log`);
|
||||
const pidFileFor = (suite) => path.join(logDir, `${suite}.pid`);
|
||||
|
||||
const hints = (suite) => {
|
||||
const alias = `yarn ${SUITES[suite].alias}`;
|
||||
return (
|
||||
` Stop: ${alias} --stop\n` +
|
||||
` Status: ${alias} --status\n` +
|
||||
` Logs: ${alias} --logs\n`
|
||||
);
|
||||
};
|
||||
|
||||
// --- shared spawning and lifecycle ------------------------------------------
|
||||
|
||||
// Signal the whole process group (the background server is its group leader),
|
||||
// falling back to the bare pid if that is not permitted.
|
||||
const killProcessTree = (pid, sig) => {
|
||||
try {
|
||||
process.kill(-pid, sig);
|
||||
} catch {
|
||||
try {
|
||||
process.kill(pid, sig);
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const urlSuffix = (port) => (port ? ` at http://localhost:${port}` : "");
|
||||
|
||||
// Run a server in the foreground, inheriting stdio; resolve with its exit code.
|
||||
const spawnInherit = (cmd, args) =>
|
||||
new Promise((resolve) => {
|
||||
const child = spawn(cmd, args, { cwd: repoRoot, stdio: "inherit" });
|
||||
child.on("exit", (code) => resolve(code ?? 0));
|
||||
});
|
||||
|
||||
// Spawn a detached server that writes stdout and stderr to the suite's log file.
|
||||
const spawnDetachedToLog = (suite, cmd, args) => {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
const logFile = logFileFor(suite);
|
||||
const fd = fs.openSync(logFile, "w");
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: repoRoot,
|
||||
detached: true,
|
||||
stdio: ["ignore", fd, fd],
|
||||
});
|
||||
fs.closeSync(fd);
|
||||
child.unref();
|
||||
return { child, logFile };
|
||||
};
|
||||
|
||||
// Poll until the server is ready, the child exits, or we time out. Prints the
|
||||
// progress dots and outcome; returns 0 when ready, 1 otherwise. onExit runs if
|
||||
// the child dies before it is ready (used to clear a stale pidfile).
|
||||
const awaitReady = async ({ suite, child, logFile, port, isReady, onExit }) => {
|
||||
let childExited = false;
|
||||
child.on("exit", () => {
|
||||
childExited = true;
|
||||
});
|
||||
const deadline = Date.now() + READY_TIMEOUT_MS;
|
||||
process.stdout.write(`Starting ${suite} dev server`);
|
||||
/* eslint-disable no-await-in-loop -- poll until the server is ready */
|
||||
while (Date.now() < deadline) {
|
||||
if (childExited) {
|
||||
process.stdout.write("\n");
|
||||
process.stderr.write(
|
||||
`Dev server (${suite}) exited before it was ready. See ${logFile}\n`
|
||||
);
|
||||
onExit?.();
|
||||
return 1;
|
||||
}
|
||||
if (await isReady()) {
|
||||
process.stdout.write("\n");
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) running${urlSuffix(port)} ` +
|
||||
`(pid ${child.pid})\n${hints(suite)}`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
process.stdout.write(".");
|
||||
await sleep(1000);
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
process.stdout.write("\n");
|
||||
process.stderr.write(
|
||||
`Dev server (${suite}) did not become ready within ${
|
||||
READY_TIMEOUT_MS / 1000
|
||||
}s. See ${logFile}\n`
|
||||
);
|
||||
return 1;
|
||||
};
|
||||
|
||||
// Stop a running background server: SIGTERM, wait for it to go, then SIGKILL.
|
||||
// isStopped reports when it is gone; onStopped runs on success (pidfile cleanup).
|
||||
const terminate = async (suite, pid, isStopped, onStopped) => {
|
||||
killProcessTree(pid, "SIGTERM");
|
||||
const deadline = Date.now() + 10_000;
|
||||
/* eslint-disable no-await-in-loop -- poll until the server is gone */
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(300);
|
||||
if (await isStopped()) {
|
||||
onStopped?.();
|
||||
process.stdout.write(`Stopped dev server (${suite}) (pid ${pid}).\n`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
// Escalate if it is still up.
|
||||
killProcessTree(pid, "SIGKILL");
|
||||
await sleep(300);
|
||||
if (!(await isStopped())) {
|
||||
process.stderr.write(
|
||||
`Failed to stop dev server (${suite}) (pid ${pid}). Stop it manually.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
onStopped?.();
|
||||
process.stdout.write(`Stopped dev server (${suite}) (pid ${pid}).\n`);
|
||||
return 0;
|
||||
};
|
||||
|
||||
// --- health liveness (port + /__ha_dev_status) ------------------------------
|
||||
|
||||
/**
|
||||
* Probe the health endpoint. Dev servers bind IPv4 or IPv6 localhost depending
|
||||
* on the OS, so try each; the port is "free" only if every address refuses.
|
||||
* @returns {Promise<{state: "ours" | "foreign" | "free", suite?: string}>}
|
||||
*/
|
||||
const PROBE_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
||||
|
||||
const probe = async (port, timeoutMs = 1000) => {
|
||||
let sawResponse = false;
|
||||
/* eslint-disable no-await-in-loop -- probe localhost addresses in order, stopping at the first that answers */
|
||||
for (const host of PROBE_HOSTS) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(`http://${host}:${port}/__ha_dev_status`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
sawResponse = true;
|
||||
if (res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
if (body && body.server === "ha-frontend-dev") {
|
||||
return { state: "ours", suite: body.suite };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Try the next address.
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
return sawResponse ? { state: "foreign" } : { state: "free" };
|
||||
};
|
||||
|
||||
// Find the pid listening on a port via the first available tool (no state file).
|
||||
const pidFromPort = (port) => {
|
||||
const attempts = [
|
||||
[
|
||||
"lsof",
|
||||
["-ti", `tcp:${port}`, "-sTCP:LISTEN"],
|
||||
(out) => out.trim().split("\n")[0],
|
||||
],
|
||||
[
|
||||
"ss",
|
||||
["-ltnpH", `sport = :${port}`],
|
||||
(out) => out.match(/pid=(\d+)/)?.[1],
|
||||
],
|
||||
["fuser", [`${port}/tcp`], (out) => out.trim().split(/\s+/)[0]],
|
||||
];
|
||||
for (const [cmd, cmdArgs, extract] of attempts) {
|
||||
try {
|
||||
const out = execFileSync(cmd, cmdArgs, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
const pid = Number(extract(out));
|
||||
if (Number.isInteger(pid) && pid > 0) {
|
||||
return pid;
|
||||
}
|
||||
} catch {
|
||||
// Try the next tool.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const runForegroundHealth = async (suite, cfg) => {
|
||||
const { port } = cfg;
|
||||
const status = await probe(port);
|
||||
if (status.state === "ours" && status.suite === suite) {
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) is already running at http://localhost:${port}\n`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
if (status.state === "foreign") {
|
||||
process.stderr.write(
|
||||
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
return spawnInherit(cfg.spawn.cmd, cfg.spawn.args);
|
||||
};
|
||||
|
||||
const runBackgroundHealth = async (suite, cfg) => {
|
||||
const { port } = cfg;
|
||||
const preflight = await probe(port);
|
||||
if (preflight.state === "ours" && preflight.suite === suite) {
|
||||
const pid = pidFromPort(port);
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) already running at http://localhost:${port}` +
|
||||
`${pid ? ` (pid ${pid})` : ""}\n${hints(suite)}`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
if (preflight.state === "foreign") {
|
||||
process.stderr.write(
|
||||
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const { child, logFile } = spawnDetachedToLog(
|
||||
suite,
|
||||
cfg.spawn.cmd,
|
||||
cfg.spawn.args
|
||||
);
|
||||
return awaitReady({
|
||||
suite,
|
||||
child,
|
||||
logFile,
|
||||
port,
|
||||
isReady: async () => {
|
||||
const status = await probe(port, 1000);
|
||||
return status.state === "ours" && status.suite === suite;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const runStatusHealth = async (suite, cfg) => {
|
||||
const { port } = cfg;
|
||||
const status = await probe(port);
|
||||
if (status.state === "ours" && status.suite === suite) {
|
||||
const pid = pidFromPort(port);
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) running at http://localhost:${port}` +
|
||||
`${pid ? ` (pid ${pid})` : ""}\n`
|
||||
);
|
||||
} else if (status.state === "ours") {
|
||||
process.stdout.write(
|
||||
`Port ${port} is serving a different Home Assistant frontend dev server (suite ${status.suite ?? "unknown"}); not ${suite}.\n`
|
||||
);
|
||||
} else if (status.state === "foreign") {
|
||||
process.stdout.write(
|
||||
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
|
||||
);
|
||||
} else {
|
||||
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const runStopHealth = async (suite, cfg) => {
|
||||
const { port } = cfg;
|
||||
const status = await probe(port);
|
||||
if (!(status.state === "ours" && status.suite === suite)) {
|
||||
// Idempotent: stopping something that is not running is a success.
|
||||
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
||||
return 0;
|
||||
}
|
||||
const pid = pidFromPort(port);
|
||||
if (!pid) {
|
||||
process.stderr.write(
|
||||
`Dev server (${suite}) is running but its pid could not be found ` +
|
||||
`(no lsof/ss/fuser?). Stop it manually.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
return terminate(
|
||||
suite,
|
||||
pid,
|
||||
async () => (await probe(port, 800)).state === "free"
|
||||
);
|
||||
};
|
||||
|
||||
// --- process liveness (pidfile + log-readiness) -----------------------------
|
||||
|
||||
const isAlive = (pid) => {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// EPERM means the process exists but is owned by someone else.
|
||||
return err.code === "EPERM";
|
||||
}
|
||||
};
|
||||
|
||||
const readPidFile = (suite) => {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(pidFileFor(suite), "utf8"));
|
||||
if (data && Number.isInteger(data.pid)) {
|
||||
return data;
|
||||
}
|
||||
} catch {
|
||||
// Missing or corrupt.
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const writePidFile = (suite, data) => {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
fs.writeFileSync(pidFileFor(suite), JSON.stringify(data));
|
||||
};
|
||||
|
||||
const removePidFile = (suite) => {
|
||||
try {
|
||||
fs.rmSync(pidFileFor(suite));
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
};
|
||||
|
||||
const logIsReady = (logFile, readyLog) => {
|
||||
try {
|
||||
return readyLog.test(fs.readFileSync(logFile, "utf8"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// app-serve serves on 8124 by default (8123 in a devcontainer), or whatever -p
|
||||
// the caller passed. Used only to show a URL; liveness comes from the pidfile.
|
||||
const resolveServePort = (passthrough) => {
|
||||
const i = passthrough.indexOf("-p");
|
||||
if (i !== -1) {
|
||||
const port = Number(passthrough[i + 1]);
|
||||
if (Number.isInteger(port) && port > 0) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
return process.env.DEVCONTAINER ? 8123 : 8124;
|
||||
};
|
||||
|
||||
const spawnArgs = (cfg, passthrough) => [
|
||||
...cfg.spawn.args,
|
||||
...(cfg.acceptsArgs ? passthrough : []),
|
||||
];
|
||||
|
||||
const runForegroundProcess = async (suite, cfg, passthrough) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (existing && isAlive(existing.pid)) {
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) already running in the background ` +
|
||||
`(pid ${existing.pid}). Stop it with yarn ${cfg.alias} --stop.\n`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
}
|
||||
return spawnInherit(cfg.spawn.cmd, spawnArgs(cfg, passthrough));
|
||||
};
|
||||
|
||||
const runBackgroundProcess = async (suite, cfg, passthrough) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (existing && isAlive(existing.pid)) {
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) already running${urlSuffix(existing.port)} ` +
|
||||
`(pid ${existing.pid})\n${hints(suite)}`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
}
|
||||
|
||||
const { child, logFile } = spawnDetachedToLog(
|
||||
suite,
|
||||
cfg.spawn.cmd,
|
||||
spawnArgs(cfg, passthrough)
|
||||
);
|
||||
|
||||
const port = cfg.acceptsArgs ? resolveServePort(passthrough) : cfg.port;
|
||||
writePidFile(suite, { pid: child.pid, port });
|
||||
|
||||
return awaitReady({
|
||||
suite,
|
||||
child,
|
||||
logFile,
|
||||
port,
|
||||
isReady: () => logIsReady(logFile, cfg.readyLog),
|
||||
onExit: () => removePidFile(suite),
|
||||
});
|
||||
};
|
||||
|
||||
const runStatusProcess = async (suite) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (existing && isAlive(existing.pid)) {
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) running${urlSuffix(existing.port)} ` +
|
||||
`(pid ${existing.pid})\n`
|
||||
);
|
||||
} else {
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
}
|
||||
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const runStopProcess = async (suite) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (!existing || !isAlive(existing.pid)) {
|
||||
// Idempotent: stopping something that is not running is a success.
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
}
|
||||
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
||||
return 0;
|
||||
}
|
||||
const { pid } = existing;
|
||||
return terminate(
|
||||
suite,
|
||||
pid,
|
||||
() => !isAlive(pid),
|
||||
() => removePidFile(suite)
|
||||
);
|
||||
};
|
||||
|
||||
// --- shared -----------------------------------------------------------------
|
||||
|
||||
const runLogs = (suite, follow) => {
|
||||
const logFile = logFileFor(suite);
|
||||
if (!fs.existsSync(logFile)) {
|
||||
process.stdout.write(
|
||||
`No log for the ${suite} dev server yet (${logFile}).\n`
|
||||
);
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
if (!follow) {
|
||||
process.stdout.write(fs.readFileSync(logFile, "utf8"));
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const tail = spawn("tail", ["-f", logFile], { stdio: "inherit" });
|
||||
tail.on("error", () => {
|
||||
// No tail available; fall back to a one-shot dump.
|
||||
process.stdout.write(fs.readFileSync(logFile, "utf8"));
|
||||
resolve(0);
|
||||
});
|
||||
tail.on("exit", (code) => resolve(code ?? 0));
|
||||
});
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const cfg = SUITES[args.suite];
|
||||
if (!cfg) {
|
||||
usage();
|
||||
return 1;
|
||||
}
|
||||
if (args.passthrough.length && !cfg.acceptsArgs) {
|
||||
process.stderr.write(
|
||||
`Ignoring unexpected arguments: ${args.passthrough.join(" ")}\n`
|
||||
);
|
||||
}
|
||||
|
||||
// A plain dev:<suite> under a coding agent backgrounds itself; explicit modes
|
||||
// are untouched.
|
||||
let { mode } = args;
|
||||
if (
|
||||
mode === "foreground" &&
|
||||
!["0", "false"].includes(process.env.HA_DEV_BACKGROUND)
|
||||
) {
|
||||
const agent = detectAgent();
|
||||
if (agent) {
|
||||
process.stdout.write(
|
||||
`Detected coding agent (${agent}); starting in the background. ` +
|
||||
`Set HA_DEV_BACKGROUND=0 to force foreground.\n`
|
||||
);
|
||||
mode = "background";
|
||||
}
|
||||
}
|
||||
|
||||
const health = cfg.liveness === "health";
|
||||
switch (mode) {
|
||||
case "background":
|
||||
return health
|
||||
? runBackgroundHealth(args.suite, cfg)
|
||||
: runBackgroundProcess(args.suite, cfg, args.passthrough);
|
||||
case "status":
|
||||
return health
|
||||
? runStatusHealth(args.suite, cfg)
|
||||
: runStatusProcess(args.suite);
|
||||
case "stop":
|
||||
return health
|
||||
? runStopHealth(args.suite, cfg)
|
||||
: runStopProcess(args.suite);
|
||||
case "logs":
|
||||
return runLogs(args.suite, args.follow);
|
||||
default:
|
||||
return health
|
||||
? runForegroundHealth(args.suite, cfg)
|
||||
: runForegroundProcess(args.suite, cfg, args.passthrough);
|
||||
}
|
||||
};
|
||||
|
||||
main().then(
|
||||
(code) => {
|
||||
process.exitCode = code;
|
||||
},
|
||||
(err) => {
|
||||
process.stderr.write(`${err?.stack || err}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
);
|
||||
@@ -1,34 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const paths = require("./paths.cjs");
|
||||
|
||||
const isTrue = (value) => value === "1" || value?.toLowerCase() === "true";
|
||||
|
||||
module.exports = {
|
||||
isProdBuild() {
|
||||
return (
|
||||
process.env.NODE_ENV === "production" || module.exports.isStatsBuild()
|
||||
);
|
||||
},
|
||||
isStatsBuild() {
|
||||
return isTrue(process.env.STATS);
|
||||
},
|
||||
isTestBuild() {
|
||||
return isTrue(process.env.IS_TEST);
|
||||
},
|
||||
isNetlify() {
|
||||
return isTrue(process.env.NETLIFY);
|
||||
},
|
||||
version() {
|
||||
const version = fs
|
||||
.readFileSync(path.resolve(paths.root_dir, "pyproject.toml"), "utf8")
|
||||
.match(/version\W+=\W"(\d{8}\.\d(?:\.dev)?)"/);
|
||||
if (!version) {
|
||||
throw Error("Version not found");
|
||||
}
|
||||
return version[1];
|
||||
},
|
||||
isDevContainer() {
|
||||
return isTrue(process.env.DEV_CONTAINER);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const paths = require("./paths.js");
|
||||
|
||||
module.exports = {
|
||||
useRollup() {
|
||||
return process.env.ROLLUP === "1";
|
||||
},
|
||||
useWDS() {
|
||||
return process.env.WDS === "1";
|
||||
},
|
||||
isProdBuild() {
|
||||
return (
|
||||
process.env.NODE_ENV === "production" || module.exports.isStatsBuild()
|
||||
);
|
||||
},
|
||||
isStatsBuild() {
|
||||
return process.env.STATS === "1";
|
||||
},
|
||||
isTest() {
|
||||
return process.env.IS_TEST === "true";
|
||||
},
|
||||
isNetlify() {
|
||||
return process.env.NETLIFY === "true";
|
||||
},
|
||||
version() {
|
||||
const version = fs
|
||||
.readFileSync(path.resolve(paths.polymer_dir, "setup.py"), "utf8")
|
||||
.match(/\d{8}\.\d+/);
|
||||
if (!version) {
|
||||
throw Error("Version not found");
|
||||
}
|
||||
return version[0];
|
||||
},
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
import rootConfig from "../eslint.config.mjs";
|
||||
|
||||
export default tseslint.config(...rootConfig, {
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
rules: {
|
||||
"no-console": "off",
|
||||
"import-x/no-extraneous-dependencies": "off",
|
||||
"import-x/extensions": "off",
|
||||
"import-x/no-dynamic-require": "off",
|
||||
"global-require": "off",
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"prefer-arrow-callback": "off",
|
||||
},
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
// Browser-only replacement for core-js/internals/get-built-in-node-module.
|
||||
// The original helper evaluates `Function('return require("...")')()`
|
||||
// when it detects a Node environment, which causes a runtime
|
||||
// ReferenceError on browsers (notably Safari 14) if environment
|
||||
// detection mis-classifies the page. Since browser bundles never need to
|
||||
// access Node built-in modules, return undefined unconditionally.
|
||||
//
|
||||
// Wired up via rspack `NormalModuleReplacementPlugin` in build-scripts/rspack.cjs.
|
||||
module.exports = function () {
|
||||
return undefined;
|
||||
};
|
||||
+31
-34
@@ -1,15 +1,19 @@
|
||||
import gulp from "gulp";
|
||||
import env from "../env.cjs";
|
||||
import "./clean.js";
|
||||
import "./compress.js";
|
||||
import "./entry-html.js";
|
||||
import "./gather-static.js";
|
||||
import "./gen-icons-json.js";
|
||||
import "./licenses.js";
|
||||
import "./locale-data.js";
|
||||
import "./service-worker.js";
|
||||
import "./translations.js";
|
||||
import "./rspack.js";
|
||||
// Run HA develop mode
|
||||
const gulp = require("gulp");
|
||||
|
||||
const env = require("../env");
|
||||
|
||||
require("./clean.js");
|
||||
require("./translations.js");
|
||||
require("./locale-data.js");
|
||||
require("./gen-icons-json.js");
|
||||
require("./gather-static.js");
|
||||
require("./compress.js");
|
||||
require("./webpack.js");
|
||||
require("./service-worker.js");
|
||||
require("./entry-html.js");
|
||||
require("./rollup.js");
|
||||
require("./wds.js");
|
||||
|
||||
gulp.task(
|
||||
"develop-app",
|
||||
@@ -21,12 +25,17 @@ gulp.task(
|
||||
gulp.parallel(
|
||||
"gen-service-worker-app-dev",
|
||||
"gen-icons-json",
|
||||
"gen-pages-app-dev",
|
||||
"gen-pages-dev",
|
||||
"gen-index-app-dev",
|
||||
"build-translations",
|
||||
"build-locale-data"
|
||||
),
|
||||
"copy-static-app",
|
||||
"rspack-watch-app"
|
||||
env.useWDS()
|
||||
? "wds-watch-app"
|
||||
: env.useRollup()
|
||||
? "rollup-watch-app"
|
||||
: "webpack-watch-app"
|
||||
)
|
||||
);
|
||||
|
||||
@@ -37,27 +46,15 @@ gulp.task(
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
"clean",
|
||||
gulp.parallel(
|
||||
"gen-icons-json",
|
||||
"build-translations",
|
||||
"build-locale-data",
|
||||
"gen-licenses"
|
||||
),
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
"copy-static-app",
|
||||
"rspack-prod-app",
|
||||
gulp.parallel("gen-pages-app-prod", "gen-service-worker-app-prod"),
|
||||
env.useRollup() ? "rollup-prod-app" : "webpack-prod-app",
|
||||
// Don't compress running tests
|
||||
...(env.isTestBuild() || env.isStatsBuild() ? [] : ["compress-app"])
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"analyze-app",
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.STATS = "1";
|
||||
},
|
||||
"clean",
|
||||
"rspack-prod-app"
|
||||
...(env.isTest() ? [] : ["compress-app"]),
|
||||
gulp.parallel(
|
||||
"gen-pages-prod",
|
||||
"gen-index-app-prod",
|
||||
"gen-service-worker-app-prod"
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
+15
-11
@@ -1,10 +1,14 @@
|
||||
import gulp from "gulp";
|
||||
import "./clean.js";
|
||||
import "./entry-html.js";
|
||||
import "./gather-static.js";
|
||||
import "./service-worker.js";
|
||||
import "./translations.js";
|
||||
import "./rspack.js";
|
||||
const gulp = require("gulp");
|
||||
|
||||
const env = require("../env");
|
||||
|
||||
require("./clean.js");
|
||||
require("./translations.js");
|
||||
require("./gather-static.js");
|
||||
require("./webpack.js");
|
||||
require("./service-worker.js");
|
||||
require("./entry-html.js");
|
||||
require("./rollup.js");
|
||||
|
||||
gulp.task(
|
||||
"develop-cast",
|
||||
@@ -16,8 +20,8 @@ gulp.task(
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
"copy-static-cast",
|
||||
"gen-pages-cast-dev",
|
||||
"rspack-dev-server-cast"
|
||||
"gen-index-cast-dev",
|
||||
env.useRollup() ? "rollup-dev-server-cast" : "webpack-dev-server-cast"
|
||||
)
|
||||
);
|
||||
|
||||
@@ -31,7 +35,7 @@ gulp.task(
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
"copy-static-cast",
|
||||
"rspack-prod-cast",
|
||||
"gen-pages-cast-prod"
|
||||
env.useRollup() ? "rollup-prod-cast" : "webpack-prod-cast",
|
||||
"gen-index-cast-prod"
|
||||
)
|
||||
);
|
||||
|
||||
+16
-34
@@ -1,54 +1,36 @@
|
||||
import { deleteSync } from "del";
|
||||
import gulp from "gulp";
|
||||
import paths from "../paths.cjs";
|
||||
import "./translations.js";
|
||||
const del = require("del");
|
||||
const gulp = require("gulp");
|
||||
const paths = require("../paths");
|
||||
require("./translations");
|
||||
|
||||
gulp.task(
|
||||
"clean",
|
||||
gulp.parallel("clean-translations", async () =>
|
||||
deleteSync([paths.app_output_root, paths.build_dir])
|
||||
gulp.parallel("clean-translations", () =>
|
||||
del([paths.app_output_root, paths.build_dir])
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"clean-demo",
|
||||
gulp.parallel("clean-translations", async () =>
|
||||
deleteSync([paths.demo_output_root, paths.build_dir])
|
||||
gulp.parallel("clean-translations", () =>
|
||||
del([paths.demo_output_root, paths.build_dir])
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"clean-cast",
|
||||
gulp.parallel("clean-translations", async () =>
|
||||
deleteSync([paths.cast_output_root, paths.build_dir])
|
||||
gulp.parallel("clean-translations", () =>
|
||||
del([paths.cast_output_root, paths.build_dir])
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("clean-hassio", () =>
|
||||
del([paths.hassio_output_root, paths.build_dir])
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"clean-gallery",
|
||||
gulp.parallel("clean-translations", async () =>
|
||||
deleteSync([
|
||||
paths.gallery_output_root,
|
||||
paths.gallery_build,
|
||||
paths.build_dir,
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"clean-landing-page",
|
||||
gulp.parallel("clean-translations", async () =>
|
||||
deleteSync([
|
||||
paths.landingPage_output_root,
|
||||
paths.landingPage_build,
|
||||
paths.build_dir,
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"clean-e2e-test-app",
|
||||
gulp.parallel("clean-translations", async () =>
|
||||
deleteSync([paths.e2eTestApp_output_root, paths.build_dir])
|
||||
gulp.parallel("clean-translations", () =>
|
||||
del([paths.gallery_output_root, paths.build_dir])
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,59 +1,45 @@
|
||||
// Tasks to compress
|
||||
|
||||
import { constants } from "node:zlib";
|
||||
import gulp from "gulp";
|
||||
import brotli from "gulp-brotli";
|
||||
import zopfli from "gulp-zopfli-green";
|
||||
import paths from "../paths.cjs";
|
||||
const gulp = require("gulp");
|
||||
const zopfli = require("gulp-zopfli-green");
|
||||
const merge = require("merge-stream");
|
||||
const path = require("path");
|
||||
const paths = require("../paths");
|
||||
|
||||
const filesGlob = "*.{js,json,css,svg,xml}";
|
||||
const brotliOptions = {
|
||||
skipLarger: true,
|
||||
params: {
|
||||
[constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY,
|
||||
},
|
||||
};
|
||||
const zopfliOptions = { threshold: 150 };
|
||||
|
||||
const compressModern = (rootDir, modernDir, compress) =>
|
||||
gulp
|
||||
.src([`${modernDir}/**/${filesGlob}`, `${rootDir}/sw-modern.js`], {
|
||||
base: rootDir,
|
||||
allowEmpty: true,
|
||||
})
|
||||
.pipe(compress === "zopfli" ? zopfli(zopfliOptions) : brotli(brotliOptions))
|
||||
.pipe(gulp.dest(rootDir));
|
||||
gulp.task("compress-app", function compressApp() {
|
||||
const jsLatest = gulp
|
||||
.src(path.resolve(paths.app_output_latest, "**/*.js"))
|
||||
.pipe(zopfli(zopfliOptions))
|
||||
.pipe(gulp.dest(paths.app_output_latest));
|
||||
|
||||
const compressOther = (rootDir, modernDir, compress) =>
|
||||
gulp
|
||||
.src(
|
||||
[
|
||||
`${rootDir}/**/${filesGlob}`,
|
||||
`!${modernDir}/**/${filesGlob}`,
|
||||
`!${rootDir}/{sw-modern,service_worker}.js`,
|
||||
`${rootDir}/{authorize,onboarding}.html`,
|
||||
],
|
||||
{ base: rootDir, allowEmpty: true }
|
||||
)
|
||||
.pipe(compress === "zopfli" ? zopfli(zopfliOptions) : brotli(brotliOptions))
|
||||
.pipe(gulp.dest(rootDir));
|
||||
const jsEs5 = gulp
|
||||
.src(path.resolve(paths.app_output_es5, "**/*.js"))
|
||||
.pipe(zopfli(zopfliOptions))
|
||||
.pipe(gulp.dest(paths.app_output_es5));
|
||||
|
||||
const compressAppModernBrotli = () =>
|
||||
compressModern(paths.app_output_root, paths.app_output_latest, "brotli");
|
||||
const compressAppModernZopfli = () =>
|
||||
compressModern(paths.app_output_root, paths.app_output_latest, "zopfli");
|
||||
const polyfills = gulp
|
||||
.src(path.resolve(paths.app_output_static, "polyfills/*.js"))
|
||||
.pipe(zopfli(zopfliOptions))
|
||||
.pipe(gulp.dest(path.resolve(paths.app_output_static, "polyfills")));
|
||||
|
||||
const compressAppOtherBrotli = () =>
|
||||
compressOther(paths.app_output_root, paths.app_output_latest, "brotli");
|
||||
const compressAppOtherZopfli = () =>
|
||||
compressOther(paths.app_output_root, paths.app_output_latest, "zopfli");
|
||||
const translations = gulp
|
||||
.src(path.resolve(paths.app_output_static, "translations/**/*.json"))
|
||||
.pipe(zopfli(zopfliOptions))
|
||||
.pipe(gulp.dest(path.resolve(paths.app_output_static, "translations")));
|
||||
|
||||
gulp.task(
|
||||
"compress-app",
|
||||
gulp.parallel(
|
||||
compressAppModernBrotli,
|
||||
compressAppOtherBrotli,
|
||||
compressAppModernZopfli,
|
||||
compressAppOtherZopfli
|
||||
)
|
||||
);
|
||||
const icons = gulp
|
||||
.src(path.resolve(paths.app_output_static, "mdi/*.json"))
|
||||
.pipe(zopfli(zopfliOptions))
|
||||
.pipe(gulp.dest(path.resolve(paths.app_output_static, "mdi")));
|
||||
|
||||
return merge(jsLatest, jsEs5, polyfills, translations, icons);
|
||||
});
|
||||
|
||||
gulp.task("compress-hassio", function compressApp() {
|
||||
return gulp
|
||||
.src(path.resolve(paths.hassio_output_root, "**/*.js"))
|
||||
.pipe(zopfli(zopfliOptions))
|
||||
.pipe(gulp.dest(paths.hassio_output_root));
|
||||
});
|
||||
|
||||
+17
-23
@@ -1,11 +1,16 @@
|
||||
import gulp from "gulp";
|
||||
import "./clean.js";
|
||||
import "./entry-html.js";
|
||||
import "./gather-static.js";
|
||||
import "./gen-icons-json.js";
|
||||
import "./service-worker.js";
|
||||
import "./translations.js";
|
||||
import "./rspack.js";
|
||||
// Run demo develop mode
|
||||
const gulp = require("gulp");
|
||||
|
||||
const env = require("../env");
|
||||
|
||||
require("./clean.js");
|
||||
require("./translations.js");
|
||||
require("./gen-icons-json.js");
|
||||
require("./gather-static.js");
|
||||
require("./webpack.js");
|
||||
require("./service-worker.js");
|
||||
require("./entry-html.js");
|
||||
require("./rollup.js");
|
||||
|
||||
gulp.task(
|
||||
"develop-demo",
|
||||
@@ -17,12 +22,12 @@ gulp.task(
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel(
|
||||
"gen-icons-json",
|
||||
"gen-pages-demo-dev",
|
||||
"gen-index-demo-dev",
|
||||
"build-translations",
|
||||
"build-locale-data"
|
||||
),
|
||||
"copy-static-demo",
|
||||
"rspack-dev-server-demo"
|
||||
env.useRollup() ? "rollup-dev-server-demo" : "webpack-dev-server-demo"
|
||||
)
|
||||
);
|
||||
|
||||
@@ -37,18 +42,7 @@ gulp.task(
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
"copy-static-demo",
|
||||
"rspack-prod-demo",
|
||||
"gen-pages-demo-prod"
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"analyze-demo",
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.STATS = "1";
|
||||
},
|
||||
"clean",
|
||||
"rspack-prod-demo"
|
||||
env.useRollup() ? "rollup-prod-demo" : "webpack-prod-demo",
|
||||
"gen-index-demo-prod"
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
import fs from "fs/promises";
|
||||
import gulp from "gulp";
|
||||
import path from "path";
|
||||
import mapStream from "map-stream";
|
||||
import transform from "gulp-json-transform";
|
||||
import { LokaliseApi } from "@lokalise/node-api";
|
||||
import JSZip from "jszip";
|
||||
|
||||
const inDir = "translations";
|
||||
const inDirFrontend = `${inDir}/frontend`;
|
||||
const inDirBackend = `${inDir}/backend`;
|
||||
const srcMeta = "src/translations/translationMetadata.json";
|
||||
const encoding = "utf8";
|
||||
|
||||
function hasHtml(data) {
|
||||
return /<\S*>/i.test(data);
|
||||
}
|
||||
|
||||
function recursiveCheckHasHtml(file, data, errors, recKey) {
|
||||
Object.keys(data).forEach(function (key) {
|
||||
if (typeof data[key] === "object") {
|
||||
const nextRecKey = recKey ? `${recKey}.${key}` : key;
|
||||
recursiveCheckHasHtml(file, data[key], errors, nextRecKey);
|
||||
} else if (hasHtml(data[key])) {
|
||||
errors.push(`HTML found in ${file.path} at key ${recKey}.${key}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function checkHtml() {
|
||||
const errors = [];
|
||||
|
||||
return mapStream(function (file, cb) {
|
||||
const content = file.contents;
|
||||
let error;
|
||||
if (content) {
|
||||
if (hasHtml(String(content))) {
|
||||
const data = JSON.parse(String(content));
|
||||
recursiveCheckHasHtml(file, data, errors);
|
||||
if (errors.length > 0) {
|
||||
error = errors.join("\r\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
cb(error, file);
|
||||
});
|
||||
}
|
||||
|
||||
function convertBackendTranslations(data, _file) {
|
||||
const output = { component: {} };
|
||||
if (!data.component) {
|
||||
return output;
|
||||
}
|
||||
Object.keys(data.component).forEach((domain) => {
|
||||
if (!("entity_component" in data.component[domain])) {
|
||||
return;
|
||||
}
|
||||
output.component[domain] = { entity_component: {} };
|
||||
Object.keys(data.component[domain].entity_component).forEach((key) => {
|
||||
output.component[domain].entity_component[key] =
|
||||
data.component[domain].entity_component[key];
|
||||
});
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
gulp.task("convert-backend-translations", function () {
|
||||
return gulp
|
||||
.src([`${inDirBackend}/*.json`])
|
||||
.pipe(transform((data, file) => convertBackendTranslations(data, file)))
|
||||
.pipe(gulp.dest(inDirBackend));
|
||||
});
|
||||
|
||||
gulp.task("check-translations-html", function () {
|
||||
return gulp
|
||||
.src([`${inDirFrontend}/*.json`, `${inDirBackend}/*.json`])
|
||||
.pipe(checkHtml());
|
||||
});
|
||||
|
||||
gulp.task("check-all-files-exist", async function () {
|
||||
const file = await fs.readFile(srcMeta, { encoding });
|
||||
const meta = JSON.parse(file);
|
||||
const writings = [];
|
||||
Object.keys(meta).forEach((lang) => {
|
||||
writings.push(
|
||||
fs.writeFile(`${inDirFrontend}/${lang}.json`, JSON.stringify({}), {
|
||||
flag: "wx",
|
||||
}),
|
||||
fs.writeFile(`${inDirBackend}/${lang}.json`, JSON.stringify({}), {
|
||||
flag: "wx",
|
||||
})
|
||||
);
|
||||
});
|
||||
await Promise.allSettled(writings);
|
||||
});
|
||||
|
||||
const lokaliseProjects = {
|
||||
backend: "130246255a974bd3b5e8a1.51616605",
|
||||
frontend: "3420425759f6d6d241f598.13594006",
|
||||
};
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
|
||||
/* eslint-disable no-await-in-loop */
|
||||
async function pollProcess(lokaliseApi, projectId, processId) {
|
||||
while (true) {
|
||||
const process = await lokaliseApi
|
||||
.queuedProcesses()
|
||||
.get(processId, { project_id: projectId });
|
||||
|
||||
const project =
|
||||
projectId === lokaliseProjects.backend ? "backend" : "frontend";
|
||||
|
||||
if (process.status === "finished") {
|
||||
console.log(`Lokalise export process for ${project} finished`);
|
||||
return process;
|
||||
}
|
||||
|
||||
if (process.status === "failed" || process.status === "cancelled") {
|
||||
throw new Error(
|
||||
`Lokalise export process for ${project} ${process.status}: ${process.message}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Lokalise export process for ${project} in progress...`,
|
||||
process.status,
|
||||
process.details?.items_to_process
|
||||
? `${Math.round(((process.details.items_processed || 0) / process.details.items_to_process) * 100)}% (${process.details.items_processed}/${process.details.items_to_process})`
|
||||
: ""
|
||||
);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, POLL_INTERVAL_MS);
|
||||
});
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
|
||||
gulp.task("fetch-lokalise", async function () {
|
||||
let apiKey;
|
||||
try {
|
||||
apiKey =
|
||||
process.env.LOKALISE_TOKEN ||
|
||||
(await fs.readFile(".lokalise_token", { encoding }));
|
||||
} catch {
|
||||
throw new Error(
|
||||
"An Administrator Lokalise API token is required to download the latest set of translations. Place your token in a new file `.lokalise_token` in the repo root directory."
|
||||
);
|
||||
}
|
||||
const lokaliseApi = new LokaliseApi({ apiKey });
|
||||
|
||||
const mkdirPromise = Promise.all([
|
||||
fs.mkdir(inDirFrontend, { recursive: true }),
|
||||
fs.mkdir(inDirBackend, { recursive: true }),
|
||||
]);
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(lokaliseProjects).map(async ([project, projectId]) => {
|
||||
try {
|
||||
const exportProcess = await lokaliseApi
|
||||
.files()
|
||||
.async_download(projectId, {
|
||||
format: "json",
|
||||
original_filenames: false,
|
||||
replace_breaks: false,
|
||||
json_unescaped_slashes: true,
|
||||
export_empty_as: "skip",
|
||||
filter_data: ["verified"],
|
||||
});
|
||||
|
||||
const finishedProcess = await pollProcess(
|
||||
lokaliseApi,
|
||||
projectId,
|
||||
exportProcess.process_id
|
||||
);
|
||||
|
||||
const bundleUrl = finishedProcess.details.download_url;
|
||||
|
||||
console.log(`Downloading translations from: ${bundleUrl}`);
|
||||
|
||||
const response = await fetch(bundleUrl);
|
||||
|
||||
if (response.status !== 200 && response.status !== 0) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
|
||||
console.log(`Extracting translations...`);
|
||||
|
||||
const contents = await JSZip.loadAsync(await response.arrayBuffer());
|
||||
|
||||
await mkdirPromise;
|
||||
await Promise.all(
|
||||
Object.keys(contents.files).map(async (filename) => {
|
||||
const file = contents.file(filename);
|
||||
if (!file) {
|
||||
// no file, probably a directory
|
||||
return;
|
||||
}
|
||||
const content = await file.async("nodebuffer");
|
||||
await fs.writeFile(
|
||||
path.join(inDir, project, filename.split("/").splice(-1)[0]),
|
||||
content,
|
||||
{ flag: "w", encoding }
|
||||
);
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
gulp.task(
|
||||
"download-translations",
|
||||
gulp.series(
|
||||
"fetch-lokalise",
|
||||
"convert-backend-translations",
|
||||
"check-translations-html",
|
||||
"check-all-files-exist"
|
||||
)
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user