瀏覽代碼

Merge branch 'master' into dependency-links

Andrew Tch 13 年之前
父節點
當前提交
649ced4dd8
共有 35 個文件被更改,包括 958 次插入101 次删除
  1. 12 0
      README.md
  2. 1 0
      app/AppKernel.php
  3. 1 0
      app/Resources/views/base_nolayout.html.twig
  4. 2 0
      app/autoload.php
  5. 4 1
      app/config/config.yml
  6. 1 0
      app/config/config_dev.yml
  7. 5 0
      app/config/twig_dev.yml
  8. 9 0
      deps
  9. 7 5
      deps.lock
  10. 272 0
      doc/schema.xml
  11. 112 0
      src/Packagist/WebBundle/Command/IndexPackagesCommand.php
  12. 9 10
      src/Packagist/WebBundle/Command/UpdatePackagesCommand.php
  13. 0 2
      src/Packagist/WebBundle/Controller/ApiController.php
  14. 61 4
      src/Packagist/WebBundle/Controller/WebController.php
  15. 25 0
      src/Packagist/WebBundle/Entity/Package.php
  16. 12 0
      src/Packagist/WebBundle/Entity/PackageRepository.php
  17. 0 49
      src/Packagist/WebBundle/Entity/Version.php
  18. 33 0
      src/Packagist/WebBundle/Form/Model/SearchQuery.php
  19. 40 0
      src/Packagist/WebBundle/Form/Type/SearchQueryType.php
  20. 33 6
      src/Packagist/WebBundle/Resources/public/css/main.css
  21. 10 9
      src/Packagist/WebBundle/Resources/public/js/layout.js
  22. 51 0
      src/Packagist/WebBundle/Resources/public/js/search.js
  23. 1 1
      src/Packagist/WebBundle/Resources/public/js/submitPackage.js
  24. 1 1
      src/Packagist/WebBundle/Resources/public/js/view.js
  25. 4 2
      src/Packagist/WebBundle/Resources/views/Web/index.html.twig
  26. 9 7
      src/Packagist/WebBundle/Resources/views/Web/list.html.twig
  27. 20 0
      src/Packagist/WebBundle/Resources/views/Web/search.html.twig
  28. 9 0
      src/Packagist/WebBundle/Resources/views/Web/searchForm.html.twig
  29. 18 2
      src/Packagist/WebBundle/Resources/views/layout.html.twig
  30. 2 2
      src/Packagist/WebBundle/Resources/views/macros.html.twig
  31. 24 0
      src/Packagist/WebBundle/Tests/Controller/AboutControllerTest.php
  32. 16 0
      src/Packagist/WebBundle/Tests/Controller/ApiControllerTest.php
  33. 19 0
      src/Packagist/WebBundle/Tests/Controller/WebControllerTest.php
  34. 124 0
      web/css/humane/jackedup.css
  35. 11 0
      web/js/libs/humane.min.js

+ 12 - 0
README.md

@@ -13,3 +13,15 @@ Installation
 - Run `app/console assets:install web` to deploy the assets on the web dir.
 - Make a VirtualHost with DocumentRoot pointing to web/
 - You should now be able to access the site, create a user, etc.
+
+Setting up search
+-----------------
+
+The search index uses [Solr](http://lucene.apache.org/solr/), so you will have to install that on your server.
+If you are running it on a non-standard host or port, you will have to adjust the configuration. See the
+[NelmioSolariumBundle](https://github.com/nelmio/NelmioSolariumBundle) for more details.
+
+You will also have to configure Solr. Use the `schema.xml` provided in the doc/ directory for that.
+
+To index packages, just run `app/console packagist:index`. It is recommended to set up a cron job for
+this command, and have it run every few minutes.

+ 1 - 0
app/AppKernel.php

@@ -22,6 +22,7 @@ class AppKernel extends Kernel
             new FOS\UserBundle\FOSUserBundle(),
             new Packagist\WebBundle\PackagistWebBundle(),
             new WhiteOctober\PagerfantaBundle\WhiteOctoberPagerfantaBundle(),
+            new Nelmio\SolariumBundle\NelmioSolariumBundle(),
         );
 
         if (in_array($this->getEnvironment(), array('dev', 'test'))) {

+ 1 - 0
app/Resources/views/base_nolayout.html.twig

@@ -0,0 +1 @@
+{% block content %}{% endblock %}

+ 2 - 0
app/autoload.php

@@ -19,10 +19,12 @@ $loader->registerNamespaces(array(
     'Packagist'        => __DIR__.'/../src',
     'WhiteOctober\PagerfantaBundle' => __DIR__.'/../vendor/bundles',
     'Pagerfanta'  => __DIR__.'/../vendor/pagerfanta/src',
+    'Nelmio'           => __DIR__.'/../vendor/bundles',
 ));
 $loader->registerPrefixes(array(
     'Twig_Extensions_' => __DIR__.'/../vendor/twig-extensions/lib',
     'Twig_'            => __DIR__.'/../vendor/twig/lib',
+    'Solarium_'        => __DIR__.'/../vendor/solarium/library',
 ));
 
 // intl

+ 4 - 1
app/config/config.yml

@@ -62,4 +62,7 @@ fos_user:
     use_username_form_type: true
 #    from_email:
 #        address:        webmaster@example.com
-#        sender_name:    Admin
+#        sender_name:    Admin
+
+nelmio_solarium:
+    adapter: ~

+ 1 - 0
app/config/config_dev.yml

@@ -1,5 +1,6 @@
 imports:
     - { resource: config.yml }
+    - { resource: twig_dev.yml }
 
 framework:
     router:   { resource: "%kernel.root_dir%/config/routing_dev.yml" }

+ 5 - 0
app/config/twig_dev.yml

@@ -0,0 +1,5 @@
+services:
+    twig.extension.debug:
+        class: Twig_Extensions_Extension_Debug
+        tags:
+            - { name: twig.extension }

+ 9 - 0
deps

@@ -72,3 +72,12 @@
 [WhiteOctoberPagerfantaBundle]
     git=http://github.com/whiteoctober/WhiteOctoberPagerfantaBundle.git
     target=/bundles/WhiteOctober/PagerfantaBundle
+    version=origin/symfony2.0
+
+[solarium]
+    git=https://github.com/basdenooijer/solarium.git
+    version=2.3.0-RC1
+
+[NelmioSolariumBundle]
+    git=https://github.com/nelmio/NelmioSolariumBundle.git
+    target=/bundles/Nelmio/SolariumBundle

+ 7 - 5
deps.lock

@@ -6,13 +6,15 @@ doctrine-dbal e0b69790ab1ffd646fd70a04fdb91e5dfbb3ccf1
 doctrine 144d0de0ab61dffc738d7fb590cff8d77919f553
 swiftmailer daaff2b8515390fbb10882647311f476b89a67e6
 assetic f829ad23d23c87480151a21faad49fefe7c09e5d
-twig-extensions 3076c971976e1baaf86e5820c7a1da3f5c1c14eb
+twig-extensions 560990d47ba3fefea18420f9951b54d490715b19
 metadata 8717ad2a5689480765d9ffafe925cd8a2457e582
-composer b947420cae2a60230565e2c6d5c7a4b0eee84b1e
+composer b1f528fb0673f987399caa3d01c457d5cf9f4598
 SensioFrameworkExtraBundle 1c7e92f466d11f83130b0c1271f44d067a2c3b31
 SensioDistributionBundle 20b66a408084ad8752f98e50f10533f5245310bf
-SensioGeneratorBundle c6af9719ae9e81fa4e086f40697d35e7090921cc
+SensioGeneratorBundle 87fe88c4c8dc09cb197ba4b2d6d5b834e2c64980
 AsseticBundle 41b5913b5086a0909af92adcb4a6005ee0051b16
 FOSUserBundle 67300def4b2628512197c1fe05cd25727c309e20
-WhiteOctoberPagerfanta bbbf256d830a6036a6a7671d6b1dc3038e38294a
-WhiteOctoberPagerfantaBundle 60db33325971201cfacb3cbe90d8e25c8c89fd66
+WhiteOctoberPagerfanta a061ad2f464192e72d82fd2aa793d3eb8e397d76
+WhiteOctoberPagerfantaBundle 9c1d1bd119cde420ca3fd1ba2011594446d6d825
+solarium 672d77be590bdbbd919fd4e3bfdcdd6107d08807
+NelmioSolariumBundle a9b40e09ad80d1d7f0626bd4da3f701220bd7676

+ 272 - 0
doc/schema.xml

@@ -0,0 +1,272 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<schema name="packagist" version="1.4">
+  <types>
+    <!-- The StrField type is not analyzed, but indexed/stored verbatim. -->
+    <fieldType name="string" class="solr.StrField" sortMissingLast="true" omitNorms="true"/>
+    <!-- boolean type: "true" or "false" -->
+    <fieldType name="boolean" class="solr.BoolField" sortMissingLast="true" omitNorms="true"/>
+    <!--Binary data type. The data should be sent/retrieved in as Base64 encoded Strings -->
+    <fieldtype name="binary" class="solr.BinaryField"/>
+
+    <!-- The optional sortMissingLast and sortMissingFirst attributes are
+         currently supported on types that are sorted internally as strings
+         and on numeric types.
+	       This includes "string","boolean", and, as of 3.5 (and 4.x),
+	       int, float, long, date, double, including the "Trie" variants.
+       - If sortMissingLast="true", then a sort on this field will cause documents
+         without the field to come after documents with the field,
+         regardless of the requested sort order (asc or desc).
+       - If sortMissingFirst="true", then a sort on this field will cause documents
+         without the field to come before documents with the field,
+         regardless of the requested sort order.
+       - If sortMissingLast="false" and sortMissingFirst="false" (the default),
+         then default lucene sorting will be used which places docs without the
+         field first in an ascending sort and last in a descending sort.
+    -->    
+
+    <fieldType name="int" class="solr.TrieIntField" precisionStep="0" omitNorms="true" positionIncrementGap="0"/>
+    <fieldType name="float" class="solr.TrieFloatField" precisionStep="0" omitNorms="true" positionIncrementGap="0"/>
+    <fieldType name="long" class="solr.TrieLongField" precisionStep="0" omitNorms="true" positionIncrementGap="0"/>
+    <fieldType name="double" class="solr.TrieDoubleField" precisionStep="0" omitNorms="true" positionIncrementGap="0"/>
+
+    <fieldType name="date" class="solr.TrieDateField" omitNorms="true" precisionStep="0" positionIncrementGap="0"/>
+
+    <!-- A Trie based date field for faster date range queries and date faceting. -->
+    <fieldType name="tdate" class="solr.TrieDateField" omitNorms="true" precisionStep="6" positionIncrementGap="0"/>
+
+    <!-- A edge-ngram'd text field that can be used for wildcard matching -->
+    <fieldType name="text_edgengram" class="solr.TextField" positionIncrementGap="100">
+        <analyzer type="index">
+            <tokenizer class="solr.KeywordTokenizerFactory"/>
+            <filter class="solr.LowerCaseFilterFactory"/>
+            <filter class="solr.EdgeNGramFilterFactory" minGramSize="1" maxGramSize="100" />
+        </analyzer>
+        <analyzer type="query">
+            <tokenizer class="solr.KeywordTokenizerFactory"/>
+            <filter class="solr.LowerCaseFilterFactory"/>
+        </analyzer>
+    </fieldType>
+
+    <!-- A general text field that has reasonable, generic
+         cross-language defaults: it tokenizes with StandardTokenizer,
+	 removes stop words from case-insensitive "stopwords.txt"
+	 (empty by default), and down cases.  At query time only, it
+	 also applies synonyms. -->
+    <fieldType name="text_general" class="solr.TextField" positionIncrementGap="100">
+      <analyzer type="index">
+        <tokenizer class="solr.StandardTokenizerFactory"/>
+        <filter class="solr.StopFilterFactory" ignoreCase="true" words="stopwords.txt" enablePositionIncrements="true" />
+        <filter class="solr.LowerCaseFilterFactory"/>
+      </analyzer>
+      <analyzer type="query">
+        <tokenizer class="solr.StandardTokenizerFactory"/>
+        <filter class="solr.StopFilterFactory" ignoreCase="true" words="stopwords.txt" enablePositionIncrements="true" />
+        <filter class="solr.SynonymFilterFactory" synonyms="synonyms.txt" ignoreCase="true" expand="true"/>
+        <filter class="solr.LowerCaseFilterFactory"/>
+      </analyzer>
+    </fieldType>
+
+    <!-- Just like text_general except it reverses the characters of
+	 each token, to enable more efficient leading wildcard queries. -->
+    <fieldType name="text_general_rev" class="solr.TextField" positionIncrementGap="100">
+      <analyzer type="index">
+        <tokenizer class="solr.StandardTokenizerFactory"/>
+        <filter class="solr.StopFilterFactory" ignoreCase="true" words="stopwords.txt" enablePositionIncrements="true" />
+        <filter class="solr.LowerCaseFilterFactory"/>
+        <filter class="solr.ReversedWildcardFilterFactory" withOriginal="true"
+           maxPosAsterisk="3" maxPosQuestion="2" maxFractionAsterisk="0.33"/>
+      </analyzer>
+      <analyzer type="query">
+        <tokenizer class="solr.StandardTokenizerFactory"/>
+        <filter class="solr.SynonymFilterFactory" synonyms="synonyms.txt" ignoreCase="true" expand="true"/>
+        <filter class="solr.StopFilterFactory" ignoreCase="true" words="stopwords.txt" enablePositionIncrements="true" />
+        <filter class="solr.LowerCaseFilterFactory"/>
+      </analyzer>
+    </fieldType>
+
+    <!-- A text field with defaults appropriate for English: it
+         tokenizes with StandardTokenizer, removes English stop words
+         (stopwords_en.txt), down cases, protects words from protwords.txt, and
+         finally applies Porter's stemming.  The query time analyzer
+         also applies synonyms from synonyms.txt. -->
+    <fieldType name="text_en" class="solr.TextField" positionIncrementGap="100">
+      <analyzer type="index">
+        <tokenizer class="solr.StandardTokenizerFactory"/>
+        <!-- in this example, we will only use synonyms at query time
+        <filter class="solr.SynonymFilterFactory" synonyms="index_synonyms.txt" ignoreCase="true" expand="false"/>
+        -->
+        <!-- Case insensitive stop word removal.
+          add enablePositionIncrements=true in both the index and query
+          analyzers to leave a 'gap' for more accurate phrase queries.
+        -->
+        <filter class="solr.StopFilterFactory"
+                ignoreCase="true"
+                words="stopwords_en.txt"
+                enablePositionIncrements="true"
+                />
+        <filter class="solr.LowerCaseFilterFactory"/>
+        <filter class="solr.EnglishPossessiveFilterFactory"/>
+        <filter class="solr.KeywordMarkerFilterFactory" protected="protwords.txt"/>
+	<!-- Optionally you may want to use this less aggressive stemmer instead of PorterStemFilterFactory:
+        <filter class="solr.EnglishMinimalStemFilterFactory"/>
+	-->
+        <filter class="solr.PorterStemFilterFactory"/>
+      </analyzer>
+      <analyzer type="query">
+        <tokenizer class="solr.StandardTokenizerFactory"/>
+        <filter class="solr.SynonymFilterFactory" synonyms="synonyms.txt" ignoreCase="true" expand="true"/>
+        <filter class="solr.StopFilterFactory"
+                ignoreCase="true"
+                words="stopwords_en.txt"
+                enablePositionIncrements="true"
+                />
+        <filter class="solr.LowerCaseFilterFactory"/>
+        <filter class="solr.EnglishPossessiveFilterFactory"/>
+        <filter class="solr.KeywordMarkerFilterFactory" protected="protwords.txt"/>
+	<!-- Optionally you may want to use this less aggressive stemmer instead of PorterStemFilterFactory:
+        <filter class="solr.EnglishMinimalStemFilterFactory"/>
+	-->
+        <filter class="solr.PorterStemFilterFactory"/>
+      </analyzer>
+    </fieldType>
+
+    <!-- A text field with defaults appropriate for English, plus
+	 aggressive word-splitting and autophrase features enabled.
+	 This field is just like text_en, except it adds
+	 WordDelimiterFilter to enable splitting and matching of
+	 words on case-change, alpha numeric boundaries, and
+	 non-alphanumeric chars.  This means certain compound word
+	 cases will work, for example query "wi fi" will match
+	 document "WiFi" or "wi-fi".  However, other cases will still
+	 not match, for example if the query is "wifi" and the
+	 document is "wi fi" or if the query is "wi-fi" and the
+	 document is "wifi".
+        -->
+    <fieldType name="text_en_splitting" class="solr.TextField" positionIncrementGap="100" autoGeneratePhraseQueries="true">
+      <analyzer type="index">
+        <tokenizer class="solr.WhitespaceTokenizerFactory"/>
+        <!-- in this example, we will only use synonyms at query time
+        <filter class="solr.SynonymFilterFactory" synonyms="index_synonyms.txt" ignoreCase="true" expand="false"/>
+        -->
+        <!-- Case insensitive stop word removal.
+          add enablePositionIncrements=true in both the index and query
+          analyzers to leave a 'gap' for more accurate phrase queries.
+        -->
+        <filter class="solr.StopFilterFactory"
+                ignoreCase="true"
+                words="stopwords_en.txt"
+                enablePositionIncrements="true"
+                />
+        <filter class="solr.WordDelimiterFilterFactory" generateWordParts="1" generateNumberParts="1" catenateWords="1" catenateNumbers="1" catenateAll="0" splitOnCaseChange="1"/>
+        <filter class="solr.LowerCaseFilterFactory"/>
+        <filter class="solr.KeywordMarkerFilterFactory" protected="protwords.txt"/>
+        <filter class="solr.PorterStemFilterFactory"/>
+      </analyzer>
+      <analyzer type="query">
+        <tokenizer class="solr.WhitespaceTokenizerFactory"/>
+        <filter class="solr.SynonymFilterFactory" synonyms="synonyms.txt" ignoreCase="true" expand="true"/>
+        <filter class="solr.StopFilterFactory"
+                ignoreCase="true"
+                words="stopwords_en.txt"
+                enablePositionIncrements="true"
+                />
+        <filter class="solr.WordDelimiterFilterFactory" generateWordParts="1" generateNumberParts="1" catenateWords="0" catenateNumbers="0" catenateAll="0" splitOnCaseChange="1"/>
+        <filter class="solr.LowerCaseFilterFactory"/>
+        <filter class="solr.KeywordMarkerFilterFactory" protected="protwords.txt"/>
+        <filter class="solr.PorterStemFilterFactory"/>
+      </analyzer>
+    </fieldType>
+
+    <!-- Less flexible matching, but less false matches.  Probably not ideal for product names,
+         but may be good for SKUs.  Can insert dashes in the wrong place and still match. -->
+    <fieldType name="text_en_splitting_tight" class="solr.TextField" positionIncrementGap="100" autoGeneratePhraseQueries="true">
+      <analyzer>
+        <tokenizer class="solr.WhitespaceTokenizerFactory"/>
+        <filter class="solr.SynonymFilterFactory" synonyms="synonyms.txt" ignoreCase="true" expand="false"/>
+        <filter class="solr.StopFilterFactory" ignoreCase="true" words="stopwords_en.txt"/>
+        <filter class="solr.WordDelimiterFilterFactory" generateWordParts="0" generateNumberParts="0" catenateWords="1" catenateNumbers="1" catenateAll="0"/>
+        <filter class="solr.LowerCaseFilterFactory"/>
+        <filter class="solr.KeywordMarkerFilterFactory" protected="protwords.txt"/>
+        <filter class="solr.EnglishMinimalStemFilterFactory"/>
+        <!-- this filter can remove any duplicate tokens that appear at the same position - sometimes
+             possible with WordDelimiterFilter in conjuncton with stemming. -->
+        <filter class="solr.RemoveDuplicatesTokenFilterFactory"/>
+      </analyzer>
+    </fieldType>
+
+    <fieldtype name="phonetic" stored="false" indexed="true" class="solr.TextField" >
+      <analyzer>
+        <tokenizer class="solr.StandardTokenizerFactory"/>
+        <filter class="solr.DoubleMetaphoneFilterFactory" inject="false"/>
+      </analyzer>
+    </fieldtype>
+
+    <!-- since fields of this type are by default not stored or indexed,
+         any data added to them will be ignored outright.  --> 
+    <fieldtype name="ignored" stored="false" indexed="false" multiValued="true" class="solr.StrField" />
+ </types>
+
+ <fields>
+   <!-- Valid attributes for fields:
+     name: mandatory - the name for the field
+     type: mandatory - the name of a previously defined type from the 
+       <types> section
+     indexed: true if this field should be indexed (searchable or sortable)
+     stored: true if this field should be retrievable
+     multiValued: true if this field may contain multiple values per document
+     omitNorms: (expert) set to true to omit the norms associated with
+       this field (this disables length normalization and index-time
+       boosting for the field, and saves some memory).  Only full-text
+       fields or fields that need an index-time boost need norms.
+     termVectors: [false] set to true to store the term vector for a
+       given field.
+       When using MoreLikeThis, fields used for similarity should be
+       stored for best performance.
+     termPositions: Store position information with the term vector.  
+       This will increase storage costs.
+     termOffsets: Store offset information with the term vector. This 
+       will increase storage costs.
+     default: a value that should be used if no value is specified
+       when adding a document.
+   -->
+
+   <field name="id" type="string" indexed="true" stored="true" required="true" /> 
+   <field name="name" type="text_general_rev" indexed="true" stored="true"/>
+   <field name="description" type="text_general_rev" indexed="true" stored="true"/>
+   <field name="tags" type="text_general_rev" indexed="true" stored="true" multiValued="true"/>
+   
+   <!-- catchall field, containing all other searchable text fields (implemented
+        via copyField further on in this schema  -->
+   <field name="text" type="text_en" indexed="true" stored="false" multiValued="true"/>
+   
+   <!-- extra name field allowing dashes to be omitted/misplaced -->
+   <field name="name_split" type="text_en_splitting_tight" indexed="true" stored="false" />
+
+   <!-- extra catchall for ngram searches -->
+   <field name="text_ngram" type="text_edgengram" indexed="true" stored="false" multiValued="true" />
+ </fields>
+
+ <!-- Field to use to determine and enforce document uniqueness. 
+      Unless this field is marked with required="false", it will be a required field
+   -->
+ <uniqueKey>id</uniqueKey>
+
+ <!-- field for the QueryParser to use when an explicit fieldname is absent -->
+ <defaultSearchField>text</defaultSearchField>
+
+ <!-- SolrQueryParser configuration: defaultOperator="AND|OR" -->
+ <solrQueryParser defaultOperator="OR"/>
+  <!-- copyField commands copy one field to another at the time a document
+        is added to the index.  It's used either to index the same field differently,
+        or to add multiple fields to the same field for easier/faster searching.  -->
+
+   <copyField source="name" dest="text"/>
+   <copyField source="description" dest="text"/>
+   <copyField source="tags" dest="text"/>
+
+   <copyField source="name" dest="name_split"/>
+
+   <copyField source="name" dest="text_ngram"/>
+   <copyField source="description" dest="text_ngram"/>
+   <copyField source="tags" dest="text_ngram"/>
+</schema>

+ 112 - 0
src/Packagist/WebBundle/Command/IndexPackagesCommand.php

@@ -0,0 +1,112 @@
+<?php
+
+/*
+ * This file is part of Packagist.
+ *
+ * (c) Jordi Boggiano <j.boggiano@seld.be>
+ *     Nils Adermann <naderman@naderman.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Packagist\WebBundle\Command;
+
+use Packagist\WebBundle\Entity\Package;
+
+use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * @author Igor Wiedler <igor@wiedler.ch>
+ */
+class IndexPackagesCommand extends ContainerAwareCommand
+{
+    /**
+     * {@inheritdoc}
+     */
+    protected function configure()
+    {
+        $this
+            ->setName('packagist:index')
+            ->setDefinition(array(
+                new InputOption('force', null, InputOption::VALUE_NONE, 'Force a re-indexing of all packages'),
+                new InputArgument('package', InputArgument::OPTIONAL, 'Package name to index'),
+            ))
+            ->setDescription('Indexes packages in Solr')
+        ;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $verbose = $input->getOption('verbose');
+        $force = $input->getOption('force');
+        $package = $input->getArgument('package');
+
+        $doctrine = $this->getContainer()->get('doctrine');
+        $solarium = $this->getContainer()->get('solarium.client');
+
+        if ($package) {
+            $packages = array($doctrine->getRepository('PackagistWebBundle:Package')->findOneByName($package));
+        } elseif ($force) {
+            $packages = $doctrine->getRepository('PackagistWebBundle:Package')->findAll();
+        } else {
+            $packages = $doctrine->getRepository('PackagistWebBundle:Package')->getStalePackagesForIndexing();
+        }
+
+        // clear index before a full-update
+        if ($force && !$package) {
+            if ($verbose) {
+                $output->writeln('Deleting existing index');
+            }
+
+            $update = $solarium->createUpdate();
+            $update->addDeleteQuery('*:*');
+            $update->addCommit();
+
+            $solarium->update($update);
+        }
+
+        // update package index
+        foreach ($packages as $package) {
+            if ($verbose) {
+                $output->writeln('Indexing '.$package->getName());
+            }
+
+            try {
+                $update = $solarium->createUpdate();
+                $document = $update->createDocument();
+                $this->updateDocumentFromPackage($document, $package);
+                $update->addDocument($document);
+                $update->addCommit();
+                $solarium->update($update);
+                $package->setIndexedAt(new \DateTime);
+            } catch (\Exception $e) {
+                $output->writeln('<error>Exception: '.$e->getMessage().', skipping package '.$package->getName().'.</error>');
+            }
+        }
+
+        $doctrine->getEntityManager()->flush();
+    }
+
+    private function updateDocumentFromPackage(\Solarium_Document_ReadWrite $document, Package $package)
+    {
+        $document->id = $package->getId();
+        $document->name = $package->getName();
+        $document->description = $package->getDescription();
+
+        $tags = array();
+        foreach ($package->getVersions() as $version) {
+            foreach ($version->getTags() as $tag) {
+                $tags[mb_strtolower($tag->getName(), 'UTF-8')] = true;
+            }
+        }
+        $document->tags = array_keys($tags);
+    }
+}

+ 9 - 10
src/Packagist/WebBundle/Command/UpdatePackagesCommand.php

@@ -16,6 +16,7 @@ use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
 use Symfony\Bridge\Doctrine\RegistryInterface;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Input\InputArgument;
 use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\HttpKernel\KernelInterface;
 use Symfony\Component\Finder\Finder;
@@ -53,13 +54,9 @@ class UpdatePackagesCommand extends ContainerAwareCommand
             ->setName('packagist:update')
             ->setDefinition(array(
                 new InputOption('force', null, InputOption::VALUE_NONE, 'Force a re-crawl of all packages'),
-                new InputOption('package', null, InputOption::VALUE_NONE, 'Package name to update (implicitly enables --force)'),
+                new InputArgument('package', InputArgument::OPTIONAL, 'Package name to update (implicitly enables --force)'),
             ))
             ->setDescription('Updates packages')
-            ->setHelp(<<<EOF
-
-EOF
-            )
         ;
     }
 
@@ -69,15 +66,17 @@ EOF
     protected function execute(InputInterface $input, OutputInterface $output)
     {
         $verbose = $input->getOption('verbose');
-        $doctrine = $this->getContainer()->get('doctrine');
+        $force = $input->getOption('force');
+        $package = $input->getArgument('package');
 
+        $doctrine = $this->getContainer()->get('doctrine');
         $logger = $this->getContainer()->get('logger');
 
         $this->versionParser = new VersionParser;
 
-        if ($input->getOption('package')) {
-            $packages = array($doctrine->getRepository('PackagistWebBundle:Package')->findOneByName($input->getOption('package')));
-        } elseif ($input->getOption('force')) {
+        if ($package) {
+            $packages = array($doctrine->getRepository('PackagistWebBundle:Package')->findOneByName($package));
+        } elseif ($force) {
             $packages = $doctrine->getRepository('PackagistWebBundle:Package')->findAll();
         } else {
             $packages = $doctrine->getRepository('PackagistWebBundle:Package')->getStalePackages();
@@ -98,7 +97,7 @@ EOF
 
             try {
                 // clear versions to force a clean reloading if --force is enabled
-                if ($input->getOption('force')) {
+                if ($force) {
                     $versionRepo = $doctrine->getRepository('PackagistWebBundle:Version');
                     foreach ($package->getVersions() as $version) {
                         $versionRepo->remove($version);

+ 0 - 2
src/Packagist/WebBundle/Controller/ApiController.php

@@ -28,8 +28,6 @@ class ApiController extends Controller
      */
     public function packagesAction()
     {
-        $version = $this->get('request')->query->get('version');
-
         $packages = $this->get('doctrine')
             ->getRepository('Packagist\WebBundle\Entity\Package')
             ->findAll();

+ 61 - 4
src/Packagist/WebBundle/Controller/WebController.php

@@ -14,6 +14,8 @@ namespace Packagist\WebBundle\Controller;
 
 use Packagist\WebBundle\Form\Type\AddMaintainerRequestType;
 use Packagist\WebBundle\Form\Model\AddMaintainerRequest;
+use Packagist\WebBundle\Form\Type\SearchQueryType;
+use Packagist\WebBundle\Form\Model\SearchQuery;
 use Symfony\Bundle\FrameworkBundle\Controller\Controller;
 use Packagist\WebBundle\Entity\Package;
 use Packagist\WebBundle\Entity\Version;
@@ -24,9 +26,11 @@ use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
+use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
 use Symfony\Component\Security\Core\Exception\AccessDeniedException;
 use Pagerfanta\Pagerfanta;
 use Pagerfanta\Adapter\DoctrineORMAdapter;
+use Pagerfanta\Adapter\SolariumAdapter;
 
 /**
  * @author Jordi Boggiano <j.boggiano@seld.be>
@@ -44,11 +48,10 @@ class WebController extends Controller
      */
     public function indexAction()
     {
-        return array('page' => 'home');
+        return array('page' => 'home', 'searchForm' => $this->createSearchForm()->createView());
     }
 
     /**
-     * @Template()
      * @Route("/packages/", name="browse")
      */
     public function browseAction(Request $req)
@@ -76,6 +79,46 @@ class WebController extends Controller
         return $this->render('PackagistWebBundle:Web:browse.html.twig', array('packages' => $paginator));
     }
 
+    /**
+     * @Route("/search/", name="search")
+     */
+    public function searchAction(Request $req)
+    {
+        $form = $this->createSearchForm();
+
+        if ($req->query->has('search_query')) {
+            $form->bindRequest($req);
+            if ($form->isValid()) {
+                $solarium = $this->get('solarium.client');
+
+                $select = $solarium->createSelect();
+
+                $escapedQuery = $select->getHelper()->escapePhrase($form->getData()->getQuery());
+
+                $dismax = $select->getDisMax();
+                $dismax->setQueryFields(array('name', 'description', 'tags', 'text', 'text_ngram', 'name_split'));
+                $dismax->setBoostQuery('name:"'.$escapedQuery.'"^2 name_split:"'.$escapedQuery.'"^1.5');
+                $dismax->setQueryParser('edismax');
+                $select->setQuery($form->getData()->getQuery());
+
+                $paginator = new Pagerfanta(new SolariumAdapter($solarium, $select));
+                $paginator->setMaxPerPage(15);
+                $paginator->setCurrentPage($req->query->get('page', 1), false, true);
+
+                if ($req->isXmlHttpRequest()) {
+                    return $this->render('PackagistWebBundle:Web:list.html.twig', array(
+                        'packages' => $paginator,
+                        'noLayout' => true,
+                    ));
+                }
+
+                return $this->render('PackagistWebBundle:Web:search.html.twig', array('packages' => $paginator, 'form' => $form->createView()));
+            }
+        }
+
+        return $this->render('PackagistWebBundle:Web:search.html.twig', array('form' => $form->createView()));
+    }
+
     /**
      * @Template()
      * @Route("/packages/submit", name="submit")
@@ -107,7 +150,7 @@ class WebController extends Controller
             }
         }
 
-        return array('form' => $form->createView(), 'page' => 'submit');
+        return array('form' => $form->createView(), 'page' => 'submit', 'searchForm' => $this->createSearchForm()->createView());
     }
 
     /**
@@ -159,7 +202,7 @@ class WebController extends Controller
             throw new NotFoundHttpException('The requested vendor, '.$vendor.', was not found.');
         }
 
-        return array('packages' => $packages, 'vendor' => $vendor, 'paginate' => false);
+        return array('packages' => $packages, 'vendor' => $vendor, 'paginate' => false, 'searchForm' => $this->createSearchForm()->createView());
     }
 
     /**
@@ -183,6 +226,8 @@ class WebController extends Controller
             $data['form'] = $this->createAddMaintainerForm()->createView();
         }
 
+        $data['searchForm'] = $this->createSearchForm()->createView();
+
         return $data;
     }
 
@@ -238,12 +283,24 @@ class WebController extends Controller
             }
         }
 
+        $data['searchForm'] = $this->createSearchForm()->createView();
         return $data;
     }
 
+    public function render($view, array $parameters = array(), Response $response = null)
+    {
+        $parameters['searchForm'] = $this->createSearchForm()->createView();
+        return parent::render($view, $parameters, $response);
+    }
+
     private function createAddMaintainerForm()
     {
         $addMaintainerRequest = new AddMaintainerRequest;
         return $this->createForm(new AddMaintainerRequestType, $addMaintainerRequest);
     }
+
+    private function createSearchForm()
+    {
+        return $this->createForm(new SearchQueryType, new SearchQuery);
+    }
 }

+ 25 - 0
src/Packagist/WebBundle/Entity/Package.php

@@ -88,6 +88,11 @@ class Package
      */
     private $crawledAt;
 
+    /**
+     * @ORM\Column(type="datetime", nullable=true)
+     */
+    private $indexedAt;
+
     private $entityRepository;
 
     public function __construct()
@@ -347,6 +352,26 @@ class Package
         return $this->crawledAt;
     }
 
+    /**
+     * Set indexedAt
+     *
+     * @param datetime $indexedAt
+     */
+    public function setIndexedAt($indexedAt)
+    {
+        $this->indexedAt = $indexedAt;
+    }
+
+    /**
+     * Get indexedAt
+     *
+     * @return datetime $indexedAt
+     */
+    public function getIndexedAt()
+    {
+        return $this->indexedAt;
+    }
+
     /**
      * Add maintainers
      *

+ 12 - 0
src/Packagist/WebBundle/Entity/PackageRepository.php

@@ -47,6 +47,18 @@ class PackageRepository extends EntityRepository
         return $qb->getQuery()->getResult();
     }
 
+    public function getStalePackagesForIndexing()
+    {
+        $qb = $this->getEntityManager()->createQueryBuilder();
+        $qb->select('p, v, t')
+            ->from('Packagist\WebBundle\Entity\Package', 'p')
+            ->leftJoin('p.versions', 'v')
+            ->leftJoin('v.tags', 't')
+            ->where('p.indexedAt IS NULL OR p.indexedAt < ?0')
+            ->setParameters(array(new \DateTime('-1hour')));
+        return $qb->getQuery()->getResult();
+    }
+
     public function findOneByName($name)
     {
         $qb = $this->getBaseQueryBuilder()

+ 0 - 49
src/Packagist/WebBundle/Entity/Version.php

@@ -527,55 +527,6 @@ class Version
         return $this->tags;
     }
 
-    public function setTagsText($text)
-    {
-        $tags = array();
-        if (trim($text)) {
-            $tags = preg_split('#[\s,]+#', trim($text));
-            $tags = array_map(function($el) {
-                return trim(ltrim($el, '#'), '"\'');
-            }, $tags);
-            $uniqueTags = array();
-            foreach ($tags as $tag) {
-                if ($tag && !isset($uniqueTags[strtolower($tag)])) {
-                    $uniqueTags[strtolower($tag)] = $tag;
-                }
-            }
-            $tags = array_values($uniqueTags);
-        }
-
-        foreach ($this->tags as $k => $tag) {
-            if (false !== ($idx = array_search($tag->getName(), $tags))) {
-                unset($tags[$idx]);
-            } else {
-                unset($this->tags[$k]);
-            }
-        }
-
-        foreach ($tags as $tag) {
-            $this->addTags($this->getTagEntity($tag));
-        }
-    }
-
-    public function setEntityManager($em)
-    {
-        $this->em = $em;
-    }
-
-    protected function getTagEntity($name)
-    {
-        return Tag::getByName($this->em, $name, true);
-    }
-
-    public function getTagsText()
-    {
-        $tags = array();
-        foreach ($this->tags as $tag) {
-            $tags[] = $tag->getName();
-        }
-        return implode(', ', $tags);
-    }
-
     /**
      * Set updatedAt
      *

+ 33 - 0
src/Packagist/WebBundle/Form/Model/SearchQuery.php

@@ -0,0 +1,33 @@
+<?php
+
+/*
+ * This file is part of Packagist.
+ *
+ * (c) Jordi Boggiano <j.boggiano@seld.be>
+ *     Nils Adermann <naderman@naderman.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Packagist\WebBundle\Form\Model;
+
+use Symfony\Component\Validator\Constraints as Assert;
+
+class SearchQuery
+{
+    /**
+     * @Assert\NotBlank()
+     */
+    protected $query;
+
+    public function setQuery($query)
+    {
+        $this->query = $query;
+    }
+
+    public function getQuery()
+    {
+        return $this->query;
+    }
+}

+ 40 - 0
src/Packagist/WebBundle/Form/Type/SearchQueryType.php

@@ -0,0 +1,40 @@
+<?php
+
+/*
+ * This file is part of Packagist.
+ *
+ * (c) Jordi Boggiano <j.boggiano@seld.be>
+ *     Nils Adermann <naderman@naderman.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Packagist\WebBundle\Form\Type;
+
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\FormBuilder;
+
+/**
+ * @author Igor Wiedler <igor@wiedler.ch>
+ */
+class SearchQueryType extends AbstractType
+{
+    public function buildForm(FormBuilder $builder, array $options)
+    {
+        $builder->add('query', 'search');
+    }
+
+    public function getDefaultOptions(array $options)
+    {
+        return array(
+            'data_class' => 'Packagist\WebBundle\Form\Model\SearchQuery',
+            'csrf_protection' => false,
+        );
+    }
+
+    public function getName()
+    {
+        return 'search_query';
+    }
+}

+ 33 - 6
src/Packagist/WebBundle/Resources/public/css/main.css

@@ -108,7 +108,8 @@ a:hover {
 
 .container div.user, .container div.box, .container header, .container div.flash-message {
   width: 900px;
-  margin: auto;
+  margin-left: auto;
+  margin-right: auto;
 }
 
 header h1 {
@@ -144,10 +145,11 @@ header p {
   -moz-border-radius: 6px;
   border-radius: 6px;
   box-shadow: rgba(0, 0, 0, 0.25) 0 1px 3px;
+  margin-bottom: 10px;
 }
 
 header {
-  margin: 0 10px 20px;
+  margin: 0 10px;
   font-size: 15px;
 }
 
@@ -337,7 +339,7 @@ input[type="submit"].loading {
   background-image: url("../img/loader.gif");
 }
 
-input[type="text"], input[type="password"], input[type="email"] {
+input[type="text"], input[type="password"], input[type="email"], input[type="search"] {
   padding: 4px;
   background-color: #fff;
   border: 1px solid #ccc;
@@ -346,8 +348,8 @@ input[type="text"], input[type="password"], input[type="email"] {
   border-radius: 6px;
   box-shadow: none;
 }
-input[type="text"]:hover, input[type="password"]:hover, input[type="email"]:hover,
-input[type="text"]:focus, input[type="password"]:focus, input[type="email"]:focus {
+input[type="text"]:hover, input[type="password"]:hover, input[type="email"]:hover, input[type="search"]:hover,
+input[type="text"]:focus, input[type="password"]:focus, input[type="email"]:focus, input[type="search"]:focus {
   border-color: #64c523;
   outline-style: none;
 }
@@ -355,7 +357,11 @@ input[type="text"]:invalid, input[type="password"]:invalid, input[type="email"]:
   border-color: #c67700;
   color: #bf7300;
 }
-
+input[type="search"] {
+  -moz-appearance:none;
+  -webkit-appearance:none;
+  font-size: 25px;
+}
 
 input[type="checkbox"] {
   float: left;
@@ -370,6 +376,27 @@ form ul {
   margin: 10px 0;
 }
 
+/* Search */
+#search_query_query {
+  width: 780px;
+}
+#search-form .submit-wrapper {
+  width: 100px;
+  float: right;
+}
+#search-form .submit {
+  margin: 0;
+  padding: 6px 20px;
+  width: 100px;
+}
+#search-form p {
+  margin: 0;
+}
+.search-list {
+  margin-top: 10px;
+}
+
+/* Package */
 .package form h2 {
   margin: 10px 0;
 }

+ 10 - 9
src/Packagist/WebBundle/Resources/public/js/layout.js

@@ -1,9 +1,10 @@
-/*
-    Adjust width for packades data
-*/
-$(window).load(function()
-{
-    var row = $('ul.packages');
-    if(!row.length) return;
-    $('div.package-details > div').css('min-width', Math.max(400, Math.floor(860 - row.width())) + 'px');
-});
+"use strict";
+
+/**
+ * Ajax error handler
+ */
+$.ajaxSetup({
+    error: function (xhr) {
+        humane.info("We're so sorry, something is wrong on our end.");
+    }
+})

+ 51 - 0
src/Packagist/WebBundle/Resources/public/js/search.js

@@ -0,0 +1,51 @@
+"use strict";
+
+(function ($) {
+    var form = $('form#search-form'),
+        showResults,
+        doSearch,
+        searching = false,
+        searchQueued = false,
+        previousQuery;
+
+    showResults = function (page) {
+        var list = $('.search-list'),
+            newList = $(page);
+
+        list.html(newList.html());
+        list.removeClass('hidden');
+
+        searching = false;
+
+        if (searchQueued) {
+            doSearch();
+            searchQueued = false;
+        }
+    };
+
+    doSearch = function () {
+        var currentQuery;
+
+        if (searching) {
+            searchQueued = true;
+            return;
+        }
+
+        currentQuery = form.serialize();
+
+        if (previousQuery === currentQuery) {
+            return;
+        }
+
+        $.ajax({
+            url: form.attr('action'),
+            data: currentQuery,
+            success: showResults
+        });
+
+        searching = true;
+        previousQuery = currentQuery;
+    };
+
+    form.bind('keyup search', doSearch);
+})(jQuery);

+ 1 - 1
src/Packagist/WebBundle/Resources/public/js/submitPackage.js

@@ -1,4 +1,4 @@
-(function($) {
+(function ($) {
     var onSubmit = function(e) {
         var success;
         $('div > ul, div.confirmation', this).remove();

+ 1 - 1
src/Packagist/WebBundle/Resources/public/js/view.js

@@ -1,4 +1,4 @@
-(function ($){
+(function ($) {
     $('#add-maintainer').click(function (e) {
         $('#add-maintainer-form').toggleClass('hidden');
         e.preventDefault();

+ 4 - 2
src/Packagist/WebBundle/Resources/views/Web/index.html.twig

@@ -1,12 +1,14 @@
 {% extends "PackagistWebBundle::layout.html.twig" %}
 
-{% block content %}
+{% block search %}
     <div class="box">
         <p>Packagist is the main <a href="{{ path('about.composer') }}">Composer</a> repository. It aggregates all sorts of PHP packages that are installable with Composer.<br /><a href="{{ path('browse') }}">Browse packages</a> or <a href="{{ path('submit') }}">submit your own</a>.</p>
     </div>
 
-    &nbsp;
+    {{ parent() }}
+{% endblock %}
 
+{% block content %}
     <div class="box clearfix">
         <section class="getting-started">
             <h1>Getting Started</h1>

+ 9 - 7
src/Packagist/WebBundle/Resources/views/Web/list.html.twig

@@ -1,14 +1,16 @@
-{% extends "PackagistWebBundle::layout.html.twig" %}
+{% extends noLayout|default(false) ? "::base_nolayout.html.twig" : "PackagistWebBundle::layout.html.twig" %}
 
 {% import "PackagistWebBundle::macros.html.twig" as macros %}
 
 {% block content %}
-    <div class="box clearfix">
+    <div class="box clearfix package-list">
         {% block content_title %}<h1>Packages</h1>{% endblock %}
-        {% if packages|length %}
-            {{ macros.listPackages(packages, paginate is not defined or paginate) }}
-        {% else %}
-            <p>No packages found.</p>
-        {% endif %}
+        {% block list %}
+            {% if packages|length %}
+                {{ macros.listPackages(packages, paginate is not defined or paginate) }}
+            {% else %}
+                <p>No packages found.</p>
+            {% endif %}
+        {% endblock %}
     </div>
 {% endblock %}

+ 20 - 0
src/Packagist/WebBundle/Resources/views/Web/search.html.twig

@@ -0,0 +1,20 @@
+{% extends "PackagistWebBundle:Web:list.html.twig" %}
+
+{% block scripts %}
+    <script src="{{ asset('bundles/packagistweb/js/search.js?v=2')}}"></script>
+{% endblock %}
+
+{% block content %}
+{% endblock %}
+
+{% block search %}
+    <div class="box clearfix">
+        {% include "PackagistWebBundle:Web:searchForm.html.twig" %}
+
+        <div class="search-list {% if packages is not defined %}hidden{% endif %}">
+            {% if packages is defined %}
+                {{ block('list') }}
+            {% endif %}
+        </div>
+    </div>
+{% endblock %}

+ 9 - 0
src/Packagist/WebBundle/Resources/views/Web/searchForm.html.twig

@@ -0,0 +1,9 @@
+<form id="search-form" action="{{ path('search') }}" method="GET" {{ form_enctype(searchForm) }} autocomplete="off">
+    <p class="submit-wrapper"><input class="submit" type="submit" value="Search" /></p>
+    <p>
+        {{ form_errors(searchForm.query) }}
+        {{ form_widget(searchForm.query, {'attr': {'autocomplete': 'off', 'placeholder': 'Search packages...'}}) }}
+        {{ form_rest(searchForm) }}
+    </p>
+</form>
+

+ 18 - 2
src/Packagist/WebBundle/Resources/views/layout.html.twig

@@ -17,9 +17,13 @@
         <script src="{{ asset('bundles/packagistweb/js/html5.js') }}"></script>
         <![endif]-->
 
-        <link rel="stylesheet" href="{{ asset('bundles/packagistweb/css/main.css?v=2') }}" />
+        <link rel="stylesheet" href="{{ asset('bundles/packagistweb/css/main.css?v=3') }}" />
+        <link rel="stylesheet" href="{{ asset('css/humane/jackedup.css?v=2') }}" />
 
-        {# {% stylesheets '@PackagistWebBundle/Resources/public/css/main.css' filter="yui_css" output='css/main.css' %}
+        {# {% stylesheets
+            '@PackagistWebBundle/Resources/public/css/main.css'
+            'css/humane/jackedup.css'
+            filter="yui_css" output='css/main.css' %}
             <link rel="stylesheet" href="{{ asset_url }}" />
         {% endstylesheets %} #}
 
@@ -58,6 +62,16 @@
                 {% endif %}
                 {{ app.session.clearFlashes }}
 
+                {% block search %}
+                    {% if searchForm is defined %}
+                        <div class="box">
+                            {% include "PackagistWebBundle:Web:searchForm.html.twig" %}
+                            <div class="search-list hidden">
+                            </div>
+                        </div>
+                    {% endif %}
+                {% endblock %}
+
                 {% block content %}
                 {% endblock %}
             </div>
@@ -86,7 +100,9 @@
 
         <script src="//ajax.googleapis.com/ajax/libs/jquery/1.5.2/jquery.min.js"></script>
         <script>!window.jQuery && document.write(unescape('%3Cscript src="/js/libs/jquery-1.5.2.min.js"%3E%3C/script%3E'))</script>
+        <script src="{{ asset('js/libs/humane.min.js') }}"></script>
         <script src="{{ asset('bundles/packagistweb/js/layout.js') }}"></script>
+        <script src="{{ asset('bundles/packagistweb/js/search.js')}}"></script>
 
         {% if not app.debug and google_analytics.ga_key %}
             <script>

+ 2 - 2
src/Packagist/WebBundle/Resources/views/macros.html.twig

@@ -3,8 +3,8 @@
         {% for package in packages %}
             <li>
                 <h1><a href="{{ path('view_package', { 'name' : package.name }) }}">{{ package.name }}</a></h1>
-                {% if package.versions|length and package.versions[0].description|length %}
-                    <p class="package-description">{{ package.versions[0].description }}</p>
+                {% if package.description %}
+                    <p class="package-description">{{ package.description }}</p>
                 {% endif %}
             </li>
         {% endfor %}

+ 24 - 0
src/Packagist/WebBundle/Tests/Controller/AboutControllerTest.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace Packagist\WebBundle\Tests\Controller;
+
+use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+
+class AboutControllerTest extends WebTestCase
+{
+    public function testPackagist()
+    {
+        $client = self::createClient();
+
+        $crawler = $client->request('GET', '/about');
+        $this->assertEquals('What is Packagist?', $crawler->filter('.box h1')->first()->text());
+    }
+    
+    public function testComposer()
+    {
+        $client = self::createClient();
+
+        $crawler = $client->request('GET', '/about-composer');
+        $this->assertEquals('What is Composer?', $crawler->filter('.box h1')->first()->text());
+    }
+}

+ 16 - 0
src/Packagist/WebBundle/Tests/Controller/ApiControllerTest.php

@@ -0,0 +1,16 @@
+<?php
+
+namespace Packagist\WebBundle\Tests\Controller;
+
+use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+
+class ApiControllerTest extends WebTestCase
+{
+    public function testPackages()
+    {
+        $client = self::createClient();
+
+        $client->request('GET', '/packages.json');
+        $this->assertTrue(count(json_decode($client->getResponse()->getContent())) > 0);
+    }
+}

+ 19 - 0
src/Packagist/WebBundle/Tests/Controller/WebControllerTest.php

@@ -13,4 +13,23 @@ class WebControllerTest extends WebTestCase
         $crawler = $client->request('GET', '/');
         $this->assertEquals('Getting Started', $crawler->filter('.getting-started h1')->text());
     }
+    
+    public function testPackages()
+    {
+        $client = self::createClient();
+        //we expect at least one package
+        $crawler = $client->request('GET', '/packages/');
+        $this->assertTrue($crawler->filter('.packages li')->count() > 0);
+    }
+    
+    public function testPackage()
+    {
+        $client = self::createClient();
+        //we expect package to be clickable and showing at least 'package' div
+        $crawler = $client->request('GET', '/packages/');
+        $link = $crawler->filter('.packages li h1 a')->first()->attr('href');
+        
+        $crawler = $client->request('GET', $link);
+        $this->assertTrue($crawler->filter('.package')->count() > 0);
+    }
 }

+ 124 - 0
web/css/humane/jackedup.css

@@ -0,0 +1,124 @@
+html,
+body {
+  height: 100%;
+}
+.humane {
+  position: fixed;
+  -moz-transition: all 0.6s ease-in-out;
+  -webkit-transition: all 0.6s ease-in-out;
+  -ms-transition: all 0.6s ease-in-out;
+  -o-transition: all 0.6s ease-in-out;
+  transition: all 0.6s ease-in-out;
+  z-index: -1;
+}
+.humane.humane-animate,
+.humane.humane-js-animate {
+  z-index: 100000;
+}
+.humane {
+  font-family: Helvetica Neue, Helvetica, san-serif;
+  font-size: 18px;
+  letter-spacing: -1px;
+  top: 20px;
+  left: 30%;
+  opacity: 0;
+  filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=0);
+  width: 40%;
+  color: #333;
+  padding: 10px;
+  text-align: center;
+  background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAACWCAYAAAAfduJyAAAABmJLR0QA/wD/AP+gvaeTAAAAIklEQVQokWNgYGCQZGJgYGDARTDSQnboGDqsnDt0DKWNLAAkiQFdC+vZNQAAAABJRU5ErkJggg==');
+  background: -webkit-gradient(linear, left top, left bottom, color-stop(0, rgba(0,0,0,0.10)), color-stop(1, rgba(0,0,0,0.20))) no-repeat;
+  background: -moz-linear-gradient(top, rgba(0,0,0,0.10) 0%, rgba(0,0,0,0.20) 100%) no-repeat;
+  background: -webkit-linear-gradient(top, rgba(0,0,0,0.10) 0%, rgba(0,0,0,0.20) 100%) no-repeat;
+  background: -ms-linear-gradient(top, rgba(0,0,0,0.10) 0%, rgba(0,0,0,0.20) 100%) no-repeat;
+  background: -o-linear-gradient(top, rgba(0,0,0,0.10) 0%, rgba(0,0,0,0.20) 100%) no-repeat;
+  background: linear-gradient(top, rgba(0,0,0,0.10) 0%, rgba(0,0,0,0.20) 100%) no-repeat;
+  background-color: #fff;
+  -moz-border-radius: 3px;
+  -webkit-border-radius: 3px;
+  -ms-border-radius: 3px;
+  -o-border-radius: 3px;
+  border-radius: 3px;
+  text-shadow: 0 1px 1px rgba(255,255,255,0.80);
+  -moz-box-shadow: 0 1px 2px rgba(0,0,0,0.50);
+  -webkit-box-shadow: 0 1px 2px rgba(0,0,0,0.50);
+  -ms-box-shadow: 0 1px 2px rgba(0,0,0,0.50);
+  -o-box-shadow: 0 1px 2px rgba(0,0,0,0.50);
+  box-shadow: 0 1px 2px rgba(0,0,0,0.50);
+  -moz-transform: translateY(-100px);
+  -webkit-transform: translateY(-100px);
+  -ms-transform: translateY(-100px);
+  -o-transform: translateY(-100px);
+  transform: translateY(-100px);
+}
+.humane p,
+.humane ul {
+  margin: 0;
+  padding: 0;
+}
+.humane ul {
+  list-style: none;
+}
+.humane.humane-info {
+  background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAADICAYAAAAp8ov1AAAABmJLR0QA/wD/AP+gvaeTAAAAR0lEQVQokWNISfn/n4mBgeE/EwMDAwMqQYQYmdoGlxgjI4rY//+Dx2nUFRsQZ2ALTrQQp8QL1DWeqASC014y7aCx8QwMDAwA1aZBIulmpvwAAAAASUVORK5CYII=');
+  background: -webkit-gradient(linear, left top, left bottom, color-stop(0, rgba(0,0,0,0.70)), color-stop(1, rgba(0,0,0,0.85))) no-repeat;
+  background: -moz-linear-gradient(top, rgba(0,0,0,0.70) 0%, rgba(0,0,0,0.85) 100%) no-repeat;
+  background: -webkit-linear-gradient(top, rgba(0,0,0,0.70) 0%, rgba(0,0,0,0.85) 100%) no-repeat;
+  background: -ms-linear-gradient(top, rgba(0,0,0,0.70) 0%, rgba(0,0,0,0.85) 100%) no-repeat;
+  background: -o-linear-gradient(top, rgba(0,0,0,0.70) 0%, rgba(0,0,0,0.85) 100%) no-repeat;
+  background: linear-gradient(top, rgba(0,0,0,0.70) 0%, rgba(0,0,0,0.85) 100%) no-repeat;
+  background-color: #fff;
+  color: #fff;
+  text-shadow: 0 -1px 1px rgba(0,0,0,0.35);
+}
+.humane.humane-success {
+  background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAADICAYAAAAp8ov1AAAABmJLR0QA/wD/AP+gvaeTAAAASElEQVQokc2SMQ4AIAgDD9/K/79QVzWaENTownAJbWnA5SqACkA/Aiy59hczrGVC30Q7y57EmNU5NL5zwln50IMsfZMel+UBKtFBQSLWM9wLAAAAAElFTkSuQmCC');
+  background: -webkit-gradient(linear, left top, left bottom, color-stop(0, #62c462), color-stop(1, #57a957)) no-repeat;
+  background: -moz-linear-gradient(top, #62c462 0%, #57a957 100%) no-repeat;
+  background: -webkit-linear-gradient(top, #62c462 0%, #57a957 100%) no-repeat;
+  background: -ms-linear-gradient(top, #62c462 0%, #57a957 100%) no-repeat;
+  background: -o-linear-gradient(top, #62c462 0%, #57a957 100%) no-repeat;
+  background: linear-gradient(top, #62c462 0%, #57a957 100%) no-repeat;
+  background-color: #64ff64;
+  color: #fff;
+  text-shadow: 0 -1px 1px rgba(0,0,0,0.35);
+}
+.humane.humane-error {
+  background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAADICAIAAACmkByiAAAABmJLR0QA/wD/AP+gvaeTAAAAf0lEQVQokY2TOQ7AIAwER/5mivy/yRc2RQDhA0jhghFYO5bhuS+TZMAoIUMEhhH4loGhfu71cenM3DutWMsaeGKjv3zO5N17KLPJ0+fQD8cpv5uVLPo4vnX0PpXj0nuaaeVzdmw+yXG1O96n2p3kozB757Ni1Z5UPsU9SP8AeAG1kHXE+7RlPAAAAABJRU5ErkJggg==');
+  background: -webkit-gradient(linear, left top, left bottom, color-stop(0, #ee5f5b), color-stop(1, #c43c35)) no-repeat;
+  background: -moz-linear-gradient(top, #ee5f5b 0%, #c43c35 100%) no-repeat;
+  background: -webkit-linear-gradient(top, #ee5f5b 0%, #c43c35 100%) no-repeat;
+  background: -ms-linear-gradient(top, #ee5f5b 0%, #c43c35 100%) no-repeat;
+  background: -o-linear-gradient(top, #ee5f5b 0%, #c43c35 100%) no-repeat;
+  background: linear-gradient(top, #ee5f5b 0%, #c43c35 100%) no-repeat;
+  background-color: #ee5f5b;
+  color: #fff;
+  text-shadow: 0 -1px 1px rgba(0,0,0,0.35);
+}
+.humane.humane-animate {
+  opacity: 1;
+  filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=100);
+  -moz-transform: translateY(0);
+  -webkit-transform: translateY(0);
+  -ms-transform: translateY(0);
+  -o-transform: translateY(0);
+  transform: translateY(0);
+}
+.humane.humane-animate:hover {
+  opacity: 0.7;
+  filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=70);
+}
+.humane.humane-js-animate {
+  opacity: 1;
+  filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=100);
+  -moz-transform: translateY(0);
+  -webkit-transform: translateY(0);
+  -ms-transform: translateY(0);
+  -o-transform: translateY(0);
+  transform: translateY(0);
+}
+.humane.humane-js-animate:hover {
+  opacity: 0.7;
+  filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=70);
+}

文件差異過大導致無法顯示
+ 11 - 0
web/js/libs/humane.min.js


部分文件因文件數量過多而無法顯示