diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..fc7ad27 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,86 @@ +# Copilot Instructions for `goark/ml` + +## Project purpose + +`ml` is a CLI and library that fetches metadata from web pages and formats links +as markdown/wiki/html/csv/json output. + +## Design principles + +- Keep CLI behavior simple and deterministic. +- Keep output formatting stable for all supported styles. +- Reuse `github.com/goark/webinfo` for metadata extraction. +- Preserve compatibility of exported symbols when possible. + +## Error handling + +- Use `github.com/goark/errs` for internal error handling. +- Prefer `errs.Wrap`, `errs.Join`, and `errs.WithContext`. +- Keep `errors.Is` compatibility for callers. +- Keep sentinel errors stable (`ErrNullPointer`, `ErrNoImplement`, `ErrInvalidRequest`). +- Include useful context keys such as `url`, `style`, and `path`. + +## Fetch behavior + +- Fetch and metadata parsing are delegated to `github.com/goark/webinfo`. +- Keep `makelink.New` mapping behavior stable: + - `Link.URL <- Webinfo.URL` + - `Link.Location <- Webinfo.Location` + - `Link.Canonical <- Webinfo.Canonical` + - `Link.Title <- Webinfo.Title` + - `Link.Description <- Webinfo.Description` +- Preserve URL fallback behavior for output rendering: + - title fallback: `Title` -> `URL` + - URL fallback: `Canonical` -> `Location` -> `URL` + +## Output style behavior + +- Keep style names and output formats stable: + - `markdown`: `[title](url)` + - `wiki`: `[url title]` + - `html`: `title` + - `csv`: escaped CSV fields in fixed order + - `json`: JSON encoded `Link` +- Keep CSV quote escaping behavior unchanged. + +## Coding style + +- Write idiomatic Go with straightforward control flow. +- Avoid unnecessary dependencies. +- Keep comments concise and in English. + +## Testing and validation + +- Add or update tests for behavior changes. +- Prefer local validation with Taskfile targets: + - `task test` + - `task govulncheck` + +## Documentation + +- Keep `README.md` aligned with actual CLI and exported API behavior. +- Keep examples concise and runnable. + +## Release process + +- Create release tags from `master`. +- Use semantic versioning tags in `vMAJOR.MINOR.PATCH` format. +- Ensure repository is clean and synced before tagging. + +Release steps: + +1. Ensure `master` is up to date. +2. Create annotated tag: + - `git tag -a vX.Y.Z -m "Release vX.Y.Z"` +3. Push tag: + - `git push origin vX.Y.Z` +4. Wait for the `build` workflow triggered by the tag push to complete. + +Verification steps: + +- Check tag exists: + - `git tag -l "vX.Y.Z"` +- Check `build` workflow result for the tag run (success/failure). +- Check release exists: + - `gh release view vX.Y.Z` +- Check generated release notes are present in the release page. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dc0d6f1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: ci + +on: + push: + branches: + - master + pull_request: + +permissions: + contents: read + +jobs: + test-and-lint: + name: lint and test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: latest + args: --enable gosec + + - name: Test module + run: go test -shuffle on ./... + + govulncheck: + name: govulncheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Run govulncheck + uses: golang/govulncheck-action@v1 + with: + go-version-file: go.mod + go-package: ./... + repo-checkout: false diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index c189f79..0000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,58 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -name: "CodeQL" - -on: - push: - branches: [master] - pull_request: - # The branches below must be a subset of the branches above - branches: [master] - schedule: - - cron: '0 20 * * 0' - -jobs: - CodeQL-Build: - # CodeQL runs on ubuntu-latest, windows-latest, and macos-latest - runs-on: ubuntu-latest - - permissions: - # required for all workflows - security-events: write - - # only required for workflows in private repositories - actions: read - contents: read - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - # Override language selection by uncommenting this and choosing your languages - with: - languages: go - - # 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@v2 - - # 鈩癸笍 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@v2 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..94aa2bb --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,35 @@ +name: CodeQL + +on: + push: + branches: + - master + pull_request: + branches: + - master + schedule: + - cron: "0 20 * * 0" + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: go + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v3 \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 084bb8a..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: lint -on: - push: - branches: - - master - pull_request: - -permissions: - contents: read - # Optional: allow read access to pull request. Use with `only-new-issues` option. - # pull-requests: read -jobs: - golangci: - name: lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 - with: - go-version-file: 'go.mod' - - name: golangci-lint - uses: golangci/golangci-lint-action@v9 - with: - # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version - version: latest - - # Optional: working directory, useful for monorepos - # working-directory: somedir - - # Optional: golangci-lint command line arguments. - args: --enable gosec - - # Optional: show only new issues if it's a pull request. The default value is `false`. - # only-new-issues: true - - # Optional: if set to true then the all caching functionality will be complete disabled, - # takes precedence over all other caching options. - # skip-cache: true - - # Optional: if set to true then the action don't cache or restore ~/go/pkg. - # skip-pkg-cache: true - - # Optional: if set to true then the action don't cache or restore ~/.cache/go-build. - # skip-build-cache: true - - name: testing - run: go test -shuffle on ./... - - name: install govulncheck - run: go install golang.org/x/vuln/cmd/govulncheck@latest - - name: running govulncheck - run: govulncheck ./... diff --git a/.github/workflows/vulns.yml b/.github/workflows/vulns.yml deleted file mode 100644 index 0ce3aae..0000000 --- a/.github/workflows/vulns.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: vulns -on: - push: - branches: - - master - pull_request: -jobs: - vulns: - name: Vulnerability scanner - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-go@v5 - with: - go-version-file: 'go.mod' - - name: install depm - run: go install github.com/goark/depm@latest - - name: WriteGoList - run: depm list --json > go.list - - name: Nancy - uses: sonatype-nexus-community/nancy-github-action@main diff --git a/README.md b/README.md index 3a3c833..5ef19f7 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,124 @@ -# [ml] -- Make Link with Markdown Format +# [ml] -- Make links from web page metadata -[](https://github.com/goark/ml/actions) -[](https://github.com/goark/ml/actions) -[](https://github.com/goark/ml/actions) +[](https://github.com/goark/ml/actions) +[](https://github.com/goark/ml/actions) +[](https://github.com/goark/ml/actions) [](https://raw.githubusercontent.com/goark/ml/master/LICENSE) -[](https://github.com/goark/ml/releases/latest) +[](https://github.com/goark/ml/releases/latest) +[](https://pkg.go.dev/github.com/goark/ml) -This package is required Go 1.26.3 or later. +`ml` is a CLI and Go package to fetch web page metadata and render it as links +in multiple output styles (`markdown`, `wiki`, `html`, `csv`, `json`). -**Migrated repository to [github.com/goark/ml][ml]** +## Design goals -## Build and Install +- Keep CLI behavior simple for command-line and stdin workflows. +- Keep output style conversion deterministic and explicit. +- Reuse `github.com/goark/webinfo` for metadata extraction logic. +- Preserve compatibility of exported symbols when possible. +## Development + +### Requirements + +- Go 1.26.3 or later +- [Task](https://taskfile.dev/) command (local tool for this repository) + +### Local validation + +```text +task test +task govulncheck ``` -$ go install github.com/goark/ml@latest + +Run all maintenance tasks: + +```text +task ``` -## Binaries +## CI Workflows -See [latest release](https://github.com/goark/ml/releases/latest). +- `ci`: lint (`golangci-lint` with `gosec`), tests, and `govulncheck` +- `CodeQL`: scheduled and push/PR static analysis +- `build`: release build on `v*` tags via GoReleaser ## Usage -``` -$ ml -h -Usage: - ml [flags] [URL [URL]...] - -Flags: - --debug for debug - -h, --help help for ml - -i, --interactive interactive mode - -l, --log int history log size - -s, --style string link style [markdown|wiki|html|csv|json] (default "markdown") - -a, --user-agent string User-Agent string - -v, --version output version of ml -``` +### Install +```bash +go install github.com/goark/ml@latest ``` -$ ml https://git.io/vFR5M -[GitHub - goark/ml: Make Link with Markdown Format 路 GitHub](https://github.com/goark/ml) -``` +### CLI examples + +```bash +ml https://git.io/vFR5M ``` -$ echo https://git.io/vFR5M | ml -[GitHub - goark/ml: Make Link with Markdown Format 路 GitHub](https://github.com/goark/ml) + +```bash +echo https://git.io/vFR5M | ml ``` -### Support Other Styles +Use another style: +```bash +ml -s html https://git.io/vFR5M ``` -$ ml -s html https://git.io/vFR5M -GitHub - goark/ml: Make Link with Markdown Format 路 GitHub -``` - -Support Styles: `markdown`, `wiki`, `html`, `csv`, `json` -### Interactive Mode +Interactive mode: -``` -$ ml -i +```text +ml -i Input 'q' or 'quit' to stop ml> https://git.io/vFR5M -[GitHub - goark/ml: Make Link with Markdown Format 路 GitHub](https://github.com/goark/ml) +[GitHub - goark/ml: Make Link with Markdown Format](https://github.com/goark/ml) ml> ``` -## With Go Codes +### Public API + +- `makelink.New(ctx, urlStr, userAgent)` fetches metadata and builds a `Link`. +- `(*makelink.Link).Encode(style)` renders output in the given style. +- `makelink.GetStyle(name)` resolves style strings. + +### Use as a Go package ```go package main import ( - "context" - "fmt" - "io" - "os" + "context" + "fmt" + "io" + "os" - "github.com/goark/ml/makelink" + "github.com/goark/ml/makelink" ) func main() { - lnk, err := makelink.New(context.Background(), "https://git.io/vFR5M", "") - if err != nil { - fmt.Fprintln(os.Stderr, err) - return - } - _, _ = io.Copy(os.Stdout, lnk.Encode(makelink.StyleMarkdown)) - // Output: - // [GitHub - goark/ml: Make Link with Markdown Format 路 GitHub](https://github.com/goark/ml) + lnk, err := makelink.New(context.Background(), "https://git.io/vFR5M", "") + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + _, _ = io.Copy(os.Stdout, lnk.Encode(makelink.StyleMarkdown)) } ``` +## Behavior notes + +- Metadata extraction is delegated to `github.com/goark/webinfo`. +- `CanonicalURL()` falls back in this order: canonical -> location -> input URL. +- `TitleName()` falls back to URL when title is empty. + +## Error handling + +This project wraps internal errors with `github.com/goark/errs`. + ## Modules Requirement Graph [](./dependency.png) -[ml]: https://github.com/goark/ml "goark/ml: Make Link with Markdown Format" +[ml]: https://github.com/goark/ml "goark/ml" diff --git a/dependency.png b/dependency.png index d91f4c0..62b438a 100644 Binary files a/dependency.png and b/dependency.png differ diff --git a/facade/facade.go b/facade/facade.go index 81fcb5c..7a95272 100644 --- a/facade/facade.go +++ b/facade/facade.go @@ -17,9 +17,9 @@ import ( ) var ( - //Name is applicatin name + // Name is the application name. Name = "ml" - //Version is version for applicatin + // Version is the application version. Version = "dev-version" ) @@ -29,7 +29,7 @@ var ( debugFlag bool //debug flag ) -//newRootCmd returns cobra.Command instance for root command +// newRootCmd returns the root cobra command. func newRootCmd(ui *rwi.RWI, args []string) *cobra.Command { rootCmd := &cobra.Command{ Use: Name + " [flags] [URL [URL]...]", @@ -62,7 +62,10 @@ func newRootCmd(ui *rwi.RWI, args []string) *cobra.Command { if err := mkdirHistory(); err != nil { _ = ui.OutputErrln(err) } else if err := hist.Load(); err != nil { - _ = ui.OutputErrln(err) + // First run can have no history file; keep other load errors visible. + if !os.IsNotExist(err) { + _ = ui.OutputErrln(err) + } } } defer func() { @@ -136,7 +139,7 @@ func newRootCmd(ui *rwi.RWI, args []string) *cobra.Command { return rootCmd } -//Execute is called from main function +// Execute runs CLI processing and returns an exit code. func Execute(ui *rwi.RWI, args []string) (exit exitcode.ExitCode) { defer func() { //panic hundling @@ -161,7 +164,7 @@ func Execute(ui *rwi.RWI, args []string) (exit exitcode.ExitCode) { return } -/* Copyright 2017-2021 Spiegel +/* Copyright 2017-2026 Spiegel * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/facade/history/file.go b/facade/history/file.go index 25ff612..b08fb29 100644 --- a/facade/history/file.go +++ b/facade/history/file.go @@ -1,13 +1,14 @@ package history import ( + "errors" "os" "github.com/goark/errs" "github.com/nyaosorg/go-readline-ny" ) -// HistoryFile is a history file class. +// HistoryFile stores history entries backed by a file. type HistoryFile struct { *History path string @@ -15,18 +16,22 @@ type HistoryFile struct { var _ readline.IHistory = (*HistoryFile)(nil) -// NewFile function returns new HistoryFile instance. +// NewFile returns a new HistoryFile. func NewFile(size int, path string) *HistoryFile { return &HistoryFile{History: New(size), path: path} } -// Load method imports history data from file. +// Load imports history entries from the file. func (hf *HistoryFile) Load() (err error) { if hf == nil || hf.Size() == 0 || len(hf.path) == 0 { return nil } file, err := os.Open(hf.path) if err != nil { + // Missing history file on first run is expected. + if errors.Is(err, os.ErrNotExist) { + return nil + } return errs.Wrap(err) } defer func() { @@ -38,13 +43,13 @@ func (hf *HistoryFile) Load() (err error) { return } -// Save method exports history data to file. +// Save writes history entries to the file. func (hf *HistoryFile) Save() (err error) { if hf == nil || hf.Size() == 0 || len(hf.path) == 0 { return nil } - // file, err := os.Create(hf.path) - file, err := os.OpenFile(hf.path, os.O_RDWR|os.O_CREATE, 0600) + // Use OpenFile to preserve explicit 0600 permission while truncating stale data. + file, err := os.OpenFile(hf.path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return errs.Wrap(err) } @@ -57,7 +62,7 @@ func (hf *HistoryFile) Save() (err error) { return } -/* Copyright 2021-2025 Spiegel +/* Copyright 2021-2026 Spiegel * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/facade/history/history.go b/facade/history/history.go index 0d0ac8f..9de83e4 100644 --- a/facade/history/history.go +++ b/facade/history/history.go @@ -9,7 +9,7 @@ import ( "github.com/nyaosorg/go-readline-ny" ) -//History is a ring-buffer class for history (string) data. +// History is a ring buffer for command history strings. type History struct { head, tail int buffer []string @@ -17,6 +17,7 @@ type History struct { var _ readline.IHistory = (*History)(nil) +// New returns a History with the given size. func New(size int) *History { if size < 1 { return nil @@ -24,7 +25,7 @@ func New(size int) *History { return &History{head: 0, tail: -1, buffer: make([]string, size)} } -//Len method returns count of buffering strings. +// Len returns the number of entries currently buffered. func (hist *History) Len() int { if hist.Size() == 0 { return 0 @@ -44,7 +45,7 @@ func (hist *History) Len() int { return hist.tail - hist.head } -//At method returns .histroty (string) data at index. +// At returns the entry at index n. func (hist *History) At(n int) string { if hist.Size() == 0 || n >= hist.Len() { return "" @@ -53,7 +54,7 @@ func (hist *History) At(n int) string { return hist.buffer[i] } -//Size method returns size of history buffer. +// Size returns the capacity of the history buffer. func (hist *History) Size() int { if hist == nil { return 0 @@ -61,7 +62,7 @@ func (hist *History) Size() int { return len(hist.buffer) } -//Add method adds new string in history buffer. +// Add appends a new history entry. func (hist *History) Add(s string) { if hist.Size() == 0 || len(s) == 0 { return @@ -82,7 +83,7 @@ func (hist *History) Add(s string) { } } -//Import method imports history data from reader. +// Import loads history entries from r. func (hist *History) Import(r io.Reader) error { if hist.Size() == 0 { return nil @@ -94,7 +95,7 @@ func (hist *History) Import(r io.Reader) error { return errs.Wrap(scanner.Err()) } -//Export method exports history data to writer. +// Export writes all history entries to w. func (hist *History) Export(w io.Writer) error { for i := 0; i < hist.Len(); i++ { if _, err := fmt.Fprintln(w, hist.At(i)); err != nil { diff --git a/facade/interactive/interactive.go b/facade/interactive/interactive.go index 66026b2..1b0928e 100644 --- a/facade/interactive/interactive.go +++ b/facade/interactive/interactive.go @@ -14,6 +14,7 @@ import ( "github.com/nyaosorg/go-readline-ny" ) +// Do runs the interactive prompt loop. func Do(opts *options.Options) error { editor := &readline.Editor{ PromptWriter: func(w io.Writer) (int, error) { diff --git a/facade/options/options.go b/facade/options/options.go index f26db27..3849129 100644 --- a/facade/options/options.go +++ b/facade/options/options.go @@ -10,14 +10,14 @@ import ( "github.com/goark/ml/makelink" ) -//Options class is Options for making link +// Options holds settings for link generation. type Options struct { linkStyle makelink.Style hist *history.HistoryFile userAgent string } -//New returns new Options instance +// New returns a new Options instance. func New(s makelink.Style, hist *history.HistoryFile, userAgent string) *Options { if hist == nil { hist = history.NewFile(0, "") @@ -25,23 +25,23 @@ func New(s makelink.Style, hist *history.HistoryFile, userAgent string) *Options return &Options{linkStyle: s, hist: hist, userAgent: userAgent} } -//History method returns history.HistoryFile instance. +// History returns the history store. func (c *Options) History() *history.HistoryFile { return c.hist } -//MakeLink is making link +// MakeLink creates an encoded link from the given URL. func (c *Options) MakeLink(ctx context.Context, urlStr string) (io.Reader, error) { if c == nil { return nil, errs.Wrap(ecode.ErrNullPointer) } - c.History().Add(urlStr) lnk, err := makelink.New(ctx, urlStr, c.userAgent) if err != nil { return nil, errs.Wrap(err) } + c.History().Add(urlStr) return lnk.Encode(c.linkStyle), nil } -/* Copyright 2017-2021 Spiegel +/* Copyright 2017-2026 Spiegel * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/facade/options/options_test.go b/facade/options/options_test.go index 62dc2e4..d8747e2 100644 --- a/facade/options/options_test.go +++ b/facade/options/options_test.go @@ -5,6 +5,8 @@ import ( "context" "fmt" "io" + "net/http" + "net/http/httptest" "os" "testing" @@ -13,8 +15,17 @@ import ( "github.com/goark/ml/makelink" ) +func newTestServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `