From f5b10c6cd84fd1c0d5fce815eb025b28e4efad25 Mon Sep 17 00:00:00 2001
From: Bernhard Seefeld
Date: Fri, 21 Jun 2024 11:58:57 -0700
Subject: [PATCH 1/3] sagas can name themselves now
---
.../common-ui/src/components/input.ts | 2 +-
.../common-ui/src/hyperscript/tags.ts | 1 +
.../src/components/saga-link.ts | 42 +++++++++++++--
.../packages/lookslike-high-level/src/data.ts | 31 +++++------
.../packages/lookslike-high-level/src/main.ts | 6 +--
.../lookslike-high-level/src/recipe.ts | 4 +-
.../src/recipes/{saga-list.ts => home.ts} | 11 ++--
.../src/recipes/todo-list-as-task.ts | 2 +-
.../src/recipes/todo-list.ts | 52 ++++++++++++-------
9 files changed, 103 insertions(+), 48 deletions(-)
rename typescript/packages/lookslike-high-level/src/recipes/{saga-list.ts => home.ts} (59%)
diff --git a/typescript/packages/common-ui/src/components/input.ts b/typescript/packages/common-ui/src/components/input.ts
index 3b9a40f24..4706b8191 100644
--- a/typescript/packages/common-ui/src/components/input.ts
+++ b/typescript/packages/common-ui/src/components/input.ts
@@ -27,4 +27,4 @@ export const fileInput = (props: Props) => input({ ...props, type: "file" });
export const imageInput = (props: Props) => input({ ...props, type: "image" });
export const password = (props: Props) => input({ ...props, type: "password" });
export const searchInput = (props: Props) =>
- input({ ...props, type: "search" });
\ No newline at end of file
+ input({ ...props, type: "search" });
diff --git a/typescript/packages/common-ui/src/hyperscript/tags.ts b/typescript/packages/common-ui/src/hyperscript/tags.ts
index 8a521e59f..c707c1c10 100644
--- a/typescript/packages/common-ui/src/hyperscript/tags.ts
+++ b/typescript/packages/common-ui/src/hyperscript/tags.ts
@@ -5,6 +5,7 @@ export { navpanel } from "../components/common-navpanel.js";
export { record } from "../components/common-record.js";
export { dict } from "../components/common-dict.js";
export { datatable } from "../components/common-datatable.js";
+export { commonInput } from "../components/common-input.js";
export { list } from "../components/common-list.js";
export { suggestions } from "../components/common-suggestions.js";
export { unibox } from "../components/common-unibox.js";
diff --git a/typescript/packages/lookslike-high-level/src/components/saga-link.ts b/typescript/packages/lookslike-high-level/src/components/saga-link.ts
index 88da1f655..feafbc20b 100644
--- a/typescript/packages/lookslike-high-level/src/components/saga-link.ts
+++ b/typescript/packages/lookslike-high-level/src/components/saga-link.ts
@@ -1,11 +1,12 @@
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";
import { render } from "@commontools/common-ui";
+import { signal, Cancel } from "@commontools/common-frp";
import { Gem, ID, NAME } from "../recipe.js";
export const sagaLink = render.view("common-saga-link", {
saga: { type: "object" },
- name: { tyoe: "string" },
+ name: { type: "string" },
});
@customElement("common-saga-link")
@@ -26,6 +27,9 @@ export class CommonSagaLink extends LitElement {
@property({ type: String })
name: string | undefined = undefined;
+ private nameEffect: Cancel | undefined;
+ private nameFromGem: string | undefined;
+
handleClick(e: Event) {
e.preventDefault();
this.dispatchEvent(
@@ -37,13 +41,43 @@ export class CommonSagaLink extends LitElement {
);
}
+ override connectedCallback() {
+ super.connectedCallback();
+ this.maybeListenToName();
+ }
+
+ override disconnectedCallback() {
+ super.disconnectedCallback();
+ this.nameEffect?.();
+ }
+
+ override updated(changedProperties: Map) {
+ super.updated(changedProperties);
+ if (changedProperties.has("saga")) {
+ this.maybeListenToName();
+ }
+ }
+
+ private maybeListenToName() {
+ if (signal.isSignal(this.saga?.[NAME])) {
+ console.log("listening to name", this.saga[NAME]);
+ this.nameEffect = signal.effect([this.saga[NAME]], (name: string) => {
+ this.nameFromGem = name;
+ this.requestUpdate();
+ });
+ } else {
+ this.nameEffect?.();
+ this.nameFromGem = this.saga?.[NAME];
+ }
+ }
+
override render() {
console.log("rendering saga link", this.saga, this.name);
if (!this.saga) return html``;
+ const name = this.name ?? this.nameFromGem;
+ console.log("rendering saga link", name, this.saga[NAME]);
return html`
-
- ${this.name ?? this.saga[NAME]}
-
+ 🔮 ${name}
`;
}
}
diff --git a/typescript/packages/lookslike-high-level/src/data.ts b/typescript/packages/lookslike-high-level/src/data.ts
index 4ac40af7b..96bb54e25 100644
--- a/typescript/packages/lookslike-high-level/src/data.ts
+++ b/typescript/packages/lookslike-high-level/src/data.ts
@@ -5,25 +5,21 @@ const { state } = signal;
import { todoList, todoTask } from "./recipes/todo-list.js";
import "./recipes/todo-list-as-task.js"; // Necessary, so that suggestions are indexed.
-import { sagaList } from "./recipes/saga-list.js";
+import { todo } from "@commontools/common-ui/tags.js";
export const keywords: { [key: string]: string[] } = {
groceries: ["grocery list"],
};
-export const dataGems = state<{ [key: string]: Gem }>({});
+export const dataGems = state([]);
-export function addGems(gems: { [key: string]: Gem }) {
- Object.entries(gems).forEach(([name, gem]) => (gem[NAME] = name));
- dataGems.send({ ...dataGems.get(), ...gems });
+export function addGems(gems: Gem[]) {
+ dataGems.send([...dataGems.get(), ...gems]);
}
-export function getGemByName(name: string): Gem | undefined {
- return dataGems.get()[name];
-}
-
-const recipes: { [name: string]: Gem } = {
- "todo list": todoList({
+addGems([
+ todoList({
+ title: "My TODOs",
items: ["Buy groceries", "Walk the dog", "Wash the car"].map((item) =>
todoTask({
title: item,
@@ -31,7 +27,8 @@ const recipes: { [name: string]: Gem } = {
})
),
}),
- "grocery list": todoList({
+ todoList({
+ title: "My grocery shopping list",
items: ["milk", "eggs", "bread"].map((item) =>
todoTask({
title: item,
@@ -39,7 +36,11 @@ const recipes: { [name: string]: Gem } = {
})
),
}),
- home: sagaList({ sagas: dataGems }),
-};
+]);
-addGems(recipes);
+export const recipes = {
+ "Create a new TODO list": {
+ recipe: todoList,
+ inputs: { title: "", items: [] },
+ },
+};
diff --git a/typescript/packages/lookslike-high-level/src/main.ts b/typescript/packages/lookslike-high-level/src/main.ts
index 1e5bc9d5b..c79ce2494 100644
--- a/typescript/packages/lookslike-high-level/src/main.ts
+++ b/typescript/packages/lookslike-high-level/src/main.ts
@@ -1,12 +1,12 @@
export { components } from "@commontools/common-ui";
import { CommonWindowManager } from "./components/window-manager.js";
export { components as myComponents } from "./components.js";
-import { getGemByName } from "./data.js";
+import { dataGems } from "./data.js";
+import { home } from "./recipes/home.js";
document.addEventListener("DOMContentLoaded", () => {
const windowManager = document.getElementById(
"window-manager"
)! as CommonWindowManager;
- console.log(getGemByName("home"));
- windowManager.openSaga(getGemByName("home")!);
+ windowManager.openSaga(home({ sagas: dataGems }));
});
diff --git a/typescript/packages/lookslike-high-level/src/recipe.ts b/typescript/packages/lookslike-high-level/src/recipe.ts
index 93f7b6665..f6abe93bc 100644
--- a/typescript/packages/lookslike-high-level/src/recipe.ts
+++ b/typescript/packages/lookslike-high-level/src/recipe.ts
@@ -34,7 +34,7 @@ export type Recipe = (inputs: RecipeInputs) => Gem;
let id = 0;
export const recipe = (
- name: string,
+ type: string,
impl: (inputs: Bindings) => Bindings
): Recipe => {
return (inputs: Bindings) => {
@@ -45,7 +45,7 @@ export const recipe = (
])
);
const outputs = impl(inputsAsSignals);
- return { [ID]: id++, [TYPE]: name, ...outputs };
+ return { [ID]: id++, [TYPE]: type, ...outputs };
};
};
diff --git a/typescript/packages/lookslike-high-level/src/recipes/saga-list.ts b/typescript/packages/lookslike-high-level/src/recipes/home.ts
similarity index 59%
rename from typescript/packages/lookslike-high-level/src/recipes/saga-list.ts
rename to typescript/packages/lookslike-high-level/src/recipes/home.ts
index aecabf62d..99c7e6ce4 100644
--- a/typescript/packages/lookslike-high-level/src/recipes/saga-list.ts
+++ b/typescript/packages/lookslike-high-level/src/recipes/home.ts
@@ -3,9 +3,9 @@ import { signal } from "@commontools/common-frp";
import { recipe, Gem, ID } from "../recipe.js";
import { sagaLink } from "../components/saga-link.js";
const { binding, repeat } = view;
-const { list } = tags;
+const { list, vstack } = tags;
-export const sagaList = recipe("saga list", ({ sagas }) => {
+export const home = recipe("home screen", ({ sagas, recipes }) => {
const sagasWithIDs = signal.computed(
[sagas],
(sagas: { [key: string]: Gem }) =>
@@ -17,8 +17,11 @@ export const sagaList = recipe("saga list", ({ sagas }) => {
return {
UI: [
- list({}, repeat("sagas", sagaLink({ saga: binding("saga") }))),
- { sagas: sagasWithIDs },
+ list({}, [
+ vstack({}, repeat("sagas", sagaLink({ saga: binding("saga") }))),
+ // vstack({}, repeat("recipes", recipeLink({ saga: binding("saga") }))),
+ ]),
+ { sagas: sagasWithIDs, recipes },
],
};
});
diff --git a/typescript/packages/lookslike-high-level/src/recipes/todo-list-as-task.ts b/typescript/packages/lookslike-high-level/src/recipes/todo-list-as-task.ts
index 8b1c23149..5e243b33e 100644
--- a/typescript/packages/lookslike-high-level/src/recipes/todo-list-as-task.ts
+++ b/typescript/packages/lookslike-high-level/src/recipes/todo-list-as-task.ts
@@ -81,7 +81,7 @@ export const todoListAsTask = recipe("todo list as task", ({ list, done }) => {
});
addSuggestion({
- description: description`Add ${"list"} as sub tasks`,
+ description: description`Add 💎${"list"} as sub tasks`,
recipe: todoListAsTask,
bindings: { done: "done" },
dataGems: {
diff --git a/typescript/packages/lookslike-high-level/src/recipes/todo-list.ts b/typescript/packages/lookslike-high-level/src/recipes/todo-list.ts
index 866013f15..c4308cca8 100644
--- a/typescript/packages/lookslike-high-level/src/recipes/todo-list.ts
+++ b/typescript/packages/lookslike-high-level/src/recipes/todo-list.ts
@@ -1,18 +1,23 @@
import { view, tags } from "@commontools/common-ui";
import { signal, stream } from "@commontools/common-frp";
-import { recipe } from "../recipe.js";
+import { recipe, NAME } from "../recipe.js";
import { annotation } from "../components/annotation.js";
const { binding, repeat } = view;
-const { list, vstack, hstack, checkbox, div, include, sendInput, todo } = tags;
+const { list, vstack, include, sendInput, todo, commonInput } = tags;
const { state } = signal;
const { subject } = stream;
-export const todoList = recipe("todo list", ({ items }) => {
- const newTasks = subject<{
- type: "messageSend";
- detail: { message: string };
- }>();
+export const todoList = recipe("todo list", ({ title, items }) => {
+ const newTitle = subject<{ detail: { value: string } }>();
+ newTitle.sink({
+ send: (event) => {
+ const updatedTitle = event.detail?.value?.trim();
+ if (!updatedTitle) return;
+ title.send(updatedTitle);
+ },
+ });
+ const newTasks = subject<{ detail: { message: string } }>();
newTasks.sink({
send: (event) => {
const task = event.detail?.message?.trim();
@@ -24,6 +29,11 @@ export const todoList = recipe("todo list", ({ items }) => {
return {
UI: [
list({}, [
+ commonInput({
+ value: binding("title"),
+ placeholder: "List title",
+ "@common-input": binding("newTitle"),
+ }),
vstack({}, repeat("items", include({ content: binding("itemUI") }))),
sendInput({
name: "Add",
@@ -31,9 +41,11 @@ export const todoList = recipe("todo list", ({ items }) => {
"@messageSend": binding("newTasks"),
}),
]),
- { items, newTasks },
+ { items, title, newTitle, newTasks },
],
+ title,
items,
+ [NAME]: title,
};
});
@@ -52,16 +64,20 @@ export const todoTask = recipe("todo task", ({ title, done }) => {
return {
itemUI: state([
vstack({}, [
- todo({
- checked: binding("done"),
- value: binding("title"),
- "@todo-checked": binding("update"),
- "@todo-input": binding("update"),
- }),
- annotation({
- query: title,
- data: { done, title },
- }),
+ todo(
+ {
+ checked: binding("done"),
+ value: binding("title"),
+ "@todo-checked": binding("update"),
+ "@todo-input": binding("update"),
+ },
+ [
+ annotation({
+ query: title,
+ data: { done, title },
+ }),
+ ]
+ ),
]),
{ done, title, update },
]),
From 7cfd6f29aced4d5102ec9a3e3a8fa432507690cf Mon Sep 17 00:00:00 2001
From: Bernhard Seefeld
Date: Fri, 21 Jun 2024 11:59:38 -0700
Subject: [PATCH 2/3] add vdom for common-input
---
.../common-ui/src/components/common-input.ts | 90 ++++++++++---------
1 file changed, 49 insertions(+), 41 deletions(-)
diff --git a/typescript/packages/common-ui/src/components/common-input.ts b/typescript/packages/common-ui/src/components/common-input.ts
index 3f417b27c..80ac5ba59 100644
--- a/typescript/packages/common-ui/src/components/common-input.ts
+++ b/typescript/packages/common-ui/src/components/common-input.ts
@@ -1,11 +1,20 @@
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";
import { baseStyles } from "./style.js";
+import { view } from "../hyperscript/render.js";
+import { eventProps } from "../hyperscript/schema-helpers.js";
+
+export const commonInput = view("common-input", {
+ ...eventProps(),
+ value: { type: "string" },
+ placeholder: { type: "string" },
+ appearance: { type: "string" },
+});
export type CommonInput = {
id: string;
value: string;
-}
+};
export class CommonInputEvent extends Event {
detail: CommonInput;
@@ -21,35 +30,35 @@ export class CommonInputElement extends LitElement {
static override styles = [
baseStyles,
css`
- :host {
- display: block;
- --height: 24px;
- }
-
- .input-wrapper {
- display: flex;
- flex-direction: column;
- justify-content: center;
- }
+ :host {
+ display: block;
+ --height: 24px;
+ }
- .input {
- appearance: none;
- border: 0;
- outline: 0;
- box-sizing: border-box;
- font-size: var(--body-size);
- width: 100%;
- height: var(--height);
- }
+ .input-wrapper {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ }
- :host([appearance="rounded"]) .input {
- --height: 40px;
- background-color: var(--input-background);
- border-radius: calc(var(--height) / 2);
- padding: 8px 16px;
- height: var(--height);
- }
- `
+ .input {
+ appearance: none;
+ border: 0;
+ outline: 0;
+ box-sizing: border-box;
+ font-size: var(--body-size);
+ width: 100%;
+ height: var(--height);
+ }
+
+ :host([appearance="rounded"]) .input {
+ --height: 40px;
+ background-color: var(--input-background);
+ border-radius: calc(var(--height) / 2);
+ padding: 8px 16px;
+ height: var(--height);
+ }
+ `,
];
@property({ type: String }) value = "";
@@ -61,20 +70,19 @@ export class CommonInputElement extends LitElement {
const value = (event.target as HTMLInputElement).value;
this.value = value;
- this.dispatchEvent(
- new CommonInputEvent({ id: this.id, value })
- );
- }
+ this.dispatchEvent(new CommonInputEvent({ id: this.id, value }));
+ };
return html`
-
-
-
+
+
+
`;
}
-}
\ No newline at end of file
+}
From 37801640095b22cd6bf4a4f22d4ca0787db87a3e Mon Sep 17 00:00:00 2001
From: Bernhard Seefeld
Date: Fri, 21 Jun 2024 12:30:09 -0700
Subject: [PATCH 3/3] fixed suggestions again
---
.../src/components/saga-link.ts | 10 +--
.../packages/lookslike-high-level/src/data.ts | 5 --
.../lookslike-high-level/src/recipe.ts | 9 +--
.../src/recipes/annotation.ts | 76 +++++++++++--------
4 files changed, 52 insertions(+), 48 deletions(-)
diff --git a/typescript/packages/lookslike-high-level/src/components/saga-link.ts b/typescript/packages/lookslike-high-level/src/components/saga-link.ts
index feafbc20b..7d8840e5f 100644
--- a/typescript/packages/lookslike-high-level/src/components/saga-link.ts
+++ b/typescript/packages/lookslike-high-level/src/components/saga-link.ts
@@ -54,16 +54,16 @@ export class CommonSagaLink extends LitElement {
override updated(changedProperties: Map) {
super.updated(changedProperties);
if (changedProperties.has("saga")) {
- this.maybeListenToName();
+ this.maybeListenToName(true);
}
}
- private maybeListenToName() {
+ private maybeListenToName(skipUpdate = false) {
if (signal.isSignal(this.saga?.[NAME])) {
- console.log("listening to name", this.saga[NAME]);
this.nameEffect = signal.effect([this.saga[NAME]], (name: string) => {
this.nameFromGem = name;
- this.requestUpdate();
+ if (!skipUpdate) this.requestUpdate();
+ skipUpdate = false;
});
} else {
this.nameEffect?.();
@@ -72,10 +72,8 @@ export class CommonSagaLink extends LitElement {
}
override render() {
- console.log("rendering saga link", this.saga, this.name);
if (!this.saga) return html``;
const name = this.name ?? this.nameFromGem;
- console.log("rendering saga link", name, this.saga[NAME]);
return html`
🔮 ${name}
`;
diff --git a/typescript/packages/lookslike-high-level/src/data.ts b/typescript/packages/lookslike-high-level/src/data.ts
index 96bb54e25..98525f5ab 100644
--- a/typescript/packages/lookslike-high-level/src/data.ts
+++ b/typescript/packages/lookslike-high-level/src/data.ts
@@ -5,11 +5,6 @@ const { state } = signal;
import { todoList, todoTask } from "./recipes/todo-list.js";
import "./recipes/todo-list-as-task.js"; // Necessary, so that suggestions are indexed.
-import { todo } from "@commontools/common-ui/tags.js";
-
-export const keywords: { [key: string]: string[] } = {
- groceries: ["grocery list"],
-};
export const dataGems = state([]);
diff --git a/typescript/packages/lookslike-high-level/src/recipe.ts b/typescript/packages/lookslike-high-level/src/recipe.ts
index f6abe93bc..97348ffe6 100644
--- a/typescript/packages/lookslike-high-level/src/recipe.ts
+++ b/typescript/packages/lookslike-high-level/src/recipe.ts
@@ -63,12 +63,11 @@ export type Suggestion = {
dataGems: { [key: string]: string };
};
-export const suggestions = signal.state([]);
+export const suggestions: Suggestion[] = [];
+export function addSuggestion(suggestion: Suggestion) {
+ suggestions.push(suggestion);
+}
export function description(strings: TemplateStringsArray, ...values: any[]) {
return strings.map((string, i) => [string, values[i]]).flat();
}
-
-export function addSuggestion(suggestion: Suggestion) {
- setTimeout(() => suggestions.send([...suggestions.get(), suggestion]));
-}
diff --git a/typescript/packages/lookslike-high-level/src/recipes/annotation.ts b/typescript/packages/lookslike-high-level/src/recipes/annotation.ts
index f5e288cd1..7036cf039 100644
--- a/typescript/packages/lookslike-high-level/src/recipes/annotation.ts
+++ b/typescript/packages/lookslike-high-level/src/recipes/annotation.ts
@@ -1,16 +1,17 @@
import { view, tags } from "@commontools/common-ui";
import { signal, stream } from "@commontools/common-frp";
-import { dataGems, keywords } from "../data.js";
+import { dataGems } from "../data.js";
import {
recipe,
Recipe,
Gem,
TYPE,
+ NAME,
suggestions,
type Suggestion,
} from "../recipe.js";
const { include } = tags;
-const { state, computed } = signal;
+const { state, computed, isSignal } = signal;
const { subject } = stream;
const { binding } = view;
@@ -30,10 +31,8 @@ const { binding } = view;
*/
export const annotation = recipe("annotation", ({ "?": query, ...data }) => {
- const suggestion = computed(
- [dataGems, suggestions, query],
- (dataGems, suggestions, query: string) =>
- findSuggestion(dataGems, suggestions, query, Object.keys(data))
+ const suggestion = computed([dataGems, query], (dataGems, query: string) =>
+ findSuggestion(dataGems, suggestions, query, Object.keys(data))
);
const acceptSuggestion = subject();
@@ -81,30 +80,16 @@ type Result = {
};
function findSuggestion(
- dataGems: { [key: string]: Gem },
+ dataGems: Gem[],
suggestions: Suggestion[],
query: string,
data: string[]
): Result | undefined {
// Step 1: Find candidate data gems by doing a dumb keyword seach
- const parts: string[] = query
- .toLowerCase()
- .split(/ +/)
- .map((word) => word.trim())
- .filter((word) => word.length > 0);
-
- const words = parts.flatMap((word, i) => {
- const w = [word];
- if (i < parts.length - 1) w.push(word + " " + parts[i + 1]);
- if (i < parts.length - 2)
- w.push(word + " " + parts[i + 1] + " " + parts[i + 2]);
- return w;
- });
+ const terms = queryToTerms(query);
- const aliases = words.flatMap((word) => keywords[word] ?? []);
-
- const gems = Object.entries(dataGems).filter(
- ([name]) => words.includes(name) || aliases.includes(name)
+ const gems = dataGems.filter((gem) =>
+ queryToTerms(getNameFromGem(gem)).some((term) => terms.includes(term))
);
// Step 2: Find suggestions that bridge matching gems to recipes:
@@ -116,27 +101,26 @@ function findSuggestion(
const suggestion = suggestions.find(
(suggestion) =>
Object.values(suggestion.dataGems).every((type) =>
- gems.find(([_, gem]) => gem[TYPE] === type)
+ gems.find((gem) => gem[TYPE] === type)
) &&
Object.values(suggestion.bindings).every((binding) =>
data.includes(binding)
)
);
- console.log("suggestion", suggestion, suggestions);
+ console.log("suggestion", suggestion, query, gems, suggestions);
if (suggestion) {
const bindings = Object.entries(suggestion.dataGems).map(([key, type]) => [
key,
- gems.find(([_, gem]) => gem[TYPE] === type)!,
- ]);
+ gems.find((gem) => gem[TYPE] === type),
+ ]) as [[string, Gem]];
const nameBindings = Object.fromEntries(
- bindings.map(([key, [name, _gem]]) => [key, name])
- );
- const gemBindings = Object.fromEntries(
- bindings.map(([key, [_name, gem]]) => [key, gem])
+ bindings.map(([key, gem]) => [key, getNameFromGem(gem)])
);
+ const gemBindings = Object.fromEntries(bindings);
+
const description = suggestion.description
.map((part, i) => (i % 2 === 0 ? part : nameBindings[part]))
.join("");
@@ -146,3 +130,31 @@ function findSuggestion(
return undefined;
}
}
+
+function getNameFromGem(gem: Gem): string {
+ return (isSignal(gem[NAME]) ? gem[NAME].get() : gem[NAME]) as string;
+}
+
+const keywords: { [key: string]: string[] } = {
+ groceries: ["grocery"],
+};
+
+function queryToTerms(query: string): string[] {
+ const parts: string[] = query
+ .toLowerCase()
+ .split(/ +/)
+ .map((word) => word.trim())
+ .filter((word) => word.length > 0);
+
+ const aliases = parts.flatMap((word) => keywords[word] ?? []);
+
+ const words = [...parts, ...aliases].flatMap((word, i) => {
+ const w = [word];
+ if (i < parts.length - 1) w.push(word + " " + parts[i + 1]);
+ if (i < parts.length - 2)
+ w.push(word + " " + parts[i + 1] + " " + parts[i + 2]);
+ return w;
+ });
+
+ return words;
+}