diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..59b394e --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,39 @@ +name: Lint + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + biome: + name: Biome + runs-on: ubuntu-slim + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + # Must be done before setup-node. + - name: Enable Corepack + run: corepack enable + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "yarn" + cache-dependency-path: actions/yarn.lock + + - name: Install Dependencies + run: yarn install --frozen-lockfile + working-directory: ./actions + + - name: Run Biome + run: yarn ci + working-directory: ./actions diff --git a/actions/biome.json b/actions/biome.json new file mode 100644 index 0000000..bd43e9b --- /dev/null +++ b/actions/biome.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.6/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": true, + "includes": ["**/*.js"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended" + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "always" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/actions/lib/feed.js b/actions/lib/feed.js index 8957f5c..6aed443 100644 --- a/actions/lib/feed.js +++ b/actions/lib/feed.js @@ -6,6 +6,6 @@ export async function getFeedFor(agent, handle, limit) { return agent.getAuthorFeed({ actor: did, filter: 'posts_and_author_threads', - limit: limit + limit: limit, }); } diff --git a/actions/lib/login.js b/actions/lib/login.js index 2472e37..0c023b8 100644 --- a/actions/lib/login.js +++ b/actions/lib/login.js @@ -3,13 +3,13 @@ import { AtpAgent } from '@atproto/api'; // TODO(joyeecheung): implement OAuth export async function login(account) { const agent = new AtpAgent({ - service: 'https://bsky.social' + service: 'https://bsky.social', }); await agent.login({ identifier: account.identifier, - password: account.password + password: account.password, }); return agent; -}; +} diff --git a/actions/lib/posts.js b/actions/lib/posts.js index 28fb8c8..4ec7266 100644 --- a/actions/lib/posts.js +++ b/actions/lib/posts.js @@ -1,5 +1,5 @@ -import AtpAgent, { AppBskyFeedPost, BlobRef, RichText } from "@atproto/api"; import assert from 'node:assert'; +import AtpAgent, { AppBskyFeedPost, BlobRef, RichText } from '@atproto/api'; import * as cheerio from 'cheerio'; export const REPLY_IN_THREAD = Symbol('Reply in thread'); @@ -11,7 +11,7 @@ export const REPLY_IN_THREAD = Symbol('Reply in thread'); const kURLPattern = /https:\/\/bsky\.app\/profile\/(.+)\/post\/(.+)/; /** - * @param {string} url + * @param {string} url */ export function validatePostURL(url) { const match = url.match(kURLPattern); @@ -20,12 +20,12 @@ export function validatePostURL(url) { return { handle: match[1], postId: match[2], - isDid: match[1].startsWith('did:') + isDid: match[1].startsWith('did:'), }; } /** - * @param {AtpAgent} agent + * @param {AtpAgent} agent * @param {string} postUrl */ export async function getPostInfoFromUrl(agent, postUrl) { @@ -46,14 +46,14 @@ export async function getPostInfoFromUrl(agent, postUrl) { } // URI format: at://${did}/app.bsky.feed.post/${postId} -const kURIPattern = /at:\/\/(.*)+\/app\.bsky\.feed\.post\/(.*)+/ +const kURIPattern = /at:\/\/(.*)+\/app\.bsky\.feed\.post\/(.*)+/; export function validatePostURI(uri) { const match = uri.match(kURIPattern); assert(match, `Post URI ${uri} does not match the expected pattern`); return { did: match[1], - postId: match[2] + postId: match[2], }; } @@ -77,7 +77,7 @@ export async function getPostURLFromURI(agent, uri) { */ async function uploadImage(agent, imgData) { const res = await agent.uploadBlob(imgData, { - encoding: 'image/jpeg' + encoding: 'image/jpeg', }); return res.data.blob; } @@ -179,19 +179,19 @@ export async function populateRecord(agent, request, shouldUploadImage = false) const rt = new RichText({ text: request.richText }); await rt.detectFacets(agent); // automatically detects mentions and links - + const record = { $type: 'app.bsky.feed.post', text: rt.text, facets: rt.facets, createdAt: new Date().toISOString(), }; - + // https://docs.bsky.app/docs/tutorials/creating-a-post#quote-posts if (request.repostInfo) { record.embed = { $type: 'app.bsky.embed.record', - record: request.repostInfo + record: request.repostInfo, }; } updateReplyRecord(request, record); @@ -199,7 +199,7 @@ export async function populateRecord(agent, request, shouldUploadImage = false) // If there is already another embed, don't generate the card embed. if (!record.embed) { // Find the first URL, match until the first whitespace or punctuation. - const urlMatch = request.richText.match(/https?:\/\/[^\s\]\[\"\'\<\>]+/); + const urlMatch = request.richText.match(/https?:\/\/[^\s\]["'<>]+/); if (urlMatch !== null) { const url = urlMatch[0]; const card = await fetchEmbedUrlCard(url); @@ -256,13 +256,15 @@ export function maybeUpdateReplyInThread(request, previousPostInfo, rootPostInfo // If the request contains rich text with thematic breaks, it will split the request into multiple // requests. export function maybeSplitRequests(request) { - if (request.action === 'repost') { // reposts are always single posts. + if (request.action === 'repost') { + // reposts are always single posts. return [request]; } if (!request.richText) { return [request]; } - const thread = request.richText.split(/^\s*(?:[-*_]\s*){2,}\s*$/m) + const thread = request.richText + .split(/^\s*(?:[-*_]\s*){2,}\s*$/m) .map((text) => text.trim()) .filter((text) => text.length > 0); @@ -271,12 +273,14 @@ export function maybeSplitRequests(request) { } return thread.map((richText, i) => ({ - ...request, - ...(i === 0 ? undefined : { - action: 'reply', // Posts other than the first one are replies. - replyURL: REPLY_IN_THREAD, - }), - richText, + ...request, + ...(i === 0 + ? undefined + : { + action: 'reply', // Posts other than the first one are replies. + replyURL: REPLY_IN_THREAD, + }), + richText, })); } diff --git a/actions/lib/validator.js b/actions/lib/validator.js index 98a52ae..4b05f50 100644 --- a/actions/lib/validator.js +++ b/actions/lib/validator.js @@ -1,5 +1,5 @@ import assert from 'node:assert'; -import { getPostInfoFromUrl, REPLY_IN_THREAD } from './posts.js'; +import { REPLY_IN_THREAD } from './posts.js'; export function validateAccount(request, env) { assert(request.account, 'JSON must contain "account" field'); @@ -10,7 +10,7 @@ export function validateAccount(request, env) { assert(env[passwordKey], `Must provide ${passwordKey} in the environment variable.`); return { identifier: env[identifierKey], - password: env[passwordKey] + password: env[passwordKey], }; } @@ -18,14 +18,15 @@ export function validateAccount(request, env) { * Validate the request based on the action requested. */ export function validateRequest(request) { - switch(request.action) { + switch (request.action) { case 'post': { assert(typeof request.richText === 'string', 'JSON must contain "richText" string field'); assert( request.richText.length > 0 && request.richText.length <= 300, - '"richText" field cannot be longer than 300 chars'); + '"richText" field cannot be longer than 300 chars', + ); break; - }; + } case 'repost': { assert(typeof request.repostURL === 'string', 'JSON must contain "repostURL" string field'); break; @@ -34,7 +35,8 @@ export function validateRequest(request) { assert(typeof request.richText === 'string', 'JSON must contain "richText" string field'); assert( request.richText.length > 0 && request.richText.length <= 300, - '"richText" field cannot be longer than 300 chars'); + '"richText" field cannot be longer than 300 chars', + ); assert(typeof request.repostURL === 'string', 'JSON must contain "repostURL" string field'); break; } @@ -42,11 +44,15 @@ export function validateRequest(request) { assert(typeof request.richText === 'string', 'JSON must contain "richText" string field'); assert( request.richText.length > 0 && request.richText.length <= 300, - '"richText" field cannot be longer than 300 chars'); - assert(typeof request.replyURL === 'string' || request.replyURL === REPLY_IN_THREAD, 'JSON must contain "replyURL" string field'); + '"richText" field cannot be longer than 300 chars', + ); + assert( + typeof request.replyURL === 'string' || request.replyURL === REPLY_IN_THREAD, + 'JSON must contain "replyURL" string field', + ); break; } default: - assert.fail('Unknown action ' + request.action); + assert.fail(`Unknown action ${request.action}`); } } diff --git a/actions/login-and-validate.js b/actions/login-and-validate.js index 2299624..c49bec5 100755 --- a/actions/login-and-validate.js +++ b/actions/login-and-validate.js @@ -1,11 +1,11 @@ #!/usr/bin/env node import assert from 'node:assert'; import fs from 'node:fs'; -import process from 'node:process'; import path from 'node:path'; +import process from 'node:process'; import { login } from './lib/login.js'; +import { maybeSplitRequests, populateRecord } from './lib/posts.js'; import { validateAccount, validateRequest } from './lib/validator.js'; -import { populateRecord, maybeSplitRequests } from './lib/posts.js'; // The JSON file must contains the following fields: // - "account": a string field indicating the account to use to perform the action. @@ -32,6 +32,6 @@ requests.forEach(validateRequest); const agent = await login(account); // Validate and extend the post URLs in the request into { cid, uri } records. -await Promise.all(requests.map(request => populateRecord(agent, request, false))); +await Promise.all(requests.map((request) => populateRecord(agent, request, false))); -export { agent, requests, requestFilePath, richTextFile }; +export { agent, requestFilePath, requests, richTextFile }; diff --git a/actions/package.json b/actions/package.json index b7c2080..2050d8c 100644 --- a/actions/package.json +++ b/actions/package.json @@ -6,8 +6,17 @@ "type": "module", "repository": "https://github.com/nodejs/bluesky-playground", "packageManager": "yarn@4.5.3", + "scripts": { + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", + "ci": "biome ci ." + }, "dependencies": { "@atproto/api": "^0.13.18", "cheerio": "^1.0.0" + }, + "devDependencies": { + "@biomejs/biome": "2.5.6" } } diff --git a/actions/process.js b/actions/process.js index cafef2c..3ae8e51 100755 --- a/actions/process.js +++ b/actions/process.js @@ -1,10 +1,10 @@ #!/usr/bin/env node -import fs from 'node:fs'; import assert from 'node:assert'; -import process from 'node:process'; +import fs from 'node:fs'; import path from 'node:path'; -import { post, maybeUpdateReplyInThread } from './lib/posts.js'; +import process from 'node:process'; +import { maybeUpdateReplyInThread, post } from './lib/posts.js'; // This script takes a path to a JSON with the pattern $base_path/new/$any_name.json, // where $any_name can be anything, and then performs the action specified in it. @@ -20,15 +20,15 @@ let rootPostInfo; let previousPostInfo; for (const request of requests) { let result; - switch(request.action) { + switch (request.action) { case 'post': { console.log(`Posting...`, request.richText); result = await post(agent, request); break; - }; + } case 'repost': { console.log('Reposting...', request.repostURL); - assert(request.repostInfo); // Extended by populateRecord. + assert(request.repostInfo); // Extended by populateRecord. result = await agent.repost(request.repostInfo.uri, request.repostInfo.cid); break; } @@ -44,7 +44,7 @@ for (const request of requests) { break; } default: - assert.fail('Unknown action ' + request.action); + assert.fail(`Unknown action ${request.action}`); } console.log('Result', result); // Extend the result to be written to the processed JSON file. @@ -89,5 +89,4 @@ if (richTextFile) { fs.rmSync(richTextFile); } - console.log(`Processed and moved file: ${requestFilePath} -> ${newFilePath}`); diff --git a/actions/test/integration.js b/actions/test/integration.js index 90d5212..61f9402 100644 --- a/actions/test/integration.js +++ b/actions/test/integration.js @@ -2,11 +2,11 @@ // It expects BLUESKY_APP_PASSWORD_$account and BLUESKY_IDENTIFIER_$account // environment variables to be set. +import assert from 'node:assert'; +import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import process from 'node:process'; -import assert from 'node:assert'; const tmpdir = path.join(import.meta.dirname, '.tmp'); const newDir = path.join(tmpdir, 'new'); @@ -60,7 +60,7 @@ fs.writeFileSync(postPath, JSON.stringify(postRequest, null, 2), 'utf8'); console.log('--- Test posting ---'); console.log(postRequest); -const postChild = checkProcess(process.execPath, [ processPath, postPath ]); +const postChild = checkProcess(process.execPath, [processPath, postPath]); const postURL = await getURLFromLastResult(postChild.stdout); console.log(`Post URL`, postURL); @@ -72,7 +72,7 @@ fs.writeFileSync(quotePostPath, JSON.stringify(quotePostRequest, null, 2), 'utf8 console.log('--- Test quote posting the first post ---'); console.log(quotePostRequest); -const quotePostChild = checkProcess(process.execPath, [ processPath, quotePostPath ]); +const quotePostChild = checkProcess(process.execPath, [processPath, quotePostPath]); const quotePostURL = await getURLFromLastResult(quotePostChild.stdout); console.log(`Quote post URL`, quotePostURL); @@ -84,7 +84,7 @@ fs.writeFileSync(replyPath, JSON.stringify(replyRequest, null, 2), 'utf8'); console.log('--- Test replying to the quote post ---'); console.log(replyRequest); -const replyChild = checkProcess(process.execPath, [ processPath, replyPath ]); +const replyChild = checkProcess(process.execPath, [processPath, replyPath]); const replyURL = await getURLFromLastResult(replyChild.stdout); console.log(`Reply URL`, replyURL); @@ -95,7 +95,7 @@ fs.writeFileSync(repostPath, JSON.stringify(repostRequest, null, 2), 'utf8'); console.log('--- Test reposting the reply ---'); console.log(repostRequest); -checkProcess(process.execPath, [ processPath, repostPath ]); +checkProcess(process.execPath, [processPath, repostPath]); // repost alone does not generate new URLs. // Test threading. @@ -107,7 +107,7 @@ fs.cpSync(path.join(examplesDir, 'thread.txt'), path.join(newDir, 'thread.txt')) console.log('--- Test threading ---'); console.log(threadRequest); -const threadChild = checkProcess(process.execPath, [ processPath, threadPath ]); +const threadChild = checkProcess(process.execPath, [processPath, threadPath]); const threadURL = await getURLFromLastResult(threadChild.stdout); console.log(`thread URL`, threadURL); diff --git a/actions/yarn.lock b/actions/yarn.lock index 3b637f9..ffe0b4e 100644 --- a/actions/yarn.lock +++ b/actions/yarn.lock @@ -63,6 +63,97 @@ __metadata: languageName: node linkType: hard +"@biomejs/biome@npm:2.5.6": + version: 2.5.6 + resolution: "@biomejs/biome@npm:2.5.6" + dependencies: + "@biomejs/cli-darwin-arm64": "npm:2.5.6" + "@biomejs/cli-darwin-x64": "npm:2.5.6" + "@biomejs/cli-linux-arm64": "npm:2.5.6" + "@biomejs/cli-linux-arm64-musl": "npm:2.5.6" + "@biomejs/cli-linux-x64": "npm:2.5.6" + "@biomejs/cli-linux-x64-musl": "npm:2.5.6" + "@biomejs/cli-win32-arm64": "npm:2.5.6" + "@biomejs/cli-win32-x64": "npm:2.5.6" + dependenciesMeta: + "@biomejs/cli-darwin-arm64": + optional: true + "@biomejs/cli-darwin-x64": + optional: true + "@biomejs/cli-linux-arm64": + optional: true + "@biomejs/cli-linux-arm64-musl": + optional: true + "@biomejs/cli-linux-x64": + optional: true + "@biomejs/cli-linux-x64-musl": + optional: true + "@biomejs/cli-win32-arm64": + optional: true + "@biomejs/cli-win32-x64": + optional: true + bin: + biome: bin/biome + checksum: 10c0/e3d1dbcde77c756933241b3e2e7e1bec7ea54a6137791c50afbada925c69852f50bd08c1554afba6d74cd6cb0b7d1f79525348b95368cc863060c37c2864f0d9 + languageName: node + linkType: hard + +"@biomejs/cli-darwin-arm64@npm:2.5.6": + version: 2.5.6 + resolution: "@biomejs/cli-darwin-arm64@npm:2.5.6" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@biomejs/cli-darwin-x64@npm:2.5.6": + version: 2.5.6 + resolution: "@biomejs/cli-darwin-x64@npm:2.5.6" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@biomejs/cli-linux-arm64-musl@npm:2.5.6": + version: 2.5.6 + resolution: "@biomejs/cli-linux-arm64-musl@npm:2.5.6" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@biomejs/cli-linux-arm64@npm:2.5.6": + version: 2.5.6 + resolution: "@biomejs/cli-linux-arm64@npm:2.5.6" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@biomejs/cli-linux-x64-musl@npm:2.5.6": + version: 2.5.6 + resolution: "@biomejs/cli-linux-x64-musl@npm:2.5.6" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@biomejs/cli-linux-x64@npm:2.5.6": + version: 2.5.6 + resolution: "@biomejs/cli-linux-x64@npm:2.5.6" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@biomejs/cli-win32-arm64@npm:2.5.6": + version: 2.5.6 + resolution: "@biomejs/cli-win32-arm64@npm:2.5.6" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@biomejs/cli-win32-x64@npm:2.5.6": + version: 2.5.6 + resolution: "@biomejs/cli-win32-x64@npm:2.5.6" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "await-lock@npm:^2.2.2": version: 2.2.2 resolution: "await-lock@npm:2.2.2" @@ -75,6 +166,7 @@ __metadata: resolution: "bluesky-playground@workspace:." dependencies: "@atproto/api": "npm:^0.13.18" + "@biomejs/biome": "npm:2.5.6" cheerio: "npm:^1.0.0" languageName: unknown linkType: soft