commit 77e916d209f303a1595f4c549beb17fdeb120aa3 Author: Huakun Shen Date: Sat Jan 18 22:24:42 2025 -0500 init hacker news diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000..f55d9ed --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,44 @@ +# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created +# For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages + +name: NPM Package Publish + +on: + push: + branches: [main] + release: + types: [created] + workflow_dispatch: + +jobs: + publish-npm: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org/ + - uses: oven-sh/setup-bun@v2 + - run: bun install + - run: bun run build + - run: | + PACKAGE_NAME=$(jq -r '.name' package.json) + PACKAGE_VERSION=$(jq -r '.version' package.json) + + # Get the version from npm registry + REGISTRY_VERSION=$(npm show "$PACKAGE_NAME" version) + + # Compare versions + if [ "$PACKAGE_VERSION" == "$REGISTRY_VERSION" ]; then + echo "Version $PACKAGE_VERSION already exists in the npm registry." + exit 0 + else + echo "Version $PACKAGE_VERSION does not exist in the npm registry. Proceeding..." + npm publish --provenance --access public + fi + env: + NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..498dd6a --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +## Kunkun Extension Hacker News + + + diff --git a/build.ts b/build.ts new file mode 100644 index 0000000..6a7ba8f --- /dev/null +++ b/build.ts @@ -0,0 +1,20 @@ +import { watch } from "fs" +import { join } from "path" +import { refreshTemplateWorkerExtension } from "@kksh/api/dev" +import { $ } from "bun" + +async function build() { + await $`bun build --minify --target=browser --outdir=./dist ./src/index.ts` + await refreshTemplateWorkerExtension() +} + +const srcDir = join(import.meta.dir, "src") + +await build() + +if (Bun.argv.includes("dev")) { + console.log(`Watching ${srcDir} for changes...`) + watch(srcDir, { recursive: true }, async (event, filename) => { + await build() + }) +} diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..6b0a510 Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..bfa257d --- /dev/null +++ b/package.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://schema.kunkun.sh/", + "version": "0.0.8", + "name": "kunkun-ext-hacker-news", + "repository": "https://github.com/kunkunsh/kunkun-ext-hacker-news", + "type": "module", + "license": "MIT", + "kunkun": { + "name": "Hacker News", + "identifier": "hacker-news", + "shortDescription": "List latest top hacker news", + "icon": { + "type": "iconify", + "value": "fa:hacker-news" + }, + "longDescription": "", + "demoImages": [], + "permissions": [ + { + "permission": "open:url", + "allow": [ + { + "url": "https://**" + }, + { + "url": "http://**" + } + ] + } + ], + "templateUiCmds": [ + { + "name": "Hacker News", + "main": "dist/index.js", + "description": "Read the latest Hacker News stories", + "cmds": [] + } + ], + "customUiCmds": [] + }, + "scripts": { + "dev": "bun build.ts dev", + "build": "bun build.ts" + }, + "dependencies": { + "@kksh/api": "^0.0.48", + "valibot": "^0.40.0" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "files": [ + "dist" + ], + "packageManager": "pnpm@9.9.0" +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..5e98f96 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,158 @@ +import { IconEnum } from "@kksh/api/models" +import { Action, expose, Icon, List, open, toast, ui, WorkerExtension } from "@kksh/api/ui/worker" +import { + array, + number, + object, + optional, + parse, + safeParse, + string, + type InferOutput +} from "valibot" + +const HackerNewsItem = object({ + by: string(), + title: string(), + url: optional(string()), + score: number() +}) +type HackerNewsItem = InferOutput + +function hackerNewsItemToListItem(item: HackerNewsItem, idx: number): List.Item { + return new List.Item({ + title: item.title, + value: item.title, + subTitle: `${item.by}`, + icon: new Icon({ type: IconEnum.Text, value: (idx + 1).toString() }), + keywords: [item.by], + accessories: [ + new List.ItemAccessory({ + icon: new Icon({ + type: IconEnum.Iconify, + value: "fa6-regular:circle-up" + }), + text: `${item.score}` + }) + ], + defaultAction: "Open", + actions: new Action.ActionPanel({ + items: [ + new Action.Action({ + title: "Open", + icon: new Icon({ type: IconEnum.Iconify, value: "ion:open-outline" }), + value: "open" + }) + ] + }) + }) +} + +class HackerNews extends WorkerExtension { + items: HackerNewsItem[] + listitems: List.Item[] + storyIds: number[] + value?: string + + constructor() { + super() + this.items = [] + this.listitems = [] + this.storyIds = [] + } + + onActionSelected(actionValue: string): Promise { + switch (actionValue) { + case "open": + const target = this.items.find((item) => item.title === this.value) + if (target) { + if (target.url) { + return open.url(target.url) + } + } + toast.error("Item not found") + break + default: + break + } + return Promise.resolve() + } + async onListScrolledToBottom(): Promise { + await ui.setScrollLoading(true) + return Promise.all( + this.storyIds + .slice(this.items.length, this.items.length + 20) + .map((id) => + fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`).then((res) => res.json()) + ) + ) + .then((stories) => { + const parsed = safeParse(array(HackerNewsItem), stories) + if (parsed.issues) { + for (const issue of parsed.issues) { + toast.error(issue.message) + } + return + } + this.items = this.items.concat(parsed.output) + this.listitems = this.items.map(hackerNewsItemToListItem) + return ui.render(new List.List({ items: this.listitems })) + }) + .then(() => ui.setScrollLoading(false)) + } + async load(): Promise { + return ui + .setSearchBarPlaceholder("Scroll down to load more...") + .then(() => fetch("https://hacker-news.firebaseio.com/v0/topstories.json")) + .then((res) => res.json()) + .then((data) => { + const storyIds = parse(array(number()), data) + this.storyIds = storyIds + return Promise.all( + this.storyIds + .slice(0, 20) + .map((id) => + fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`).then((res) => + res.json() + ) + ) + ) + }) + .then((stories) => { + const parsed = safeParse(array(HackerNewsItem), stories) + if (parsed.issues) { + for (const issue of parsed.issues) { + toast.error(issue.message) + } + return + } + this.items = parsed.output + this.listitems = this.items.map(hackerNewsItemToListItem) + return ui.render( + new List.List({ + items: this.listitems + + // detail: new List.ItemDetail({width: 50, children: [ + // new Markdown(`# Hacker News\n1. hello\n2. world\n\n**bold**`) + // ]}) + }) + ) + }) + .catch((err) => { + console.error(err) + }) + } + + onListItemSelected(value: string): Promise { + const target = this.items.find((item) => item.title === value) + if (target) { + if (target.url) { + return open.url(target.url) + } + } + toast.error("Item not found") + return Promise.resolve() + } +} + +expose(new HackerNews()) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5905e7a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es2021", + "module": "esnext", + "moduleResolution": "Bundler", + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": false, + "noImplicitAny": true, + "noEmit": true, + "outDir": "dist", + "baseUrl": ".", + "esModuleInterop": true, + "allowSyntheticDefaultImports": false, + "verbatimModuleSyntax": true, + }, + "include": [ + "." + ], + "exclude": [ + "dist", + "node_modules" + ] +} \ No newline at end of file