@devwithbobby/loops 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.changeset/README.md +8 -0
- package/.changeset/config.json +14 -0
- package/.config/commitlint.config.ts +11 -0
- package/.config/lefthook.yml +11 -0
- package/.github/workflows/release.yml +52 -0
- package/.github/workflows/test-and-lint.yml +39 -0
- package/README.md +517 -0
- package/biome.json +45 -0
- package/bun.lock +1166 -0
- package/bunfig.toml +7 -0
- package/convex.json +3 -0
- package/example/CLAUDE.md +106 -0
- package/example/README.md +21 -0
- package/example/bun-env.d.ts +17 -0
- package/example/convex/_generated/api.d.ts +53 -0
- package/example/convex/_generated/api.js +23 -0
- package/example/convex/_generated/dataModel.d.ts +60 -0
- package/example/convex/_generated/server.d.ts +149 -0
- package/example/convex/_generated/server.js +90 -0
- package/example/convex/convex.config.ts +7 -0
- package/example/convex/example.ts +76 -0
- package/example/convex/schema.ts +3 -0
- package/example/convex/tsconfig.json +34 -0
- package/example/src/App.tsx +185 -0
- package/example/src/frontend.tsx +39 -0
- package/example/src/index.css +15 -0
- package/example/src/index.html +12 -0
- package/example/src/index.tsx +19 -0
- package/example/tsconfig.json +28 -0
- package/package.json +95 -0
- package/prds/CHANGELOG.md +38 -0
- package/prds/CLAUDE.md +408 -0
- package/prds/CONTRIBUTING.md +274 -0
- package/prds/ENV_SETUP.md +222 -0
- package/prds/MONITORING.md +301 -0
- package/prds/RATE_LIMITING.md +412 -0
- package/prds/SECURITY.md +246 -0
- package/renovate.json +32 -0
- package/src/client/index.ts +530 -0
- package/src/client/types.ts +64 -0
- package/src/component/_generated/api.d.ts +55 -0
- package/src/component/_generated/api.js +23 -0
- package/src/component/_generated/dataModel.d.ts +60 -0
- package/src/component/_generated/server.d.ts +149 -0
- package/src/component/_generated/server.js +90 -0
- package/src/component/convex.config.ts +27 -0
- package/src/component/lib.ts +1125 -0
- package/src/component/schema.ts +17 -0
- package/src/component/tables/contacts.ts +16 -0
- package/src/component/tables/emailOperations.ts +22 -0
- package/src/component/validators.ts +39 -0
- package/src/utils.ts +6 -0
- package/test/client/_generated/_ignore.ts +1 -0
- package/test/client/index.test.ts +65 -0
- package/test/client/setup.test.ts +54 -0
- package/test/component/lib.test.ts +225 -0
- package/test/component/setup.test.ts +21 -0
- package/tsconfig.build.json +20 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { useAction } from "convex/react";
|
|
2
|
+
import { api } from "../convex/_generated/api";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import "./index.css";
|
|
5
|
+
|
|
6
|
+
export function App() {
|
|
7
|
+
const [email, setEmail] = useState("");
|
|
8
|
+
const [firstName, setFirstName] = useState("");
|
|
9
|
+
const [lastName, setLastName] = useState("");
|
|
10
|
+
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
|
11
|
+
|
|
12
|
+
const addContact = useAction(api.example.addContact);
|
|
13
|
+
const sendEvent = useAction(api.example.sendEvent);
|
|
14
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
15
|
+
|
|
16
|
+
const handleAddContact = async (e: React.FormEvent) => {
|
|
17
|
+
e.preventDefault();
|
|
18
|
+
setIsLoading(true);
|
|
19
|
+
setMessage(null);
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
await addContact({
|
|
23
|
+
email,
|
|
24
|
+
firstName: firstName || undefined,
|
|
25
|
+
lastName: lastName || undefined,
|
|
26
|
+
});
|
|
27
|
+
setMessage({ type: "success", text: "Contact added successfully!" });
|
|
28
|
+
setEmail("");
|
|
29
|
+
setFirstName("");
|
|
30
|
+
setLastName("");
|
|
31
|
+
} catch (error) {
|
|
32
|
+
setMessage({
|
|
33
|
+
type: "error",
|
|
34
|
+
text: error instanceof Error ? error.message : "Failed to add contact",
|
|
35
|
+
});
|
|
36
|
+
} finally {
|
|
37
|
+
setIsLoading(false);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const handleSendEvent = async () => {
|
|
42
|
+
if (!email) {
|
|
43
|
+
setMessage({ type: "error", text: "Please enter an email first" });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
setIsLoading(true);
|
|
48
|
+
setMessage(null);
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
await sendEvent({
|
|
52
|
+
email,
|
|
53
|
+
eventName: "welcome",
|
|
54
|
+
eventProperties: {
|
|
55
|
+
firstName: firstName || undefined,
|
|
56
|
+
lastName: lastName || undefined,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
setMessage({ type: "success", text: "Event sent successfully!" });
|
|
60
|
+
} catch (error) {
|
|
61
|
+
setMessage({
|
|
62
|
+
type: "error",
|
|
63
|
+
text: error instanceof Error ? error.message : "Failed to send event",
|
|
64
|
+
});
|
|
65
|
+
} finally {
|
|
66
|
+
setIsLoading(false);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className="min-h-screen bg-gray-50 w-full flex items-center justify-center p-4">
|
|
72
|
+
<div className="container mx-auto max-w-md">
|
|
73
|
+
<div className="bg-white rounded-lg shadow border border-gray-200 p-8">
|
|
74
|
+
<div className="text-center mb-8">
|
|
75
|
+
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
|
76
|
+
Loops Component
|
|
77
|
+
</h1>
|
|
78
|
+
<p className="text-gray-600 text-sm">
|
|
79
|
+
Powered by Convex Components & Loops.so
|
|
80
|
+
</p>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<form onSubmit={handleAddContact} className="space-y-4 mb-6">
|
|
84
|
+
<div>
|
|
85
|
+
<label
|
|
86
|
+
htmlFor="email"
|
|
87
|
+
className="block text-sm font-medium text-gray-700 mb-1"
|
|
88
|
+
>
|
|
89
|
+
Email *
|
|
90
|
+
</label>
|
|
91
|
+
<input
|
|
92
|
+
id="email"
|
|
93
|
+
type="email"
|
|
94
|
+
required
|
|
95
|
+
value={email}
|
|
96
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
97
|
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
98
|
+
placeholder="user@example.com"
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<div>
|
|
103
|
+
<label
|
|
104
|
+
htmlFor="firstName"
|
|
105
|
+
className="block text-sm font-medium text-gray-700 mb-1"
|
|
106
|
+
>
|
|
107
|
+
First Name
|
|
108
|
+
</label>
|
|
109
|
+
<input
|
|
110
|
+
id="firstName"
|
|
111
|
+
type="text"
|
|
112
|
+
value={firstName}
|
|
113
|
+
onChange={(e) => setFirstName(e.target.value)}
|
|
114
|
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
115
|
+
placeholder="John"
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<div>
|
|
120
|
+
<label
|
|
121
|
+
htmlFor="lastName"
|
|
122
|
+
className="block text-sm font-medium text-gray-700 mb-1"
|
|
123
|
+
>
|
|
124
|
+
Last Name
|
|
125
|
+
</label>
|
|
126
|
+
<input
|
|
127
|
+
id="lastName"
|
|
128
|
+
type="text"
|
|
129
|
+
value={lastName}
|
|
130
|
+
onChange={(e) => setLastName(e.target.value)}
|
|
131
|
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
132
|
+
placeholder="Doe"
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<button
|
|
137
|
+
type="submit"
|
|
138
|
+
disabled={isLoading}
|
|
139
|
+
className="w-full bg-blue-600 text-white font-medium px-6 py-2 rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
140
|
+
>
|
|
141
|
+
{isLoading ? "Adding..." : "Add Contact"}
|
|
142
|
+
</button>
|
|
143
|
+
</form>
|
|
144
|
+
|
|
145
|
+
<div className="mb-6">
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
onClick={handleSendEvent}
|
|
149
|
+
disabled={isLoading || !email}
|
|
150
|
+
className="w-full bg-green-600 text-white font-medium px-6 py-2 rounded-md hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
151
|
+
>
|
|
152
|
+
Send Welcome Event
|
|
153
|
+
</button>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
{message && (
|
|
157
|
+
<div
|
|
158
|
+
className={`p-4 rounded-md mb-4 ${
|
|
159
|
+
message.type === "success"
|
|
160
|
+
? "bg-green-50 border border-green-200 text-green-800"
|
|
161
|
+
: "bg-red-50 border border-red-200 text-red-800"
|
|
162
|
+
}`}
|
|
163
|
+
>
|
|
164
|
+
{message.text}
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
<div className="bg-blue-50 rounded-lg p-4 border border-blue-100">
|
|
169
|
+
<p className="text-sm text-gray-700 text-center">
|
|
170
|
+
<code className="text-blue-600 font-mono text-xs">
|
|
171
|
+
example/convex/example.ts
|
|
172
|
+
</code>
|
|
173
|
+
<br />
|
|
174
|
+
<span className="text-gray-500 text-xs">
|
|
175
|
+
Check out the code to see all available features
|
|
176
|
+
</span>
|
|
177
|
+
</p>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export default App;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is the entry point for the React app, it sets up the root
|
|
3
|
+
* element and renders the App component to the DOM.
|
|
4
|
+
*
|
|
5
|
+
* It is included in `src/index.html`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ConvexProvider, ConvexReactClient } from "convex/react";
|
|
9
|
+
import { createRoot } from "react-dom/client";
|
|
10
|
+
import { App } from "./App";
|
|
11
|
+
|
|
12
|
+
const convexURL = process.env.CONVEX_URL;
|
|
13
|
+
|
|
14
|
+
if (!convexURL) {
|
|
15
|
+
throw new Error("No convex URL provided!");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const convex = new ConvexReactClient(convexURL);
|
|
19
|
+
|
|
20
|
+
function start() {
|
|
21
|
+
const rootElement = document.getElementById("root");
|
|
22
|
+
|
|
23
|
+
if (!rootElement) {
|
|
24
|
+
throw new Error("Could not find root");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const root = createRoot(rootElement);
|
|
28
|
+
root.render(
|
|
29
|
+
<ConvexProvider client={convex}>
|
|
30
|
+
<App />
|
|
31
|
+
</ConvexProvider>,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (document.readyState === "loading") {
|
|
36
|
+
document.addEventListener("DOMContentLoaded", start);
|
|
37
|
+
} else {
|
|
38
|
+
start();
|
|
39
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Loops Component Example</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="./frontend.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { serve } from "bun";
|
|
2
|
+
import index from "./index.html";
|
|
3
|
+
|
|
4
|
+
const server = serve({
|
|
5
|
+
routes: {
|
|
6
|
+
// Serve index.html for all unmatched routes.
|
|
7
|
+
"/*": index,
|
|
8
|
+
},
|
|
9
|
+
|
|
10
|
+
development: process.env.NODE_ENV !== "production" && {
|
|
11
|
+
// Enable browser hot reloading in development
|
|
12
|
+
hmr: true,
|
|
13
|
+
|
|
14
|
+
// Echo console logs from the browser to the server
|
|
15
|
+
console: true,
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
console.log(`🚀 Server running at ${server.url}`);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"lib": ["ESNext", "DOM"],
|
|
4
|
+
"target": "ESNext",
|
|
5
|
+
"module": "Preserve",
|
|
6
|
+
"moduleDetection": "force",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"allowJs": true,
|
|
9
|
+
"moduleResolution": "bundler",
|
|
10
|
+
"allowImportingTsExtensions": true,
|
|
11
|
+
"verbatimModuleSyntax": true,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"strict": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"noFallthroughCasesInSwitch": true,
|
|
16
|
+
"noUncheckedIndexedAccess": true,
|
|
17
|
+
"noImplicitOverride": true,
|
|
18
|
+
"baseUrl": ".",
|
|
19
|
+
"paths": {
|
|
20
|
+
"@/*": ["./src/*"]
|
|
21
|
+
},
|
|
22
|
+
"noUnusedLocals": false,
|
|
23
|
+
"noUnusedParameters": false,
|
|
24
|
+
"noPropertyAccessFromIndexSignature": false
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
"exclude": ["dist", "node_modules"]
|
|
28
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@devwithbobby/loops",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Convex component for integrating with Loops.so email marketing platform",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"convex",
|
|
9
|
+
"component",
|
|
10
|
+
"template",
|
|
11
|
+
"boilerplate",
|
|
12
|
+
"bun",
|
|
13
|
+
"biome",
|
|
14
|
+
"typescript"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/robertalv/loops-component.git"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/robertalv/loops-component/issues"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/robertalv/loops-component#readme",
|
|
24
|
+
"exports": {
|
|
25
|
+
"./package.json": "./package.json",
|
|
26
|
+
".": {
|
|
27
|
+
"@convex-dev/component-source": "./src/client/index.ts",
|
|
28
|
+
"types": "./dist/client/index.d.ts",
|
|
29
|
+
"default": "./dist/client/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./convex.config": {
|
|
32
|
+
"@convex-dev/component-source": "./src/component/convex.config.ts",
|
|
33
|
+
"types": "./dist/component/convex.config.d.ts",
|
|
34
|
+
"default": "./dist/component/convex.config.js"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"dev": "run-p -r 'dev:backend' 'dev:frontend' 'build:watch'",
|
|
39
|
+
"dev:backend": "convex dev --live-component-sources --typecheck-components",
|
|
40
|
+
"dev:frontend": "bun --hot example/src/index.tsx",
|
|
41
|
+
"build": "tsc --project tsconfig.build.json",
|
|
42
|
+
"build:watch": "chokidar 'tsconfig*.json' 'src/**/*.ts' -i '**/*.test.*' -i '**/test-setup.ts' -c 'bun run build' --initial",
|
|
43
|
+
"clean": "rm -rf dist node_modules",
|
|
44
|
+
"test": "bun test",
|
|
45
|
+
"test:watch": "bun test --watch",
|
|
46
|
+
"test:coverage": "bun test --coverage",
|
|
47
|
+
"test:bail": "bun test --bail",
|
|
48
|
+
"lint": "biome lint .",
|
|
49
|
+
"lint:fix": "biome lint --write .",
|
|
50
|
+
"format": "biome format --write .",
|
|
51
|
+
"check": "biome check .",
|
|
52
|
+
"check:fix": "biome check --write .",
|
|
53
|
+
"typecheck": "tsc --noEmit",
|
|
54
|
+
"attw": "attw $(npm pack -s) --exclude-entrypoints ./convex.config --profile esm-only",
|
|
55
|
+
"prepare": "bun run build",
|
|
56
|
+
"changeset": "changeset",
|
|
57
|
+
"ci:version": "changeset version && bun update",
|
|
58
|
+
"ci:publish": "bun run preversion && changeset publish",
|
|
59
|
+
"preversion": "bun run clean && bun install && run-p test lint typecheck attw",
|
|
60
|
+
"version": "pbcopy <<<$npm_package_version; vim CHANGELOG.md && biome format --write CHANGELOG.md && git add CHANGELOG.md",
|
|
61
|
+
"postversion": "git push --follow-tags",
|
|
62
|
+
"alpha": "bun run preversion && npm version prerelease --preid alpha && npm publish --tag alpha",
|
|
63
|
+
"release": "bun run preversion && npm version patch && npm publish"
|
|
64
|
+
},
|
|
65
|
+
"peerDependencies": {
|
|
66
|
+
"convex": "^1.0.0",
|
|
67
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
68
|
+
"typescript": "^5.9.3"
|
|
69
|
+
},
|
|
70
|
+
"devDependencies": {
|
|
71
|
+
"@arethetypeswrong/cli": "^0.18.2",
|
|
72
|
+
"@biomejs/biome": "2.3.0",
|
|
73
|
+
"@changesets/changelog-github": "^0.5.1",
|
|
74
|
+
"@changesets/cli": "^2.29.7",
|
|
75
|
+
"@commitlint/cli": "^20.1.0",
|
|
76
|
+
"@commitlint/types": "^20.0.0",
|
|
77
|
+
"@types/bun": "^1.3.1",
|
|
78
|
+
"@types/react": "^19.2.2",
|
|
79
|
+
"@types/react-dom": "^19.2.2",
|
|
80
|
+
"bun-plugin-tailwind": "^0.1.2",
|
|
81
|
+
"chokidar-cli": "^3.0.0",
|
|
82
|
+
"commitlint-config-gitmoji": "^2.3.1",
|
|
83
|
+
"convex": "^1.28.0",
|
|
84
|
+
"convex-helpers": "^0.1.104",
|
|
85
|
+
"convex-test": "^0.0.38",
|
|
86
|
+
"lefthook": "^2.0.0",
|
|
87
|
+
"npm-run-all": "^4.1.5",
|
|
88
|
+
"pkg-pr-new": "^0.0.60",
|
|
89
|
+
"react": "^19.2.0",
|
|
90
|
+
"react-dom": "^19.2.0",
|
|
91
|
+
"tailwindcss": "^4.1.16",
|
|
92
|
+
"zod": "^4.1.12",
|
|
93
|
+
"zodvex": "^0.2.3"
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- Moved `convex` and `react` to `peerDependencies` to prevent version conflicts
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- Added `@arethetypeswrong/cli` for package validation
|
|
15
|
+
- Added `attw` script for validating package exports
|
|
16
|
+
|
|
17
|
+
## [0.1.0] - 2025-10-24
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- Initial release of the Convex component template
|
|
21
|
+
- Sharded counter component implementation
|
|
22
|
+
- React hooks for component integration (`useLoopsComponent`, `useIncrementCounter`, `useCounterValue`)
|
|
23
|
+
- Complete Convex component architecture with example app
|
|
24
|
+
- GitHub Actions workflow with `pkg.pr.new` integration
|
|
25
|
+
- Lefthook for automated pre-commit checks
|
|
26
|
+
- Biome for linting and formatting
|
|
27
|
+
- Comprehensive test suite using `convex-test`
|
|
28
|
+
- TypeScript support with strict mode
|
|
29
|
+
- Bun-based development workflow
|
|
30
|
+
- Live component sources for hot-reloading during development
|
|
31
|
+
|
|
32
|
+
### Documentation
|
|
33
|
+
- README with setup and usage instructions
|
|
34
|
+
- CLAUDE.md for AI-assisted development guidance
|
|
35
|
+
- Example app demonstrating component usage
|
|
36
|
+
|
|
37
|
+
[Unreleased]: https://github.com/robertalv/loops-component/compare/v0.1.0...HEAD
|
|
38
|
+
[0.1.0]: https://github.com/robertalv/loops-component/releases/tag/v0.1.0
|